Compare commits

...

185 Commits

Author SHA1 Message Date
holger krekel
805d743e9e show PATH env 2023-12-11 15:29:21 +01:00
holger krekel
b0d5ee084e fix cmdeploy test command 2023-12-11 15:22:12 +01:00
holger krekel
56c7853e5b remove tox run from deploy-chatmail and use 'cmdeploy fmt' and 'pytest' directly 2023-12-11 13:51:45 +01:00
holger krekel
071d708a89 move tests/chatmaild to chatmaild package, streamline tests and fixtures accordingly 2023-12-11 13:04:11 +01:00
holger krekel
c7c7ed8ff2 fix tests and run all tests on "cmdeploy test" 2023-12-11 12:18:10 +01:00
holger krekel
0aa0ef8a74 discover chatmail.ini in tests from CWD and all parent dirs (tox runs change dirs) 2023-12-11 02:27:05 +01:00
holger krekel
0662e3a8b1 "cmdeploy test" now installs deltachat if it's not there 2023-12-11 02:13:46 +01:00
holger krekel
6c453f93a1 consistently use shell helper 2023-12-11 01:58:31 +01:00
holger krekel
54f29f6bae consistently show ssh/shell output 2023-12-11 01:50:56 +01:00
holger krekel
ad8fee76cd add "build" dependency 2023-12-11 01:45:32 +01:00
holger krekel
528cd3da25 always show which ssh-commands execute 2023-12-11 01:43:09 +01:00
holger krekel
5734e00625 some more shifting around 2023-12-11 01:36:11 +01:00
holger krekel
947e1d6f89 shift functions around, discover sub commands automatically 2023-12-11 01:18:57 +01:00
holger krekel
33423459fe make tests depend on chatmail.ini, not env var 2023-12-11 00:49:32 +01:00
holger krekel
c70b72a21a tweak for making CI happy 2023-12-11 00:05:25 +01:00
holger krekel
33352f4694 try to fix workflow 2023-12-10 18:28:40 +01:00
holger krekel
59083ad16a don't print a traceback but do a proper return code for "cmdeploy test" 2023-12-10 18:24:08 +01:00
holger krekel
11518c2ef4 add chatmail.ini to ignore 2023-12-10 18:05:11 +01:00
holger krekel
a0cdfe6126 add manifest so that ini files get included 2023-12-10 18:02:00 +01:00
holger krekel
c25eefccc4 address nami comment 2023-12-10 17:56:26 +01:00
holger krekel
8b878e38cf fix readme 2023-12-10 14:41:20 +01:00
holger krekel
9e1e6d3c69 add test command 2023-12-10 14:38:57 +01:00
holger krekel
a1c817d758 fix README 2023-12-10 12:52:35 +01:00
holger krekel
ccc552f852 add status command and delete last script 2023-12-10 12:45:23 +01:00
holger krekel
73768256f6 some more housekeeping 2023-12-10 12:17:05 +01:00
holger krekel
34f7b3c0d3 introduce "cmdeploy bench" 2023-12-10 12:10:36 +01:00
holger krekel
e2828f4103 cleanup 2023-12-10 12:00:03 +01:00
holger krekel
df515bea41 generate dns zone file via cmdeploy 2023-12-10 11:56:41 +01:00
holger krekel
5614f03611 add dns command beginning 2023-12-09 18:07:44 +01:00
holger krekel
70443545d7 make cmdeploy test work 2023-12-09 17:55:08 +01:00
holger krekel
10ef842061 snap 2023-12-09 17:45:26 +01:00
holger krekel
98f92cd9b6 fix various test setups 2023-12-09 16:54:05 +01:00
holger krekel
fe99b97386 add webdev sub command 2023-12-09 16:42:03 +01:00
holger krekel
542decf798 making it work 2023-12-09 15:15:57 +01:00
holger krekel
2e7f8483b3 rework UI for chatmail setup 2023-12-09 13:43:56 +01:00
holger krekel
5c58e625f0 draft init flow 2023-12-09 02:07:09 +01:00
holger krekel
009f549619 document some attributes in chatmail.ini 2023-12-09 01:20:17 +01:00
holger krekel
99d36235fe get passthrough_recipients list from config 2023-12-09 01:07:37 +01:00
holger krekel
b52a8c969f various fixes 2023-12-09 00:22:58 +01:00
holger krekel
8520a9d8f2 introduce basic config file 2023-12-08 21:56:15 +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
holger krekel
1e7d0d10f5 follow link2xt advise and don't check subject/body at all -- turns out there were no tests anyway. 2023-10-22 14:59:37 +02:00
holger krekel
3dd94cbe69 passes the test 2023-10-22 14:59:37 +02:00
holger krekel
ed1b2f9da1 add a failing test for read receipts between two instances 2023-10-22 14:59:37 +02:00
link2xt
7ee84b44df Use tox -c option 2023-10-22 14:48:30 +02:00
link2xt
02205246dd Setup deltachat dependency in init.sh 2023-10-22 14:48:30 +02:00
holger krekel
fcd3194eb1 run tests via scripts 2023-10-22 14:48:30 +02:00
holger krekel
bdef189ce1 try to run all offline tests in CI 2023-10-22 14:48:30 +02:00
holger krekel
3058ddc542 fix init.sh and test.sh
use tox for chatmaild non-online tests
2023-10-22 14:48:30 +02:00
holger krekel
bada933fef add missing file 2023-10-22 14:48:30 +02:00
holger krekel
1d74b94162 rename fixture to maildata and rename doveauth 2023-10-22 14:48:30 +02:00
holger krekel
eee6d0c871 more maildata shifting 2023-10-22 14:48:30 +02:00
holger krekel
ed5e37f1fa move all inlined mails to a data directory 2023-10-22 14:48:30 +02:00
holger krekel
364300274e move all tests into a root "tests" folder so they can share setup and config 2023-10-22 14:48:30 +02:00
holger krekel
848b25c790 add marker dynamically to allow "pytest" to execute nicely at repo root without warnings 2023-10-20 23:07:18 +02:00
holger krekel
107d10ace4 rename test files to be unambigously numbered 2023-10-20 23:07:18 +02:00
holger krekel
83e6a42252 slight refinement for benchmark formatting, not worth a PR 2023-10-20 18:43:06 +02:00
link2xt
eb69dd58f7 Setup CI 2023-10-20 15:18:17 +02:00
link2xt
31c45f951d dictproxy: use crypt instead of doveadm pw 2023-10-20 14:05:25 +02:00
holger krekel
3012bfb79d some reformatting and striking overall 2023-10-20 11:05:58 +02:00
holger krekel
03442bc115 some improvements, adding a bnech 2023-10-20 11:05:58 +02:00
holger krekel
1ae6291d06 add ping-pong bench and formatting 2023-10-20 11:05:58 +02:00
holger krekel
1b347f97a0 better benchmarking and reporting 2023-10-20 11:05:58 +02:00
link2xt
902f98c9ba Set syslog name for reinject proxy 2023-10-19 03:22:27 +00:00
link2xt
89311063f8 Turn filtermail into a beforequeue handler and implement rate limit 2023-10-19 03:04:00 +00:00
link2xt
1cdc5d1351 Revert "open a persistent client between the BeforeQueueHandler and postfix smtpd without content filter"
This reverts commit fb2ea27477.
2023-10-19 02:22:38 +00:00
link2xt
30680cb170 filtermail: port is args[0], not args[1] 2023-10-19 02:22:30 +00:00
link2xt
c514fb00a3 Import SMTP from aiosmtpd.lmtp, not aiosmtpd.smtp 2023-10-19 02:22:15 +00:00
holger krekel
c7995356b9 shift for simpler diff 2023-10-19 01:16:19 +02:00
holger krekel
fb2ea27477 open a persistent client between the BeforeQueueHandler and postfix smtpd without content filter 2023-10-19 01:10:06 +02:00
holger krekel
7cf6cc2c91 remove filtermail split and LMTP backend 2023-10-19 01:05:49 +02:00
holger krekel
4358d5fe61 only do a smtp beforequeue-handler, also simplifies the send-rate-limiting test and improves DC behaviour 2023-10-19 00:54:45 +02:00
holger krekel
10cb099c0e all tests pass 2023-10-19 00:07:22 +02:00
link2xt
329b845c79 Configure journald to retain logs for 3 days 2023-10-18 22:54:50 +02:00
holger krekel
bbd2773506 refactor test and filtermail to prepare it for BeforeQueue handling 2023-10-18 21:43:06 +02:00
missytake
410bc50a8b test: report if rate limit from last test was still active 2023-10-18 19:02:40 +02:00
missytake
015269fa7b test: test that there is no internal limit (xfail for now) 2023-10-18 19:02:40 +02:00
missytake
b8673d8625 postfix: add simple rate limiting without allow list or leaky bucket, also for internal mail 2023-10-18 19:02:40 +02:00
missytake
31c71fa6e9 add test for postfix rate limiting 2023-10-18 19:02:40 +02:00
holger krekel
8fcd423015 apply most of linkxt review comments 2023-10-18 18:59:01 +02:00
holger krekel
df39d05263 doc the test 2023-10-18 18:59:01 +02:00
holger krekel
05ce4f769b make test more readable 2023-10-18 18:59:01 +02:00
holger krekel
8dc05ba7ec also test that external addresses fail to be forged 2023-10-18 18:59:01 +02:00
holger krekel
6701c9749c refactor test to be more strict 2023-10-18 18:59:01 +02:00
holger krekel
c6d8f7e759 initial forged-from protection 2023-10-18 18:59:01 +02:00
link2xt
76765164dc Deploy nginx and autoconfig XML 2023-10-18 18:32:28 +02:00
link2xt
1ea25eb28c Add /etc/mailname 2023-10-17 19:27:57 +00:00
missytake
f333226abe dictproxy: make NOCREATE_FILE a constant; log warning if creating account fails 2023-10-17 20:03:18 +02:00
missytake
45fe8a668b tests: pass CLI arguments to pytest, don't run chatmails tests in weird subdir 2023-10-17 20:03:18 +02:00
missytake
beac91159d tests: install doveadm for being able to run dictproxy tests 2023-10-17 20:03:18 +02:00
missytake
75e7c85e61 tests: adjust to dictproxy, test /tmp/nocreate 2023-10-17 20:03:18 +02:00
missytake
040b7a74a6 doveauth: don't create users if /tmp/nocreate exists 2023-10-17 20:03:18 +02:00
missytake
0138e59355 doveauth: removed doveauth.py from the project 2023-10-17 20:03:18 +02:00
holger krekel
fffbdc10c3 minimize capabilities 2023-10-17 01:22:19 +02:00
holger krekel
3419e359c8 create build venv in chatmaild/venv 2023-10-17 00:35:07 +02:00
holger krekel
0b051f8154 move deploy.py file and revamp README 2023-10-17 00:35:07 +02:00
link2xt
5936f7a3be postfix: disable virtual and local MDAs
We do not need them, all mails are delivered to Dovecot over LMTP.
2023-10-16 22:21:39 +00:00
link2xt
983ffa6236 Disable acmetool redirector 2023-10-16 22:12:45 +00:00
holger krekel
b74fde2a9f add postfix instrumented debugging 2023-10-16 23:38:02 +02:00
holger krekel
e176595f1f add global debug flag and instrument dovecot with it 2023-10-16 23:38:02 +02:00
link2xt
179c79a052 Allow to send securejoin 2023-10-16 21:15:56 +00:00
link2xt
408da296f1 test.sh: do not run slow tests by default 2023-10-16 20:13:41 +00:00
missytake
192238567b add some initial benchmarks
Co-Authored-By: holger krekel <holger@merlinux.eu>
2023-10-16 21:51:53 +02:00
holger krekel
c35e485510 an empty message in the handler means EOF 2023-10-16 21:49:56 +02:00
holger krekel
1bac4b5b46 generalize remotelog to "remote" and offer remote.iter_output method 2023-10-16 20:49:30 +02:00
holger krekel
63a7ad82ff fix capturing of logging to capture postfix better 2023-10-16 20:49:30 +02:00
holger krekel
37ef3f13b4 fix bugs 2023-10-16 20:49:30 +02:00
holger krekel
9dfd0ceb5a simplify and speedup multi-chatmail instance support 2023-10-16 20:49:30 +02:00
holger krekel
55c58e3c7a add support for using a second chatmail server 2023-10-16 20:49:30 +02:00
holger krekel
c2692c7e92 introduce remotelog fixture for capturing systemd-unit logs 2023-10-16 20:49:30 +02:00
missytake
ea5eccf377 plan: seen messages should be expunged, too 2023-10-16 17:56:51 +02:00
missytake
c9ecf24b3e dovecot: expunge seen messages older than 40 days each night 2023-10-16 17:56:51 +02:00
holger krekel
b943f24587 apply nami's suggestions (chatmail SSH env var, running --slow in test.sh) 2023-10-16 17:52:08 +02:00
holger krekel
df00333a19 also show the chatmail instance prominently in the test header 2023-10-16 17:52:08 +02:00
holger krekel
4fc63461fb - introduce pytest.mark.slow marker and "--slow" CLI option
- refactor login tests to allow running them against both imap/smtp
2023-10-16 17:52:08 +02:00
holger krekel
00af333694 test works by logging into remote machine and checking the dovecot quota log 2023-10-16 15:19:29 +02:00
holger krekel
c9fd133942 improved test but still not doing what it should 2023-10-16 15:19:29 +02:00
holger krekel
caed6a3754 some fixes but still not quite running through 2023-10-16 15:19:29 +02:00
holger krekel
f71d372491 add a quota test (inspired by nami's #21 ) and try to get postfix/dovecot to implement the limit and the test to pass (it doesn't yet) 2023-10-16 15:19:29 +02:00
missytake
6debf11f6f sieve is not installed and we don't need it 2023-10-16 15:19:29 +02:00
holger krekel
60e1671062 make quota work 2023-10-16 15:19:29 +02:00
81 changed files with 4986 additions and 1060 deletions

