Compare commits

..

247 Commits

Author SHA1 Message Date
link2xt
7c9abfbde3 Reject on DKIM PERMFAIL and SPF PERMFAIL as well 2024-01-14 09:19:04 +00:00
link2xt
95de87a325 Fixup rspamd disabled.conf deployment message 2024-01-14 08:45:39 +00:00
link2xt
5366df8dc6 Replace rspamd rule weights with a strict rule 2024-01-14 08:45:23 +00:00
link2xt
0a6db5161d Remove unused _configure_opendkim 2024-01-12 19:05:23 +00:00
link2xt
62e25e44fd Disable ratelimit module like other modules 2024-01-12 18:56:11 +00:00
link2xt
ce9fe920dc Do not return anything from remove_opendkim() 2024-01-12 18:47:57 +00:00
link2xt
c171866faf Actually disable phising, rbl and hfilter 2024-01-12 18:46:07 +00:00
missytake
7758c94e31 rspamd: remove redis (not needed) 2024-01-12 15:49:06 +00:00
missytake
66debb9245 lint fixes, final touch 2024-01-12 15:49:06 +00:00
missytake
3542232393 rspamd: reject emails with invalid SPF, DKIM, DMARC 2024-01-12 15:49:06 +00:00
missytake
536c12d989 tests: use generic recipient for DKIM testing 2024-01-12 15:49:06 +00:00
missytake
265403e110 revert "Significantly lower ratelimit" 2024-01-12 15:49:01 +00:00
missytake
fd679af577 rspamd: generate DKIM keys with rspamadm 2024-01-12 15:47:36 +00:00
missytake
ecbf135549 rspamd: install rspamd + redis 2024-01-12 15:47:36 +00:00
missytake
7b90b936dd tests: add test for rejecting SPF & DMARC fails 2024-01-12 15:47:36 +00:00
missytake
17a919ee53 lint: fix 3 issues 2024-01-12 15:47:36 +00:00
missytake
1b15ec0eae rspamd: Significantly lower ratelimit; without read receipts this should be more than fine 2024-01-12 15:47:36 +00:00
missytake
bf863f05b6 rspamd: add redis-server for caching 2024-01-12 15:47:36 +00:00
missytake
a2316beab1 rspamd: disable RBL checks 2024-01-12 15:47:36 +00:00
missytake
28fc91f5f3 rspamd: add rate limiting 2024-01-12 15:47:36 +00:00
missytake
67062677b0 disable some unnecessary rspamd modules 2024-01-12 15:47:36 +00:00
missytake
faf8ffe678 do DKIM signing with rspamd instead of openDKIM 2024-01-12 15:47:36 +00:00
missytake
5821098699 DNS: added www subdomain to zonefile 2024-01-12 13:34:23 +00:00
link2xt
542d63888a nginx: redirect www. to non-www 2024-01-12 13:34:23 +00:00
link2xt
449f8a014c Fix indentation in nginx.conf.j2 2024-01-12 13:34:23 +00:00
link2xt
57764d0cf5 dns: require www. subdomain and request TLS certificate for it 2024-01-12 13:34:23 +00:00
link2xt
c39a79e26a dns: check mta-sts CNAME directly without resolving to IP 2024-01-12 13:34:23 +00:00
link2xt
b6622fc68e chore: run scripts/cmdeploy fmt 2024-01-12 12:18:28 +00:00
link2xt
75b41641f0 doveauth: fix home directory returned from lookup_passdb
It is currently unused, but better have it correct
in case of enabling debugging options such as rawlogs.
2024-01-08 16:40:08 +00:00
link2xt
30a61972fb Update autoconfig XML URL with RFC draft
Old page does not exist anymore and linking to web archive is not nice.
2024-01-08 16:33:04 +01:00
missytake
bcc54602ee postfix: cleanup submission headers 2024-01-05 12:13:31 +01:00
missytake
f9998d5721 tests: if sender's public IP address is in the Received header 2024-01-05 12:13:31 +01:00
nudeldudel
8605ceba5e Update master.cf.j2
Add submission-header-cleanup to reduce the meta-data
2024-01-05 12:13:31 +01:00
missytake
30bcf9ff77 www: change nine.testrun.org occurence to mail_domain 2024-01-05 12:12:52 +01:00
link2xt
70b0e9d5e5 postfix: increase compatibility_level to 3.6 2023-12-27 00:29:12 +01:00
missytake
fdd533aa3b acmetool: stop nginx so acmetool-redirector can start 2023-12-25 23:45:40 +01:00
link2xt
a44ed0aeb3 Use dig +short option to simplify DNS parsing
Without this option parsing of answer was flaky
as for long records like
_submission._tcp.nine.testrun.org.
dig printed the result with a space rather
than tab as a separator and .split("\t") did not work.

This change makes the `dig` command print the answer
in the form we need so there is no need for complex parsing
other than taking the first line.