40
.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: CI
on:
pull_request:
push:
jobs:
tox:
name: isolated chatmaild tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: run chatmaild tests
working-directory: chatmaild
run: pipx run tox
scripts:
name: deploy-chatmail tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- 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 tests
- name: initialize with chatmail domain
run: cmdeploy init chat.example.org
# 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

177
README.md
View File

@@ -1,67 +1,160 @@
# Chat Mail server configuration
This package deploys Postfix and Dovecot servers, including OpenDKIM for DKIM signing.
<img width="800px" src="www/src/collage-top.png"/>
Postfix uses Dovecot for authentication as described in <https://www.postfix.org/SASL_README.html#server_dovecot>
# Chatmail services optimized for Delta Chat apps
## Getting started
This repository helps to setup a ready-to-use chatmail server
comprised of a minimal setup of the battle-tested
[postfix smtp](https://www.postfix.org) and [dovecot imap](https://www.dovecot.org) services.
prepare:
The setup is designed and optimized for providing chatmail accounts
for use by [Delta Chat apps](https://delta.chat).
pip install -e chatmail-infra
Chatmail accounts are automatically created by a first login,
after which the initially specified password is required for using them.
## Deploying your own chatmail server
We subsequently use `CHATMAIL_DOMAIN` as a placeholder for your fully qualified
DNS domain name (FQDN), for example `chat.example.org`.
1. Setup DNS `A` and `AAAA` records for your `CHATMAIL_DOMAIN`.
Verify that DNS is set and SSH root login works:
```
ssh root@CHATMAIL_DOMAIN
```
2. Install the `cmdeploy` command in a virtualenv
```
source scripts/initenv.sh
```
3. Create chatmail configuration file `chatmail.ini`:
```
cmdeploy init CHATMAIL_DOMAIN
```
4. Deploy to the remote chatmail server:
```
cmdeploy run
```
5. To output a DNS zone file from which you can transfer DNS records
to your DNS provider:
```
cmdeploy dns
```
6. To check status of your remotely running chatmail service:
```
cmdeploy status
```
7. To test your chatmail service:
```
cmdeploy test
```
8. To benchmark your chatmail service:
```
cmdeploy bench
```
### Refining the web pages
then run with pyinfra command line tool:
CHATMAIL_DOMAIN=c1.testrun.org pyinfra --ssh-user root c1.testrun.org deploy.py
## Structure (wip)
```
cmdeploy webdev
```
# package doveauth tool and deploy chatmail server to a envvar-specified ssh-reachable host
deploy.py
This starts a local live development cycle for chatmail Web pages:
# chatmail pyinfra deploy package
chatmail-pyinfra
pyproject.toml
chatmail/__init__ ...
- uses the `www/src/page-layout.html` file for producing static
HTML pages from `www/src/*.md` files
# doveauth tool used by dovecot's auth mechanism on the host system
doveauth
README.md
pyproject.toml
doveauth.py
test_doveauth.py
- continously builds the web presence reading files from `www/src` directory
and generating html files and copying assets to the `www/build` directory.
# lmtp server to block (outgoing) unencrypted messages
filtermail
README.md
pyproject.toml
....
# online tests (after deploy)
online-tests # runnable via pytest
- Starts a browser window automatically where you can "refresh" as needed.
### Home page and getting started for users
# scripts for setup/development/deployment
`cmdeploy run` sets up mail services,
and also creates default static Web pages and deploys them:
scripts/
init.sh # create venv/other perequires
deploy.sh # run pyinfra based deploy of everything
test.sh # run all local and online tests
- 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.
## Dovecot/Postfix configuration
### Ports
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
Dovecot listens on ports 143(imap) and 993 (imaps).
## DNS
Delta Chat will, however, discover all ports and configurations
automatically by reading the `autoconfig.xml` file from the chatmail instance.
## Emergency Commands to disable automatic account creation
If you need to stop account creation,
e.g. because some script is wildly creating accounts, run:
touch /etc/chatmail-nocreate
While this file is present, account creation will be blocked.
## Running tests and benchmarks (offline and online)
1. Set `CHATMAIL_SSH` so that `ssh root@$CHATMAIL_SSH` allows
to login to the chatmail instance server.
2. To run local and online tests:
scripts/test.sh
3. To run benchmarks against your chatmail instance:
scripts/bench.sh
## Development Background for chatmail instances
This repository drives the development of "chatmail instances",
comprised of minimal setups of
- [postfix smtp server](https://www.postfix.org)
- [dovecot imap server](https://www.dovecot.org)
as well as two 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 instance
and is integrated into postfix's outbound mail pipelines.
For DKIM you must add a DNS entry as in /etc/opendkim/selector.txt (where selector is the opendkim_selector configured in the chatmail inventory).

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,27 +1,39 @@
[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",
]
[tool.setuptools]
include-package-data = true
[tool.setuptools.packages.find]
where = ['src']
[project.scripts]
doveauth = "chatmaild.doveauth:main"
doveauth-dictproxy = "chatmaild.dictproxy:main"
filtermail = "chatmaild.filtermail: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 = """
[tox]
isolated_build = true
envlist = lint
envlist = lint,py
[testenv:lint]
skipdist = True
@@ -32,4 +44,9 @@ deps =
commands =
black --quiet --check --diff src/
ruff src/
[testenv]
deps = pytest
pdbpp
commands = pytest -v -rsXx {posargs}
"""

View File

@@ -0,0 +1,51 @@
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.mailname = self.mail_domain = params["mailname"]
self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.postfix_reinject_port = int(params["postfix_reinject_port"])
self.passthrough_recipients = params["passthrough_recipients"].split()
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, mailname):
from importlib.resources import files
inidir = files(__package__).joinpath("ini")
content = inidir.joinpath("chatmail.ini.f").read_text().format(mailname=mailname)
if mailname.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,115 +0,0 @@
import os
import sys
import json
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
ThreadingMixIn,
)
import pwd
import subprocess
from .database import Database
def encrypt_password(password: str):
password = password.encode("ascii")
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
process = subprocess.Popen(
["doveadm", "pw", "-s", "SHA512-CRYPT"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
stdout_data, _stderr_data = process.communicate(
input=password + b"\n" + password + b"\n"
)
return stdout_data.decode("ascii").strip()
def create_user(db, user, password):
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):
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":
res = lookup_userdb(db, user)
if res:
reply_command = "O"
else:
reply_command = "N"
elif type == "passdb":
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])
class Handler(StreamRequestHandler):
def handle(self):
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
continue
res = handle_dovecot_request(msg, db)
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

@@ -1,9 +1,51 @@
#!/usr/bin/env python3
import base64
import logging
import os
import time
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 is_allowed_to_create(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) < 9:
logging.warning("Password needs to be at least 9 characters long")
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 domain == "nine.testrun.org":
# nine.testrun.org policy, username has to be exactly nine chars
if len(localpart) != 9:
logging.warning(f"localpart {localpart!r} has not exactly nine chars")
return False
return True
def get_user_data(db, user):
with db.read_connection() as conn:
@@ -14,52 +56,101 @@ def get_user_data(db, user):
return result
def create_user(db, user, password):
def lookup_userdb(db, user):
return get_user_data(db, user)
def lookup_passdb(db, user, cleartext_password):
with db.write_transaction() as conn:
conn.create_user(user, password)
return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password)
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(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/{user}",
uid="vmail",
gid="vmail",
password=encrypted_password,
)
def verify_user(db, user, password):
userdata = get_user_data(db, user)
if userdata:
if userdata.get("password") == password:
userdata["status"] = "ok"
else:
userdata["status"] = "fail"
else:
userdata = create_user(db, user, password)
userdata["status"] = "ok"
return userdata
def handle_dovecot_request(msg, db, mail_domain):
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, 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
def lookup_user(db, user):
userdata = get_user_data(db, user)
if userdata:
userdata["status"] = "ok"
else:
userdata["status"] = "fail"
return userdata
def dump_result(res):
for key, value in res.items():
print(f"{key}={value}")
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
request_queue_size = 100
def main():
db = Database("/home/vmail/passdb.sqlite")
if sys.argv[1] == "hexauth":
login = base64.b16decode(sys.argv[2]).decode()
password = base64.b16decode(sys.argv[3]).decode()
res = verify_user(db, login, password)
dump_result(res)
elif sys.argv[1] == "hexlookup":
login = base64.b16decode(sys.argv[2]).decode()
res = lookup_user(db, login)
dump_result(res)
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):
try:
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, mail_domain)
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
if __name__ == "__main__":
main()
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
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -1,17 +1,20 @@
#!/usr/bin/env python3
import asyncio
import logging
import time
import sys
from email.parser import BytesParser
from email import policy
from email.utils import parseaddr
from aiosmtpd.lmtp import LMTP
from aiosmtpd.controller import UnixSocketController
from aiosmtpd.controller import Controller
from smtplib import SMTP as SMTPClient
from .config import read_config
def check_encrypted(content):
def check_encrypted(message):
"""Check that the message is an OpenPGP-encrypted message."""
message = BytesParser(policy=policy.default).parsebytes(content)
if not message.is_multipart():
return False
if message.get("subject") != "...":
@@ -32,72 +35,126 @@ def check_encrypted(content):
return True
class ExampleController(UnixSocketController):
def factory(self):
return LMTP(self.handler, **self.SMTP_kwargs)
def check_mdn(message, envelope):
if len(envelope.rcpt_tos) != 1:
return False
for name in ["auto-submitted", "chat-version"]:
if not message.get(name):
return False
if message.get_content_type() != "multipart/report":
return False
body = message.get_body()
if body.get_content_type() != "text/plain":
return False
if list(body.iter_attachments()) or list(body.iter_parts()):
return False
# even with all mime-structural checks an attacker
# could try to abuse the subject or body to contain links or other
# annoyance -- we skip on checking subject/body for now as Delta Chat
# should evolve to create E2E-encrypted read receipts anyway.
# and then MDNs are just encrypted mail and can pass the border
# to other instances.
return True
class ExampleHandler:
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
envelope.rcpt_tos.append(address)
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, 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
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("@")
if len(parts) != 2:
return f"500 Invalid from address <{envelope.mail_from!r}>"
return "250 OK"
async def handle_DATA(self, server, session, envelope):
logging.info("Processing DATA message from %s", envelope.mail_from)
logging.info("handle_DATA before-queue")
error = self.check_DATA(envelope)
if error:
return error
logging.info("re-injecting the mail that passed checks")
client = SMTPClient("localhost", self.config.postfix_reinject_port)
client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
return "250 OK"
valid_recipients = []
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {envelope.mail_from}")
mail_encrypted = check_encrypted(envelope.content)
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
res = []
_, 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}>"
if not mail_encrypted and check_mdn(message, envelope):
return
passthrough_recipients = self.config.passthrough_recipients
envelope_from_domain = from_addr.split("@").pop()
for recipient in envelope.rcpt_tos:
my_local_domain = envelope.mail_from.split("@")
if len(my_local_domain) != 2:
res += [f"500 Invalid from address <{envelope.mail_from}>"]
continue
if envelope.mail_from == recipient:
# Always allow sending emails to self.
valid_recipients += [recipient]
res += ["250 OK"]
continue
recipient_local_domain = recipient.split("@")
if len(recipient_local_domain) != 2:
res += [f"500 Invalid address <{recipient}>"]
if recipient in passthrough_recipients:
continue
res = recipient.split("@")
if len(res) != 2:
return f"500 Invalid address <{recipient}>"
_recipient_addr, recipient_domain = res
is_outgoing = recipient_local_domain[1] != my_local_domain[1]
is_outgoing = recipient_domain != envelope_from_domain
if is_outgoing and not mail_encrypted:
res += ["500 Outgoing mail must be encrypted"]
continue
valid_recipients += [recipient]
res += ["250 OK"]
# Reinject the mail back into Postfix.
if valid_recipients:
logging.info("Reinjecting the mail")
client = SMTPClient("localhost", "10026")
client.sendmail(envelope.mail_from, valid_recipients, envelope.content)
return "\r\n".join(res)
is_securejoin = message.get("secure-join") in [
"vc-request",
"vg-request",
]
if not is_securejoin:
return f"500 Invalid unencrypted mail to <{recipient}>"
async def asyncmain(loop):
controller = ExampleController(
ExampleHandler(), unix_socket="/var/spool/postfix/private/filtermail"
)
controller.start()
class SendRateLimiter:
def __init__(self):
self.addr2timestamps = {}
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) <= max_send_per_minute:
last.append(now)
return True
return False
def main():
logging.basicConfig(level=logging.INFO)
args = sys.argv[1:]
assert len(args) == 1
config = read_config(args[0])
logging.basicConfig(level=logging.WARN)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.create_task(asyncmain(loop=loop))
task = asyncmain_beforequeue(config)
loop.create_task(task)
loop.run_forever()
if __name__ == "__main__":
main()

View File

@@ -1,8 +1,8 @@
[Unit]
Description=Email filter for chatmail servers
Description=Chatmail Postfix BeforeQeue filter
[Service]
ExecStart=/usr/local/bin/filtermail
ExecStart={execpath} {config_path}
Restart=always
RestartSec=30

View File

@@ -0,0 +1,33 @@
[params]
# mail domain (MUST be set to fully qualified chat mail domain)
mailname = {mailname}
#
# If you only do private test deploys, you don't need to modify any settings below
#
# how many mails a user can send out per minute
max_user_send_per_minute = 60
# list of e-mail recipients for which to accept outbound un-encrypted mails
passthrough_recipients =
# where the filtermail SMTP service listens
filtermail_smtp_port = 10080
# postfix accepts on the localhost reinject SMTP port
postfix_reinject_port = 10025
# 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,28 @@
#!/usr/bin/python3
""" CGI script for creating new accounts. """
import json
import random
mailname_path = "/etc/mailname"
def create_newemail_dict(domain):
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
user = "".join(random.choices(alphanumeric, k=9))
password = "".join(random.choices(alphanumeric, k=12))
return dict(email=f"{user}@{domain}", password=f"{password}")
def print_new_account():
domain = open(mailname_path).read().strip()
creds = create_newemail_dict(domain=domain)
print("Content-Type: application/json")
print("")
print(json.dumps(creds))
if __name__ == "__main__":
print_new_account()

View File

@@ -1,36 +0,0 @@
import pytest
from .dictproxy import get_user_data
from .doveauth import verify_user
from .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):
verify_user(db, "link2xt@c1.testrun.org", "asdf")
data = get_user_data(db, "link2xt@c1.testrun.org")
assert data
def test_verify_or_create(db):
res = verify_user(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
assert res["status"] == "ok"
res = verify_user(db, "newuser1@something.org", "kajdlqweqwe")
assert res["status"] == "fail"
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,286 +0,0 @@
import pytest
from .filtermail import check_encrypted
def test_filtermail():
assert not check_encrypted(b"foo")
assert not check_encrypted(
"\r\n".join(
[
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
"Chat-Disposition-Notification-To: foobar@c2.testrun.org",
"Chat-User-Avatar: 0",
"From: <foobar@c2.testrun.org>",
"To: <barbaz@c2.testrun.org>",
"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;",
"\tkeydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m",
"\tnNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID",
"\tFgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I",
"\tHjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK",
"\tKwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ",
"\tJlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI",
"\tvYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
"",
"Hi!",
"",
"",
]
).encode()
)
assert not check_encrypted(
"\r\n".join(
[
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
"Chat-Disposition-Notification-To: foobar@c2.testrun.org",
"Chat-User-Avatar: 0",
"From: <foobar@c2.testrun.org>",
"To: <barbaz@c2.testrun.org>",
"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;",
"\tkeydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m",
"\tnNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID",
"\tFgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I",
"\tHjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK",
"\tKwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ",
"\tJlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI",
"\tvYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
"",
"Hi!",
"",
"",
]
).encode()
)
# https://xkcd.com/1181/
assert not check_encrypted(
"\r\n".join(
[
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
"Chat-Disposition-Notification-To: foobar@c2.testrun.org",
"Chat-User-Avatar: 0",
"From: <foobar@c2.testrun.org>",
"To: <barbaz@c2.testrun.org>",
"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;",
"\tkeydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m",
"\tnNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID",
"\tFgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I",
"\tHjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK",
"\tKwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ",
"\tJlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI",
"\tvYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
"",
"-----BEGIN PGP MESSAGE-----",
"Hi!",
"-----END PGP MESSAGE-----",
"",
"",
]
).encode()
)
assert check_encrypted(
"\r\n".join(
[
"Subject: ...",
"From: <barbaz@c2.testrun.org>",
"To: <foobar@c2.testrun.org>",
"Date: Sun, 15 Oct 2023 16:43:21 +0000",
"Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>",
"In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>",
"References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
"\t<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>",
"Chat-Version: 1.0",
"Autocrypt: addr=barbaz@c2.testrun.org; prefer-encrypt=mutual;",
"\tkeydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH",
"\trNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID",
"\tFgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW",
"\tXQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK",
"\tKwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ",
"\tJlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP",
"\tIXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO",
"MIME-Version: 1.0",
'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";',
'\tboundary="YFrteb74qSXmggbOxZL9dRnhymywAi"',
"",
"",
"--YFrteb74qSXmggbOxZL9dRnhymywAi",
"Content-Description: PGP/MIME version identification",
"Content-Type: application/pgp-encrypted",
"",
"Version: 1",
"",
"",
"--YFrteb74qSXmggbOxZL9dRnhymywAi",
"Content-Description: OpenPGP encrypted message",
'Content-Disposition: inline; filename="encrypted.asc";',
'Content-Type: application/octet-stream; name="encrypted.asc"',
"",
"-----BEGIN PGP MESSAGE-----",
"",
"wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg",
"O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae",
"8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI",
"JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no",
"lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz",
"ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM",
"YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA",
"kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI",
"+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg",
"RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo",
"tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7",
"rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp",
"H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI",
"fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9",
"61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN",
"XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3",
"w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb",
"NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs",
"baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW",
"A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8",
"uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI",
"E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn",
"lkOWnEbCD+XTnbDd",
"=agR5",
"-----END PGP MESSAGE-----",
"",
"",
"--YFrteb74qSXmggbOxZL9dRnhymywAi--",
"",
"",
]
).encode()
)
assert not check_encrypted(
"\r\n".join(
[
"Subject: Buy Penis Enlargement at www.malicious-domain.com",
"From: <barbaz@c2.testrun.org>",
"To: <foobar@c2.testrun.org>",
"Date: Sun, 15 Oct 2023 16:43:21 +0000",
"Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>",
"In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>",
"References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
"\t<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>",
"Chat-Version: 1.0",
"Autocrypt: addr=barbaz@c2.testrun.org; prefer-encrypt=mutual;",
"\tkeydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH",
"\trNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID",
"\tFgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW",
"\tXQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK",
"\tKwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ",
"\tJlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP",
"\tIXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO",
"MIME-Version: 1.0",
'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";',
'\tboundary="YFrteb74qSXmggbOxZL9dRnhymywAi"',
"",
"",
"--YFrteb74qSXmggbOxZL9dRnhymywAi",
"Content-Description: PGP/MIME version identification",
"Content-Type: application/pgp-encrypted",
"",
"Version: 1",
"",
"",
"--YFrteb74qSXmggbOxZL9dRnhymywAi",
"Content-Description: OpenPGP encrypted message",
'Content-Disposition: inline; filename="encrypted.asc";',
'Content-Type: application/octet-stream; name="encrypted.asc"',
"",
"-----BEGIN PGP MESSAGE-----",
"",
"wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg",
"O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae",
"8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI",
"JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no",
"lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz",
"ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM",
"YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA",
"kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI",
"+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg",
"RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo",
"tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7",
"rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp",
"H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI",
"fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9",
"61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN",
"XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3",
"w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb",
"NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs",
"baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW",
"A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8",
"uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI",
"E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn",
"lkOWnEbCD+XTnbDd",
"=agR5",
"-----END PGP MESSAGE-----",
"",
"",
"--YFrteb74qSXmggbOxZL9dRnhymywAi--",
"",
"",
]
).encode()
)
assert not check_encrypted(
"\r\n".join(
[
"Subject: Message opened",
"From: <barbaz@c2.testrun.org>",
"To: <foobar@c2.testrun.org>",
"Date: Sun, 15 Oct 2023 16:43:25 +0000",
"Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>",
"Auto-Submitted: auto-replied",
"Chat-Version: 1.0",
"MIME-Version: 1.0",
"Content-Type: multipart/report; report-type=disposition-notification;",
'\tboundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"',
"",
"",
"--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi",
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
"",
'The "Hi!" message you sent was displayed on the screen of the recipient.',
"",
"This is no guarantee the content was read.",
"",
"",
"--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi",
"Content-Type: message/disposition-notification",
"",
"Reporting-UA: Delta Chat 1.124.1",
"Original-Recipient: rfc822;barbaz@c2.testrun.org",
"Final-Recipient: rfc822;barbaz@c2.testrun.org",
"Original-Message-ID: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>",
"Disposition: manual-action/MDN-sent-automatically; displayed",
"",
"",
"--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--",
"",
"",
]
).encode()
)

View File

@@ -0,0 +1,66 @@
From: {from_addr}
To: {to_addr}
Subject: ...
Date: Sun, 15 Oct 2023 16:43:21 +0000
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
MIME-Version: 1.0
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: PGP/MIME version identification
Content-Type: application/pgp-encrypted
Version: 1
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: OpenPGP encrypted message
Content-Disposition: inline; filename="encrypted.asc";
Content-Type: application/octet-stream; name="encrypted.asc"
-----BEGIN PGP MESSAGE-----
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
lkOWnEbCD+XTnbDd
=agR5
-----END PGP MESSAGE-----
--YFrteb74qSXmggbOxZL9dRnhymywAi--

View File

@@ -0,0 +1,25 @@
Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=
Chat-Disposition-Notification-To: foobar@c2.testrun.org
Chat-User-Avatar: 0
From: <{from_addr}>
To: <{to_addr}>
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={from_addr}; prefer-encrypt=mutual;
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK
KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ
JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI
vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
-----BEGIN PGP MESSAGE-----
Hi!
-----END PGP MESSAGE-----

View File

@@ -0,0 +1,33 @@
Subject: Message opened
From: <{from_addr}>
To: <{to_addr}>
Date: Sun, 15 Oct 2023 16:43:25 +0000
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
Auto-Submitted: auto-replied
Chat-Version: 1.0
MIME-Version: 1.0
Content-Type: multipart/report; report-type=disposition-notification;
boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
The "Hi!" message you sent was displayed on the screen of the recipient.
This is no guarantee the content was read.
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
Content-Type: message/disposition-notification
Reporting-UA: Delta Chat 1.124.1
Original-Recipient: rfc822;barbaz@c2.testrun.org
Final-Recipient: rfc822;barbaz@c2.testrun.org
Original-Message-ID: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
Disposition: manual-action/MDN-sent-automatically; displayed
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--

View File

@@ -0,0 +1,23 @@
Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=
Chat-Disposition-Notification-To: foobar@c2.testrun.org
Chat-User-Avatar: 0
From: <{from_addr}>
To: <{to_addr}>
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;
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK
KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ
JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI
vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK
MIME-Version: 1.0
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(mailname):
write_initial_config(inipath, mailname=mailname)
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.mailname
@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,27 @@
from chatmaild.config import read_config
def test_read_config_basic(make_config):
config = make_config("chat.example.org")
assert config.mailname == "chat.example.org"
assert not config.privacy_supervisor and not config.privacy_mail
assert not config.privacy_pdo and not config.privacy_postal
inipath = config._inipath
inipath.write_text(inipath.read_text().replace("60", "37"))
config = read_config(inipath)
assert config.max_user_send_per_minute == 37
assert config.mailname == "chat.example.org"
def test_read_config_testrun(make_config):
config = make_config("something.testrun.org")
assert config.mailname == "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.passthrough_recipients

View File

@@ -0,0 +1,89 @@
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):
lookup_passdb(db, "link2xt@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
data = get_user_data(db, "link2xt@c1.testrun.org")
assert data
data2 = lookup_passdb(db, "link2xt@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
assert data == data2
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, monkeypatch, tmpdir):
p = tmpdir.join("nocreate")
p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
lookup_passdb(db, "newuser1@something.org", "zequ0Aimuchoodaechik")
assert not get_user_data(db, "newuser1@something.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):
msg = (
"Lshared/passdb/laksjdlaksjdlaksjdlk12j3l1k2j3123/"
"some42@c3.testrun.org\tsome42@c3.testrun.org"
)
res = handle_dovecot_request(msg, db, "c3.testrun.org")
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert userdata["home"] == "/home/vmail/some42@c3.testrun.org"
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_50_concurrent_lookups_different_accounts(db, gencreds):
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, 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,129 @@
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)

View File

@@ -0,0 +1,29 @@
import json
import chatmaild
from chatmaild.newemail import create_newemail_dict, print_new_account
def test_create_newemail_dict():
ac1 = create_newemail_dict(domain="example.org")
assert "@" in ac1["email"]
assert len(ac1["password"]) >= 10
ac2 = create_newemail_dict(domain="example.org")
assert ac1["email"] != ac2["email"]
assert ac1["password"] != ac2["password"]
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir):
p = tmpdir.join("mailname")
p.write(maildomain)
monkeypatch.setattr(chatmaild.newemail, "mailname_path", str(p))
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"@{maildomain}")
assert len(dic["password"]) >= 10

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=45"]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
@@ -7,24 +7,25 @@ name = "deploy-chatmail"
version = "0.1"
dependencies = [
"pyinfra",
"pillow",
"qrcode",
"markdown",
"pytest",
"setuptools>=68",
"termcolor",
"build",
"tox",
"ruff",
"black",
"pytest",
"pytest-xdist",
]
[project.scripts]
cmdeploy = "deploy_chatmail.cmdeploy:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
[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,71 +1,110 @@
"""
Chat Mail pyinfra deploy.
"""
import sys
import importlib.resources
import subprocess
import shutil
import io
from pathlib import Path
from pyinfra import host, logger
from pyinfra.operations import apt, files, server, systemd, python
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 _install_chatmaild() -> None:
chatmaild_filename = "chatmaild-0.1.tar.gz"
chatmaild_path = importlib.resources.files(__package__).joinpath(
f"../../../dist/{chatmaild_filename}"
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)]
)
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,
)
entries = list(dist_dir.iterdir())
assert len(entries) == 1
return entries[0]
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}"],
)
files.put(
name="upload doveauth-dictproxy.service",
src=importlib.resources.files("chatmaild")
.joinpath("doveauth-dictproxy.service")
.open("rb"),
dest="/etc/systemd/system/doveauth-dictproxy.service",
user="root",
group="root",
mode="644",
)
def remove_legacy_artifacts():
# disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service(
name="Setup doveauth-dictproxy service",
name="Disable legacy doveauth-dictproxy.service",
service="doveauth-dictproxy.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
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}"
],
)
# install systemd units
for fn in (
"doveauth",
"filtermail",
):
params = dict(
execpath=f"{remote_venv_dir}/bin/{fn}",
config_path=remote_chatmail_inipath,
)
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
content = source_path.read_text().format(**params).encode()
files.put(
name="upload filtermail.service",
src=importlib.resources.files("chatmaild")
.joinpath("filtermail.service")
.open("rb"),
dest="/etc/systemd/system/filtermail.service",
user="root",
group="root",
mode="644",
name=f"Upload {fn}.service",
src=io.BytesIO(content),
dest=f"/etc/systemd/system/{fn}.service",
**root_owned,
)
systemd.service(
name="Setup filtermail service",
service="filtermail.service",
name=f"Setup {fn} service",
service=f"{fn}.service",
running=True,
enabled=True,
restarted=True,
@@ -85,6 +124,36 @@ def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= main_config.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
keytable = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/KeyTable"),
dest="/etc/dkimkeys/KeyTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= keytable.changed
signing_table = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/SigningTable"),
dest="/etc/dkimkeys/SigningTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= signing_table.changed
files.directory(
name="Add opendkim socket directory to /var/spool/postfix",
@@ -105,12 +174,48 @@ def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
_sudo_user="opendkim",
)
need_restart |= main_config.changed
return need_restart
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 venv /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(domain: str) -> bool:
def _configure_postfix(config: Config, debug: bool = False) -> bool:
"""Configures Postfix SMTP server."""
need_restart = False
@@ -120,25 +225,25 @@ def _configure_postfix(domain: str) -> bool:
user="root",
group="root",
mode="644",
config={"domain_name": domain},
config=config,
)
need_restart |= main_config.changed
master_config = files.put(
src=importlib.resources.files(__package__)
.joinpath("postfix/master.cf")
.open("rb"),
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
return need_restart
def _configure_dovecot(mail_server: str) -> bool:
def _configure_dovecot(mail_server: str, debug: bool = False) -> bool:
"""Configures Dovecot IMAP server."""
need_restart = False
@@ -149,6 +254,7 @@ def _configure_dovecot(mail_server: str) -> bool:
group="root",
mode="644",
config={"hostname": mail_server},
debug=debug,
)
need_restart |= main_config.changed
auth_config = files.put(
@@ -160,9 +266,98 @@ def _configure_dovecot(mail_server: str) -> bool:
)
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",
)
# 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 check_config(config):
mailname = config.mailname
if mailname != "testrun.org" and not mailname.endswith(".testrun.org"):
blocked_words = "merlinux schmieder testrun.org".split()
for value in config.__dict__.values():
if any(x in value for x in blocked_words):
raise ValueError(
f"please set your own privacy contacts/addresses in {config._inipath}"
)
return config
def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> None:
"""Deploy a chat-mail instance.
@@ -170,6 +365,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
:param mail_server: the DNS name under which your mail server is reachable
:param dkim_selector:
"""
from .www import build_webpages
apt.update(name="apt update", cache_time=24 * 3600)
server.group(name="Create vmail group", group="vmail", system=True)
@@ -184,7 +380,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
)
# Deploy acmetool to have TLS certificates.
deploy_acmetool(domains=[mail_server])
deploy_acmetool(nginx_hook=True, domains=[mail_server, f"mta-sts.{mail_server}"])
apt.packages(
name="Install Postfix",
@@ -204,10 +400,34 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
],
)
_install_chatmaild()
dovecot_need_restart = _configure_dovecot(mail_server)
postfix_need_restart = _configure_postfix(mail_domain)
apt.packages(
name="Install nginx",
packages=["nginx"],
)
apt.packages(
name="Install fcgiwrap",
packages=["fcgiwrap"],
)
pkg_root = importlib.resources.files(__package__)
chatmail_ini = pkg_root.joinpath("../../../chatmail.ini").resolve()
config = read_config(chatmail_ini)
check_config(config)
www_path = pkg_root.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(mail_server, debug=debug)
postfix_need_restart = _configure_postfix(config, debug=debug)
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
mta_sts_need_restart = _install_mta_sts_daemon()
nginx_need_restart = _configure_nginx(mail_domain)
systemd.service(
name="Start and enable OpenDKIM",
@@ -217,6 +437,15 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
restarted=opendkim_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",
@@ -233,13 +462,33 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
restarted=dovecot_need_restart,
)
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}")
systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=nginx_need_restart,
)
python.call(name="Print TXT entry for DKIM", function=callback)
# 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,6 @@
import importlib.resources
from pyinfra.operations import apt, files, systemd, server
from pyinfra.operations import apt, files, server
def deploy_acmetool(nginx_hook=False, email="", domains=[]):
@@ -38,25 +38,15 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
email=email,
)
service_file = files.put(
src=importlib.resources.files(__package__)
.joinpath("acmetool-redirector.service")
.open("rb"),
dest="/etc/systemd/system/acmetool-redirector.service",
files.template(
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
dest="/var/lib/acme/conf/target",
user="root",
group="root",
mode="644",
)
systemd.service(
name="Setup acmetool-redirector service",
service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=service_file.changed,
)
for domain in domains:
server.shell(
name=f"Request certificate for {domain}",
commands=[f"acmetool want {domain}"],
)
server.shell(
name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want { ' '.join(domains)}"],
)

View File

@@ -1,11 +0,0 @@
[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,7 @@
request:
provider: https://acme-v02.api.letsencrypt.org/directory
key:
type: rsa
challenge:
webroot-paths:
- /var/www/html/.well-known/acme-challenge

View File

@@ -0,0 +1,12 @@
{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}. IN 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=r;aspf=r"
_mta-sts.{chatmail_domain}. IN TXT "v=STSv1; id={sts_id}"
mta-sts.{chatmail_domain}. IN CNAME {chatmail_domain}.
_smtp._tls.{chatmail_domain}. IN TXT "v=TLSRPTv1;rua=mailto:{email}"
{dkim_entry}

View File

@@ -0,0 +1,301 @@
"""
Provides the `cmdeploy` entry point function,
along with command line option and subcommand parsing.
"""
import argparse
import datetime
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
#
# 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."""
if args.inipath.exists():
out.red(f"Path exists, not modifying: {args.inipath}")
raise SystemExit(1)
write_initial_config(args.inipath, args.chatmail_domain)
out.green(f"created config file for {args.chatmail_domain} in {args.inipath}")
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."""
env = os.environ.copy()
env["CHATMAIL_DOMAIN"] = args.config.mailname
deploypy = "deploy-chatmail/src/deploy_chatmail/deploy.py"
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {args.config.mailname} {deploypy}"
out.check_call(cmd, env=env)
def dns_cmd(args, out):
"""Generate dns zone file."""
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
ssh = f"ssh root@{args.config.mailname}"
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)
return "\n".join(lines)
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
dkim_entry = read_dkim_entries(out.shell_output(f"{ssh} -- opendkim-genzone -F"))
out(
f"[writing {args.config.mailname} zone data (using space as separator) to stdout output]",
green=True,
)
print(
template.read_text()
.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mailname}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mailname,
dkim_entry=dkim_entry,
)
.strip()
)
def status_cmd(args, out):
"""Display status for online chatmail instance."""
ssh = f"ssh root@{args.config.mailname}"
out.green(f"chatmail domain: {args.config.mailname}")
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(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")
ret = out.run_ret(
[pytest_path, "tests/", "-n4", "-rs", "-x", "-vrx", "--durations=5"]
)
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 (fuff and black) on all chatmail source code."""
chatmaild = importlib.resources.files("chatmaild")
deploy_chatmail = importlib.resources.files("deploy_chatmail")
tests = deploy_chatmail.joinpath("../../../tests")
sources = list(str(x) for x in [chatmaild, deploy_chatmail, tests])
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."""
pytest_path = shutil.which("pytest")
benchmark = "tests/online/benchmark.py"
subprocess.check_call([pytest_path, benchmark, "-vrx"])
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):
self(f"[$ {arg}]", file=sys.stderr)
return subprocess.check_output(arg, shell=True).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

View File

@@ -6,7 +6,7 @@ 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")
dkim_selector = os.getenv("CHATMAIL_DKIM_SELECTOR", "dkim")
assert mail_domain
assert mail_server

View File

@@ -4,11 +4,23 @@ protocols = imap lmtp
auth_mechanisms = plain
{% if debug == true %}
auth_verbose = yes
auth_debug = yes
auth_debug_passwords = yes
auth_verbose_passwords = plain
auth_cache_size = 100M
mail_debug = yes
{% endif %}
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 NOTIFY
# Authentication for system users.
passdb {
@@ -60,13 +72,28 @@ mail_privileged_group = vmail
# Enable IMAP COMPRESS (RFC 4978).
# <https://datatracker.ietf.org/doc/html/rfc4978.html>
protocol imap {
mail_plugins = $mail_plugins imap_zlib
mail_plugins = $mail_plugins imap_zlib imap_quota
}
protocol lmtp {
mail_plugins = $mail_plugins quota
}
plugin {
imap_compress_deflate_level = 6
}
plugin {
# for now we define static quota-rules for all users
quota = maildir:User quota
quota_rule = *:storage=100M
quota_max_mail_size=30M
quota_grace = 0
# quota_over_flag_value = TRUE
}
service lmtp {
user=vmail
@@ -91,6 +118,24 @@ 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

View File

@@ -0,0 +1,4 @@
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

@@ -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,2 @@
[Journal]
MaxRetentionSec=3d

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="{{ config.domain_name }}">
<domain>{{ config.domain_name }}</domain>
<displayName>{{ config.domain_name }} chatmail</displayName>
<displayShortName>{{ config.domain_name }}</displayShortName>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<port>143</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>

View File

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

View File

@@ -0,0 +1,55 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
events {
worker_connections 768;
# multi_accept on;
}
http {
sendfile on;
tcp_nopush on;
# Do not emit nginx version on error pages.
server_tokens off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain;
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey;
gzip on;
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
root /var/www/html;
index index.html index.htm;
server_name _;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
# add cgi-bin support
include /usr/share/doc/fcgiwrap/examples/nginx.conf;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 301 https://$host$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.mailname }}
smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU)
biff = no
@@ -16,16 +16,17 @@ readme_directory = no
compatibility_level = 2
# 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.mailname }}/fullchain
smtpd_tls_key_file=/var/lib/acme/live/{{ config.mailname }}/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.mailname }}
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
@@ -37,12 +38,14 @@ mydestination =
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
# maximum 30MB sized messages
message_size_limit = 31457280
recipient_delimiter = +
inet_interfaces = all
inet_protocols = all
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.domain_name }}
virtual_mailbox_domains = {{ config.mailname }}
smtpd_milters = unix:opendkim/opendkim.sock
non_smtpd_milters = $smtpd_milters

View File

@@ -9,7 +9,11 @@
# service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (no) (never) (100)
# ==========================================================================
smtp inet n - y - - smtpd
{% if debug == true %}
smtp inet n - y - - smtpd -v
{% else %}
smtp inet n - y - - smtpd
{% endif %}
#smtp inet n - y - 1 postscreen
#smtpd pass - - y - - smtpd
#dnsblog unix - - y - 0 dnsblog
@@ -28,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 content_filter=filter:unix:private/filtermail
-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
@@ -42,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 content_filter=filter:unix:private/filtermail
-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
@@ -66,13 +72,11 @@ showq unix n - y - - showq
error unix - - y - - error
retry unix - - y - - error
discard unix - - y - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil
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:10026 inet n - n - 10 smtpd
-o content_filter=
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,110 @@
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 _build_webpages(src_dir, build_dir, config):
mail_domain = config.mailname
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)
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.mailname
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,106 +0,0 @@
import os
import io
import imaplib
import smtplib
import itertools
import pytest
import time
@pytest.fixture
def maildomain():
return os.environ.get("CHATMAIL_DOMAIN", "c1.testrun.org")
@pytest.fixture
def imap(maildomain):
return ImapConn(maildomain)
class ImapConn:
def __init__(self, host):
self.host = host
def connect(self):
print(f"imap-connect {self.host}")
self.conn = imaplib.IMAP4_SSL(self.host)
def login(self, user, password):
print(f"imap-login {user!r} {password!r}")
self.conn.login(user, password)
@pytest.fixture
def smtp(maildomain):
return SmtpConn(maildomain)
class SmtpConn:
def __init__(self, host):
self.host = host
def connect(self):
print(f"smtp-connect {self.host}")
self.conn = smtplib.SMTP_SSL(self.host)
def login(self, user, password):
print(f"smtp-login {user!r} {password!r}")
self.conn.login(user, password)
@pytest.fixture
def gencreds(maildomain):
prefix = str(time.time())
count = itertools.count()
def gen():
while 1:
num = next(count)
yield f"user{prefix}_{num}@{maildomain}", f"password{prefix}_{num}"
return lambda: next(gen())
#
# Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts
#
class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
def __init__(self, pytestconfig, maildomain, gencreds):
self.pytestconfig = pytestconfig
self.maildomain = maildomain
self.gencreds = gencreds
self._addr2files = {}
def get_liveconfig_producer(self):
while 1:
user, password = self.gencreds()
config = {"addr": user, "mail_pw": password}
yield config
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
pass
def cache_maybe_store_configured_db_files(self, acc):
pass
@pytest.fixture
def cmfactory(request, maildomain, gencreds, tmpdir, data):
# cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory
testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=data)
yield am
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
if testprocess.pytestconfig.getoption("--extra-info"):
logfile = io.StringIO()
am.dump_imap_summary(logfile=logfile)
print(logfile.getvalue())
# request.node.add_report_section("call", "imap-server-state", s)

View File

@@ -1,2 +0,0 @@
[pytest]
addopts = -vrsx

View File

@@ -1,55 +0,0 @@
import pytest
import imaplib
import smtplib
class TestDovecot:
def test_login_ok(self, imap, gencreds):
user, password = gencreds()
imap.connect()
imap.login(user, password)
# verify it works on another connection
imap.connect()
imap.login(user, password)
def test_login_same_password(self, imap, gencreds):
"""Test two different users logging in with the same password.
This ensures that authentication process does not confuse the users
by using only the password hash as a key.
"""
user1, password1 = gencreds()
user2, _password2 = gencreds()
imap.connect()
imap.login(user1, password1)
imap.connect()
imap.login(user2, password1)
def test_login_fail(self, imap, gencreds):
user, password = gencreds()
imap.connect()
imap.login(user, password)
imap.connect()
with pytest.raises(imaplib.IMAP4.error) as excinfo:
imap.login(user, password + "wrong")
assert "AUTHENTICATIONFAILED" in str(excinfo)
class TestPostfix:
def test_login_ok(self, smtp, gencreds):
user, password = gencreds()
smtp.connect()
smtp.login(user, password)
# verify it works on another connection
smtp.connect()
smtp.login(user, password)
def test_login_fail(self, smtp, gencreds):
user, password = gencreds()
smtp.connect()
smtp.login(user, password)
smtp.connect()
with pytest.raises(smtplib.SMTPAuthenticationError) as excinfo:
smtp.login(user, password + "wrong")
assert excinfo.value.smtp_code == 535
assert "authentication failed" in str(excinfo)

View File

@@ -1,11 +0,0 @@
class TestMailSending:
def test_one_on_one(self, cmfactory, lp):
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: prepare and send text message to ac2")
msg1 = chat.send_text("message0")
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0"

View File

@@ -1,74 +0,0 @@
# Chat-mail server development (up until Oct 18th)
## Dovecot goals/steps
2. (holger) per-user storage quota (adaptive)
a) define a static 100MB per-user quota
3. automatic expiry of messages older than M days
- delete unconditionally messages older than 40 days
4. limit: max-connections per account
## Filtermail
- (alex, Only allow (outgoing) mails if secure-join or autocrypt-pgp-encrypted format.
TODO: mime-parse mails and check/add tests
## 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,9 +0,0 @@
#!/usr/bin/env bash
: ${CHATMAIL_DOMAIN:=c1.testrun.org}
export CHATMAIL_DOMAIN
venv/bin/python3 -m build -n --sdist chatmaild --outdir dist
deploy-chatmail/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" 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"measure{time.time()}", "pass")
status, res = conn.capability()
for capa in sorted(res[0].decode().split()):
print(capa)

View File

@@ -1,17 +0,0 @@
#!/bin/sh
set -e
python3 -m venv deploy-chatmail/venv
deploy-chatmail/venv/bin/pip install pyinfra pytest
deploy-chatmail/venv/bin/pip install -e deploy-chatmail
deploy-chatmail/venv/bin/pip install -e chatmaild
python3 -m venv chatmaild/venv
chatmaild/venv/bin/pip install pytest
chatmaild/venv/bin/pip install -e chatmaild
python3 -m venv online-tests/venv
online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat
python3 -m venv venv
venv/bin/pip install build
venv/bin/pip install 'setuptools>=68'

9
scripts/initenv.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
set -e
python3 -m venv venv
venv/bin/pip install -e deploy-chatmail
venv/bin/pip install -e chatmaild
source venv/bin/activate
echo activated 'venv' python virtualenv environment containing "cmdeploy" tool

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_HOST:=$CHATMAIL_DOMAIN}
rsync -avz . "root@$CHATMAIL_SSH_HOST:/root/chatmail" --exclude='/.git' --filter="dir-merge,- .gitignore"
ssh "root@$CHATMAIL_SSH_HOST" "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,7 +0,0 @@
#!/bin/bash
set -e
pushd chatmaild/src/chatmaild
../../venv/bin/pytest
popd
online-tests/venv/bin/pytest online-tests/ -vrx --durations=5

398
tests/conftest.py Normal file
View File

@@ -0,0 +1,398 @@
import os
import io
import time
import random
import subprocess
import imaplib
import smtplib
import itertools
from pathlib import Path
import pytest
from chatmaild.database import Database
from chatmaild.config import read_config
conftestdir = Path(__file__).parent
def pytest_addoption(parser):
parser.addoption(
"--slow", action="store_true", default=False, help="also run slow tests"
)
def pytest_configure(config):
config._benchresults = {}
config.addinivalue_line(
"markers", "slow: mark test to require --slow option to run"
)
def pytest_runtest_setup(item):
markers = list(item.iter_markers(name="slow"))
if markers:
if not item.config.getoption("--slow"):
pytest.skip("skipping slow test, use --slow to run")
@pytest.fixture
def chatmail_config(pytestconfig):
current = basedir = Path()
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.mailname
@pytest.fixture
def sshdomain(maildomain):
return os.environ.get("CHATMAIL_SSH", maildomain)
@pytest.fixture
def maildomain2():
domain = os.environ.get("CHATMAIL_DOMAIN2")
if not domain:
pytest.skip("set CHATMAIL_DOMAIN2 to a ssh-reachable chatmail instance")
return domain
@pytest.fixture
def sshdomain2(maildomain2):
return os.environ.get("CHATMAIL_SSH2", maildomain2)
def pytest_report_header():
domain = os.environ.get("CHATMAIL_DOMAIN")
if domain:
text = f"chatmail test instance: {domain}"
return ["-" * len(text), text, "-" * len(text)]
@pytest.fixture
def benchmark(request):
def bench(func, num, name=None, reportfunc=None):
if name is None:
name = func.__name__
durations = []
for i in range(num):
now = time.time()
func()
durations.append(time.time() - now)
durations.sort()
request.config._benchresults[name] = (reportfunc, durations)
return bench
def pytest_terminal_summary(terminalreporter):
tr = terminalreporter
results = tr.config._benchresults
if not results:
return
tr.section("benchmark results")
float_names = "median min max".split()
width = max(map(len, float_names))
def fcol(parts):
return " ".join(part.rjust(width) for part in parts)
headers = f"{'benchmark name': <30} " + fcol(float_names)
tr.write_line(headers)
tr.write_line("-" * len(headers))
summary_lines = []
for name, (reportfunc, durations) in results.items():
measures = [
sorted(durations)[len(durations) // 2],
min(durations),
max(durations),
]
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
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"
name = "dovecot"
def __init__(self, host):
self.host = host
def connect(self):
print(f"imap-connect {self.host}")
self.conn = imaplib.IMAP4_SSL(self.host)
def login(self, user, password):
print(f"imap-login {user!r} {password!r}")
self.conn.login(user, password)
def fetch_all(self):
print("imap-fetch all")
status, res = self.conn.select()
if int(res[0]) == 0:
raise ValueError("no messages in imap folder")
status, results = self.conn.fetch("1:*", "(RFC822)")
assert status == "OK"
return results
def fetch_all_messages(self):
print("imap-fetch all messages")
results = self.fetch_all()
messages = []
for item in results:
if len(item) == 2:
messages.append(item[1].decode())
return messages
@pytest.fixture
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"
name = "postfix"
def __init__(self, host):
self.host = host
def connect(self):
print(f"smtp-connect {self.host}")
self.conn = smtplib.SMTP_SSL(self.host)
def login(self, user, password):
print(f"smtp-login {user!r} {password!r}")
self.conn.login(user, password)
def sendmail(self, from_addr, to_addrs, msg):
print(f"smtp-sendmail from={from_addr!r} to_addrs={to_addrs!r}")
print(f"smtp-sendmail message size: {len(msg)}")
return self.conn.sendmail(from_addr=from_addr, to_addrs=to_addrs, msg=msg)
@pytest.fixture(params=["imap", "smtp"])
def imap_or_smtp(request):
return request.getfixturevalue(request.param)
@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)
#
# Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts
#
class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
def __init__(self, pytestconfig, maildomain, gencreds):
self.pytestconfig = pytestconfig
self.maildomain = maildomain
assert "." in self.maildomain, maildomain
self.gencreds = gencreds
self._addr2files = {}
def get_liveconfig_producer(self):
while 1:
user, password = self.gencreds(self.maildomain)
config = {
"addr": user,
"mail_pw": password,
}
# speed up account configuration
config["mail_server"] = self.maildomain
config["send_server"] = self.maildomain
yield config
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
pass
def cache_maybe_store_configured_db_files(self, acc):
pass
@pytest.fixture
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)
# nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2):
am.testprocess.maildomain = maildomain2
am.switch_maildomain = switch_maildomain
yield am
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
if testproc.pytestconfig.getoption("--extra-info"):
logfile = io.StringIO()
am.dump_imap_summary(logfile=logfile)
print(logfile.getvalue())
# request.node.add_report_section("call", "imap-server-state", s)
@pytest.fixture
def remote(sshdomain):
return Remote(sshdomain)
class Remote:
def __init__(self, sshdomain):
self.sshdomain = sshdomain
def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd
self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal],
stdout=subprocess.PIPE,
)
while 1:
line = self.popen.stdout.readline()
res = line.decode().strip().lower()
if res:
yield res
else:
break
@pytest.fixture
def lp(request):
class LP:
def sec(self, msg):
print(f"---- {msg} ----")
def indent(self, msg):
print(f" {msg}")
return LP()
@pytest.fixture
def cmsetup(maildomain, gencreds):
return CMSetup(maildomain, gencreds)
class CMSetup:
def __init__(self, maildomain, gencreds):
self.maildomain = maildomain
self.gencreds = gencreds
def gen_users(self, num):
print(f"Creating {num} online users")
users = []
for i in range(num):
addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password)
assert user.smtp
users.append(user)
return users
class CMUser:
def __init__(self, maildomain, addr, password):
self.maildomain = maildomain
self.addr = addr
self.password = password
self._smtp = None
self._imap = None
@property
def smtp(self):
if not self._smtp:
handle = SmtpConn(self.maildomain)
handle.connect()
handle.login(self.addr, self.password)
self._smtp = handle
return self._smtp
@property
def imap(self):
if not self._imap:
imap = ImapConn(self.maildomain)
imap.connect()
imap.login(self.addr, self.password)
self._imap = imap
return self._imap

60
tests/online/benchmark.py Normal file
View File

@@ -0,0 +1,60 @@
def test_tls_imap(benchmark, imap):
def imap_connect():
imap.connect()
benchmark(imap_connect, 10)
def test_login_imap(benchmark, imap, gencreds):
def imap_connect_and_login():
imap.connect()
imap.login(*gencreds())
benchmark(imap_connect_and_login, 10)
def test_tls_smtp(benchmark, smtp):
def smtp_connect():
smtp.connect()
benchmark(smtp_connect, 10)
def test_login_smtp(benchmark, smtp, gencreds):
def smtp_connect_and_login():
smtp.connect()
smtp.login(*gencreds())
benchmark(smtp_connect_and_login, 10)
class TestDC:
def test_autoconfigure(self, benchmark, cmfactory):
def autoconfig_and_idle_ready():
cmfactory.get_online_accounts(1)
benchmark(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():
chat.send_text("ping")
msg = ac2.wait_next_incoming_message()
msg.chat.send_text("pong")
ac1.wait_next_incoming_message()
benchmark(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():
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)

View File

@@ -0,0 +1,70 @@
import pytest
import threading
import queue
def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
"""Test a) that an initial login creates a user automatically
and b) verify we can also login a second time with the same password
and c) that using a different password fails the login."""
user, password = gencreds()
lp.sec(f"login first time with {user} {password}")
imap_or_smtp.connect()
imap_or_smtp.login(user, password)
lp.indent("success")
lp.sec(f"reconnect and login second time {user} {password}")
imap_or_smtp.connect()
imap_or_smtp.login(user, password)
imap_or_smtp.connect()
lp.sec("success")
lp.sec(f"reconnect and verify wrong password fails {user} ")
imap_or_smtp.connect()
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
to ensure that authentication process does not confuse the users
by using only the password hash as a key.
"""
user1, password1 = gencreds()
user2, _ = gencreds()
imap_or_smtp.connect()
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,6 @@
from deploy_chatmail.genqr import gen_qr_png_data
def test_gen_qr_png_data(maildomain):
data = gen_qr_png_data(maildomain)
assert data

View File

@@ -0,0 +1,63 @@
import smtplib
import pytest
def test_remote(remote, imap_or_smtp):
lineproducer = remote.iter_output(imap_or_smtp.logcmd)
imap_or_smtp.connect()
assert imap_or_smtp.name in next(lineproducer)
def test_use_two_chatmailservers(cmfactory, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
cmfactory.get_accepted_chat(ac1, ac2)
domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1]
assert domain1 != domain2
@pytest.mark.parametrize("forgeaddr", ["internal", "someone@example.org"])
def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr):
user1, user3 = cmsetup.gen_users(2)
lp.sec("send encrypted message with forged from")
print("envelope_from", user1.addr)
if forgeaddr == "internal":
addr_to_forge = cmsetup.gen_users(1)[0].addr
else:
addr_to_forge = "someone@example.org"
print("message to inject:")
msg = maildata("encrypted.eml", from_addr=addr_to_forge, to_addr=user3.addr)
msg = msg.as_string()
for line in msg.split("\n")[:4]:
print(f" {line}")
lp.sec("Send forged mail and check remote postfix lmtp processing result")
with pytest.raises(smtplib.SMTPException) as e:
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user3.addr], msg=msg)
assert "500" in str(e.value)
@pytest.mark.slow
def test_exceed_rate_limit(cmsetup, gencreds, maildata):
"""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):
print("Sending mail", str(i))
try:
user1.smtp.sendmail(user1.addr, [user2.addr], mail)
except smtplib.SMTPException as e:
if i < 60:
pytest.fail(f"rate limit was exceeded too early with msg {i}")
outcome = e.recipients[user2.addr]
assert outcome[0] == 450
assert b"4.7.1: Too much mail from" in outcome[1]
return
pytest.fail("Rate limit was not exceeded")

View File

@@ -0,0 +1,110 @@
import time
import random
import pytest
class TestEndToEndDeltaChat:
"Tests that use Delta Chat accounts on the chat mail instance."
def test_one_on_one(self, cmfactory, lp):
"""Test that a DC account can send a message to a second DC account
on the same chat-mail instance."""
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: prepare and send text message to ac2")
chat.send_text("message0")
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0"
@pytest.mark.slow
def test_exceed_quota(self, cmfactory, lp, tmpdir, remote):
"""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
attachsize = 1 * 1024 * 1024
num_to_send = quota // attachsize + 2
lp.sec(f"ac1: send {num_to_send} large files to ac2")
lp.indent(f"per-user quota is assumed to be: {quota/(1024*1024)}MB")
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
msgs = []
for i in range(num_to_send):
attachment = tmpdir / f"attachment{i}"
data = "".join(random.choice(alphanumeric) for i in range(1024))
with open(attachment, "w+") as f:
for j in range(attachsize // len(data)):
f.write(data)
msg = chat.send_file(str(attachment))
msgs.append(msg)
lp.indent(f"Sent out msg {i}, size {attachsize/(1024*1024)}MB")
lp.sec("ac2: check messages are arriving until quota is reached")
addr = ac2.get_config("addr").lower()
saved_ok = 0
for line in remote.iter_output("journalctl -f -u dovecot"):
if addr not in line:
# print(line)
continue
if "quota" in line:
if "quota exceeded" in line:
if saved_ok < num_to_send // 2:
pytest.fail(
f"quota exceeded too early: after {saved_ok} messages already"
)
lp.indent("good, message sending failed because quota was exceeded")
return
if "saved mail to inbox" in line:
saved_ok += 1
print(f"{saved_ok}: {line}")
if saved_ok >= num_to_send:
break
pytest.fail("sending succeeded although messages should exceed quota")
def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin")
qr = ac1.get_setup_contact_qr()
lp.sec("ac2: start QR-code based setup contact protocol")
ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000)
def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_setup_contact_qr()
ac2.qr_setup_contact(qr)
msg = ac2.wait_next_incoming_message()
assert "verified" in msg.text
lp.sec("ac1 sends a message and ac2 marks it as seen")
chat = ac1.create_chat(ac2)
msg = chat.send_text("hi")
m = ac2.wait_next_incoming_message()
m.mark_seen()
# we can only indirectly wait for mark-seen to cause an smtp-error
lp.sec("try to wait for markseen to complete and check error states")
deadline = time.time() + 3.1
while time.time() < deadline:
msgs = m.chat.get_messages()
for msg in msgs:
assert "error" not in m.get_message_info()
time.sleep(1)

2
tests/pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
addopts = -vrsx --strict-markers

33
tests/test_cmdeploy.py Normal file
View File

@@ -0,0 +1,33 @@
import os
import pytest
from deploy_chatmail.cmdeploy import get_parser, main
from chatmaild.config import read_config
@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
def test_init(self, tmp_path):
main(["init", "chat.example.org"])
inipath = tmp_path.joinpath("chatmail.ini")
config = read_config(inipath)
assert config.mailname == "chat.example.org"
def test_init_not_overwrite(self):
main(["init", "chat.example.org"])
with pytest.raises(SystemExit):
main(["init", "chat.example.org"])

13
tests/test_helpers.py Normal file
View File

@@ -0,0 +1,13 @@
import importlib.resources
from deploy_chatmail.www import build_webpages
def test_build_webpages(tmp_path, make_config):
pkgroot = importlib.resources.files("deploy_chatmail")
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

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 width="800px" 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)
## ⚡ Note: this is an experimental service ⚡

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

@@ -0,0 +1,39 @@
<img width="800px" src="collage-info.png"/>
## More information
### 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 *exactly* nine characters
and append `@{{config.mail_domain}}` to it.
- `Password`: invent at least 9 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 60 messages per minute
- Messages are unconditionally removed 40 days after arriving on the server
- You can store up to [100MB 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.

20
www/src/page-layout.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
{% if config.webdev %}
<meta http-equiv="refresh" content="3">
{% endif %}
<title>{{ config.mail_domain }} {{ pagename }}</title>
<link rel="stylesheet" href="./water.css">
</head>
<body>
{{ markdown_html }}
<footer>
<a href="index.html">home</a> |
<a href="info.html">more info</a> |
<a href="privacy.html">privacy</a> |
<a href="https://github.com/deltachat/chatmail">-> public development </a>
</footer>
</body>
</html>

321
www/src/privacy.md Normal file
View File

@@ -0,0 +1,321 @@
<img width="800px" src="collage-privacy.png"/>
# Privacy Policy for {{ config.mail_domain }}
We want to show you in a fair and transparent way
what personal data is processed by us.
We follow a strict privacy-by-design approach
and try to avoid processing your data in the first place,
but as you may know,
the internet,
and in particular sending e-mail messages,
does not work without data.
Still,
it's only fair that you know at all times
what personal data is processed
when you use our service.
If you have any remaining questions about data protection, please contact us.
## 1. Name and contact information
Responsible for the processing of your personal data is:
```
{{ config.privacy_postal }}
```
E-mail: {{ config.privacy_mail }}
We have appointed a data protection officer:
```
{{ config.privacy_pdo }}
```
## 2. Processing when using chat e-mail services
We provide e-mail services optimized for the use from [Delta Chat](https://delta.chat) apps
and process only the data necessary
for the setup and technical execution of the e-mail dispatch.
The purpose of the processing is to
read, write, manage, delete, send, and receive emails.
For this purpose,
we operate server-side software
that enables us to send and receive e-mail messages.
Allowing the use of the e-mail service,
we process the following data and details:
- Outgoing and incoming messages (SMTP) are stored for transit
on behalf of their users until the message can be delivered.
- E-Mail-Messages are stored for the recipient and made accessible via IMAP protocols,
until explicitly deleted by the user or until a fixed time period is exceeded,
(*usually 4-8 weeks*).
- IMAP and SMTP protocols are password protected with unique credentials for each account.
- Users can retrieve or delete all stored messages
without intervention from the operators using standard IMAP client tools.
### 3.1 Account setup
Creating an account happens in one of two ways on our mail servers:
- with a QR invitation token
which is scanned using the DeltaChat app
and then the account is created.
- by letting Delta Chat otherwise create an account
and register it with a {{ config.mail_domain }} mail server.
In either case, we process the newly created email address.
No phone numbers,
other email addresses,
or other identifiable data
is currently required.
The legal basis for the processing is
Art. 6 (1) lit. b GDPR,
as you have a usage contract with us
by using our services.
## 3.2 Processing of E-Mail-Messages
In addition,
we will process data
to keep the server infrastructure operational
for purposes of e-mail dispatch
and abuse prevention.
- Therefore,
it is necessary to process the content and/or metadata
(e.g., headers of the email as well as smtp chatter)
of E-Mail-Messages in transit.
- We will keep logs of messages in transit for a limited time.
These logs are used to debug delivery problems and software bugs.
In addition,
we process data to protect the systems from excessive use.
Therefore, limits are enforced:
- rate limits
- storage limits
- message size limits
- any other limit neccessary for the whole server to function in a healthy way
and to prevent abuse.
The processing and use of the above permissions
are performed to provide the service.
The data processing is necessary for the use of our services,
therefore the legal basis of the processing is
Art. 6 (1) lit. b GDPR,
as you have a usage contract with us
by using our services.
The legal basis for the data processing
for the purposes of security and abuse prevention is
Art. 6 (1) lit. f GDPR.
Our legitimate interest results
from the aforementioned purposes.
We will not use the collected data
for the purpose of drawing conclusions
about your person.
## 3. Processing when using our Website
When you visit our website,
the browser used on your end device
automatically sends information to the server of our website.
This information is temporarily stored in a so-called log file.
The following information is collected and stored
until it is automatically deleted
(*usually 7 days*):
- used type of browser,
- used operating system,
- access date and time as well as
- country of origin and IP address,
- the requested file name or HTTP resource,
- the amount of data transferred,
- the access status (file transferred, file not found, etc.) and
- the page from which the file was requested.
This website is hosted by an external service provider (hoster).
The personal data collected on this website is stored
on the hoster's servers.
Our hoster will process your data
only to the extent necessary to fulfill its obligations
to perform under our instructions.
In order to ensure data protection-compliant processing,
we have concluded a data processing agreement with our hoster.
The aforementioned data is processed by us for the following purposes:
- Ensuring a reliable connection setup of the website,
- ensuring a convenient use of our website,
- checking and ensuring system security and stability, and
- for other administrative purposes.
The legal basis for the data processing is
Art. 6 (1) lit. f GDPR.
Our legitimate interest results
from the aforementioned purposes of data collection.
We will not use the collected data
for the purpose of drawing conclusions about your person.
## 4. Transfer of Data
Your personal data
will not be transferred to third parties
for purposes other than those listed below:
a) you have given your express consent
in accordance with Art. 6 para. 1 sentence 1 lit. a GDPR,
b) the disclosure is necessary for the assertion, exercise or defence of legal claims
pursuant to Art. 6 (1) sentence 1 lit. f GDPR
and there is no reason to assume that you have
an overriding interest worthy of protection
in the non-disclosure of your data,
c) in the event that there is a legal obligation to disclose your data
pursuant to Art. 6 para. 1 sentence 1 lit. c GDPR,
as well as
d) this is legally permissible and necessary
in accordance with Art. 6 Para. 1 S. 1 lit. b GDPR
for the processing of contractual relationships with you,
e) this is carried out by a service provider
acting on our behalf and on our exclusive instructions,
whom we have carefully selected (Art. 28 (1) GDPR)
and with whom we have concluded a corresponding contract on commissioned processing (Art. 28 (3) GDPR),
which obliges our contractor,
among other things,
to implement appropriate security measures
and grants us comprehensive control powers.
## 5. Rights of the data subject
The rights arise from Articles 12 to 23 GDPR.
Since no personal data is stored on our servers,
even in encrypted form,
there is no need to provide information
on these or possible objections.
A deletion can be made
directly in the Delta Chat email messenger.
a) request information about your personal data processed by us
in accordance with Art. 15 GDPR.
In particular,
you can request information about the processing purposes,
the category of personal data,
the categories of recipients to whom your data have been or will be disclosed,
the planned storage period,
the existence of a right to rectification, erasure, restriction of processing or objection,
the existence of a right of complaint,
the origin of your data if it has not been collected by us,
as well as the existence of automated decision-making including profiling
and, if applicable,
meaningful information about its details;
b) in accordance with Art. 16 of the GDPR,
immediately request the correction
of inaccurate or incomplete personal data stored by us;
c) pursuant to Article 17 of the GDPR,
to request the erasure of your personal data stored by us,
unless the processing is necessary
for the exercise of the right to freedom of expression and information,
for compliance with a legal obligation,
for reasons of public interest,
or for the establishment, exercise or defence of legal claims;
d) pursuant to Art. 18 GDPR,
to request the restriction of the processing of your personal data,
insofar as the accuracy of the data is disputed by you,
the processing is unlawful,
but you object to its erasure
and we no longer require the data,
but you need it for the assertion, exercise or defence of legal claims
or you have objected to the processing pursuant to Art. 21 GDPR;
e) pursuant to Art. 20 GDPR,
to receive your personal data that you have provided to us
in a structured, common and machine-readable format
or to request that it be transferred to another controller;
f) in accordance with Art. 7 (3) of the GDPR,
to revoke your consent given to us at any time.
This has the consequence that we may no longer continue the data processing
based on this consent in the future; and
g) complain to a supervisory authority
in accordance with Article 77 of the GDPR.
As a rule,
you can contact the supervisory authority of your usual place of residence
or workplace
or our registered office for this purpose.
The supervisory authority responsible for our place of business
is the `{{ config.privacy_supervisor }}`.
If you have any questions or complaints, please feel free to contact us by email:
{{ config.privacy_mail }}
### 5.1 Right to object
If your personal data is processed on the basis of our legitimate interests
in accordance with Art. 6 (1) lit. f GDPR,
you have the right to object to the processing of your personal data
in accordance with Art. 21 GDPR,
provided that there are grounds for this based on your particular situation
or the objection is directed against direct advertising.
In the latter case,
you have a general right of objection,
which will be implemented by us
without specifying a particular situation.
If you wish to exercise your right of objection,
simply send an e-mail to: {{ config.privacy_mail }}
### 5.2 Right to withdraw
If your personal data is processed on the basis of your consent
in accordance with Art. 6 (1) lit. a GDPR
(e.g. via the mailing list),
you can withdraw your consent at any time
and without any disadvantages.
As a result,
we may no longer continue the data processing
that was based on this consent for the future.
However,
the withdrawal of your consent
does not affect the lawfulness of the processing
carried out on the basis of the consent until the withdrawal.
If you wish to make use of your right of withdrawal,
simply send an e-mail to: {{ config.privacy_mail }}
## 6. Validity of this privacy policy
This data protection declaration is valid
as of *December 2023*.
Due to the further development of our service and offers
or due to changed legal or official requirements,
it may become necessary to revise this data protection declaration from time to time.

1690
www/src/water.css Normal file

File diff suppressed because it is too large Load Diff