`-r` option is added to make sure options are not changed by .digrc
in the root home directory.
2023-12-22 21:49:12 +00:00
link2xt
f5bfa6bd56 test: test scanning QR code 2023-12-21 22:22:38 +00:00
link2xt
81a6f8808b fix: escape login and password when passed from dovecot to doveauth
This should allow to use / in the password
2023-12-21 22:22:38 +00:00
link2xt
be3685519f Document ports 80 and 443 and add more hyperlinks 2023-12-21 16:16:17 +00:00
missytake
2cf950e901 echo: fail if configure doesn't work 2023-12-21 01:06:23 +01:00
missytake
46d5dbb07d DNS: nicer output for reverse DNS/PTR records. fixes #143 2023-12-20 19:26:50 +01:00
missytake
d2e0d1fecc DNS: flush_zone before validating DNS entries. fixes #140 2023-12-20 19:26:50 +01:00
missytake
d333cfdd5a lint: fix 1 issue 2023-12-20 19:26:50 +01:00
missytake
32238e99ab tests: testing cmdeploy init only makes sense with a staging server as well now 2023-12-20 19:26:50 +01:00
missytake
40a3a2cc86 tests: make test init work with reachable chatmail_domain 2023-12-20 19:26:50 +01:00
missytake
fe978a1971 DNS: increase SSH command's timeout to 10 seconds (the default) 2023-12-20 19:26:50 +01:00
missytake
b426c2e7ff DNS: error if can't connect with SSH. fixes #144 2023-12-20 19:26:50 +01:00
missytake
b626464453 cmdeploy: fail init and run if SSH doesn't connect 2023-12-20 19:26:50 +01:00
missytake
76c3316f02 cmdeploy init: make output green if DNS is correct 2023-12-19 19:39:49 +01:00
missytake
a6a9406228 DNS: making CLI output slightly prettier 2023-12-19 19:39:49 +01:00
missytake
7921f5dd0b DNS: fix some crashes in cmdeploy dns 2023-12-19 19:39:49 +01:00
missytake
39fc9d628f cmdeploy: only run cmdeploy dns after cmdeploy run 2023-12-19 19:39:49 +01:00
link2xt
85a9183b61 Do not call show_dns with run args 2023-12-19 19:39:49 +01:00
missytake
36a4381484 DNS: use local dig if ssh fails 2023-12-19 19:39:49 +01:00
missytake
5ff98a571c DNS: commit hpk's suggestion 2023-12-19 19:39:49 +01:00
missytake
0a91aeb4a3 cmdeploy: simplify check_necessary_dns output 2023-12-19 19:39:49 +01:00
missytake
c38f1d7e54 DNS: fix reverse DNS checking 2023-12-19 19:39:49 +01:00
missytake
42bba52f66 README: move cmdeploy dns to additional commands 2023-12-19 19:39:49 +01:00
missytake
03aab4043c DNS: fix CNAME resolving, don't print ssh commands for DNS requests 2023-12-19 19:39:49 +01:00
missytake
146def2f06 cmdeploy: show DNS info at begin and end of cmdeploy run 2023-12-19 19:39:49 +01:00
missytake
d642224a73 DNS: flush cache in the beginning 2023-12-19 19:39:49 +01:00
missytake
0238437ce7 DNS: get DNS records with server-side dig 2023-12-19 19:39:49 +01:00
missytake
7ed59ea8bc DNS: move getting IPs to dns.py 2023-12-19 19:39:49 +01:00
missytake
49d0a0bbb0 DNS: fix parsing 2023-12-19 19:39:49 +01:00
missytake
330a034329 DNS: ignore DNS resolvers which don't give us JSON 2023-12-19 19:39:49 +01:00
missytake
aee18215fc DNS: Also check A and CNAME entries 2023-12-19 19:39:49 +01:00
missytake
336f87770d cmdeploy: write --zonefile to file 2023-12-19 19:39:49 +01:00
missytake
4199e04ab3 cmdeploy: fixing DNS CLI output 2023-12-19 19:39:49 +01:00
missytake
50922fb1d2 docs: dns doesn't just output a zone file anymore 2023-12-19 19:39:49 +01:00
missytake
d2fe417715 DNS: try other resolvers if the first doesn't have it 2023-12-19 19:39:49 +01:00
missytake
2b731bf909 DNS: also add IPv4 entry to zonefile 2023-12-19 19:39:49 +01:00
missytake
2669babb53 DNS: added checks for PTR records 2023-12-19 19:39:49 +01:00
missytake
fe675a9a72 cmdeploy: dns --zonefile subcommand to just print the zonefile 2023-12-19 19:39:49 +01:00
missytake
79f766b28e tests: mark test as xfail until we can test for CLI output 2023-12-19 19:39:49 +01:00
missytake
0eeb692c4b DNS: re-use HTTP session to reduce query time by 7 seconds 2023-12-19 19:39:49 +01:00
missytake
6c401173db DNS: also generate AAAA entry 2023-12-19 19:39:49 +01:00
missytake
b474b86e7b cmdeploy: only output DNS entries which are not correct yet 2023-12-19 19:39:49 +01:00
missytake
6a9beb8ff7 DNS: ensure mta-sts.@ is also pointing to @ 2023-12-19 19:39:49 +01:00
missytake
d0f5d08443 cmdeploy run: don't run if crucial DNS entries are missing 2023-12-19 19:39:49 +01:00
missytake
49848ec01e cmdeploy init: show DNS entries required for deployment if not set 2023-12-19 19:39:49 +01:00
missytake
0ffe4d4996 Revert "pyinfra: only install unbound-anchor on Debian systems"
This reverts commit c1d3de926e.
2023-12-19 17:45:00 +01:00
missytake
7a2a889585 pyinfra: only install unbound-anchor on Debian systems 2023-12-19 17:45:00 +01:00
missytake
1e4b776de5 unbound: generate root.key manually if it doesn't exist 2023-12-19 17:45:00 +01:00
link2xt
3d00ca1672 doveauth: add support for Dovecot 2.3.16 2023-12-18 19:44:11 +00:00
link2xt
485bbb9cbd Let acmetool manage port 80
This avoids circular dependency with nginx.
nginx needs a certificate to start
and getting a certificate requires someone
listening on port 80.
2023-12-18 16:36:36 +01:00
holger krekel
359c195419 count ci accounts correctly 2023-12-16 17:06:13 +01:00
holger krekel
1b9e822ff6 strike this weird CHATMAIL_DOMAIN variable 2023-12-16 16:36:56 +01:00
holger krekel
9f6c00d62c strike last mentins of "instance" in readme 2023-12-16 16:36:56 +01:00
missytake
a1355c10ca fix: check config failed for non-testrun domains 2023-12-15 20:25:58 +01:00
link2xt
92ca3283fd Add metrics 2023-12-14 22:22:10 +00:00
missytake
cea1f3f5f7 dovecot: remove -depth from expunge find commands 2023-12-14 19:11:43 +01:00
missytake
39550d3096 small fixes 2023-12-14 19:11:43 +01:00
missytake
070003b983 dovecot: deleting mails with find instead of doveadm expunge 2023-12-14 19:11:43 +01:00
missytake
049ed79e59 dovecot: unconditionally delete all mails after 40 days 2023-12-14 19:11:43 +01:00
missytake
a9e55e3b25 cmdeploy: get cmdeploy run --config working 2023-12-14 18:50:14 +01:00
Septias
5a178ed235 feat: one more paragraph to explain chatmail
close #126
2023-12-14 16:39:41 +01:00
Floris Bruynooghe
8a338f1320 Use more characters for passwords (#124)
This expands the character set used for passwords generated for new
accounts.  The set it taken from the set used by the pass tool.  The
special characters is the full GNU grep [:punct:] set.
2023-12-14 11:51:22 +01:00
Sebastian Klähn
d437b8a943 Merge pull request #125 from deltachat/sk/fix_typo
fix: align spelling of Delta Chat
2023-12-14 11:35:41 +01:00
Septias
a4d520a9ad fix: align spelling of Delta Chat 2023-12-14 11:00:48 +01:00
missytake
9c7dfdf2ff echobot: add echo bot for trying out sending 2023-12-13 22:04:30 +01:00
holger krekel
ffd15e4a9f refine warnings for experimental service,
only show for non-nine domains.
2023-12-13 19:59:52 +01:00
missytake
7f8e0620ca README: formatting 2023-12-13 19:50:45 +01:00
missytake
dc9aebcb55 README: rework and reorder 2023-12-13 19:43:39 +01:00
holger krekel
648b3e0ec3 fix typo, uff 2023-12-13 16:03:46 +01:00
holger krekel
2adfed2714 another attempt 2023-12-13 16:03:10 +01:00
holger krekel
12542f7bed rename for better display 2023-12-13 16:01:47 +01:00
holger krekel
4aca88acf8 fix/streamline link 2023-12-13 15:59:53 +01:00
B. Petersen
b1ac2b78c2 slightly smaller font size 2023-12-13 15:57:59 +01:00
B. Petersen
4a2b37f740 add 'viewport' instructions 2023-12-13 15:57:59 +01:00
holger krekel
bb3a0a9945 strike section 5 mostly -- we need to double-check with lexict sometime
but i am pretty sure this stems from a time where we had non-ephemeral
non-automated account setup (regular testrun.org) and does not apply to chatmail.
2023-12-13 15:57:29 +01:00
holger krekel
3cde5be3b4 adding MIT license and COC to chatmail repo 2023-12-13 15:57:16 +01:00
holger krekel
b055736439 rename benchmarks for blog post 2023-12-13 11:27:52 +01:00
missytake
2817ffd411 www: get actual account restrictions from chatmail.ini 2023-12-13 00:40:01 +01:00
B. Petersen
db45dc071b extract really needed styles from water.css; skip darkmode as this adds quite some burden to site owners and is easily overseen (eg. already our banners are not-so-nice in tdarkmode), if ppl want to use darkmode, they should actively adapt 2023-12-13 00:20:58 +01:00
link2xt
1c0543cb46 Update README.md
Co-authored-by: missytake <missytake@systemli.org>
2023-12-12 23:37:16 +01:00
link2xt
dde879c7fc Add scripts/cmdeploy 2023-12-12 23:37:16 +01:00
link2xt
cf95dfd49d Setup unbound DNS resolver 2023-12-12 21:52:05 +00:00
link2xt
4a96f19faf s/fuff/ruff/ 2023-12-12 20:29:34 +00:00
link2xt
a2a78c0aff Do not attmpt to activate venv from scripts/initenv.sh
If you run it as scripts/initenv.sh,
activating venv is useless as bash will exit immediately afterwards.

If you `source` it as suggested by README.md,
`set -e` will set the flag for the current shell
and your shell will exit as soon as some command returns non-zero status,
e.g. cmdeploy fails or you simply do `ls /foo/bar/baz` and `ls`
complains that `/foo/bar/baz` does not exist.
2023-12-12 20:22:34 +00:00
missytake
4b3a214276 tests: test against quota in chatmail.ini 2023-12-12 20:25:23 +01:00
missytake
bbf0e91761 tests: test against ratelimit in chatmail.ini 2023-12-12 20:25:23 +01:00
missytake
f4b8ec6e10 tests: pass --slow to cmdeploy test 2023-12-12 20:25:23 +01:00
missytake
3b45e82c76 www: be honest that only seen messages are removed for now 2023-12-12 20:12:25 +01:00
missytake
4551f36b85 chatmail.ini: switch username length defaults back to 9 2023-12-12 20:12:25 +01:00
missytake
f2d32324e3 tests: use something.testrun.org instead of chat.example.org for 1 test 2023-12-12 20:12:25 +01:00
missytake
5d4b6eec69 tests: ensure len(username) = 9 if CHATMAIL_DOMAIN2==nine.testrun.org 2023-12-12 20:12:25 +01:00
missytake
98fd4b61c9 tests: replace make_config with example_config, add default config params 2023-12-12 20:12:25 +01:00
missytake
74f9e7536b doveauth: fix logging statement 2023-12-12 20:12:25 +01:00
missytake
849b9d430c chatmail.ini: changed docstring must -> can
Co-authored-by: holger krekel  <holger@merlinux.eu>
2023-12-12 20:12:25 +01:00
missytake
0d0cc908c2 chatmaild: move username/password length and passthrough_senders to chatmail.ini 2023-12-12 20:12:25 +01:00
missytake
156434b952 cmdeploy: move max_mailbox_size + delete_mails_after to chatmail.ini 2023-12-12 20:12:25 +01:00
B. Petersen
02d07912dd make 'experimental' more outstanding 2023-12-12 20:02:46 +01:00
B. Petersen
4a7e36618f use a class for the banner, make sure it is always width of page 2023-12-12 20:02:46 +01:00
B. Petersen
e272294e07 add domain right of the menu, standardize menu help-code 2023-12-12 20:02:46 +01:00
B. Petersen
401f215dc9 move menu up 2023-12-12 20:02:46 +01:00
missytake
3cd4265c94 www: add favicon 2023-12-11 18:26:50 +01:00
holger krekel
8e6869d8e3 move tests into cmdeploy 2023-12-11 18:12:23 +01:00
holger krekel
d6df0f0604 rename deploy_chatmail to cmdeploy 2023-12-11 18:12:23 +01:00
missytake
cad1d32682 mta-sts-resolver: fix virtualenv deployment 2023-12-11 17:54:22 +01:00
holger krekel
fd10652f48 fix event waiting in a test 2023-12-11 17:07:07 +01:00
holger krekel
02918de6c1 rename mailname to mail_domain everywhere 2023-12-11 17:07:07 +01:00
holger krekel
e27dd84501 show PATH env 2023-12-11 15:52:12 +01:00
holger krekel
cdbda291c5 fix cmdeploy test command 2023-12-11 15:52:12 +01:00
holger krekel
d70eb78a76 remove tox run from deploy-chatmail and use 'cmdeploy fmt' and 'pytest' directly 2023-12-11 15:52:12 +01:00
holger krekel
a5e4562505 move tests/chatmaild to chatmaild package, streamline tests and fixtures accordingly 2023-12-11 15:52:12 +01:00
holger krekel
3f2eb84323 fix tests and run all tests on "cmdeploy test" 2023-12-11 15:52:12 +01:00
holger krekel
613d3e14ea discover chatmail.ini in tests from CWD and all parent dirs (tox runs change dirs) 2023-12-11 15:52:12 +01:00
holger krekel
453c401ac6 "cmdeploy test" now installs deltachat if it's not there 2023-12-11 15:52:12 +01:00
holger krekel
8b756f2e0c consistently use shell helper 2023-12-11 15:52:12 +01:00
holger krekel
9e9d5b7698 consistently show ssh/shell output 2023-12-11 15:52:12 +01:00
holger krekel
02adb758ff add "build" dependency 2023-12-11 15:52:12 +01:00
holger krekel
ffade66d97 always show which ssh-commands execute 2023-12-11 15:52:12 +01:00
holger krekel
988333d5fd some more shifting around 2023-12-11 15:52:12 +01:00
holger krekel
db41e952e3 shift functions around, discover sub commands automatically 2023-12-11 15:52:12 +01:00
holger krekel
bb1b11df15 make tests depend on chatmail.ini, not env var 2023-12-11 15:52:12 +01:00
holger krekel
b3fdebf8df tweak for making CI happy 2023-12-11 15:52:12 +01:00
holger krekel
4615df2e3b try to fix workflow 2023-12-11 15:52:12 +01:00
holger krekel
73d8e01452 don't print a traceback but do a proper return code for "cmdeploy test" 2023-12-11 15:52:12 +01:00
holger krekel
bf8a99d844 add chatmail.ini to ignore 2023-12-11 15:52:12 +01:00
holger krekel
cb19ac34e3 add manifest so that ini files get included 2023-12-11 15:52:12 +01:00
holger krekel
15e5573ab4 address nami comment 2023-12-11 15:52:12 +01:00
holger krekel
74f5b2847f fix readme 2023-12-11 15:52:12 +01:00
holger krekel
e417e43c54 add test command 2023-12-11 15:52:12 +01:00
holger krekel
158ebe1089 fix README 2023-12-11 15:52:12 +01:00
holger krekel
5fb7833677 add status command and delete last script 2023-12-11 15:52:12 +01:00
holger krekel
587d8142d2 some more housekeeping 2023-12-11 15:52:12 +01:00
holger krekel
a6d24b3af7 introduce "cmdeploy bench" 2023-12-11 15:52:12 +01:00
holger krekel
d730af2e69 cleanup 2023-12-11 15:52:12 +01:00
holger krekel
99b1e18b0a generate dns zone file via cmdeploy 2023-12-11 15:52:12 +01:00
holger krekel
6012039c56 add dns command beginning 2023-12-11 15:52:12 +01:00
holger krekel
f388e86287 make cmdeploy test work 2023-12-11 15:52:12 +01:00
holger krekel
d3ca037ebf snap 2023-12-11 15:52:12 +01:00
holger krekel
00a203c9aa fix various test setups 2023-12-11 15:52:12 +01:00
holger krekel
6bffb5470d add webdev sub command 2023-12-11 15:52:12 +01:00
holger krekel
81c4a6170f making it work 2023-12-11 15:52:12 +01:00
holger krekel
6285283b02 rework UI for chatmail setup 2023-12-11 15:52:12 +01:00
holger krekel
b71e24d6a1 draft init flow 2023-12-11 15:52:12 +01:00
holger krekel
a4e48c9919 document some attributes in chatmail.ini 2023-12-11 15:52:12 +01:00
holger krekel
bc27eb58bf get passthrough_recipients list from config 2023-12-11 15:52:12 +01:00
holger krekel
1b1f9365c9 various fixes 2023-12-11 15:52:12 +01:00
holger krekel
d8c8040f07 introduce basic config file 2023-12-11 15:52:12 +01:00
holger krekel
652b9688d3 deploy chatmaild in a virtualenv to make it easier to add dependencies 2023-12-08 21:55:18 +01:00
holger krekel
59c3730d84 fix data fixture access 2023-12-08 20:47:49 +01:00
holger krekel
84db074686 fix README link 2023-12-08 14:59:17 +01:00
holger krekel
7eec0ab301 tweak QR code generation 2023-12-08 14:56:48 +01:00
holger krekel
7cb8f90340 create a wwwdev.sh entry point for helping live web design/development (#92)
* create a wwwdev.sh entry point for developing the web part

* rename script

* fix README

* add a note

* don't depend on deltachat python package

* avoid bailing out on jinja2 errors, and provide file-url for instant clickability

* in webdev mode make page auto-refresh every 3 seconds
2023-12-08 14:32:40 +01:00
missytake
32360061b4 filtermail: address hpk's comments 2023-12-08 12:23:10 +01:00
missytake
2055e9f5b8 filtermail: always allow privacy@testrun.org 2023-12-08 12:23:10 +01:00
holger krekel
8cb77d3b98 be fine with 9 chars for password already 2023-12-07 17:34:19 +01:00
holger krekel
c67fb69af2 Parametrized privacy policy, unified and refined nine/non-nine landing pages (#89)
- move web sources to markdown
- integrate privacy policy template
- create and use chatmail.ini file to driving web-page generation 

Co-authored-by: missytake <missytake@systemli.org>

---------

Co-authored-by: missytake <missytake@systemli.org>
2023-12-07 13:52:00 +01:00
holger krekel
960bc1599b add missing simple test file for generating a qr code 2023-12-06 11:00:07 +01:00
holger krekel
0f05216ea0 mention QRcode in readme and modify nine.testrun.org index page to include it 2023-12-05 14:22:42 +01:00
holger krekel
75551224b3 revert unneccessary reformatting and unused file 2023-12-05 14:22:42 +01:00
holger krekel
0b8de41da2 put index.html into www/ dir, as it's not config 2023-12-05 14:22:42 +01:00
holger krekel
f9b5783296 streamline text to be less redundant 2023-12-05 14:22:42 +01:00
holger krekel
d3281cc746 Update deploy-chatmail/src/deploy_chatmail/nginx/index.html.j2
Co-authored-by: missytake <missytake@systemli.org>

use example config as recommended by fcgiwrap/README.debian
2023-12-05 14:22:42 +01:00
holger krekel
4c7e39c10c add origin of genqr code 2023-12-05 14:22:42 +01:00
holger krekel
7b3c1d5ab9 streamline index.html 2023-12-05 14:22:42 +01:00
holger krekel
2d5eb86776 make QR code clickable, verified it works on android and desktop 2023-12-05 14:22:42 +01:00
holger krekel
5c9d9a98b3 works 2023-12-05 14:22:42 +01:00
missytake
5eb5c09052 redirect HTTPS traffic to HTTPS. fix #81 2023-11-28 16:40:19 +01:00
missytake
a86e135967 opendkim: correctly specify SigningTable in opendkim.conf 2023-11-26 07:40:25 +01:00
missytake
776bd87888 moved mta-sts-resolver to /usr/local/lib 2023-11-25 00:39:27 +01:00
link2xt
d7683ed3f7 Move ssl_certificate back to http and fix indentation 2023-11-25 00:39:27 +01:00
missytake
0cc9f18468 acmetool: request one TLS cert for all domains 2023-11-25 00:39:27 +01:00
missytake
889e18f803 generate-dns-zone.sh doesn't need to support CHATMAIL_SERVER env var for now, let's assume A/AAAA point to the chatmail server, too 2023-11-25 00:39:27 +01:00
missytake
773b8d1e00 MTA-STS: fixing lint issues 2023-11-25 00:39:27 +01:00
missytake
dca6d35a6f MTA-STS: adding correct line breaks to config 2023-11-25 00:39:27 +01:00
missytake
d29d2d147b MTA-STS: the HTTPS route needs to be mta-sts.@ not _mta-sts 2023-11-25 00:39:27 +01:00
missytake
347dae1f84 MTA-STS: CNAME doesn't work, it needs to be A and AAAA 2023-11-25 00:39:27 +01:00
missytake
63cbb83344 fix: hetzner doesn't accept whitespace in TXT and CAA records apparently 2023-11-25 00:39:27 +01:00
missytake
27d135fee7 python3-venv was missing 2023-11-25 00:39:27 +01:00
missytake
ccd7c789f0 postfix: install MTA-STS resolver daemon 2023-11-25 00:39:27 +01:00
missytake
c7625fad81 DNS: distinguish between mail_server and mail_domain 2023-11-25 00:39:27 +01:00
missytake
5305dfab12 Added MTA-STS records and .well-known file 2023-11-25 00:39:27 +01:00
holger krekel
4478270fc9 properly call logging.exception 2023-11-20 22:54:15 +01:00
holger krekel
e7c9992fdc it's unclear what this limit really means -- with ipv6 one can easily create lots of IP addresses anyway 2023-11-20 22:54:15 +01:00
holger krekel
a9d43c42f4 - tune down logging for filtermail
- allow higher smtp connection limit
2023-11-20 22:54:15 +01:00
holger krekel
bbf2f0dd36 with help/side-comments from alex i fixed the concurrent account creation problem 2023-11-20 22:54:15 +01:00
holger krekel
43c02377ef make headlines as big as normal text 2023-11-16 11:46:47 +01:00
missytake
70f330b0e4 Changed typo to sans-serif, feel free to revert 2023-11-16 11:46:47 +01:00
holger krekel
02eaa55441 reduce retro-ness of design after @hocuri's comment :) 2023-11-16 11:46:47 +01:00
holger krekel
6c3ec903c2 Update www/nine.testrun.org/index.html
Co-authored-by: Hocuri <hocuri@gmx.de>
2023-11-15 20:48:30 +01:00
holger krekel
7d9b81863f refining the entry point, more info, more directly speaking to DC users
(we don't want to get arbitrary users to report issues)
2023-11-15 20:48:30 +01:00
missytake
af90d0a7de rename doveauth-dictproxy to doveauth 2023-11-15 15:00:27 +01:00
link2xt
322bc9a3aa Set critical flag on generated CAA record
This does not really matter as Let's Encrypt
supports current CAA `issue` syntax,
but may be useful if more records are added and this flag is copy-pasted.

For reference: <https://www.rfc-editor.org/rfc/rfc8659#name-critical-flag>
2023-11-13 15:12:32 +00:00
link2xt
e4009854dc Add NOTIFY capability
Delta Chat does not use it now,
but should: <https://github.com/deltachat/deltachat-core-rust/issues/4983>
Having no capability will confuse whoever develops it.
2023-11-12 20:41:29 +01:00
link2xt
9e14a741c3 Autoformat tests with black 2023-11-08 20:29:44 +00:00
link2xt
01fcb9ae0e Fix None dereference in benchmarks 2023-11-08 20:29:21 +00:00
link2xt
064f6d36ad Fix path in scripts/bench.sh 2023-11-08 20:23:14 +00:00
holger krekel
6b3590e7c8 test: test concurrent user creation 2023-11-08 19:36:38 +00:00
link2xt
251aac18fb fix(dictproxy): check that user exists and create it in a transaction
Otherwise user may be already created by another connection
as checking if the user exists happens
in a different read-only transaction.
This happens when Delta Chat connects IMAP and SMTP at the same time.

Also update last_login time on login.
2023-11-08 19:34:17 +00:00
link2xt
f46bf2f670 Remove authentication logs from dictproxy
They log the passwords and make it difficult to spot actual exceptions.
2023-11-07 21:04:33 +01:00
missytake
40a88c7fc6 nginx: move config to own directory 2023-11-05 01:32:21 +01:00
holger krekel
8791e7735d simplify history of nine branch 2023-11-01 23:15:25 +01:00
holger krekel
248f67dcf6 fix nocreate location 2023-11-01 22:42:38 +01:00
holger krekel
a24df735d4 streamline README, port some changes/additions from nine-branch 2023-11-01 22:42:38 +01:00
holger krekel
7d0797c510 streamline account creation and add tests
also incorporates nine.testrun.org user policies
2023-11-01 21:57:43 +01:00
holger krekel
3a9db729f8 simplify sysctl call 2023-10-31 22:03:03 +01:00
holger krekel
7eb86cba34 increase inotify limits for dovecot 2023-10-31 22:03:03 +01:00
link2xt
5633c0612e dovecot: increase number of simultaneous connections handled by imap-login
Otherwise deltachat core CI running fails with "Connection queue full"
error on IMAP connections.
2023-10-29 19:09:33 +00:00
holger krekel
d5912b909c fix benchmark script 2023-10-28 16:50:24 +02:00
link2xt
f75eb0658c Require that passwords are at least 10 characters long 2023-10-28 13:38:15 +00:00
link2xt
7c5ec1e0df Add scripts/generate-dns-zone.sh 2023-10-24 21:23:20 +00:00
holger krekel
11ebc4623c somehow this deploy.sh adpatation was missing from main, not sure why 2023-10-24 23:19:40 +02:00
missytake
cf29053389 added full path to tox 2023-10-24 23:19:27 +02:00
104 changed files with 3438 additions and 949 deletions

4
.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@@ -0,0 +1,4 @@
Please refer to
[Delta Chat community standards and practices](https://delta.chat/en/community-standards)
which also apply for all chatmail developments.

View File

@@ -6,26 +6,32 @@ on:
jobs:
tox:
name: chatmail tests
name: isolated chatmaild tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: run chatmaild tests
working-directory: chatmaild
run: pipx run tox
- name: run deploy-chatmail offline tests
working-directory: deploy-chatmail
run: pipx run tox
- name: run deploy-chatmail offline tests
working-directory: deploy-chatmail
run: pipx run tox
scripts:
name: chatmail script invocations
name: deploy-chatmail tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: run init.sh
run: ./scripts/init.sh
- name: run test.sh
run: ./scripts/test.sh
- name: initenv
run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: run formatting checks
run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
# all other cmdeploy commands require a staging server
# see https://github.com/deltachat/chatmail/issues/100

5
.gitignore vendored
View File

@@ -3,6 +3,9 @@ __pycache__/
*.py[cod]
*$py.class
*.swp
*qr-*.png
chatmail.ini
# C extensions
*.so
@@ -159,3 +162,5 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
chatmail.zone

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2023, chatmail and delta chat teams
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

167
README.md
View File

@@ -1,59 +1,162 @@
# Chat Mail server configuration
This repository setups a ready-to-go chatmail instance
<img width="800px" src="www/src/collage-top.png"/>
# Chatmail services optimized for Delta Chat apps
This repository helps to setup a ready-to-use chatmail server
comprised of a minimal setup of the battle-tested
[postfix smtp server](https://www.postfix.org) and [dovecot imap server](https://www.dovecot.org).
[postfix smtp](https://www.postfix.org) and [dovecot imap](https://www.dovecot.org) services.
## Getting started
The setup is designed and optimized for providing chatmail accounts
for use by [Delta Chat apps](https://delta.chat).
1. prepare your local system:
Chatmail accounts are automatically created by a first login,
after which the initially specified password is required for using them.
scripts/init.sh
## Deploying your own chatmail server
2. set environment variable to the chatmail domain you want to setup:
We use `chat.example.org` as the chatmail domain in the following steps.
Please substitute it with your own domain.
export CHATMAIL_DOMAIN=c1.testrun.org # replace with your host
1. Install the `cmdeploy` command in a virtualenv
3. run the deploy of the chat mail instance:
```
git clone https://github.com/deltachat/chatmail
cd chatmail
scripts/initenv.sh
```
scripts/deploy.sh
2. Create chatmail configuration file `chatmail.ini`:
```
scripts/cmdeploy init chat.example.org # <-- use your domain
```
## Running tests and benchmarks (offline and online)
3. Setup first DNS records for your chatmail domain,
according to the hints provided by `cmdeploy init`.
Verify that SSH root login works:
1. Set `CHATMAIL_SSH` so that `ssh root@$CHATMAIL_SSH` allows
to login to the chatmail instance server.
```
ssh root@chat.example.org # <-- use your domain
```
2. To run local and online tests:
4. Deploy to the remote chatmail server:
scripts/test.sh
```
scripts/cmdeploy run
```
This script will also show you additional DNS records
which you should configure at your DNS provider
(it can take some time until they are public).
3. To run benchmarks against your chatmail instance:
### Other helpful commands:
scripts/bench.sh
## Running tests (offline and online)
To check the status of your remotely running chatmail service:
```
## Dovecot/Postfix configuration
scripts/cmdeploy status
```
### Ports
To check whether your DNS records are correct:
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
Dovecot listens on ports 143(imap) and 993 (imaps).
```
scripts/cmdeploy dns
```
## DNS
To test whether your chatmail service is working correctly:
For DKIM you must add a DNS entry as found in /etc/opendkim/selector.txt on your chatmail instance.
The above `scripts/deploy.sh` prints out the DKIM selector and DNS entry you
need to setup with your DNS provider.
```
scripts/cmdeploy test
```
## Emergency Commands
To measure the performance of your chatmail service:
```
scripts/cmdeploy bench
```
## Overview of this repository
This repository drives the development of chatmail services,
comprised of minimal setups of
- [postfix smtp server](https://www.postfix.org)
- [dovecot imap server](https://www.dovecot.org)
as well as custom services that are integrated with these two:
- `chatmaild/src/chatmaild/doveauth.py` implements
create-on-login account creation semantics and is used
by Dovecot during login authentication and by Postfix
which in turn uses [Dovecot SASL](https://doc.dovecot.org/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket)
to authenticate users
to send mails for them.
- `chatmaild/src/chatmaild/filtermail.py` prevents
unencrypted e-mail from leaving the chatmail service
and is integrated into postfix's outbound mail pipelines.
There is also the `cmdeploy/src/cmdeploy/cmdeploy.py` command line tool
which helps with setting up and managing the chatmail service.
`cmdeploy run` uses [pyinfra-based scripting](https://pyinfra.com/)
in `cmdeploy/src/cmdeploy/__init__.py`
to automatically install all chatmail components on a server.
### Home page and getting started for users
`cmdeploy run` also creates default static Web pages and deploys them
to a nginx web server with:
- a default `index.html` along with a QR code that users can click to
create accounts on your chatmail provider,
- a default `info.html` that is linked from the home page,
- a default `policy.html` that is linked from the home page.
All `.html` files are generated
by the according markdown `.md` file in the `www/src` directory.
### Refining the web pages
```
scripts/cmdeploy webdev
```
This starts a local live development cycle for chatmail Web pages:
- uses the `www/src/page-layout.html` file for producing static
HTML pages from `www/src/*.md` files
- continously builds the web presence reading files from `www/src` directory
and generating html files and copying assets to the `www/build` directory.
- Starts a browser window automatically where you can "refresh" as needed.
## Emergency Commands to disable automatic account creation
If you need to stop account creation,
e.g. because some script is wildly creating accounts,
just run `touch /tmp/nocreate`.
You can remove the file
as soon as the attacker was banned
by different means.
login to the server with ssh and run:
```
touch /etc/chatmail-nocreate
```
While this file is present, account creation will be blocked.
### Ports
[Postfix](http://www.postfix.org/) listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
[Dovecot](https://www.dovecot.org/) listens on ports 143 (imap) and 993 (imaps).
[nginx](https://www.nginx.com/) listens on port 443 (https).
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (http).
Delta Chat apps will, however, discover all ports and configurations
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail service.

4
chatmaild/MANIFEST.in Normal file
View File

@@ -0,0 +1,4 @@
include src/chatmaild/*.f
include src/chatmaild/ini/*.ini.f
include src/chatmaild/ini/*.ini
include src/chatmaild/tests/mail-data/*

View File

@@ -1,20 +1,37 @@
[build-system]
requires = ["setuptools>=45"]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "chatmaild"
version = "0.1"
version = "0.2"
dependencies = [
"aiosmtpd"
"aiosmtpd",
"iniconfig",
"deltachat-rpc-server",
"deltachat-rpc-client",
]
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ['src']
[project.scripts]
doveauth-dictproxy = "chatmaild.dictproxy:main"
doveauth = "chatmaild.doveauth:main"
filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
[tool.pytest.ini_options]
addopts = "-v -ra --strict-markers"
log_format = "%(asctime)s %(levelname)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
log_level = "INFO"
[tool.tox]
legacy_tox_ini = """
@@ -33,8 +50,7 @@ commands =
ruff src/
[testenv]
passenv = CHATMAIL_DOMAIN
deps = pytest
pdbpp
commands = pytest -v -rsXx {posargs: ../tests/chatmaild}
commands = pytest -v -rsXx {posargs}
"""

View File

@@ -0,0 +1,59 @@
import iniconfig
def read_config(inipath):
cfg = iniconfig.IniConfig(inipath)
return Config(inipath, params=cfg.sections["params"])
class Config:
def __init__(self, inipath, params):
self._inipath = inipath
self.mail_domain = params["mail_domain"]
self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
self.max_mailbox_size = params["max_mailbox_size"]
self.delete_mails_after = params["delete_mails_after"]
self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.postfix_reinject_port = int(params["postfix_reinject_port"])
self.privacy_postal = params.get("privacy_postal")
self.privacy_mail = params.get("privacy_mail")
self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.get("privacy_supervisor")
def _getbytefile(self):
return open(self._inipath, "rb")
def write_initial_config(inipath, mail_domain):
from importlib.resources import files
inidir = files(__package__).joinpath("ini")
content = (
inidir.joinpath("chatmail.ini.f").read_text().format(mail_domain=mail_domain)
)
if mail_domain.endswith(".testrun.org"):
override_inipath = inidir.joinpath("override-testrun.ini")
privacy = iniconfig.IniConfig(override_inipath)["privacy"]
lines = []
for line in content.split("\n"):
for key, value in privacy.items():
value_lines = value.strip().split("\n")
if not line.startswith(f"{key} =") or not value_lines:
continue
if len(value_lines) == 1:
lines.append(f"{key} = {value}")
else:
lines.append(f"{key} =")
for vl in value_lines:
lines.append(f" {vl}")
break
else:
lines.append(line)
content = "\n".join(lines)
inipath.write_text(content)

View File

@@ -33,13 +33,6 @@ class Connection:
def cursor(self):
return self._sqlconn.cursor()
def create_user(self, addr: str, password: str):
"""Create a row in the users table."""
self.execute("PRAGMA foreign_keys=on")
q = """INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)"""
self.execute(q, (addr, password, int(time.time())))
def get_user(self, addr: str) -> {}:
"""Get a row from the users table."""
q = "SELECT addr, password, last_login from users WHERE addr = ?"

View File

@@ -1,119 +0,0 @@
import logging
import os
import sys
import json
import crypt
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
ThreadingMixIn,
)
import pwd
from .database import Database
NOCREATE_FILE = "/etc/chatmail-nocreate"
def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
return "{SHA512-CRYPT}" + passhash
def create_user(db, user, password):
if os.path.exists(NOCREATE_FILE):
logging.warning(
f"Didn't create account: {NOCREATE_FILE} exists. Delete the file to enable account creation."
)
return
with db.write_transaction() as conn:
conn.create_user(user, password)
return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password)
def get_user_data(db, user):
with db.read_connection() as conn:
result = conn.get_user(user)
if result:
result["uid"] = "vmail"
result["gid"] = "vmail"
return result
def lookup_userdb(db, user):
return get_user_data(db, user)
def lookup_passdb(db, user, password):
userdata = get_user_data(db, user)
if not userdata:
return create_user(db, user, encrypt_password(password))
userdata["password"] = userdata["password"].strip()
return userdata
def handle_dovecot_request(msg, db, mail_domain):
print(f"received msg: {msg!r}", file=sys.stderr)
short_command = msg[0]
if short_command == "L": # LOOKUP
parts = msg[1:].split("\t")
keyname, user = parts[:2]
namespace, type, *args = keyname.split("/")
reply_command = "F"
res = ""
if namespace == "shared":
if type == "userdb":
if user.endswith(f"@{mail_domain}"):
res = lookup_userdb(db, user)
if res:
reply_command = "O"
else:
reply_command = "N"
elif type == "passdb":
if user.endswith(f"@{mail_domain}"):
res = lookup_passdb(db, user, password=args[0])
if res:
reply_command = "O"
else:
reply_command = "N"
print(f"res: {res!r}", file=sys.stderr)
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
return None
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
pass
def main():
socket = sys.argv[1]
passwd_entry = pwd.getpwnam(sys.argv[2])
db = Database(sys.argv[3])
with open("/etc/mailname", "r") as fp:
mail_domain = fp.read().strip()
class Handler(StreamRequestHandler):
def handle(self):
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, mail_domain)
if res:
print(f"sending result: {res!r}", file=sys.stderr)
self.wfile.write(res.encode("ascii"))
self.wfile.flush()
try:
os.unlink(socket)
except FileNotFoundError:
pass
with ThreadedUnixStreamServer(socket, Handler) as server:
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
try:
server.serve_forever()
except KeyboardInterrupt:
pass

View File

@@ -1,10 +0,0 @@
[Unit]
Description=Dict authentication proxy for dovecot
[Service]
ExecStart=/usr/local/bin/doveauth-dictproxy /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,200 @@
import logging
import os
import time
import sys
import json
import crypt
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
ThreadingMixIn,
)
import pwd
from .database import Database
from .config import read_config, Config
NOCREATE_FILE = "/etc/chatmail-nocreate"
def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
return "{SHA512-CRYPT}" + passhash
def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
"""Return True if user and password are admissable."""
if os.path.exists(NOCREATE_FILE):
logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.")
return False
if len(cleartext_password) < config.password_min_length:
logging.warning(
"Password needs to be at least %s characters long",
config.password_min_length,
)
return False
parts = user.split("@")
if len(parts) != 2:
logging.warning(f"user {user!r} is not a proper e-mail address")
return False
localpart, domain = parts
if (
len(localpart) > config.username_max_length
or len(localpart) < config.username_min_length
):
if localpart != "echo":
logging.warning(
"localpart %s has to be between %s and %s chars long",
localpart,
config.username_min_length,
config.username_max_length,
)
return False
return True
def get_user_data(db, user):
with db.read_connection() as conn:
result = conn.get_user(user)
if result:
result["uid"] = "vmail"
result["gid"] = "vmail"
return result
def lookup_userdb(db, user):
return get_user_data(db, user)
def lookup_passdb(db, config: Config, user, cleartext_password):
with db.write_transaction() as conn:
userdata = conn.get_user(user)
if userdata:
# Update last login time.
conn.execute(
"UPDATE users SET last_login=? WHERE addr=?", (int(time.time()), user)
)
userdata["uid"] = "vmail"
userdata["gid"] = "vmail"
return userdata
if not is_allowed_to_create(config, user, cleartext_password):
return
encrypted_password = encrypt_password(cleartext_password)
q = """INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)"""
conn.execute(q, (user, encrypted_password, int(time.time())))
return dict(
home=f"/home/vmail/mail/{config.mail_domain}/{user}",
uid="vmail",
gid="vmail",
password=encrypted_password,
)
def split_and_unescape(s):
"""Split strings using double quote as a separator and backslash as escape character
into parts."""
out = ""
i = 0
while i < len(s):
c = s[i]
if c == "\\":
# Skip escape character.
i += 1
# This will raise IndexError if there is no character
# after escape character. This is expected
# as this is an invalid input.
out += s[i]
elif c == '"':
# Separator
yield out
out = ""
else:
out += c
i += 1
yield out
def handle_dovecot_request(msg, db, config: Config):
short_command = msg[0]
if short_command == "L": # LOOKUP
parts = msg[1:].split("\t")
# Dovecot <2.3.17 has only one part,
# do not attempt to read any other parts for compatibility.
keyname = parts[0]
namespace, type, args = keyname.split("/", 2)
args = list(split_and_unescape(args))
reply_command = "F"
res = ""
if namespace == "shared":
if type == "userdb":
user = args[0]
if user.endswith(f"@{config.mail_domain}"):
res = lookup_userdb(db, user)
if res:
reply_command = "O"
else:
reply_command = "N"
elif type == "passdb":
user = args[1]
if user.endswith(f"@{config.mail_domain}"):
res = lookup_passdb(db, config, user, cleartext_password=args[0])
if res:
reply_command = "O"
else:
reply_command = "N"
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
return None
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
request_queue_size = 100
def main():
socket = sys.argv[1]
passwd_entry = pwd.getpwnam(sys.argv[2])
db = Database(sys.argv[3])
config = read_config(sys.argv[4])
class Handler(StreamRequestHandler):
def handle(self):
try:
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, config)
if res:
self.wfile.write(res.encode("ascii"))
self.wfile.flush()
else:
logging.warn("request had no answer: %r", msg)
except Exception:
logging.exception("Exception in the handler")
raise
try:
os.unlink(socket)
except FileNotFoundError:
pass
with ThreadedUnixStreamServer(socket, Handler) as server:
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
try:
server.serve_forever()
except KeyboardInterrupt:
pass

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Chatmail dict authentication proxy for dovecot
[Service]
ExecStart={execpath} /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite {config_path}
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Advanced echo bot example.
it will echo back any message that has non-empty text and also supports the /help command.
"""
import logging
import os
import sys
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
from chatmaild.newemail import create_newemail_dict
from chatmaild.config import read_config
hooks = events.HookCollection()
@hooks.on(events.RawEvent)
def log_event(event):
if event.kind == EventType.INFO:
logging.info(event.msg)
elif event.kind == EventType.WARNING:
logging.warning(event.msg)
@hooks.on(events.RawEvent(EventType.ERROR))
def log_error(event):
logging.error(event.msg)
@hooks.on(events.MemberListChanged)
def on_memberlist_changed(event):
logging.info(
"member %s was %s", event.member, "added" if event.member_added else "removed"
)
@hooks.on(events.GroupImageChanged)
def on_group_image_changed(event):
logging.info("group image %s", "deleted" if event.image_deleted else "changed")
@hooks.on(events.GroupNameChanged)
def on_group_name_changed(event):
logging.info("group name changed, old name: %s", event.old_name)
@hooks.on(events.NewMessage(func=lambda e: not e.command))
def echo(event):
snapshot = event.message_snapshot
if snapshot.text or snapshot.file:
snapshot.chat.send_message(text=snapshot.text, file=snapshot.file)
@hooks.on(events.NewMessage(command="/help"))
def help_command(event):
snapshot = event.message_snapshot
snapshot.chat.send_text("Send me any message and I will echo it back")
def main():
path = os.environ.get("PATH")
venv_path = sys.argv[0].strip("echobot")
os.environ["PATH"] = path + ":" + venv_path
with Rpc() as rpc:
deltachat = DeltaChat(rpc)
system_info = deltachat.get_system_info()
logging.info("Running deltachat core %s", system_info.deltachat_core_version)
accounts = deltachat.get_all_accounts()
account = accounts[0] if accounts else deltachat.add_account()
bot = Bot(account, hooks)
if not bot.is_configured():
config = read_config(sys.argv[1])
password = create_newemail_dict(config).get("password")
email = "echo@" + config.mail_domain
bot.configure(email, password)
bot.run_forever()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Chatmail echo bot for testing it works
[Service]
ExecStart={execpath} {config_path}
Environment="PATH={remote_venv_dir}:$PATH"
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -7,10 +7,11 @@ from email.parser import BytesParser
from email import policy
from email.utils import parseaddr
from aiosmtpd.smtp import SMTP
from aiosmtpd.controller import Controller
from smtplib import SMTP as SMTPClient
from .config import read_config
def check_encrypted(message):
"""Check that the message is an OpenPGP-encrypted message."""
@@ -62,19 +63,21 @@ def check_mdn(message, envelope):
return True
class SMTPController(Controller):
def factory(self):
return SMTP(self.handler, **self.SMTP_kwargs)
async def asyncmain_beforequeue(config):
port = config.filtermail_smtp_port
Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
class BeforeQueueHandler:
def __init__(self):
def __init__(self, config):
self.config = config
self.send_rate_limiter = SendRateLimiter()
async def handle_MAIL(self, server, session, envelope, address, mail_options):
logging.info(f"handle_MAIL from {address}")
envelope.mail_from = address
if not self.send_rate_limiter.is_sending_allowed(address):
max_sent = self.config.max_user_send_per_minute
if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
return f"450 4.7.1: Too much mail from {address}"
parts = envelope.mail_from.split("@")
@@ -85,62 +88,64 @@ class BeforeQueueHandler:
async def handle_DATA(self, server, session, envelope):
logging.info("handle_DATA before-queue")
error = check_DATA(envelope)
error = self.check_DATA(envelope)
if error:
return error
logging.info("re-injecting the mail that passed checks")
client = SMTPClient("localhost", "10025")
client = SMTPClient("localhost", self.config.postfix_reinject_port)
client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
return "250 OK"
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {envelope.mail_from}")
async def asyncmain_beforequeue(port):
Controller(BeforeQueueHandler(), hostname="127.0.0.1", port=port).start()
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
_, from_addr = parseaddr(message.get("from").strip())
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
def check_DATA(envelope):
"""the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {envelope.mail_from}")
if not mail_encrypted and check_mdn(message, envelope):
return
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
if envelope.mail_from in self.config.passthrough_senders:
return
_, from_addr = parseaddr(message.get("from").strip())
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
passthrough_recipients = self.config.passthrough_recipients
envelope_from_domain = from_addr.split("@").pop()
for recipient in envelope.rcpt_tos:
if envelope.mail_from == recipient:
# Always allow sending emails to self.
continue
if recipient in passthrough_recipients:
continue
res = recipient.split("@")
if len(res) != 2:
return f"500 Invalid address <{recipient}>"
_recipient_addr, recipient_domain = res
if not mail_encrypted and check_mdn(message, envelope):
return
envelope_from_domain = from_addr.split("@").pop()
for recipient in envelope.rcpt_tos:
if envelope.mail_from == recipient:
# Always allow sending emails to self.
continue
res = recipient.split("@")
if len(res) != 2:
return f"500 Invalid address <{recipient}>"
_recipient_addr, recipient_domain = res
is_outgoing = recipient_domain != envelope_from_domain
if is_outgoing and not mail_encrypted:
is_securejoin = message.get("secure-join") in ["vc-request", "vg-request"]
if not is_securejoin:
return f"500 Invalid unencrypted mail to <{recipient}>"
is_outgoing = recipient_domain != envelope_from_domain
if is_outgoing and not mail_encrypted:
is_securejoin = message.get("secure-join") in [
"vc-request",
"vg-request",
]
if not is_securejoin:
return f"500 Invalid unencrypted mail to <{recipient}>"
class SendRateLimiter:
MAX_USER_SEND_PER_MINUTE = 80
def __init__(self):
self.addr2timestamps = {}
def is_sending_allowed(self, mail_from):
def is_sending_allowed(self, mail_from, max_send_per_minute):
last = self.addr2timestamps.setdefault(mail_from, [])
now = time.time()
last[:] = [ts for ts in last if ts >= (now - 60)]
if len(last) <= self.MAX_USER_SEND_PER_MINUTE:
if len(last) <= max_send_per_minute:
last.append(now)
return True
return False
@@ -149,9 +154,10 @@ class SendRateLimiter:
def main():
args = sys.argv[1:]
assert len(args) == 1
logging.basicConfig(level=logging.INFO)
config = read_config(args[0])
logging.basicConfig(level=logging.WARN)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = asyncmain_beforequeue(port=int(args[0]))
task = asyncmain_beforequeue(config)
loop.create_task(task)
loop.run_forever()

View File

@@ -2,7 +2,7 @@
Description=Chatmail Postfix BeforeQeue filter
[Service]
ExecStart=/usr/local/bin/filtermail 10080
ExecStart={execpath} {config_path}
Restart=always
RestartSec=30

View File

@@ -0,0 +1,63 @@
[params]
# mail domain (MUST be set to fully qualified chat mail domain)
mail_domain = {mail_domain}
#
# If you only do private test deploys, you don't need to modify any settings below
#
#
# Account Restrictions
#
# how many mails a user can send out per minute
max_user_send_per_minute = 60
# maximum mailbox size of a chatmail account
max_mailbox_size = 100M
# days after which mails are unconditionally deleted
delete_mails_after = 40
# minimum length a username must have
username_min_length = 9
# maximum length a username can have
username_max_length = 9
# minimum length a password must have
password_min_length = 9
# list of chatmail accounts which can send outbound un-encrypted mail
passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails
passthrough_recipients =
#
# Deployment Details
#
# where the filtermail SMTP service listens
filtermail_smtp_port = 10080
# postfix accepts on the localhost reinject SMTP port
postfix_reinject_port = 10025
#
# Privacy Policy
#
# postal address of privacy contact
privacy_postal =
# email address of privacy contact
privacy_mail =
# postal address of the privacy data officer
privacy_pdo =
# postal address of the privacy supervisor
privacy_supervisor =

View File

@@ -0,0 +1,16 @@
[privacy]
passthrough_recipients = privacy@testrun.org
privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel,
Reichgrafen Str. 20, 79102 Freiburg, Germany
privacy_mail = privacy@testrun.org
privacy_pdo =
Prof. Dr. Fabian Schmieder, lexICT UG (limited), Ostfeldstr. 49, 30559 Hannover.
You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO)
privacy_supervisor =
State Commissioner for Data Protection and Freedom of Information of
Baden-Württemberg in 70173 Stuttgart, Germany.

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python3
from pathlib import Path
import time
import sys
def main(vmail_dir=None):
if vmail_dir is None:
vmail_dir = sys.argv[1]
accounts = 0
ci_accounts = 0
for path in Path(vmail_dir).iterdir():
accounts += 1
if path.name[:3] in ("ci-", "ac_"):
ci_accounts += 1
timestamp = int(time.time() * 1000)
print(f"accounts {accounts} {timestamp}")
print(f"ci_accounts {ci_accounts} {timestamp}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
#!/usr/local/lib/chatmaild/venv/bin/python3
""" CGI script for creating new accounts. """
import json
import random
import secrets
import string
from chatmaild.config import read_config, Config
CONFIG_PATH = "/usr/local/lib/chatmaild/chatmail.ini"
ALPHANUMERIC = string.ascii_lowercase + string.digits
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
def create_newemail_dict(config: Config):
user = "".join(random.choices(ALPHANUMERIC, k=config.username_min_length))
password = "".join(
secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3)
)
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
def print_new_account():
config = read_config(CONFIG_PATH)
creds = create_newemail_dict(config)
print("Content-Type: application/json")
print("")
print(json.dumps(creds))
if __name__ == "__main__":
print_new_account()

View File

@@ -7,7 +7,7 @@ Date: Sun, 15 Oct 2023 16:41:44 +0000
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr=foobar@c2.testrun.org; prefer-encrypt=mutual;
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
@@ -20,4 +20,4 @@ Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Hi!

View File

@@ -0,0 +1,68 @@
import random
import importlib.resources
import itertools
from email.parser import BytesParser
from email import policy
import pytest
from chatmaild.database import Database
from chatmaild.config import read_config, write_initial_config
@pytest.fixture
def make_config(tmp_path):
inipath = tmp_path.joinpath("chatmail.ini")
def make_conf(mail_domain):
write_initial_config(inipath, mail_domain=mail_domain)
return read_config(inipath)
return make_conf
@pytest.fixture
def example_config(make_config):
return make_config("chat.example.org")
@pytest.fixture
def maildomain(example_config):
return example_config.mail_domain
@pytest.fixture
def gencreds(maildomain):
count = itertools.count()
next(count)
def gen(domain=None):
domain = domain if domain else maildomain
while 1:
num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
user = "".join(random.choices(alphanumeric, k=10))
user = f"ac{num}_{user}"[:9]
password = "".join(random.choices(alphanumeric, k=12))
yield f"{user}@{domain}", f"{password}"
return lambda domain=None: next(gen(domain))
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
@pytest.fixture
def maildata(request):
datadir = importlib.resources.files(__package__).joinpath("mail-data")
assert datadir.exists(), datadir
def maildata(name, from_addr, to_addr):
data = datadir.joinpath(name).read_text()
text = data.format(from_addr=from_addr, to_addr=to_addr)
return BytesParser(policy=policy.default).parsebytes(text.encode())
return maildata

View File

@@ -0,0 +1,32 @@
from chatmaild.config import read_config
def test_read_config_basic(example_config):
assert example_config.mail_domain == "chat.example.org"
assert not example_config.privacy_supervisor and not example_config.privacy_mail
assert not example_config.privacy_pdo and not example_config.privacy_postal
inipath = example_config._inipath
inipath.write_text(inipath.read_text().replace("60", "37"))
example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 37
assert example_config.mail_domain == "chat.example.org"
def test_read_config_testrun(make_config):
config = make_config("something.testrun.org")
assert config.mail_domain == "something.testrun.org"
assert len(config.privacy_postal.split("\n")) > 1
assert len(config.privacy_supervisor.split("\n")) > 1
assert len(config.privacy_pdo.split("\n")) > 1
assert config.privacy_mail == "privacy@testrun.org"
assert config.filtermail_smtp_port == 10080
assert config.postfix_reinject_port == 10025
assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "100M"
assert config.delete_mails_after == "40"
assert config.username_min_length == 9
assert config.username_max_length == 9
assert config.password_min_length == 9
assert config.passthrough_recipients == ["privacy@testrun.org"]
assert config.passthrough_senders == []

View File

@@ -0,0 +1,99 @@
import json
import pytest
import threading
import queue
import traceback
import chatmaild.doveauth
from chatmaild.doveauth import get_user_data, lookup_passdb, handle_dovecot_request
from chatmaild.database import DBError
def test_basic(db, example_config):
lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue")
data = get_user_data(db, "asdf12345@chat.example.org")
assert data
data2 = lookup_passdb(
db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue"
)
assert data == data2
def test_dont_overwrite_password_on_wrong_login(db, example_config):
"""Test that logging in with a different password doesn't create a new user"""
res = lookup_passdb(
db, example_config, "newuser12@chat.example.org", "kajdlkajsldk12l3kj1983"
)
assert res["password"]
res2 = lookup_passdb(db, example_config, "newuser12@chat.example.org", "kajdslqwe")
# this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"]
def test_nocreate_file(db, monkeypatch, tmpdir, example_config):
p = tmpdir.join("nocreate")
p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
lookup_passdb(
db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik"
)
assert not get_user_data(db, "newuser12@chat.example.org")
def test_db_version(db):
assert db.get_schema_version() == 1
def test_too_high_db_version(db):
with db.write_transaction() as conn:
conn.execute("PRAGMA user_version=%s;" % (999,))
with pytest.raises(DBError):
db.ensure_tables()
def test_handle_dovecot_request(db, example_config):
# Test that password can contain ", ', \ and /
msg = (
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
"some42123@chat.example.org\tsome42123@chat.example.org"
)
res = handle_dovecot_request(msg, db, example_config)
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert (
userdata["home"]
== "/home/vmail/mail/chat.example.org/some42123@chat.example.org"
)
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
num_threads = 50
req_per_thread = 5
results = queue.Queue()
def lookup(db):
for i in range(req_per_thread):
addr, password = gencreds()
try:
lookup_passdb(db, example_config, addr, password)
except Exception:
results.put(traceback.format_exc())
else:
results.put(None)
threads = []
for i in range(num_threads):
thread = threading.Thread(target=lookup, args=(db,), daemon=True)
threads.append(thread)
print(f"created {num_threads} threads, starting them and waiting for results")
for thread in threads:
thread.start()
for i in range(num_threads * req_per_thread):
res = results.get()
if res is not None:
pytest.fail(f"concurrent lookup failed\n{res}")

View File

@@ -0,0 +1,145 @@
from chatmaild.filtermail import (
check_encrypted,
BeforeQueueHandler,
SendRateLimiter,
check_mdn,
)
import pytest
@pytest.fixture
def maildomain():
# let's not depend on a real chatmail instance for the offline tests below
return "chatmail.example.org"
@pytest.fixture
def handler(make_config, maildomain):
config = make_config(maildomain)
return BeforeQueueHandler(config)
def test_reject_forged_from(maildata, gencreds, handler):
class env:
mail_from = gencreds()[0]
rcpt_tos = [gencreds()[0]]
# test that the filter lets good mail through
to_addr = gencreds()[0]
env.content = maildata(
"plain.eml", from_addr=env.mail_from, to_addr=to_addr
).as_bytes()
assert not handler.check_DATA(envelope=env)
# test that the filter rejects forged mail
env.content = maildata(
"plain.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr
).as_bytes()
error = handler.check_DATA(envelope=env)
assert "500" in error
def test_filtermail_no_encryption_detection(maildata):
msg = maildata(
"plain.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert not check_encrypted(msg)
# https://xkcd.com/1181/
msg = maildata(
"fake-encrypted.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert not check_encrypted(msg)
def test_filtermail_encryption_detection(maildata):
msg = maildata("encrypted.eml", from_addr="1@example.org", to_addr="2@example.org")
assert check_encrypted(msg)
# if the subject is not "..." it is not considered ac-encrypted
msg.replace_header("Subject", "Click this link")
assert not check_encrypted(msg)
def test_filtermail_is_mdn(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
msg = maildata("mdn.eml", from_addr, to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert check_mdn(msg, env)
print(msg.as_string())
assert not handler.check_DATA(env)
def test_filtermail_to_multiple_recipients_no_mdn(maildata, gencreds):
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
thirdaddr = gencreds()[0]
msg = maildata("mdn.eml", from_addr, to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr, thirdaddr]
content = msg.as_bytes()
assert not check_mdn(msg, env)
def test_send_rate_limiter():
limiter = SendRateLimiter()
for i in range(100):
if limiter.is_sending_allowed("some@example.org", 10):
if i <= 10:
continue
pytest.fail("limiter didn't work")
else:
assert i == 11
break
def test_excempt_privacy(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = "privacy@testrun.org"
handler.config.passthrough_recipients = [to_addr]
false_to = "privacy@something.org"
msg = maildata("plain.eml", from_addr, to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
class env2:
mail_from = from_addr
rcpt_tos = [to_addr, false_to]
content = msg.as_bytes()
assert "500" in handler.check_DATA(envelope=env2)
def test_passthrough_senders(gencreds, handler, maildata):
acc1 = gencreds()[0]
to_addr = "recipient@something.org"
handler.config.passthrough_senders = [acc1]
msg = maildata("plain.eml", acc1, to_addr)
class env:
mail_from = acc1
rcpt_tos = to_addr
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)

View File

@@ -0,0 +1,16 @@
from chatmaild.metrics import main
def test_main(tmp_path, capsys):
for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"):
tmp_path.joinpath(x).mkdir()
main(tmp_path)
out, _ = capsys.readouterr()
d = {}
for line in out.split("\n"):
if line.strip():
name, num, _ = line.split()
d[name] = int(num)
assert d["accounts"] == 4
assert d["ci_accounts"] == 3

View File

@@ -0,0 +1,27 @@
import json
import chatmaild
from chatmaild.newemail import create_newemail_dict, print_new_account
def test_create_newemail_dict(example_config):
ac1 = create_newemail_dict(example_config)
assert "@" in ac1["email"]
assert len(ac1["password"]) >= 10
ac2 = create_newemail_dict(example_config)
assert ac1["email"] != ac2["email"]
assert ac1["password"] != ac2["password"]
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account()
out, err = capsys.readouterr()
lines = out.split("\n")
assert lines[0] == "Content-Type: application/json"
assert not lines[1]
dic = json.loads(lines[2])
assert dic["email"].endswith(f"@{example_config.mail_domain}")
assert len(dic["password"]) >= 10

32
cmdeploy/pyproject.toml Normal file
View File

@@ -0,0 +1,32 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "cmdeploy"
version = "0.2"
dependencies = [
"pyinfra",
"pillow",
"qrcode",
"markdown",
"pytest",
"setuptools>=68",
"termcolor",
"build",
"tox",
"ruff",
"black",
"pytest",
"pytest-xdist",
]
[project.scripts]
cmdeploy = "cmdeploy.cmdeploy:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
"cmdeploy.testplugin" = "cmdeploy.tests.plugin"
[tool.pytest.ini_options]
addopts = "-v -ra --strict-markers"

View File

@@ -0,0 +1,559 @@
"""
Chat Mail pyinfra deploy.
"""
import sys
import importlib.resources
import subprocess
import shutil
import io
from pathlib import Path
from pyinfra import host
from pyinfra.operations import apt, files, server, systemd, pip
from pyinfra.facts.files import File
from pyinfra.facts.systemd import SystemdEnabled
from .acmetool import deploy_acmetool
from chatmaild.config import read_config, Config
def _build_chatmaild(dist_dir) -> None:
dist_dir = Path(dist_dir).resolve()
if dist_dir.exists():
shutil.rmtree(dist_dir)
dist_dir.mkdir()
subprocess.check_output(
[sys.executable, "-m", "build", "-n"]
+ ["--sdist", "chatmaild", "--outdir", str(dist_dir)]
)
entries = list(dist_dir.iterdir())
assert len(entries) == 1
return entries[0]
def remove_legacy_artifacts():
# disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service(
name="Disable legacy doveauth-dictproxy.service",
service="doveauth-dictproxy.service",
running=False,
enabled=False,
)
def _install_remote_venv_with_chatmaild(config) -> None:
remove_legacy_artifacts()
dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist"))
remote_base_dir = "/usr/local/lib/chatmaild"
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
remote_venv_dir = f"{remote_base_dir}/venv"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
apt.packages(
name="apt install python3-virtualenv",
packages=["python3-virtualenv"],
)
files.put(
name="Upload chatmaild source package",
src=dist_file.open("rb"),
dest=remote_dist_file,
create_remote_dir=True,
**root_owned,
)
files.put(
name=f"Upload {remote_chatmail_inipath}",
src=config._getbytefile(),
dest=remote_chatmail_inipath,
**root_owned,
)
pip.virtualenv(
name=f"chatmaild virtualenv {remote_venv_dir}",
path=remote_venv_dir,
always_copy=True,
)
server.shell(
name=f"forced pip-install {dist_file.name}",
commands=[
f"{remote_venv_dir}/bin/pip install --force-reinstall {remote_dist_file}"
],
)
files.template(
src=importlib.resources.files(__package__).joinpath("metrics.cron.j2"),
dest="/etc/cron.d/chatmail-metrics",
user="root",
group="root",
mode="644",
config={
"mail_domain": config.mail_domain,
"execpath": f"{remote_venv_dir}/bin/chatmail-metrics",
},
)
# install systemd units
for fn in (
"doveauth",
"filtermail",
"echobot",
):
params = dict(
execpath=f"{remote_venv_dir}/bin/{fn}",
config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir,
)
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
content = source_path.read_text().format(**params).encode()
files.put(
name=f"Upload {fn}.service",
src=io.BytesIO(content),
dest=f"/etc/systemd/system/{fn}.service",
**root_owned,
)
systemd.service(
name=f"Setup {fn} service",
service=f"{fn}.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
)
def _install_mta_sts_daemon() -> bool:
need_restart = False
config = files.put(
name="upload postfix-mta-sts-resolver config",
src=importlib.resources.files(__package__).joinpath(
"postfix/mta-sts-daemon.yml"
),
dest="/etc/mta-sts-daemon.yml",
user="root",
group="root",
mode="644",
)
need_restart |= config.changed
server.shell(
name="install postfix-mta-sts-resolver with pip",
commands=[
"python3 -m virtualenv /usr/local/lib/postfix-mta-sts-resolver",
"/usr/local/lib/postfix-mta-sts-resolver/bin/pip install postfix-mta-sts-resolver",
],
)
systemd_unit = files.put(
name="upload mta-sts-daemon systemd unit",
src=importlib.resources.files(__package__).joinpath(
"postfix/mta-sts-daemon.service"
),
dest="/etc/systemd/system/mta-sts-daemon.service",
user="root",
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
return need_restart
def _configure_postfix(config: Config, debug: bool = False) -> bool:
"""Configures Postfix SMTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/main.cf.j2"),
dest="/etc/postfix/main.cf",
user="root",
group="root",
mode="644",
config=config,
)
need_restart |= main_config.changed
master_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/master.cf.j2"),
dest="/etc/postfix/master.cf",
user="root",
group="root",
mode="644",
debug=debug,
config=config,
)
need_restart |= master_config.changed
header_cleanup = files.put(
src=importlib.resources.files(__package__).joinpath(
"postfix/submission_header_cleanup"
),
dest="/etc/postfix/submission_header_cleanup",
user="root",
group="root",
mode="644",
)
need_restart |= header_cleanup.changed
return need_restart
def _configure_dovecot(config: Config, debug: bool = False) -> bool:
"""Configures Dovecot IMAP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
user="root",
group="root",
mode="644",
config=config,
debug=debug,
)
need_restart |= main_config.changed
auth_config = files.put(
src=importlib.resources.files(__package__).joinpath("dovecot/auth.conf"),
dest="/etc/dovecot/auth.conf",
user="root",
group="root",
mode="644",
)
need_restart |= auth_config.changed
files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/expunge.cron.j2"),
dest="/etc/cron.d/expunge",
user="root",
group="root",
mode="644",
config=config,
)
# as per https://doc.dovecot.org/configuration_manual/os/
# it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
server.sysctl(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)
return need_restart
def _configure_nginx(domain: str, debug: bool = False) -> bool:
"""Configures nginx HTTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("nginx/nginx.conf.j2"),
dest="/etc/nginx/nginx.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= main_config.changed
autoconfig = files.template(
src=importlib.resources.files(__package__).joinpath("nginx/autoconfig.xml.j2"),
dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= autoconfig.changed
mta_sts_config = files.template(
src=importlib.resources.files(__package__).joinpath("nginx/mta-sts.txt.j2"),
dest="/var/www/html/.well-known/mta-sts.txt",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= mta_sts_config.changed
# install CGI newemail script
#
cgi_dir = "/usr/lib/cgi-bin"
files.directory(
name=f"Ensure {cgi_dir} exists",
path=cgi_dir,
user="root",
group="root",
)
files.put(
name="Upload cgi newemail.py script",
src=importlib.resources.files("chatmaild").joinpath("newemail.py").open("rb"),
dest=f"{cgi_dir}/newemail.py",
user="root",
group="root",
mode="755",
)
return need_restart
def remove_opendkim() -> None:
"""Remove OpenDKIM, deprecated"""
files.file(
name="Remove legacy opendkim.conf",
path="/etc/opendkim.conf",
present=False,
)
files.directory(
name="Remove legacy opendkim socket directory from /var/spool/postfix",
path="/var/spool/postfix/opendkim",
present=False,
)
apt.packages(name="Remove openDKIM", packages="opendkim", present=False)
def _configure_rspamd(dkim_selector: str, mail_domain: str) -> bool:
"""Configures rspamd for Rate Limiting."""
need_restart = False
apt.packages(
name="apt install rspamd",
packages="rspamd",
)
for module in ["phishing", "rbl", "hfilter", "ratelimit"]:
disabled_module_conf = files.put(
name=f"disable {module} rspamd plugin",
src=importlib.resources.files(__package__).joinpath("rspamd/disabled.conf"),
dest=f"/etc/rspamd/local.d/{module}.conf",
user="root",
group="root",
mode="644",
)
need_restart |= disabled_module_conf.changed
options_inc = files.put(
name="disable fuzzy checks",
src=importlib.resources.files(__package__).joinpath("rspamd/options.inc"),
dest="/etc/rspamd/local.d/options.inc",
user="root",
group="root",
mode="644",
)
need_restart |= options_inc.changed
# https://rspamd.com/doc/modules/force_actions.html
force_actions_conf = files.put(
name="Set up rules to reject on DKIM, SPF and DMARC fails",
src=importlib.resources.files(__package__).joinpath(
"rspamd/force_actions.conf"
),
dest="/etc/rspamd/local.d/force_actions.conf",
user="root",
group="root",
mode="644",
)
need_restart |= force_actions_conf.changed
dkim_directory = "/var/lib/rspamd/dkim/"
dkim_key_path = f"{dkim_directory}{mail_domain}.{dkim_selector}.key"
dkim_dns_file = f"{dkim_directory}{mail_domain}.{dkim_selector}.zone"
dkim_config = files.template(
src=importlib.resources.files(__package__).joinpath(
"rspamd/dkim_signing.conf.j2"
),
dest="/etc/rspamd/local.d/dkim_signing.conf",
user="root",
group="root",
mode="644",
config={
"dkim_selector": str(dkim_selector),
"mail_domain": mail_domain,
"dkim_key_path": dkim_key_path,
},
)
need_restart |= dkim_config.changed
files.directory(
name="ensure DKIM key directory exists",
path=dkim_directory,
present=True,
user="_rspamd",
group="_rspamd",
)
if not host.get_fact(File, dkim_key_path):
server.shell(
name="Generate DKIM domain keys with rspamd",
commands=[
f"rspamadm dkim_keygen -b 2048 -s {dkim_selector} -d {mail_domain} -k {dkim_key_path} > {dkim_dns_file}"
],
_sudo=True,
_sudo_user="_rspamd",
)
return need_restart
def check_config(config):
mail_domain = config.mail_domain
if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"):
blocked_words = "merlinux schmieder testrun.org".split()
for value in config.__dict__.values():
if any(x in str(value) for x in blocked_words):
raise ValueError(
f"please set your own privacy contacts/addresses in {config._inipath}"
)
return config
def deploy_chatmail(config_path: Path) -> None:
"""Deploy a chat-mail instance.
:param config_path: path to chatmail.ini
"""
config = read_config(config_path)
check_config(config)
mail_domain = config.mail_domain
from .www import build_webpages
apt.update(name="apt update", cache_time=24 * 3600)
server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
# Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf
# to use 127.0.0.1 as the resolver.
apt.packages(
name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"],
)
server.shell(
name="Generate root keys for validating DNSSEC",
commands=["unbound-anchor -a /var/lib/unbound/root.key || true"],
)
systemd.service(
name="Start and enable unbound",
service="unbound.service",
running=True,
enabled=True,
)
# Deploy acmetool to have TLS certificates.
deploy_acmetool(
nginx_hook=True,
domains=[mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"],
)
apt.packages(
name="Install Postfix",
packages="postfix",
)
apt.packages(
name="Install Dovecot",
packages=["dovecot-imapd", "dovecot-lmtpd"],
)
apt.packages(
name="Install nginx",
packages=["nginx"],
)
apt.packages(
name="Install fcgiwrap",
packages=["fcgiwrap"],
)
www_path = importlib.resources.files(__package__).joinpath("../../../www").resolve()
build_dir = www_path.joinpath("build")
src_dir = www_path.joinpath("src")
build_webpages(src_dir, build_dir, config)
files.rsync(f"{build_dir}/", "/var/www/html", flags=["-avz"])
_install_remote_venv_with_chatmaild(config)
debug = False
dovecot_need_restart = _configure_dovecot(config, debug=debug)
postfix_need_restart = _configure_postfix(config, debug=debug)
mta_sts_need_restart = _install_mta_sts_daemon()
nginx_need_restart = _configure_nginx(mail_domain)
remove_opendkim()
rspamd_need_restart = _configure_rspamd("dkim", mail_domain)
systemd.service(
name="Start and enable rspamd",
service="rspamd.service",
running=True,
enabled=True,
restarted=rspamd_need_restart,
)
systemd.service(
name="Start and enable MTA-STS daemon",
service="mta-sts-daemon.service",
daemon_reload=True,
running=True,
enabled=True,
restarted=mta_sts_need_restart,
)
systemd.service(
name="Start and enable Postfix",
service="postfix.service",
running=True,
enabled=True,
restarted=postfix_need_restart,
)
systemd.service(
name="Start and enable Dovecot",
service="dovecot.service",
running=True,
enabled=True,
restarted=dovecot_need_restart,
)
systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=nginx_need_restart,
)
# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName
server.shell(
name="Setup /etc/mailname",
commands=[f"echo {mail_domain} >/etc/mailname; chmod 644 /etc/mailname"],
)
journald_conf = files.put(
name="Configure journald",
src=importlib.resources.files(__package__).joinpath("journald.conf"),
dest="/etc/systemd/journald.conf",
user="root",
group="root",
mode="644",
)
systemd.service(
name="Start and enable journald",
service="systemd-journald.service",
running=True,
enabled=True,
restarted=journald_conf,
)

View File

@@ -1,6 +1,8 @@
import importlib.resources
from pyinfra.operations import apt, files, server
from pyinfra.operations import apt, files, systemd, server
from pyinfra import host
from pyinfra.facts.systemd import SystemdStatus
def deploy_acmetool(nginx_hook=False, email="", domains=[]):
@@ -46,8 +48,31 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
mode="644",
)
for domain in domains:
server.shell(
name=f"Request certificate for {domain}",
commands=[f"acmetool want {domain}"],
service_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-redirector.service"
),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
group="root",
mode="644",
)
if host.get_fact(SystemdStatus).get("nginx.service"):
systemd.service(
name="Stop nginx service to free port 80",
service="nginx",
running=False,
)
systemd.service(
name="Setup acmetool-redirector service",
service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=service_file.changed,
)
server.shell(
name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want { ' '.join(domains)}"],
)

View File

@@ -0,0 +1,11 @@
[Unit]
Description=acmetool HTTP redirector
[Service]
Type=notify
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,15 @@
{chatmail_domain}. A {ipv4}
{chatmail_domain}. AAAA {ipv6}
{chatmail_domain}. MX 10 {chatmail_domain}.
_submission._tcp.{chatmail_domain}. SRV 0 1 587 {chatmail_domain}.
_submissions._tcp.{chatmail_domain}. SRV 0 1 465 {chatmail_domain}.
_imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}.
_imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}.
{chatmail_domain}. CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}"
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all"
_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;rua=mailto:{email};ruf=mailto:{email};fo=1;adkim=s;aspf=s"
_mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}"
mta-sts.{chatmail_domain}. CNAME {chatmail_domain}.
www.{chatmail_domain}. CNAME {chatmail_domain}.
_smtp._tls.{chatmail_domain}. TXT "v=TLSRPTv1;rua=mailto:{email}"
{dkim_entry}

View File

@@ -0,0 +1,314 @@
"""
Provides the `cmdeploy` entry point function,
along with command line option and subcommand parsing.
"""
import argparse
import shutil
import subprocess
import importlib.resources
import importlib.util
import os
import sys
from pathlib import Path
from termcolor import colored
from chatmaild.config import read_config, write_initial_config
from cmdeploy.dns import show_dns, check_necessary_dns
#
# cmdeploy sub commands and options
#
def init_cmd_options(parser):
parser.add_argument(
"chatmail_domain",
action="store",
help="fully qualified DNS domain name for your chatmail instance",
)
def init_cmd(args, out):
"""Initialize chatmail config file."""
mail_domain = args.chatmail_domain
if args.inipath.exists():
print(f"Path exists, not modifying: {args.inipath}")
else:
write_initial_config(args.inipath, mail_domain)
out.green(f"created config file for {mail_domain} in {args.inipath}")
check_necessary_dns(
out,
mail_domain,
)
def run_cmd_options(parser):
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
help="don't actually modify the server",
)
def run_cmd(args, out):
"""Deploy chatmail services on the remote server."""
mail_domain = args.config.mail_domain
if not check_necessary_dns(
out,
mail_domain,
):
sys.exit(1)
env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {args.config.mail_domain} {deploy_path}"
out.check_call(cmd, env=env)
print("Deploy completed, call `cmdeploy dns` next.")
def dns_cmd_options(parser):
parser.add_argument(
"--zonefile",
dest="zonefile",
help="print the whole zonefile for deploying directly",
)
def dns_cmd(args, out):
"""Generate dns zone file."""
show_dns(args, out)
def status_cmd(args, out):
"""Display status for online chatmail instance."""
ssh = f"ssh root@{args.config.mail_domain}"
out.green(f"chatmail domain: {args.config.mail_domain}")
if args.config.privacy_mail:
out.green("privacy settings: present")
else:
out.red("no privacy settings")
s1 = "systemctl --type=service --state=running"
for line in out.shell_output(f"{ssh} -- {s1}").split("\n"):
if line.startswith(" "):
print(line)
def test_cmd_options(parser):
parser.add_argument(
"--slow",
dest="slow",
action="store_true",
help="also run slow tests",
)
def test_cmd(args, out):
"""Run local and online tests for chatmail deployment.
This will automatically pip-install 'deltachat' if it's not available.
"""
x = importlib.util.find_spec("deltachat")
if x is None:
out.check_call(f"{sys.executable} -m pip install deltachat")
pytest_path = shutil.which("pytest")
pytest_args = [
pytest_path,
"cmdeploy/src/",
"-n4",
"-rs",
"-x",
"-vrx",
"--durations=5",
]
if args.slow:
pytest_args.append("--slow")
ret = out.run_ret(pytest_args)
return ret
def fmt_cmd_options(parser):
parser.add_argument(
"--verbose",
"-v",
dest="verbose",
action="store_true",
help="provide information on invocations",
)
parser.add_argument(
"--check",
"-c",
action="store_true",
help="only check but don't fix problems",
)
def fmt_cmd(args, out):
"""Run formattting fixes (ruff and black) on all chatmail source code."""
sources = [str(importlib.resources.files(x)) for x in ("chatmaild", "cmdeploy")]
black_args = [shutil.which("black")]
ruff_args = [shutil.which("ruff")]
if args.check:
black_args.append("--check")
else:
ruff_args.append("--fix")
if not args.verbose:
black_args.append("-q")
ruff_args.append("-q")
black_args.extend(sources)
ruff_args.extend(sources)
out.check_call(" ".join(black_args), quiet=not args.verbose)
out.check_call(" ".join(ruff_args), quiet=not args.verbose)
return 0
def bench_cmd(args, out):
"""Run benchmarks against an online chatmail instance."""
args = ["pytest", "--pyargs", "cmdeploy.tests.online.benchmark", "-vrx"]
cmdstring = " ".join(args)
out.green(f"[$ {cmdstring}]")
subprocess.check_call(args)
def webdev_cmd(args, out):
"""Run local web development loop for static web pages."""
from .www import main
main()
#
# Parsing command line options and starting commands
#
class Out:
"""Convenience output printer providing coloring."""
def red(self, msg, file=sys.stderr):
print(colored(msg, "red"), file=file)
def green(self, msg, file=sys.stderr):
print(colored(msg, "green"), file=file)
def __call__(self, msg, red=False, green=False, file=sys.stdout):
color = "red" if red else ("green" if green else None)
print(colored(msg, color), file=file)
def shell_output(self, arg, no_print=False, timeout=10):
if not no_print:
self(f"[$ {arg}]", file=sys.stderr)
output = subprocess.STDOUT
else:
output = subprocess.DEVNULL
return subprocess.check_output(
arg, shell=True, timeout=timeout, stderr=output
).decode()
def check_call(self, arg, env=None, quiet=False):
if not quiet:
self(f"[$ {arg}]", file=sys.stderr)
return subprocess.check_call(arg, shell=True, env=env)
def run_ret(self, args, env=None, quiet=False):
if not quiet:
cmdstring = " ".join(args)
self(f"[$ {cmdstring}]", file=sys.stderr)
proc = subprocess.run(args, env=env)
return proc.returncode
def add_config_option(parser):
parser.add_argument(
"--config",
dest="inipath",
action="store",
default=Path("chatmail.ini"),
type=Path,
help="path to the chatmail.ini file",
)
def add_subcommand(subparsers, func):
name = func.__name__
assert name.endswith("_cmd")
name = name[:-4]
doc = func.__doc__.strip()
help = doc.split("\n")[0].strip(".")
p = subparsers.add_parser(name, description=doc, help=help)
p.set_defaults(func=func)
add_config_option(p)
return p
description = """
Setup your chatmail server configuration and
deploy it via SSH to your remote location.
"""
def get_parser():
"""Return an ArgumentParser for the 'cmdeploy' CLI"""
parser = argparse.ArgumentParser(description=description.strip())
subparsers = parser.add_subparsers(title="subcommands")
# find all subcommands in the module namespace
glob = globals()
for name, func in glob.items():
if name.endswith("_cmd"):
subparser = add_subcommand(subparsers, func)
addopts = glob.get(name + "_options")
if addopts is not None:
addopts(subparser)
return parser
def main(args=None):
"""Provide main entry point for 'xdcget' CLI invocation."""
parser = get_parser()
args = parser.parse_args(args=args)
if not hasattr(args, "func"):
return parser.parse_args(["-h"])
out = Out()
kwargs = {}
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
if not args.inipath.exists():
out.red(f"expecting {args.inipath} to exist, run init first?")
raise SystemExit(1)
try:
args.config = read_config(args.inipath)
except Exception as ex:
out.red(ex)
raise SystemExit(1)
try:
res = args.func(args, out, **kwargs)
if res is None:
res = 0
return res
except KeyboardInterrupt:
out.red("KeyboardInterrupt")
sys.exit(130)
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

View File

@@ -0,0 +1,17 @@
import os
import importlib.resources
import pyinfra
from cmdeploy import deploy_chatmail
def main():
config_path = os.getenv(
"CHATMAIL_INI",
importlib.resources.files("cmdeploy").joinpath("../../../chatmail.ini"),
)
deploy_chatmail(config_path)
if pyinfra.is_cli:
main()

View File

@@ -0,0 +1,209 @@
import sys
import requests
import importlib
import subprocess
import datetime
class DNS:
def __init__(self, out, mail_domain):
self.session = requests.Session()
self.out = out
self.ssh = f"ssh root@{mail_domain} -- "
try:
self.shell(f"unbound-control flush_zone {mail_domain}")
except subprocess.CalledProcessError:
pass
def shell(self, cmd):
try:
return self.out.shell_output(f"{self.ssh}{cmd}", no_print=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
if "exit status 255" in str(e) or "timed out" in str(e):
self.out.red(f"Error: can't reach the server with: {self.ssh[:-4]}")
sys.exit(1)
else:
raise
def get_ipv4(self):
cmd = "ip a | grep 'inet ' | grep 'scope global' | grep -oE '[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}' | head -1"
return self.shell(cmd).strip()
def get_ipv6(self):
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
return self.shell(cmd).strip()
def get(self, typ: str, domain: str) -> str | None:
"""Get a DNS entry"""
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
line = dig_result.partition("\n")[0]
if line:
return line
def check_ptr_record(self, ip: str, mail_domain) -> bool:
"""Check the PTR record for an IPv4 or IPv6 address."""
result = self.shell(f"dig -r -x {ip} +short").rstrip()
return result == f"{mail_domain}."
def show_dns(args, out):
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
mail_domain = args.config.mail_domain
ssh = f"ssh root@{mail_domain}"
dns = DNS(out, mail_domain)
def read_dkim_entries(entry):
lines = []
for line in entry.split("\n"):
if line.startswith(";") or not line.strip():
continue
line = line.replace("\t", " ")
lines.append(line)
lines[0] = f"dkim._domainkey.{mail_domain}. IN TXT " + lines[0].strip(
"dkim._domainkey IN TXT "
)
return "\n".join(lines)
print("Checking your DKIM keys and DNS entries...")
try:
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
except subprocess.CalledProcessError:
print("Please run `cmdeploy run` first.")
return
dkim_entry = read_dkim_entries(
out.shell_output(f"{ssh} -- cat /var/lib/rspamd/dkim/{mail_domain}.dkim.zone")
)
ipv6 = dns.get_ipv6()
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
ipv4 = dns.get_ipv4()
reverse_ipv4 = dns.check_ptr_record(ipv4, mail_domain)
to_print = []
with open(template, "r") as f:
zonefile = (
f.read()
.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
ipv6=ipv6,
ipv4=ipv4,
)
.strip()
)
try:
with open(args.zonefile, "w+") as zf:
zf.write(zonefile)
print(f"DNS records successfully written to: {args.zonefile}")
return
except TypeError:
pass
started_dkim_parsing = False
for line in zonefile.splitlines():
line = line.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
ipv6=ipv6,
).strip()
for typ in ["A", "AAAA", "CNAME", "CAA"]:
if f" {typ} " in line:
domain, value = line.split(f" {typ} ")
current = dns.get(typ, domain.strip()[:-1])
if current != value.strip():
to_print.append(line)
if " MX " in line:
domain, typ, prio, value = line.split()
current = dns.get(typ, domain[:-1])
if not current:
to_print.append(line)
elif current.split()[1] != value:
print(line.replace(prio, str(int(current[0]) + 1)))
if " SRV " in line:
domain, typ, prio, weight, port, value = line.split()
current = dns.get("SRV", domain[:-1])
if current != f"{prio} {weight} {port} {value}":
to_print.append(line)
if " TXT " in line:
domain, value = line.split(" TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if domain.startswith("_mta-sts."):
if current:
if current.split("id=")[0] == value.split("id=")[0]:
continue
if current != value:
to_print.append(line)
if " IN TXT ( " in line:
started_dkim_parsing = True
dkim_lines = [line]
if started_dkim_parsing and line.startswith('"'):
dkim_lines.append(" " + line)
domain, data = "\n".join(dkim_lines).split(" IN TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if current:
current = "( %s" % (current.replace('" "', '"\n "'))
if current != data:
to_print.append(dkim_entry)
else:
to_print.append(dkim_entry)
if to_print:
to_print.insert(
0, "You should configure the following DNS entries at your provider:\n"
)
to_print.append(
"\nIf you already configured the DNS entries, wait a bit until the DNS entries propagate to the Internet."
)
print("\n".join(to_print))
else:
out.green("Great! All your DNS entries are correct.")
to_print = []
if not reverse_ipv4:
to_print.append(f"\tIPv4:\t{ipv4}\t{args.config.mail_domain}")
if not reverse_ipv6:
to_print.append(f"\tIPv6:\t{ipv6}\t{args.config.mail_domain}")
if len(to_print) > 0:
if len(to_print) == 1:
warning = "You should add the following PTR/reverse DNS entry:"
else:
warning = "You should add the following PTR/reverse DNS entries:"
out.red(warning)
for entry in to_print:
print(entry)
print(
"You can do so at your hosting provider (maybe this isn't your DNS provider)."
)
def check_necessary_dns(out, mail_domain):
"""Check whether $mail_domain and mta-sts.$mail_domain resolve."""
dns = DNS(out, mail_domain)
ipv4 = dns.get("A", mail_domain)
ipv6 = dns.get("AAAA", mail_domain)
mta_entry = dns.get("CNAME", "mta-sts." + mail_domain)
www_entry = dns.get("CNAME", "www." + mail_domain)
to_print = []
if not (ipv4 or ipv6):
to_print.append(f"\t{mail_domain}.\t\t\tA<your server's IPv4 address>")
if mta_entry != mail_domain + ".":
to_print.append(f"\tmta-sts.{mail_domain}.\tCNAME\t{mail_domain}.")
if www_entry != mail_domain + ".":
to_print.append(f"\twww.{mail_domain}.\tCNAME\t{mail_domain}.")
if to_print:
to_print.insert(
0,
"\nFor chatmail to work, you need to configure this at your DNS provider:\n",
)
for line in to_print:
print(line)
print()
else:
dns.out.green("\nAll necessary DNS entries seem to be set.")
return True

View File

@@ -0,0 +1,10 @@
uri = proxy:/run/dovecot/doveauth.socket:auth
iterate_disable = yes
default_pass_scheme = plain
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
# See <https://doc.dovecot.org/configuration_manual/config_file/config_variables/#modifiers>
# for documentation.
#
# We escape user-provided input and use double quote as a separator.
password_key = passdb/%Ew"%Eu
user_key = userdb/%Eu

View File

@@ -19,7 +19,7 @@ mail_plugins = quota
# these are the capabilities Delta Chat cares about actually
# so let's keep the network overhead per login small
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY
# Authentication for system users.
@@ -86,7 +86,7 @@ plugin {
plugin {
# for now we define static quota-rules for all users
quota = maildir:User quota
quota_rule = *:storage=100M
quota_rule = *:storage={{ config.max_mailbox_size }}
quota_max_mail_size=30M
quota_grace = 0
# quota_over_flag_value = TRUE
@@ -118,9 +118,27 @@ service auth-worker {
user = vmail
}
service imap-login {
# High-security mode.
# Each process serves a single connection and exits afterwards.
# This is the default, but we set it explicitly to be sure.
# See <https://doc.dovecot.org/admin_manual/login_processes/#high-security-mode> for details.
service_count = 1
# Inrease the number of simultaneous connections.
#
# As of Dovecot 2.3.19.1 the default is 100 processes.
# Combined with `service_count = 1` it means only 100 connections
# can be handled simultaneously.
process_limit = 10000
# Avoid startup latency for new connections.
process_min_avail = 10
}
ssl = required
ssl_cert = </var/lib/acme/live/{{ config.hostname }}/fullchain
ssl_key = </var/lib/acme/live/{{ config.hostname }}/privkey
ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain
ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey
ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes

View File

@@ -0,0 +1,10 @@
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/cur -mtime +{{ config.delete_mails_after }} -type f -delete
# or in any IMAP subfolder
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/cur -mtime +{{ config.delete_mails_after }} -type f -delete
# even if they are unseen
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/new -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/new -mtime +{{ config.delete_mails_after }} -type f -delete
# or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway).
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/tmp -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/tmp -mtime +{{ config.delete_mails_after }} -type f -delete

View File

@@ -0,0 +1,87 @@
import importlib
import qrcode
import os
from PIL import ImageFont, ImageDraw, Image
import io
def gen_qr_png_data(maildomain):
url = f"DCACCOUNT:https://{maildomain}/cgi-bin/newemail.py"
image = gen_qr(maildomain, url)
temp = io.BytesIO()
image.save(temp, format="png")
temp.seek(0)
return temp
def gen_qr(maildomain, url):
# taken and modified from
# https://github.com/deltachat/mailadm/blob/master/src/mailadm/gen_qr.py
# info = f"{maildomain} invite code"
info = ""
# load QR code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=1,
border=1,
)
qr.add_data(url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
# paint all elements
ttf_path = str(
importlib.resources.files(__package__).joinpath("data/opensans-regular.ttf")
)
logo_red_path = str(
importlib.resources.files(__package__).joinpath("data/delta-chat-bw.png")
)
assert os.path.exists(ttf_path), ttf_path
font_size = 16
font = ImageFont.truetype(font=ttf_path, size=font_size)
num_lines = ((info).count("\n") + 1) if info else 0
size = width = 384
qr_padding = 6
text_height = font_size * num_lines
height = size + text_height
image = Image.new("RGBA", (width, height), "white")
qr_final_size = width - (qr_padding * 2)
if num_lines:
draw = ImageDraw.Draw(image)
# draw text
if hasattr(font, "getsize"):
info_pos = (width - font.getsize(info.strip())[0]) // 2
else:
info_pos = (width - font.getbbox(info.strip())[3]) // 2
draw.multiline_text(
(info_pos, size - qr_padding // 2),
info,
font=font,
fill="black",
align="right",
)
# paste QR code
image.paste(
qr_img.resize((qr_final_size, qr_final_size), resample=Image.NEAREST),
(qr_padding, qr_padding),
)
# background delta logo
logo2_img = Image.open(logo_red_path)
logo2_width = int(size / 6)
logo2 = logo2_img.resize((logo2_width, logo2_width), resample=Image.NEAREST)
pos = int((size / 2) - (logo2_width / 2))
image.paste(logo2, (pos, pos), mask=logo2)
return image

View File

@@ -0,0 +1 @@
*/5 * * * * root {{ config.execpath }} /home/vmail/mail/{{ config.mail_domain }} >/var/www/html/metrics

View File

@@ -0,0 +1,4 @@
version: STSv1
mode: enforce
mx: {{ config.domain_name }}
max_age: 2419200

View File

@@ -26,8 +26,6 @@ http {
gzip on;
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
@@ -42,6 +40,20 @@ http {
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
location /metrics {
default_type text/plain;
}
# add cgi-bin support
include /usr/share/doc/fcgiwrap/examples/nginx.conf;
}
# Redirect www. to non-www
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.domain_name }}$request_uri;
}
}

View File

@@ -0,0 +1 @@
dkim._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/dkim.private

View File

@@ -0,0 +1 @@
*@{{ config.domain_name }} {{ config.opendkim_selector }}._domainkey.{{ config.domain_name }}

View File

@@ -1,7 +1,4 @@
# This is a basic configuration for signing and verifying. It can easily be
# adapted to suit a basic installation. See opendkim.conf(5) and
# /usr/share/doc/opendkim/examples/opendkim.conf.sample for complete
# documentation of available configuration parameters.
# OpenDKIM configuration.
Syslog yes
SyslogSuccess yes
@@ -21,7 +18,9 @@ OversignHeaders From
# setup options can be found in /usr/share/doc/opendkim/README.opendkim.
Domain {{ config.domain_name }}
Selector {{ config.opendkim_selector }}
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
KeyTable /etc/dkimkeys/KeyTable
SigningTable refile:/etc/dkimkeys/SigningTable
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged

View File

@@ -1,4 +1,4 @@
myorigin = {{ config.domain_name }}
myorigin = {{ config.mail_domain }}
smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU)
biff = no
@@ -11,21 +11,21 @@ append_dot_mydomain = no
readme_directory = no
# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 2 on
# fresh installs.
compatibility_level = 2
# See http://www.postfix.org/COMPATIBILITY_README.html
compatibility_level = 3.6
# TLS parameters
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.domain_name }}/fullchain
smtpd_tls_key_file=/var/lib/acme/live/{{ config.domain_name }}/privkey
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain
smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey
smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=may
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = socketmap:inet:127.0.0.1:8461:postfix
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.domain_name }}
myhostname = {{ config.mail_domain }}
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
@@ -44,7 +44,9 @@ inet_interfaces = all
inet_protocols = all
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.domain_name }}
virtual_mailbox_domains = {{ config.mail_domain }}
smtpd_milters = unix:opendkim/opendkim.sock
smtpd_milters = inet:127.0.0.1:11332
non_smtpd_milters = $smtpd_milters
header_checks = regexp:/etc/postfix/submission_header_cleanup

View File

@@ -32,7 +32,8 @@ submission inet n - y - - smtpd
-o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:10080
-o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
@@ -46,8 +47,9 @@ smtps inet n - y - - smtpd
-o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit=1000
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:10080
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
#628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
@@ -76,5 +78,5 @@ scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp
# Local SMTP server for reinjecting filered mail.
localhost:10025 inet n - n - 10 smtpd
localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
-o syslog_name=postfix/reinject

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Postfix MTA-STS resolver daemon
[Service]
ExecStart=/usr/local/lib/postfix-mta-sts-resolver/bin/mta-sts-daemon
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
host: 127.0.0.1
port: 8461
reuse_port: true
shutdown_timeout: 20
cache:
type: internal
options:
cache_size: 10000
proactive_policy_fetching:
enabled: true
default_zone:
strict_testing: false
timeout: 4

View File

@@ -0,0 +1,4 @@
/^Received:/ IGNORE
/^X-Originating-IP:/ IGNORE
/^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE

View File

@@ -0,0 +1 @@
enabled = false;

View File

@@ -0,0 +1,10 @@
selector = {{ config.dkim_selector }}
use_esld = false # don't cut c1.testrun.org down to testrun.org
domain = {
{{ config.mail_domain }} {
selectors [
selector = {{ config.dkim_selector }}
path = {{ config.dkim_key_path }}
]
}
}

View File

@@ -0,0 +1,30 @@
rules {
REJECT_DKIM_SPF {
action = "reject";
# Reject if
# - R_DKIM_RJECT: DKIM reject inserted by `dkim` module.
# - R_DKIM_PERMFAIL: permanent failure inserted by `dkim` module e.g. no DKIM DNS record found.
# - No DKIM signing (R_DKIM_NA symbol inserted by `dkim` module)
#
# - SPF failure (R_SPF_FAIL)
# - SPF permanent failure, e.g. failed to resolve DNS record referenced from SPF (R_SPF_PERMFAIL)
#
# - DMARC policy failure (DMARC_POLICY_REJECT)
#
# Do not reject if:
# - R_DKIM_TEMPFAIL, it is a DNS resolution failure
# and we do not want to lose messages because of faulty network.
#
# - R_SPF_SOFTFAIL
# - R_SPF_NEUTRAL
# - R_SPF_DNSFAIL
# - R_SPF_NA
#
# - DMARC_DNSFAIL
# - DMARC_NA
# - DMARC_POLICY_SOFTFAIL
# - DMARC_POLICY_QUARANTINE
# - DMARC_BAD_POLICY
expression = "R_DKIM_REJECT | R_DKIM_PERMFAIL | R_DKIM_NA | R_SPF_FAIL | R_SPF_PERMFAIL | DMARC_POLICY_REJECT";
}
}

View File

@@ -0,0 +1 @@
filters = "dkim";

View File

View File

@@ -30,31 +30,31 @@ def test_login_smtp(benchmark, smtp, gencreds):
class TestDC:
def test_autoconfigure(self, benchmark, cmfactory):
def autoconfig_and_idle_ready():
def dc_autoconfig_and_idle_ready():
cmfactory.get_online_accounts(1)
benchmark(autoconfig_and_idle_ready, 5)
benchmark(dc_autoconfig_and_idle_ready, 5)
def test_ping_pong(self, benchmark, cmfactory):
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
def ping_pong():
def dc_ping_pong():
chat.send_text("ping")
msg = ac2.wait_next_incoming_message()
msg.chat.send_text("pong")
ac1.wait_next_incoming_message()
benchmark(ping_pong, 5)
benchmark(dc_ping_pong, 5)
def test_send_10_receive_10(self, benchmark, cmfactory, lp):
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
def send_10_receive_10():
def dc_send_10_receive_10():
for i in range(10):
chat.send_text(f"hello {i}")
for i in range(10):
ac2.wait_next_incoming_message()
benchmark(send_10_receive_10, 5)
benchmark(dc_send_10_receive_10, 5)

View File

@@ -1,5 +1,16 @@
import pytest
import smtplib
import threading
import queue
from chatmaild.config import read_config
from cmdeploy.cmdeploy import main
def test_init(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini")
main(["init", "--config", str(inipath), maildomain])
config = read_config(inipath)
assert config.mail_domain == maildomain
def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
@@ -23,6 +34,11 @@ def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
with pytest.raises(imap_or_smtp.AuthError):
imap_or_smtp.login(user, password + "wrong")
lp.sec("creating users with a short password is not allowed")
user, _password = gencreds()
with pytest.raises(imap_or_smtp.AuthError):
imap_or_smtp.login(user, "admin")
def test_login_same_password(imap_or_smtp, gencreds):
"""Test two different users logging in with the same password
@@ -35,3 +51,30 @@ def test_login_same_password(imap_or_smtp, gencreds):
imap_or_smtp.login(user1, password1)
imap_or_smtp.connect()
imap_or_smtp.login(user2, password1)
def test_concurrent_logins_same_account(
make_imap_connection, make_smtp_connection, gencreds
):
"""Test concurrent smtp and imap logins
and check remote server succeeds on each connection.
"""
user1, password1 = gencreds()
login_results = queue.Queue()
def login_smtp_imap(smtp, imap):
try:
imap.login(user1, password1)
except Exception:
login_results.put(False)
else:
login_results.put(True)
conns = [(make_smtp_connection(), make_imap_connection()) for i in range(10)]
for args in conns:
thread = threading.Thread(target=login_smtp_imap, args=args, daemon=True)
thread.start()
for _ in conns:
assert login_results.get()

View File

@@ -0,0 +1,25 @@
import requests
from cmdeploy.genqr import gen_qr_png_data
def test_gen_qr_png_data(maildomain):
data = gen_qr_png_data(maildomain)
assert data
def test_fastcgi_working(maildomain, chatmail_config):
url = f"https://{maildomain}/cgi-bin/newemail.py"
print(url)
res = requests.post(url)
assert maildomain in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length
def test_newemail_configure(maildomain, rpc):
"""Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/cgi-bin/newemail.py"
for i in range(3):
account_id = rpc.add_account()
rpc.set_config_from_qr(account_id, url)
rpc.configure(account_id)

View File

@@ -20,7 +20,7 @@ def test_use_two_chatmailservers(cmfactory, maildomain2):
@pytest.mark.parametrize("forgeaddr", ["internal", "someone@example.org"])
def test_reject_forged_from(cmsetup, maildata, lp, forgeaddr):
def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr):
user1, user3 = cmsetup.gen_users(2)
lp.sec("send encrypted message with forged from")
@@ -42,19 +42,29 @@ def test_reject_forged_from(cmsetup, maildata, lp, forgeaddr):
assert "500" in str(e.value)
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr):
"""Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
recipient = cmsetup.gen_users(1)[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
with smtplib.SMTP(cmsetup.maildomain, 25) as s:
with pytest.raises(smtplib.SMTPDataError, match="Spam message rejected"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
@pytest.mark.slow
def test_exceed_rate_limit(cmsetup, gencreds, maildata):
def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
"""Test that the per-account send-mail limit is exceeded."""
user1, user2 = cmsetup.gen_users(2)
mail = maildata(
"encrypted.eml", from_addr=user1.addr, to_addr=user2.addr
).as_string()
for i in range(100):
for i in range(chatmail_config.max_user_send_per_minute + 5):
print("Sending mail", str(i))
try:
user1.smtp.sendmail(user1.addr, [user2.addr], mail)
except smtplib.SMTPException as e:
if i < 80:
if i < chatmail_config.max_user_send_per_minute:
pytest.fail(f"rate limit was exceeded too early with msg {i}")
outcome = e.recipients[user2.addr]
assert outcome[0] == 450

View File

@@ -1,6 +1,10 @@
import time
import re
import random
import pytest
import requests
import ipaddress
class TestEndToEndDeltaChat:
@@ -20,14 +24,24 @@ class TestEndToEndDeltaChat:
assert msg2.text == "message0"
@pytest.mark.slow
def test_exceed_quota(self, cmfactory, lp, tmpdir, remote):
def test_exceed_quota(self, cmfactory, lp, tmpdir, remote, chatmail_config):
"""This is a very slow test as it needs to upload >100MB of mail data
before quota is exceeded, and thus depends on the speed of the upload.
"""
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
quota = 1024 * 1024 * 100
def parse_size_limit(limit: str) -> int:
"""Parse a size limit and return the number of bytes as integer.
Example input: 100M, 2.4T, 500 K
"""
units = {"B": 1, "K": 2**10, "M": 2**20, "G": 2**30, "T": 2**40}
size = re.sub(r"([KMGT])", r" \1", limit.upper())
number, unit = [string.strip() for string in size.split()]
return int(float(number) * units[unit])
quota = parse_size_limit(chatmail_config.max_mailbox_size)
attachsize = 1 * 1024 * 1024
num_to_send = quota // attachsize + 2
lp.sec(f"ac1: send {num_to_send} large files to ac2")
@@ -92,8 +106,8 @@ class TestEndToEndDeltaChat:
lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_setup_contact_qr()
ch = ac2.qr_setup_contact(qr)
msg = ac2.wait_next_incoming_message()
assert "verified" in msg.text
assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000)
lp.sec("ac1 sends a message and ac2 marks it as seen")
chat = ac1.create_chat(ac2)
@@ -108,3 +122,17 @@ class TestEndToEndDeltaChat:
for msg in msgs:
assert "error" not in m.get_message_info()
time.sleep(1)
def test_hide_senders_ip_address(cmfactory):
public_ip = requests.get("http://icanhazip.com").content.decode().strip()
assert ipaddress.ip_address(public_ip)
user1, user2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup")
user2.wait_next_incoming_message()
user2.direct_imap.select_folder("Inbox")
msg = user2.direct_imap.get_all_messages()[0]
assert public_ip not in msg.obj.as_string()

View File

@@ -6,12 +6,12 @@ import subprocess
import imaplib
import smtplib
import itertools
from email.parser import BytesParser
from email import policy
from pathlib import Path
from math import ceil
import pytest
from chatmaild.database import Database
from chatmaild.config import read_config
conftestdir = Path(__file__).parent
@@ -37,11 +37,22 @@ def pytest_runtest_setup(item):
@pytest.fixture
def maildomain():
domain = os.environ.get("CHATMAIL_DOMAIN")
if not domain:
pytest.skip("set CHATMAIL_DOMAIN to a ssh-reachable chatmail instance")
return domain
def chatmail_config(pytestconfig):
current = basedir = Path().resolve()
while 1:
path = current.joinpath("chatmail.ini").resolve()
if path.exists():
return read_config(path)
if current == current.parent:
break
current = current.parent
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
@pytest.fixture
def maildomain(chatmail_config):
return chatmail_config.mail_domain
@pytest.fixture
@@ -71,7 +82,7 @@ def pytest_report_header():
@pytest.fixture
def benchmark(request):
def bench(func, num, name=None):
def bench(func, num, name=None, reportfunc=None):
if name is None:
name = func.__name__
durations = []
@@ -80,7 +91,7 @@ def benchmark(request):
func()
durations.append(time.time() - now)
durations.sort()
request.config._benchresults[name] = durations
request.config._benchresults[name] = (reportfunc, durations)
return bench
@@ -101,7 +112,9 @@ def pytest_terminal_summary(terminalreporter):
headers = f"{'benchmark name': <30} " + fcol(float_names)
tr.write_line(headers)
tr.write_line("-" * len(headers))
for name, durations in results.items():
summary_lines = []
for name, (reportfunc, durations) in results.items():
measures = [
sorted(durations)[len(durations) // 2],
min(durations),
@@ -110,6 +123,16 @@ def pytest_terminal_summary(terminalreporter):
line = f"{name: <30} "
line += fcol(f"{float: 2.2f}" for float in measures)
tr.write_line(line)
vmedian, vmin, vmax = measures
if reportfunc:
for line in reportfunc(vmin=vmin, vmedian=vmedian, vmax=vmax):
summary_lines.append(line)
if summary_lines:
tr.write_line("")
tr.section("benchmark summary measures")
for line in summary_lines:
tr.write_line(line)
@pytest.fixture
@@ -117,6 +140,16 @@ def imap(maildomain):
return ImapConn(maildomain)
@pytest.fixture
def make_imap_connection(maildomain):
def make_imap_connection():
conn = ImapConn(maildomain)
conn.connect()
return conn
return make_imap_connection
class ImapConn:
AuthError = imaplib.IMAP4.error
logcmd = "journalctl -f -u dovecot"
@@ -157,6 +190,16 @@ def smtp(maildomain):
return SmtpConn(maildomain)
@pytest.fixture
def make_smtp_connection(maildomain):
def make_smtp_connection():
conn = SmtpConn(maildomain)
conn.connect()
return conn
return make_smtp_connection
class SmtpConn:
AuthError = smtplib.SMTPAuthenticationError
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
@@ -185,23 +228,37 @@ def imap_or_smtp(request):
@pytest.fixture
def gencreds(maildomain):
def gencreds(chatmail_config):
count = itertools.count()
next(count)
def gen(domain=None):
domain = domain if domain else maildomain
domain = domain if domain else chatmail_config.mail_domain
while 1:
num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
user = "".join(random.choices(alphanumeric, k=10))
user = f"ac{num}_{user}"
password = "".join(random.choices(alphanumeric, k=10))
user = "".join(
random.choices(alphanumeric, k=chatmail_config.username_max_length)
)
if domain == "nine.testrun.org":
user = f"ac{num}_{user}"[:9]
else:
user = f"ac{num}_{user}"[: chatmail_config.username_max_length]
password = "".join(
random.choices(alphanumeric, k=chatmail_config.password_min_length)
)
yield f"{user}@{domain}", f"{password}"
return lambda domain=None: next(gen(domain))
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
#
# Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts
@@ -238,11 +295,13 @@ class ChatmailTestProcess:
@pytest.fixture
def cmfactory(request, gencreds, tmpdir, data, maildomain):
def cmfactory(request, gencreds, tmpdir, maildomain):
# cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory
data = request.getfixturevalue("data")
testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=data)
@@ -272,7 +331,7 @@ class Remote:
self.sshdomain = sshdomain
def iter_output(self, logcmd=""):
getjournal = f"journalctl -f" if not logcmd else logcmd
getjournal = "journalctl -f" if not logcmd else logcmd
self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal],
stdout=subprocess.PIPE,
@@ -287,19 +346,15 @@ class Remote:
@pytest.fixture
def maildata(request, gencreds):
datadir = conftestdir.joinpath("mail-data")
def lp(request):
class LP:
def sec(self, msg):
print(f"---- {msg} ----")
def maildata(name, from_addr=None, to_addr=None):
if from_addr is None:
from_addr = gencreds()[0]
if to_addr is None:
to_addr = gencreds()[0]
data = datadir.joinpath(name).read_text()
text = data.format(from_addr=from_addr, to_addr=to_addr)
return BytesParser(policy=policy.default).parsebytes(text.encode())
def indent(self, msg):
print(f" {msg}")
return maildata
return LP()
@pytest.fixture

View File

@@ -0,0 +1,27 @@
import os
import pytest
from cmdeploy.cmdeploy import get_parser, main
@pytest.fixture(autouse=True)
def _chdir(tmp_path):
old = os.getcwd()
os.chdir(tmp_path)
yield
os.chdir(old)
class TestCmdline:
def test_parser(self, capsys):
parser = get_parser()
parser.parse_args([])
init = parser.parse_args(["init", "chat.example.org"])
run = parser.parse_args(["run"])
assert init and run
@pytest.mark.xfail(reason="init doesn't exit anymore, check for CLI output instead")
def test_init_not_overwrite(self):
main(["init", "chat.example.org"])
with pytest.raises(SystemExit):
main(["init", "chat.example.org"])

View File

@@ -0,0 +1,13 @@
import importlib.resources
from cmdeploy.www import build_webpages
def test_build_webpages(tmp_path, make_config):
pkgroot = importlib.resources.files("cmdeploy")
src_dir = pkgroot.joinpath("../../../www/src").resolve()
assert src_dir.exists(), src_dir
config = make_config("chat.example.org")
build_dir = tmp_path.joinpath("build")
build_webpages(src_dir, build_dir, config)
assert len([x for x in build_dir.iterdir() if x.suffix == ".html"]) >= 3

View File

@@ -0,0 +1,143 @@
import importlib.resources
import webbrowser
import hashlib
import time
import traceback
import markdown
from jinja2 import Template
from .genqr import gen_qr_png_data
from chatmaild.config import read_config
def snapshot_dir_stats(somedir):
d = {}
for path in somedir.iterdir():
if path.is_file() and path.name[0] != "." and path.suffix != ".swp":
mtime = path.stat().st_mtime
hash = hashlib.md5(path.read_bytes()).hexdigest()
d[path] = (mtime, hash)
return d
def prepare_template(source):
assert source.exists(), source
render_vars = {}
render_vars["pagename"] = "home" if source.stem == "index" else source.stem
render_vars["markdown_html"] = markdown.markdown(source.read_text())
page_layout = source.with_name("page-layout.html").read_text()
return render_vars, page_layout
def build_webpages(src_dir, build_dir, config):
try:
_build_webpages(src_dir, build_dir, config)
except Exception:
print(traceback.format_exc())
def int_to_english(number):
if number >= 0 and number <= 12:
a = [
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
]
return a[number]
elif number <= 50:
return str(number)
if number > 50:
return "more"
def _build_webpages(src_dir, build_dir, config):
mail_domain = config.mail_domain
assert src_dir.exists(), src_dir
if not build_dir.exists():
build_dir.mkdir()
qr_path = build_dir.joinpath(f"qr-chatmail-invite-{mail_domain}.png")
qr_path.write_bytes(gen_qr_png_data(mail_domain).read())
for path in src_dir.iterdir():
if path.suffix == ".md":
render_vars, content = prepare_template(path)
render_vars["username_min_length"] = int_to_english(
config.username_min_length
)
render_vars["username_max_length"] = int_to_english(
config.username_max_length
)
render_vars["password_min_length"] = int_to_english(
config.password_min_length
)
target = build_dir.joinpath(path.stem + ".html")
# recursive jinja2 rendering
while 1:
new = Template(content).render(config=config, **render_vars)
if new == content:
break
content = new
with target.open("w") as f:
f.write(content)
elif path.name != "page-layout.html":
target = build_dir.joinpath(path.name)
target.write_bytes(path.read_bytes())
return build_dir
def main():
path = importlib.resources.files(__package__)
reporoot = path.joinpath("../../../").resolve()
inipath = reporoot.joinpath("chatmail.ini")
config = read_config(inipath)
config.webdev = True
assert config.mail_domain
www_path = reporoot.joinpath("www")
src_path = www_path.joinpath("src")
stats = None
build_dir = www_path.joinpath("build")
src_dir = www_path.joinpath("src")
index_path = build_dir.joinpath("index.html")
# start web page generation, open a browser and wait for changes
build_webpages(src_dir, build_dir, config)
webbrowser.open(str(index_path))
stats = snapshot_dir_stats(src_path)
print(f"\nOpened URL: file://{index_path.resolve()}\n")
print(f"watching {src_path} directory for changes")
changenum = 0
for count in range(0, 1000000):
newstats = snapshot_dir_stats(src_path)
if newstats == stats and count % 60 != 0:
count += 1
time.sleep(1.0)
continue
for key in newstats:
if stats[key] != newstats[key]:
print(f"*** CHANGED: {key}")
changenum += 1
stats = newstats
build_webpages(src_dir, build_dir, config)
print(f"[{changenum}] regenerated web pages at: {index_path}")
print(f"URL: file://{index_path.resolve()}\n\n")
count = 0
if __name__ == "__main__":
main()

View File

@@ -1,30 +0,0 @@
[build-system]
requires = ["setuptools>=45"]
build-backend = "setuptools.build_meta"
[project]
name = "deploy-chatmail"
version = "0.1"
dependencies = [
"pyinfra",
]
[tool.pytest.ini_options]
addopts = "-v -ra --strict-markers"
[tool.tox]
legacy_tox_ini = """
[tox]
isolated_build = true
envlist = lint
[testenv:lint]
skipdist = True
skip_install = True
deps =
ruff
black
commands =
black --quiet --check --diff src/
ruff src/
"""

View File

@@ -1,305 +0,0 @@
"""
Chat Mail pyinfra deploy.
"""
import importlib.resources
from pathlib import Path
from pyinfra import host, logger
from pyinfra.operations import apt, files, server, systemd, python
from pyinfra.facts.files import File
from .acmetool import deploy_acmetool
def _install_chatmaild() -> None:
chatmaild_filename = "chatmaild-0.1.tar.gz"
chatmaild_path = importlib.resources.files(__package__).joinpath(
f"../../../dist/{chatmaild_filename}"
)
remote_path = f"/tmp/{chatmaild_filename}"
if Path(str(chatmaild_path)).exists():
files.put(
name="Upload chatmaild source package",
src=chatmaild_path.open("rb"),
dest=remote_path,
)
apt.packages(
name="apt install python3-aiosmtpd",
packages=["python3-aiosmtpd", "python3-pip"],
)
# --no-deps because aiosmtplib is installed with `apt`.
server.shell(
name="install chatmaild with pip",
commands=[f"pip install --break-system-packages {remote_path}"],
)
for fn in (
"doveauth-dictproxy",
"filtermail",
):
files.put(
name=f"Upload {fn}.service",
src=importlib.resources.files("chatmaild")
.joinpath(f"{fn}.service")
.open("rb"),
dest=f"/etc/systemd/system/{fn}.service",
user="root",
group="root",
mode="644",
)
systemd.service(
name=f"Setup {fn} service",
service=f"{fn}.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
)
def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
"""Configures OpenDKIM"""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
files.directory(
name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
server.shell(
name="Generate OpenDKIM domain keys",
commands=[
f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
],
_sudo=True,
_sudo_user="opendkim",
)
need_restart |= main_config.changed
return need_restart
def _configure_postfix(domain: str, debug: bool = False) -> bool:
"""Configures Postfix SMTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/main.cf.j2"),
dest="/etc/postfix/main.cf",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= main_config.changed
master_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/master.cf.j2"),
dest="/etc/postfix/master.cf",
user="root",
group="root",
mode="644",
debug=debug,
)
need_restart |= master_config.changed
return need_restart
def _configure_dovecot(mail_server: str, debug: bool = False) -> bool:
"""Configures Dovecot IMAP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
user="root",
group="root",
mode="644",
config={"hostname": mail_server},
debug=debug,
)
need_restart |= main_config.changed
auth_config = files.put(
src=importlib.resources.files(__package__).joinpath("dovecot/auth.conf"),
dest="/etc/dovecot/auth.conf",
user="root",
group="root",
mode="644",
)
need_restart |= auth_config.changed
files.put(
src=importlib.resources.files(__package__)
.joinpath("dovecot/expunge.cron")
.open("rb"),
dest="/etc/cron.d/expunge",
user="root",
group="root",
mode="644",
)
return need_restart
def _configure_nginx(domain: str, debug: bool = False) -> bool:
"""Configures nginx HTTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("nginx.conf.j2"),
dest="/etc/nginx/nginx.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= main_config.changed
autoconfig = files.template(
src=importlib.resources.files(__package__).joinpath("autoconfig.xml.j2"),
dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= autoconfig.changed
return need_restart
def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> None:
"""Deploy a chat-mail instance.
:param mail_domain: domain part of your future email addresses
:param mail_server: the DNS name under which your mail server is reachable
:param dkim_selector:
"""
apt.update(name="apt update", cache_time=24 * 3600)
server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
# Deploy acmetool to have TLS certificates.
deploy_acmetool(nginx_hook=True, domains=[mail_server])
apt.packages(
name="Install Postfix",
packages="postfix",
)
apt.packages(
name="Install Dovecot",
packages=["dovecot-imapd", "dovecot-lmtpd"],
)
apt.packages(
name="Install OpenDKIM",
packages=[
"opendkim",
"opendkim-tools",
],
)
apt.packages(
name="Install nginx",
packages=["nginx"],
)
_install_chatmaild()
debug = False
dovecot_need_restart = _configure_dovecot(mail_server, debug=debug)
postfix_need_restart = _configure_postfix(mail_domain, debug=debug)
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
nginx_need_restart = _configure_nginx(mail_domain)
systemd.service(
name="Start and enable OpenDKIM",
service="opendkim.service",
running=True,
enabled=True,
restarted=opendkim_need_restart,
)
systemd.service(
name="Start and enable Postfix",
service="postfix.service",
running=True,
enabled=True,
restarted=postfix_need_restart,
)
systemd.service(
name="Start and enable Dovecot",
service="dovecot.service",
running=True,
enabled=True,
restarted=dovecot_need_restart,
)
systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=nginx_need_restart,
)
# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName
server.shell(
name="Setup /etc/mailname",
commands=[f"echo {mail_domain} >/etc/mailname; chmod 644 /etc/mailname"],
)
journald_conf = files.put(
name="Configure journald",
src=importlib.resources.files(__package__).joinpath("journald.conf"),
dest="/etc/systemd/journald.conf",
user="root",
group="root",
mode="644",
)
systemd.service(
name="Start and enable journald",
service="systemd-journald.service",
running=True,
enabled=True,
restarted=journald_conf,
)
def callback():
result = server.shell(
commands=[
f"""sed 's/\tIN/ 600 IN/;s/\t(//;s/\"$//;s/^\t \"//g; s/ ).*//' """
f"""/etc/dkimkeys/{dkim_selector}.txt | tr --delete '\n'"""
]
)
logger.info(f"Add this TXT entry into DNS zone: {result.stdout}")
python.call(name="Print TXT entry for DKIM", function=callback)

View File

@@ -1,19 +0,0 @@
import os
import pyinfra
from deploy_chatmail import deploy_chatmail
def main():
mail_domain = os.getenv("CHATMAIL_DOMAIN")
mail_server = os.getenv("CHATMAIL_SERVER", mail_domain)
dkim_selector = os.getenv("CHATMAIL_DKIM_SELECTOR", "2023")
assert mail_domain
assert mail_server
assert dkim_selector
deploy_chatmail(mail_domain, mail_server, dkim_selector)
if pyinfra.is_cli:
main()

View File

@@ -1,5 +0,0 @@
uri = proxy:/run/dovecot/doveauth.socket:auth
iterate_disable = yes
default_pass_scheme = plain
password_key = passdb/%w/%u
user_key = userdb/%u

View File

@@ -1,4 +0,0 @@
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE 40d INBOX
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE 40d Deltachat
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE 40d Trash
2 30 * * * dovecot doveadm purge -A

View File

@@ -1,65 +0,0 @@
# Chat-mail server development (up until Oct 18th)
## Dovecot goals/steps
- automatic expiry of messages older than M days
- also expunge unread messages
- limit: configure max-connections per account
## nami: send out rate limit / rspamd
- basic outgoing send rate/limits (depending on "account-rating")
use rspamd in a minimal way, check support dkim-signing
(including an online test exceeding rate limit)
## doveauth questions/futures
- bcrypt-password scheme is slow: require long passwords, use faster hashing
- define user-name and password policies, and implement them
(be very restrictive at the beginning, we can relax later)
- password is part of the dictproxy-lookup key, is it safe to use auth-caching?
## How to limit creation of accounts?
attack: a 3-line bash script to fill the chatmail db with millions of unused accouts
- make it computationally expensive (somehow try to except our tests from it)
1st pass instant onboarding: create userid + cheap password -- if it fails then
2nd pass instant onboarding: create userdid + comput. expensive password
- probably also do firewall: limit number of new tcp-connections per IP address per duration
## Open/deferred questions
- automatic expiry of users that haven't logged in for N days
Is it neccessary? If all messages are gone, does the existence of
an e-mail address bother anybody?
## web page for chat-mail servers?
- documentation for users, privacy policy etc.
(probably also with provider-messages ...)
## online tests (first with plain python/pytest)
- write tests for dovecot login (exists)
- write tests for postfix logins (exists)
- write A<>B send/receive tests (exists)
## Delta Chat
1. qr code that defines access to a chatmail instance (like mailadm but without http etc.)
2. support for creating username/password and verifying login works

View File

@@ -1,4 +0,0 @@
#!/bin/bash
set -e
online-tests/venv/bin/pytest online-tests/benchmark.py -vrx

6
scripts/cmdeploy Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
#
# Wrapper for cmdelpoy to run it in activated virtualenv.
set -e
. venv/bin/activate
cmdeploy "$@"

View File

@@ -1,15 +0,0 @@
#!/usr/bin/env bash
echo -----------------------------------------
echo deploying to $CHATMAIL_DOMAIN
echo -----------------------------------------
echo WARNING: in five seconds deploy to $CHATMAIL_DOMAIN starts
sleep 5
venv/bin/python3 -m build -n --sdist chatmaild --outdir dist
venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" \
deploy-chatmail/src/deploy_chatmail/deploy.py
rm -r dist/

View File

@@ -1,14 +0,0 @@
import os
import time
import imaplib
domain = os.environ.get("CHATMAIL_DOMAIN", "c3.testrun.org")
print("connecting")
conn = imaplib.IMAP4_SSL(domain)
print("logging in")
conn.login(f"imapcapa", "pass")
status, res = conn.capability()
for capa in sorted(res[0].decode().split()):
print(capa)

View File

@@ -1,8 +0,0 @@
#!/bin/sh
set -e
python3 -m venv venv
pip=venv/bin/pip
$pip install pyinfra pytest build 'setuptools>=68' tox deltachat
$pip install -e deploy-chatmail
$pip install -e chatmaild

6
scripts/initenv.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -e
python3 -m venv venv
venv/bin/pip install -e chatmaild
venv/bin/pip install -e cmdeploy

View File

@@ -1,28 +0,0 @@
#!/usr/bin/env python3
import os
import time
import imaplib
domain = os.environ.get("CHATMAIL_DOMAIN", "c3.testrun.org")
NUM_CONNECTIONS=10
conns = []
start = time.time()
for i in range(NUM_CONNECTIONS):
print(f"opening connection {i} to {domain}")
conn = imaplib.IMAP4_SSL(domain)
conns.append(conn)
tlsdone = time.time()
duration = tlsdone-start
print(f"{duration}: TLS connections opening TLS connections")
for i, conn in enumerate(conns):
print(f"logging into connection {i}")
conn.login(f"measure{i}", "pass")
logindone = time.time()
duration = logindone - tlsdone
print(f"{duration}: LOGINS done")

View File

@@ -1,8 +0,0 @@
#!/bin/sh
set -e
: ${CHATMAIL_DOMAIN:=c1.testrun.org}
: ${CHATMAIL_SSH:=$CHATMAIL_DOMAIN}
rsync -avz . "root@$CHATMAIL_SSH:/root/chatmail" --exclude='/.git' --filter="dir-merge,- .gitignore"
ssh "root@$CHATMAIL_SSH" "cd /root/chatmail; apt install -y python3-venv; python3 -m venv venv; venv/bin/pip install pyinfra build; venv/bin/python3 -m build -n --sdist chatmaild --outdir dist; venv/bin/pip install -e ./deploy-chatmail -e ./chatmaild; export CHATMAIL_DOMAIN=$CHATMAIL_DOMAIN; venv/bin/pyinfra @local deploy.py"

View File

@@ -1,4 +0,0 @@
#!/bin/bash
tox -c chatmaild
tox -c deploy-chatmail
venv/bin/pytest tests/online -vrx --durations=5 $@

View File

@@ -1,53 +0,0 @@
import os
import pytest
import chatmaild.dictproxy
from chatmaild.dictproxy import get_user_data, lookup_passdb
from chatmaild.database import Database, DBError
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
def test_basic(db):
chatmaild.dictproxy.NOCREATE_FILE = "/tmp/nocreate"
if os.path.exists(chatmaild.dictproxy.NOCREATE_FILE):
os.remove(chatmaild.dictproxy.NOCREATE_FILE)
lookup_passdb(db, "link2xt@c1.testrun.org", "asdf")
data = get_user_data(db, "link2xt@c1.testrun.org")
assert data
def test_dont_overwrite_password_on_wrong_login(db):
"""Test that logging in with a different password doesn't create a new user"""
res = lookup_passdb(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
assert res["password"]
res2 = lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
# this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"]
def test_nocreate_file(db):
chatmaild.dictproxy.NOCREATE_FILE = "/tmp/nocreate"
with open(chatmaild.dictproxy.NOCREATE_FILE, "w+") as f:
f.write("")
assert os.path.exists(chatmaild.dictproxy.NOCREATE_FILE)
lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
assert not get_user_data(db, "newuser1@something.org")
os.remove(chatmaild.dictproxy.NOCREATE_FILE)
def test_db_version(db):
assert db.get_schema_version() == 1
def test_too_high_db_version(db):
with db.write_transaction() as conn:
conn.execute("PRAGMA user_version=%s;" % (999,))
with pytest.raises(DBError):
db.ensure_tables()

View File

@@ -1,82 +0,0 @@
from chatmaild.filtermail import check_encrypted, check_DATA, SendRateLimiter, check_mdn
import pytest
@pytest.fixture
def maildomain():
# let's not depend on a real chatmail instance for the offline tests below
return "chatmail.example.org"
def test_reject_forged_from(maildata, gencreds):
class env:
mail_from = gencreds()[0]
rcpt_tos = [gencreds()[0]]
# test that the filter lets good mail through
env.content = maildata("plain.eml", from_addr=env.mail_from).as_bytes()
assert not check_DATA(envelope=env)
# test that the filter rejects forged mail
env.content = maildata("plain.eml", from_addr="forged@c3.testrun.org").as_bytes()
error = check_DATA(envelope=env)
assert "500" in error
def test_filtermail_no_encryption_detection(maildata):
msg = maildata("plain.eml")
assert not check_encrypted(msg)
# https://xkcd.com/1181/
msg = maildata("fake-encrypted.eml")
assert not check_encrypted(msg)
def test_filtermail_encryption_detection(maildata):
msg = maildata("encrypted.eml")
assert check_encrypted(msg)
# if the subject is not "..." it is not considered ac-encrypted
msg.replace_header("Subject", "Click this link")
assert not check_encrypted(msg)
def test_filtermail_is_mdn(maildata, gencreds):
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
msg = maildata("mdn.eml", from_addr, to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert check_mdn(msg, env)
print(msg.as_string())
assert not check_DATA(env)
def test_filtermail_to_multiple_recipients_no_mdn(maildata, gencreds):
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
thirdaddr = gencreds()[0]
msg = maildata("mdn.eml", from_addr, to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr, thirdaddr]
content = msg.as_bytes()
assert not check_mdn(msg, env)
def test_send_rate_limiter():
limiter = SendRateLimiter()
for i in range(100):
if limiter.is_sending_allowed("some@example.org"):
if i <= SendRateLimiter.MAX_USER_SEND_PER_MINUTE:
continue
pytest.fail("limiter didn't work")
else:
assert i == SendRateLimiter.MAX_USER_SEND_PER_MINUTE + 1
break

BIN
www/src/collage-info.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
www/src/collage-privacy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
www/src/collage-top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

19
www/src/index.md Normal file
View File

@@ -0,0 +1,19 @@
<img class="banner" src="collage-top.png"/>
## Dear [Delta Chat](https://get.delta.chat) users and newcomers,
Welcome to instant, interoperable and [privacy-preserving](privacy.html) messaging :)
👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address
<a href="DCACCOUNT:https://{{ config.mail_domain }}/cgi-bin/newemail.py">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
🐣 **Choose** your Avatar and Name
💬 **Start** chatting with any Delta Chat contacts using [QR invite codes](https://delta.chat/en/help#howtoe2ee)
{% if config.mail_domain != "nine.testrun.org" %}
<div class="experimental">Note: this is only a temporary development chatmail service</div>
{% endif %}

55
www/src/info.md Normal file
View File

@@ -0,0 +1,55 @@
<img class="banner" src="collage-info.png"/>
## More information
{{ config.mail_domain }} provides a low-maintenance, resource efficient and
interoperable e-mail service for everyone. What's behind a `chatmail` is
effectively a normal e-mail address just like any other but optimized
for the usage in chats, especially DeltaChat.
### Choosing a chatmail address instead of using a random one
In the Delta Chat account setup
you may tap `LOG INTO YOUR E-MAIL ACCOUNT`
and fill the two fields like this:
- `Address`: invent a word with
{% if username_min_length == username_max_length %}
*exactly* {{ username_min_length }}
{% else %}
{{ username_min_length}}
{% if username_max_length == "more" %}
or more
{% else %}
to {{ username_max_length }}
{% endif %}
{% endif %}
characters
and append `@{{config.mail_domain}}` to it.
- `Password`: invent at least {{ password_min_length }} characters.
If the e-mail address is not yet taken, you'll get that account.
The first login sets your password.
### Rate and storage limits
- Un-encrypted messages are blocked to recipients outside
{{config.mail_domain}} but setting up contact via [QR invite codes](https://delta.chat/en/help#howtoe2ee)
allows your messages to pass freely to any outside recipients.
- You may send up to {{ config.max_user_send_per_minute }} messages per minute.
- Messages are unconditionally removed {{ config.delete_mails_after }} days after arriving on the server.
- You can store up to [{{ config.max_mailbox_size }} messages on the server](https://delta.chat/en/help#what-happens-if-i-turn-on-delete-old-messages-from-server).
### Who are the operators? Which software is running?
This chatmail provider is run by a small voluntary group of devs and sysadmins,
who [publically develop chatmail provider setups](https://github.com/deltachat/chatmail).
Chatmail setups aim to be very low-maintenance, resource efficient and
interoperable with any other standards-compliant e-mail service.

Some files were not shown because too many files have changed in this diff Show More