Compare commits

...

218 Commits

Author SHA1 Message Date
Christian Hagenest
3e934bdea4 add entry point for rm_accounts in pyproject.toml 2024-07-08 17:28:13 +02:00
Christian Hagenest
3f197695d9 rm unnecessary round() 2024-07-08 17:24:01 +02:00
Christian Hagenest
8ad72deeb2 remove unused imports in rm_accounts.py 2024-07-08 17:22:19 +02:00
Christian Hagenest
ea817a2088 rmtree => ignore_errors = True 2024-07-08 14:35:15 +02:00
Christian Hagenest
d9bf2b57a5 rename dir to vmail_basedir 2024-07-08 14:35:15 +02:00
Christian Hagenest
b4b0a098d1 use with db.write_transaction() 2024-07-08 14:35:15 +02:00
Christian Hagenest
410eae3be4 rm unecessary round in doveatuh 2024-07-08 14:35:15 +02:00
Christian Hagenest
7bc3b11594 Update grammar for doc string in chatmail.ini for delete_accounts_after
Co-authored-by: holger krekel  <holger@merlinux.eu>
2024-07-08 14:32:02 +02:00
Christian Hagenest
7bcf027837 set delete_accounts after to 25 to be under gdpr limit of 30 2024-07-08 13:00:59 +02:00
Christian Hagenest
64de63815d ruff again :) 2024-06-26 16:05:51 +02:00
Christian Hagenest
07802569ef ruff 2024-06-26 15:11:37 +02:00
Christian Hagenest
a77e03c8a1 WIP rm_accounts.py 2024-06-20 17:30:35 +02:00
Christian Hagenest
7f5ae11591 rm comment 2024-06-20 16:53:01 +02:00
Christian Hagenest
a5a486e8c5 Merge branch 'main' into hagi/#295-remove-old-accounts 2024-06-20 16:46:46 +02:00
Christian Hagenest
1a50e5b783 new config option: delete_accounts_after 2024-06-20 16:42:27 +02:00
Christian Hagenest
06f3bbf6cd round login-time in doveauth 2024-06-20 16:42:05 +02:00
missytake
6b59b8be44 CI: accept ns.testrun.org host key 2024-06-19 14:34:17 +02:00
missytake
07ffc003e4 CI: fix check whether acme certs exist 2024-06-18 14:49:37 +02:00
missytake
4cb62df33f CI: change to staging2.testrun.org 2024-06-18 14:49:37 +02:00
missytake
ef58f011fb CI: disable CAA record for now 2024-06-18 14:49:37 +02:00
Christian Hagenest
f7ef236ac8 Revert "CI: disable requesting new certs for staging.testrun.org"
This reverts commit 127d9d6460.
2024-06-18 14:49:37 +02:00
Christian Hagenest
dbe906a331 bump actions/checkout to v4 in test-and-deploy.yml 2024-06-18 14:49:37 +02:00
Christian Hagenest
3899f41c61 switch to checkout@v4 #301 2024-06-18 14:49:37 +02:00
link2xt
57c29c14a4 Reject DKIM signatures that do not cover the whole message body 2024-06-18 02:48:54 +00:00
link2xt
2b5d903cc5 Allow SKESK packets in encrypted mails
They are not used by Delta Chat now,
but this will allow to start using them in the future.
2024-06-13 19:48:59 +02:00
link2xt
c8d270a853 Check that OpenPGP has only PKESK and SEIPD packets (#323) 2024-06-12 17:21:37 +00:00
link2xt
72f4e9edbf filtermail: remove support for unencrypted MDNs
Delta Chat does not send them since 1.43.
1.44 has been released for a while already
and 1.46 is in the process of being released.
2024-06-11 16:18:39 +00:00
link2xt
1ce0a2b0ba Improve filtermail checks for encrypted messages
Ensure that first part only contains "Version: 1"
and second part only contains base64 payload
enclosed in "-----BEGIN PGP MESSAGE-----"
and "-----END PGP MESSAGE-----".
2024-06-11 16:18:39 +00:00
Christian Hagenest
044ebfb9a2 delete buggy dovecot submodule for dovebuild 2024-06-11 16:51:29 +02:00
missytake
a41b034aa2 update version to 1.3.0 2024-06-06 16:03:57 +02:00
missytake
e00f0b852d doc: add acl installation to changelog 2024-06-06 16:02:15 +02:00
missytake
501b12564c tests: mark expunged test as slow 2024-06-06 14:14:31 +02:00
holger krekel
229ad15a28 fix link 2024-06-04 16:58:25 +02:00
missytake
e4f35d8dae add changelog for #316 2024-06-04 14:30:39 +02:00
missytake
4271573e15 DNS: don't check DNS on cmdeploy init anymore 2024-06-04 14:30:39 +02:00
holger krekel
b651a9046b Apply suggestions from code review
Co-authored-by: missytake <missytake@systemli.org>
2024-05-30 19:03:09 +02:00
holger krekel
6b84eaf8af Update www/src/info.md
Co-authored-by: missytake <missytake@systemli.org>
2024-05-30 19:03:09 +02:00
holger krekel
1b076bcd22 more refinement 2024-05-30 19:03:09 +02:00
holger krekel
30437f6c46 refine 2024-05-30 19:03:09 +02:00
holger krekel
3171e40a26 reword further 2024-05-30 19:03:09 +02:00
holger krekel
61c915995b reworking the privacy policy entry point 2024-05-30 19:03:09 +02:00
Christian Hagenest
073bd86344 add changelog for PR 310 (cron) 2024-05-27 14:07:01 +02:00
Christian Hagenest
777a7addd2 Ensure cron is installed #282 (#310) 2024-05-27 14:04:40 +02:00
Christian Hagenest
4f28476c47 add a doc about dovecot building based on internal sysadmin docs (now with squash) (#309)
* add a doc about dovecot building based on internal sysadmin docs

* track discussion from chat

* WIP build-obs.sh

* add precise links for dovecot unstable

* WIP build-obs.sh

* WIP

* WIP IT BUILDS

* WIP: Build builds, OBS pushes, OBs doesn't build :( problem with .dsc

* it works

* move obs dir into script dir

* clean curl

* hack for file length problem

* wip hack

* wip hack

* wip try dpkg-source

* wip test without curl

* wip

* clean up

* remove unnecessary dependencies

* move readme wip

* edit README

* Update scripts/dovecot/build-obs.sh

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

* Update scripts/dovecot/README.md

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

* move SCRIPT_DIR

* fix up readme for dovecot script

* Add OBS

* clarify backports policy

---------

Co-authored-by: holger krekel <holger@merlinux.eu>
Co-authored-by: missytake <missytake@systemli.org>
2024-05-26 19:49:06 +02:00
Christian Hagenest
b05aec72c2 Revert "add a doc about dovecot building based on internal sysadmin docs" (#308)
* Revert "clarify backports policy"

This reverts commit 610675452e.

* Revert "Add OBS"

This reverts commit 83387f5d08.

* Revert "fix up readme for dovecot script"

This reverts commit 142206529c.

* Revert "move SCRIPT_DIR"

This reverts commit c0f200b1a9.

* Revert "Update scripts/dovecot/README.md"

This reverts commit 6d55f75bee.

* Revert "Update scripts/dovecot/build-obs.sh"

This reverts commit c68cbf1806.

* Revert "edit README"

This reverts commit 9677617c7f.

* Revert "move readme wip"

This reverts commit d8cf282953.

* Revert "remove unnecessary dependencies"

This reverts commit b959f57058.

* Revert "clean up"

This reverts commit 8768e6fd0b.

* Revert "wip"

This reverts commit acbf370383.

* Revert "wip test without curl"

This reverts commit 80dfdaee06.

* Revert "wip try dpkg-source"

This reverts commit 4d15ae9452.

* Revert "wip hack"

This reverts commit 9a68d42ee8.

* Revert "wip hack"

This reverts commit d732d099ac.

* Revert "hack for file length problem"

This reverts commit 582a2af799.

* Revert "clean curl"

This reverts commit fba3963d47.

* Revert "move obs dir into script dir"

This reverts commit e80d33e2e0.

* Revert "it works"

This reverts commit 6a3001bf22.

* Revert "WIP: Build builds, OBS pushes, OBs doesn't build :( problem with .dsc"

This reverts commit 368c41ba27.

* Revert "WIP IT BUILDS"

This reverts commit fa0d8432bc.

* Revert "WIP"

This reverts commit 2811e08563.

* Revert "WIP build-obs.sh"

This reverts commit 846a4066d8.

* Revert "add precise links for dovecot unstable"

This reverts commit 6e1477666e.

* Revert "WIP build-obs.sh"

This reverts commit 013def94f9.

* Revert "track discussion from chat"

This reverts commit 468bb04149.

* Revert "add a doc about dovecot building based on internal sysadmin docs"

This reverts commit 30a23dad17.
2024-05-26 19:46:43 +02:00
Christian Hagenest
610675452e clarify backports policy 2024-05-23 14:33:45 +02:00
Christian Hagenest
83387f5d08 Add OBS 2024-05-23 14:33:45 +02:00
Christian Hagenest
142206529c fix up readme for dovecot script 2024-05-23 14:33:45 +02:00
Christian Hagenest
c0f200b1a9 move SCRIPT_DIR 2024-05-23 14:33:45 +02:00
Christian Hagenest
6d55f75bee Update scripts/dovecot/README.md
Co-authored-by: missytake <missytake@systemli.org>
2024-05-23 14:33:45 +02:00
Christian Hagenest
c68cbf1806 Update scripts/dovecot/build-obs.sh
Co-authored-by: missytake <missytake@systemli.org>
2024-05-23 14:33:45 +02:00
Christian Hagenest
9677617c7f edit README 2024-05-23 14:33:45 +02:00
Christian Hagenest
d8cf282953 move readme wip 2024-05-23 14:33:45 +02:00
Christian Hagenest
b959f57058 remove unnecessary dependencies 2024-05-23 14:33:45 +02:00
Christian Hagenest
8768e6fd0b clean up 2024-05-23 14:33:45 +02:00
Christian Hagenest
acbf370383 wip 2024-05-23 14:33:45 +02:00
Christian Hagenest
80dfdaee06 wip test without curl 2024-05-23 14:33:45 +02:00
Christian Hagenest
4d15ae9452 wip try dpkg-source 2024-05-23 14:33:45 +02:00
Christian Hagenest
9a68d42ee8 wip hack 2024-05-23 14:33:45 +02:00
Christian Hagenest
d732d099ac wip hack 2024-05-23 14:33:45 +02:00
Christian Hagenest
582a2af799 hack for file length problem 2024-05-23 14:33:45 +02:00
Christian Hagenest
fba3963d47 clean curl 2024-05-23 14:33:45 +02:00
Christian Hagenest
e80d33e2e0 move obs dir into script dir 2024-05-23 14:33:45 +02:00
Christian Hagenest
6a3001bf22 it works 2024-05-23 14:33:45 +02:00
Christian Hagenest
368c41ba27 WIP: Build builds, OBS pushes, OBs doesn't build :( problem with .dsc 2024-05-23 14:33:45 +02:00
Christian Hagenest
fa0d8432bc WIP IT BUILDS 2024-05-23 14:33:45 +02:00
Christian Hagenest
2811e08563 WIP 2024-05-23 14:33:45 +02:00
Christian Hagenest
846a4066d8 WIP build-obs.sh 2024-05-23 14:33:45 +02:00
holger krekel
6e1477666e add precise links for dovecot unstable 2024-05-23 14:33:45 +02:00
Christian Hagenest
013def94f9 WIP build-obs.sh 2024-05-23 14:33:45 +02:00
holger krekel
468bb04149 track discussion from chat 2024-05-23 14:33:45 +02:00
holger krekel
30a23dad17 add a doc about dovecot building based on internal sysadmin docs 2024-05-23 14:33:45 +02:00
Christian Hagenest
17af249f90 fix link in changelog 2024-05-19 17:53:55 +02:00
Christian Hagenest
4e65291304 fix up 2024-05-19 17:09:35 +02:00
Christian Hagenest
505ad36b36 fix nginx.conf 2024-05-19 17:09:35 +02:00
Christian Hagenest
dcb614911a update changelog 2024-05-19 17:09:35 +02:00
Christian Hagenest
e06c3631b2 nginx logs => journald 2024-05-19 17:09:35 +02:00
Christian Hagenest
da236e6e1b only restart journald if conf was changed 2024-05-19 17:09:35 +02:00
Christian Hagenest
2796730a87 journald.conf storage=volatile 2024-05-19 17:09:35 +02:00
Christian Hagenest
f32e18c32a Recommend authentication via ssh key with ed25519 algorithm (#231) (#291)
* fix #231

* CI: disable CI for markdown files

* clarify need for ssh-add

* Update README.md

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

---------

Co-authored-by: missytake <missytake@systemli.org>
2024-05-18 23:31:03 +02:00
Christian Hagenest
1a5fd331b6 add changelog 2024-05-18 23:06:03 +02:00
Christian Hagenest
772b86a4b5 update delete-mails-after value in test_config.py 2024-05-18 23:06:03 +02:00
Christian Hagenest
e0013b9bee change delete_mails_after default to 20 2024-05-18 23:06:03 +02:00
missytake
127d9d6460 CI: disable requesting new certs for staging.testrun.org 2024-05-18 22:02:51 +02:00
Christian Hagenest
cb7de8019b add acl to apt.packages (#293) 2024-05-17 21:36:36 +02:00
Christian Hagenest
2b5b06316d fix #272 (#290)
@missytake and me both tested the deployment manually, so I'll merge
2024-05-17 17:45:28 +02:00
link2xt
76b56d7b78 metadata: add support for /shared/vendor/deltachat/irohrelay 2024-05-07 15:52:54 +00:00
holger krekel
c1163228f6 add a test for imap capabilities offered from chatmail 2024-05-06 19:57:31 +02:00
holger krekel
8af825d7ea add chatmail entry 2024-05-06 19:57:31 +02:00
holger krekel
0a968aae93 add XCHATMAIL marker 2024-05-06 19:57:31 +02:00
link2xt
879cffc056 Configure more lints and switch from black to ruff format 2024-05-06 14:41:00 +00:00
link2xt
462e92cca0 Add changelog entry for 281 2024-05-05 21:21:06 +00:00
link2xt
e1b1a945b1 Authenticate echobot by passing /run/echobot/password to doveauth 2024-05-05 15:25:44 +00:00
link2xt
0493e27312 Move echobot into /var/lib/echobot 2024-05-05 15:25:44 +00:00
link2xt
e4f8c78efe Merge pull request #276 from deltachat/acmetool-tos
acmetool: accept new terms of services
2024-05-02 13:29:28 +00:00
missytake
e2cbf4e3e4 changelog for #276 2024-05-02 13:28:42 +00:00
missytake
f35d98bb40 acmetool: enable debugging 2024-05-01 10:45:21 +02:00
missytake
7ce1a5e841 ci: don't fail if /var/lib/acme isn't present 2024-05-01 00:41:11 +02:00
missytake
0a72c2fba7 acmetool: accept new terms of services
closes #275
2024-05-01 00:21:58 +02:00
link2xt
824f70f463 Document email authentication requirements 2024-04-19 21:12:54 +02:00
link2xt
39f5f64998 Reload Dovecot and Postfix when TLS certificate updates (#271) 2024-04-15 14:08:32 +00:00
Christian Hagenest
1752803199 changelog for #270 2024-04-11 19:41:43 +02:00
Christian Hagenest
e372599ce7 change location of changes per nami's recommendation 2024-04-11 19:15:28 +02:00
Christian Hagenest
ce9fb02a75 correct key for obs home deltachat 2024-04-11 19:15:28 +02:00
Christian Hagenest
4526f5e772 apt update after adding new repository 2024-04-11 19:15:28 +02:00
Christian Hagenest
616a42c8f3 add our obs repo to cmdeploy init 2024-04-11 19:15:28 +02:00
holger krekel
ecb5ef8a10 start new untagged section post 1.2.0 2024-04-04 18:30:11 +02:00
holger krekel
824c3dc1d7 prepare tagging 1.2.0 2024-04-04 18:28:35 +02:00
holger krekel
9b76d46558 refinements and fixes 2024-04-04 12:57:49 +02:00
holger krekel
cc4920ddc7 a bit of renaming 2024-04-04 12:57:49 +02:00
holger krekel
2af10175fa ignore and remove .tmp files in notification_dir 2024-04-04 12:57:49 +02:00
holger krekel
ae455fa9e1 avoid float with time, and be safe against crashes during file writing 2024-04-04 12:57:49 +02:00
holger krekel
60d7e516dd implemented suggestion fopr using an absolute deadline instead of retrying but choose 5 hours for now because if our own notification server is down/buggy we have at least a bit of time to fix it 2024-04-04 12:57:49 +02:00
holger krekel
bf18905e02 address typo-level review comments 2024-04-04 12:57:49 +02:00
holger krekel
4d6f520f18 finally use persistent queue items with random file names, simplifying the flows 2024-04-04 12:57:49 +02:00
holger krekel
9da626dfc8 proper doc string for Notifier 2024-04-04 12:57:49 +02:00
holger krekel
1cca9aa441 fix failing CI (uncovering real bug) 2024-04-04 12:57:49 +02:00
holger krekel
3d054847a0 split metadata and notifier into separate files 2024-04-04 12:57:49 +02:00
holger krekel
a31d998e67 separate notification thread into own class, and test start_notification_threads 2024-04-04 12:57:49 +02:00
holger krekel
d313bea97f some more renaming 2024-04-04 12:57:49 +02:00
holger krekel
da04226594 fix 2024-04-04 12:57:49 +02:00
holger krekel
eb2de26638 fix changelog 2024-04-04 12:57:49 +02:00
holger krekel
f5652cdbc4 better naming 2024-04-04 12:57:49 +02:00
holger krekel
13172c92f3 some refinements and extending the tests 2024-04-04 12:57:49 +02:00
holger krekel
09df636183 extend testing 2024-04-04 12:57:49 +02:00
holger krekel
2b45ace3ba refine testing and code 2024-04-04 12:57:49 +02:00
holger krekel
9e05a7d1eb more precision 2024-04-04 12:57:49 +02:00
holger krekel
21e7c09c43 remove redundant test code for requests mocking 2024-04-04 12:57:49 +02:00
holger krekel
14d96e0a9b snap somewhat working again 2024-04-04 12:57:49 +02:00
holger krekel
459ffcabd6 better preserve notification order, using a queue again 2024-04-04 12:57:49 +02:00
missytake
75cc3fdab0 DNS: add changelog entry 2024-04-03 15:12:52 +02:00
missytake
2d26a40c2b DNS: lint 2024-04-03 15:12:52 +02:00
missytake
a78d4e6198 DNS: optimize dnsutils installation command 2024-04-03 15:12:52 +02:00
missytake
2a1e004962 DNS: ensure dig is installed 2024-04-03 15:12:52 +02:00
link2xt
5e55cc205d Run chatmail-metadata and doveauth as vmail 2024-03-30 23:08:42 +01:00
missytake
476c732373 CI: use [] consistently 2024-03-30 21:42:19 +01:00
missytake
71c50b7936 CI: fix local paths (this time\!) 2024-03-30 21:42:19 +01:00
missytake
79cb390f16 CI: fix local paths 2024-03-30 21:42:19 +01:00
missytake
c1452c9c6f CI: fix paths on ns.testrun.org 2024-03-30 21:42:19 +01:00
missytake
6e903d7498 CI: restore ACME & DKIM state from ns.testrun.org 2024-03-30 21:42:19 +01:00
link2xt
221f4a2b0c Apply systemd restrictions to echobot
These options are suggested by
`systemd-analyze security echobot.service`
2024-03-30 14:17:48 +00:00
link2xt
080ae058d8 Remove non-existent file pattern from MANIFEST.in 2024-03-30 09:14:01 +00:00
missytake
edb84c0b3b CI: chown /var/lib/acme to root after restoring state 2024-03-30 01:49:03 +01:00
missytake
04ef477d51 CI: fix rsync statements 2024-03-30 01:49:03 +01:00
holger krekel
5696788d3a add changelog entry 2024-03-29 08:54:11 +01:00
link2xt
1c2bf919ed Start Dovecot before Postfix 2024-03-29 04:24:54 +00:00
link2xt
d15c22c1e8 Configure users and groups before installing any packages
Otherwise packages may add user
without correct configuration such as groups
and the step adding user will be skipped.
2024-03-29 04:24:54 +00:00
missytake
9c6e90ae27 make sure fmt and offline checks are only run after DKIM & ACME is restored 2024-03-29 04:24:54 +00:00
missytake
481791c277 re-enable running the CI in pull requests, but not concurrently 2024-03-29 04:24:54 +00:00
holger krekel
a25c7981f9 start unreleased changelog 2024-03-28 18:02:05 +01:00
holger krekel
53519f2865 prepare 1.1.0 tag 2024-03-28 17:59:42 +01:00
link2xt
3a50d82657 Move systemd unit templates to cmdeploy
They are part of deployment rather than service itself.
Different deployments may have different users,
filesystem layout etc.
2024-03-28 16:38:30 +01:00
holger krekel
c640087498 fix error string 2024-03-28 16:11:00 +01:00
holger krekel
2089f3ab58 persist pending notifications to directory so that they survive a restart 2024-03-28 16:11:00 +01:00
holger krekel
cbaa6924c1 use json instead of python's marshal 2024-03-28 16:11:00 +01:00
holger krekel
6ab3e9657d test and fix for edge case 2024-03-28 16:11:00 +01:00
holger krekel
16f237dc60 add changelog entry 2024-03-28 16:11:00 +01:00
holger krekel
554c33423f various naming refinements 2024-03-28 16:11:00 +01:00
holger krekel
5d5e2b199c remove timeout support, it's not needed 2024-03-28 16:11:00 +01:00
holger krekel
989ce70f97 refine logging 2024-03-28 16:11:00 +01:00
holger krekel
f5dc4cb71e more resilience 2024-03-28 16:11:00 +01:00
holger krekel
76512dfa2d move persistentdict into own file, rename 2024-03-28 16:11:00 +01:00
holger krekel
850112502f extend imap online test to cover multi-device 2024-03-28 16:11:00 +01:00
holger krekel
888fa88aa3 back to using marshal, and a filelock 2024-03-28 16:11:00 +01:00
holger krekel
15e7458666 add a persistent dict impl 2024-03-28 16:11:00 +01:00
holger krekel
0a93c76e66 add multi-token support 2024-03-28 16:11:00 +01:00
holger krekel
312f86223c fix target dir 2024-03-28 16:11:00 +01:00
holger krekel
27a60418ad use "devicetoken" consistently and take it from a var 2024-03-28 16:11:00 +01:00
holger krekel
46d31a91da properly startup metadata service and add online test for metadata 2024-03-28 16:11:00 +01:00
holger krekel
a8765d8847 store metadata in a per-mbox dir 2024-03-28 16:11:00 +01:00
holger krekel
8ee6ca1b80 store tokens on a per-maildir basis 2024-03-28 16:11:00 +01:00
holger krekel
1a2b73a862 store tokens in guid-directories 2024-03-28 16:11:00 +01:00
link2xt
c44f4efced Store raw tokens instead of dictionaries in metadata 2024-03-28 16:11:00 +01:00
holger krekel
9fdf4fd2af add to changelog 2024-03-26 23:37:48 +01:00
holger krekel
33353ccaf6 don't warn on hello 2024-03-26 23:37:01 +01:00
holger krekel
5fe3a269be add changelog entries 2024-03-25 17:51:15 +01:00
holger krekel
0b4770018d add a first changelog for the last week of changes 2024-03-25 17:51:15 +01:00
link2xt
75fcbd03ce echobot: ignore info messages 2024-03-25 14:38:41 +00:00
link2xt
377121bdee Fix echobot logging
Do not put log messages into format string
and enable INFO level when bot is started
via main() as it happens with systemd.
2024-03-25 14:38:41 +00:00
missytake
e5e58f4e38 tests: fix quota test after log line changed 2024-03-25 13:55:53 +01:00
missytake
04517f284c acmetool: reload postfix+dovecot after cert renew.
fix #234
2024-03-25 11:36:29 +01:00
holger krekel
e32fb37b5d fix some test and formatting/ruff issues 2024-03-21 16:19:54 +01:00
holger krekel
8d9019b1c5 fix runtime dovecot/sieve-compile error on every incoming message 2024-03-20 19:10:54 +01:00
holger krekel
63d3e05674 remove superflous check in tests 2024-03-20 19:10:44 +01:00
holger krekel
e466a03055 fixes 2024-03-20 19:10:44 +01:00
holger krekel
1819a276cb implement persistence via marshal 2024-03-20 19:10:44 +01:00
holger krekel
9ec6430b71 make notifier take a directory 2024-03-20 19:10:44 +01:00
missytake
2097233fd6 expunge: reset maildirsize after expunging old mails 2024-03-18 07:03:06 +01:00
link2xt
4bca7891a2 Switch SPF from fail to softfail (~all instead of -all)
This is recommended to prevent SPF failure
from rejecting the message early in case messages
are remailed without breaking DKIM.
2024-03-09 20:02:29 +00:00
link2xt
2e23e743fd dovecot: increase default_client_limit 2024-03-09 14:01:00 +01:00
link2xt
edc593586b Implement "iterate" command in metadata server
Otherwise Dovecot times out when trying to iterate over metadata
of the folder. Apparently it happens when attempting to delete
folder from the server over IMAP.
2024-03-08 05:39:59 +01:00
holger krekel
1e229ad2de Add tests to metadata/token handling and post notifications in background thread (#224) 2024-03-08 01:56:33 +00:00
missytake
8baee557ee make sure rsync is installed, later commands depend on it 2024-03-07 19:14:48 +01:00
link2xt
42e50b089f Push notification extension
This change adds XDELTAPUSH capability.

Delta Chat clients detecting this capability
can set /private/devicetoken IMAP metadata
on the inbox to subscribe for Apple (APNS)
notifications.

Notifications are implemented in a new
`chatmail-metadata` service
which handles requests to set /private/devicetoken
IMAP metadata from Delta Chat clients
and /private/messagenew requests from
push_notification_lua script.

To avoid sending notifications for
MDNs, webxdc updates and Delta Chat sync messages,
messages with Auto-Submitted header are ignored
by setting $Auto keyword (flag) on them in Sieve script
and skipping such messages in push_notification_lua script.
Outgoing messages are also ignored.
2024-03-06 19:00:04 +00:00
missytake
e6a3fab6aa config: only block words if they are in privacy* config keys 2024-03-05 00:38:23 +01:00
holger krekel
ccd6e3e99c fix bailout if there is no TXT entry 2024-03-04 20:04:11 +01:00
missytake
21778fa4f3 tests: add test that we don't leak email addresses via VRFY 2024-03-03 22:49:03 +01:00
link2xt
14342383cf Generate our own single-line DKIM entry 2024-02-17 09:34:25 +00:00
missytake
926de76010 tests: make maildata work with python3.9 2024-02-17 09:27:02 +00:00
link2xt
ee25d35db1 Fix Python 3.9 support
I installed pyenv and then installed Python 3.9:
$ pyenv install 3.9
$ eval "$(pyenv init -)"
$ pyenv shell 3.9

In a clean repository I ran
$ scripts/cmdeploy init
$ scripts/cmdeploy run
$ scripts/cmdeploy dns
$ scripts/cmdeploy fmt

With the changes made all these commands work.

scripts/cmdeploy test fails some tests
using maildata fixture at
  importlib.resources.files(__package__).joinpath("mail-data")
line but this is not critical.
2024-02-17 09:27:02 +00:00
link2xt
ee2115584b Run scripts/cmdeploy fmt 2024-02-15 14:07:10 +00:00
missytake
1c9c088657 tests: add test that currently no outdated mails are stored on the server 2024-02-14 12:19:12 +01:00
missytake
b5afac2f1a expunge: run cronjob with vmail instead of dovecot. fix #210 2024-02-14 12:19:12 +01:00
link2xt
c8d9f20a48 fix: avoid "Argument list too long" in expunge.cron
Make `find` look for accounts.
2024-02-13 07:37:23 +00:00
missytake
6a30db7ce0 tests: test that echobot replies to msg. closes #199 2024-01-31 16:45:26 +01:00
link2xt
9e9ab80422 Do not subscribe to TLS reports 2024-01-31 14:35:54 +01:00
link2xt
5b9debfbdf Test dict protocol handler as a separate function 2024-01-30 23:49:17 +00:00
link2xt
788309b85a Merge Postfix TLS hardening
https://github.com/deltachat/chatmail/pull/97
2024-01-30 18:45:34 +00:00
link2xt
5bbb3d9b21 Rewrite and document smtpd_tls_exclude_ciphers 2024-01-27 02:10:02 +00:00
link2xt
6bc2186912 postfix: set tls_preempt_cipherlist 2024-01-26 19:45:53 +00:00
link2xt
8d5f91bf98 postfix: use new syntax for TLS version 2024-01-26 19:42:18 +00:00
missytake
9ddf60d0fc postfix: enforce TLS 1.2, disallow some insecure TLS ciphers 2024-01-26 19:41:48 +00:00
link2xt
05bdf65996 Add ADSP DNS record
ADSP RFC 5617 is declared historic because of no deployment:
<https://datatracker.ietf.org/doc/status-change-adsp-rfc5617-to-historic/>

However, it is declared as supported by <https://github.com/fastmail/authentication_milter>.

OpenDKIM has a release note from 2014-12-27 saying "Discontinue support for ADSP"
and does not support ADSP anymore.

Anyway, it does not hurt to publish a TXT record
indicating the strictest possible ADSP policy
that we apply to all incoming mail ourselves.
Unlike DMARC which allows either SPF or DKIM to pass,
ADSP requires that DKIM passes.
2024-01-26 15:04:09 +00:00
link2xt
6d6217812d Add missing login map 2024-01-25 23:17:57 +00:00
link2xt
ea36e73b8e postfix: require that login matches envelope FROM
Testing that envelope FROM matches From: header
already happens in filtermail
and tested with `test_reject_forged_from`.

The most important part here is
`reject_sender_login_mismatch` check
documented in
<https://www.postfix.org/postconf.5.html#reject_sender_login_mismatch>.
2024-01-25 23:17:57 +00:00
missytake
da268b57d4 tests: fix missing DKIM error message 2024-01-24 13:29:24 +00:00
link2xt
5588e13e54 Create opendkim configs before installing 2024-01-24 13:29:24 +00:00
link2xt
7c7f1cad7f Replace rspamd with OpenDKIM
OpenDKIM configuration
has two Lua scripts defining strict DKIM policy.

screen.lua filters out signatures that do not correspond
to the From: domain so they are not even checked.
final.lua rejects mail if it is not outgoing
and has no valid DKIM signatures.

OpenDKIM is configured as a milter on port 25 smtpd
to check DKIM signatures
and on mail reinjecting smtpd
to sign outgoing messages with DKIM signatures.
2024-01-24 13:29:24 +00:00
72 changed files with 2182 additions and 530 deletions

View File

@@ -9,7 +9,7 @@ jobs:
name: isolated chatmaild tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: run chatmaild tests
working-directory: chatmaild
@@ -19,7 +19,7 @@ jobs:
name: deploy-chatmail tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: initenv
run: scripts/initenv.sh

View File

@@ -1,6 +1,6 @@
;; Zone file for staging.testrun.org
;; Zone file for staging2.testrun.org
$ORIGIN staging.testrun.org.
$ORIGIN staging2.testrun.org.
$TTL 300
@ IN SOA ns.testrun.org. root.nine.testrun.org (
@@ -15,6 +15,7 @@ $TTL 300
@ IN NS ns.testrun.org.
;; DNS records.
@ IN A 37.27.37.98
mta-sts.staging.testrun.org. CNAME staging.testrun.org.
www.staging.testrun.org. CNAME staging.testrun.org.
@ IN A 37.27.24.139
mta-sts.staging2.testrun.org. CNAME staging2.testrun.org.
www.staging2.testrun.org. CNAME staging2.testrun.org.

View File

@@ -1,67 +1,87 @@
name: deploy on staging.testrun.org, and run tests
name: deploy on staging2.testrun.org, and run tests
on:
push:
branches:
- main
- staging-ci
pull_request:
paths-ignore:
- 'scripts/**'
- '**/README.md'
- 'CHANGELOG.md'
- 'LICENSE'
jobs:
deploy:
name: deploy on staging.testrun.org, and run tests
name: deploy on staging2.testrun.org, and run tests
runs-on: ubuntu-latest
concurrency:
group: staging-deploy
cancel-in-progress: true
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging.testrun.org > ~/.ssh/known_hosts
# rsync -avz root@staging.testrun.org:/var/lib/acme . || true
# rsync -avz root@staging.testrun.org:/var/lib/rspamd/dkim . || true
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
ssh -o StrictHostKeyChecking=accept-new root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
#- name: rebuild staging.testrun.org to have a clean VPS
# run: |
# curl -X POST \
# -H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
# -H "Content-Type: application/json" \
# -d '{"image":"debian-12"}' \
# "https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- name: rebuild staging2.testrun.org to have a clean VPS
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
run: |
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
# download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme/ root@staging2.testrun.org:/var/lib/acme || true
rsync -avz dkimkeys-restore/dkimkeys/ root@staging2.testrun.org:/etc/dkimkeys || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
- name: run formatting checks
run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
#- name: upload TLS cert after rebuilding
# run: |
# echo " --- wait until staging.testrun.org VPS is rebuilt --- "
# rm ~/.ssh/known_hosts
# while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u ; do sleep 1 ; done
# ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u
# rsync -avz acme root@staging.testrun.org:/var/lib/ || true
# rsync -avz dkim root@staging.testrun.org:/var/lib/rspamd/ || true
- run: cmdeploy init staging.testrun.org
- run: cmdeploy init staging2.testrun.org
- run: cmdeploy run
- name: set DNS entries
run: |
#ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown _rspamd:_rspamd -R /var/lib/rspamd/dkim
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging.testrun.org /etc/nsd/staging.testrun.org.zone
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test

98
CHANGELOG.md Normal file
View File

@@ -0,0 +1,98 @@
# Changelog for chatmail deployment
## untagged
- Reject DKIM signatures that do not cover the whole message body.
([#321](https://github.com/deltachat/chatmail/pull/321))
- check that OpenPGP has only PKESK, SKESK and SEIPD packets
([#323](https://github.com/deltachat/chatmail/pull/323),
[#324](https://github.com/deltachat/chatmail/pull/324))
- improve filtermail checks for encrypted messages and drop support for unencrypted MDNs
([#320](https://github.com/deltachat/chatmail/pull/320))
## 1.3.0 - 2024-06-06
- don't check necessary DNS records on cmdeploy init anymore
([#316](https://github.com/deltachat/chatmail/pull/316))
- ensure cron and acl are installed
([#293](https://github.com/deltachat/chatmail/pull/293),
[#310](https://github.com/deltachat/chatmail/pull/310))
- change default for delete_mails_after from 40 to 20 days
([#300](https://github.com/deltachat/chatmail/pull/300))
- save journald logs only to memory and save nginx logs to journald instead of file
([#299](https://github.com/deltachat/chatmail/pull/299))
- fix writing of multiple obs repositories in `/etc/apt/sources.list`
([#290](https://github.com/deltachat/chatmail/pull/290))
- metadata: add support for `/shared/vendor/deltachat/irohrelay`
([#284](https://github.com/deltachat/chatmail/pull/284))
- Emit "XCHATMAIL" capability from IMAP server
([#278](https://github.com/deltachat/chatmail/pull/278))
- Move echobot `into /var/lib/echobot`
([#281](https://github.com/deltachat/chatmail/pull/281))
- Accept Let's Encrypt's new Terms of Services
([#275](https://github.com/deltachat/chatmail/pull/276))
- Reload Dovecot and Postfix when TLS certificate updates
([#271](https://github.com/deltachat/chatmail/pull/271))
- Use forked version of dovecot without hardcoded delays
([#270](https://github.com/deltachat/chatmail/pull/270))
## 1.2.0 - 2024-04-04
- Install dig on the server to resolve DNS records
([#267](https://github.com/deltachat/chatmail/pull/267))
- preserve notification order and exponentially backoff with
retries for tokens where we didn't get a successful return
([#265](https://github.com/deltachat/chatmail/pull/263))
- Run chatmail-metadata and doveauth as vmail
([#261](https://github.com/deltachat/chatmail/pull/261))
- Apply systemd restrictions to echobot
([#259](https://github.com/deltachat/chatmail/pull/259))
- re-enable running the CI in pull requests, but not concurrently
([#258](https://github.com/deltachat/chatmail/pull/258))
## 1.1.0 - 2024-03-28
### The changelog starts to record changes from March 15th, 2024
- Move systemd unit templates to cmdeploy package
([#255](https://github.com/deltachat/chatmail/pull/255))
- Persist push tokens and support multiple device per address
([#254](https://github.com/deltachat/chatmail/pull/254))
- Avoid warning for regular doveauth protocol's hello message.
([#250](https://github.com/deltachat/chatmail/pull/250))
- Fix various tests to pass again with "cmdeploy test".
([#245](https://github.com/deltachat/chatmail/pull/245),
[#242](https://github.com/deltachat/chatmail/pull/242)
- Ensure lets-encrypt certificates are reloaded after renewal
([#244]) https://github.com/deltachat/chatmail/pull/244
- Persist tokens to avoid iOS users loosing push-notifications when the
chatmail metadata service is restarted (happens regularly during deploys)
([#238](https://github.com/deltachat/chatmail/pull/239)
- Fix failing sieve-script compile errors on incoming messages
([#237](https://github.com/deltachat/chatmail/pull/239)
- Fix quota reporting after expunging of old mails
([#233](https://github.com/deltachat/chatmail/pull/239)

View File

@@ -15,6 +15,8 @@ after which the initially specified password is required for using them.
## Deploying your own chatmail server
To deploy chatmail on your own server, you must have set-up ssh authentication and need to use an ed25519 key, due to an [upstream bug in paramiko](https://github.com/paramiko/paramiko/issues/2191). You also need to add your private key to the local ssh-agent, because you can't type in your password during deployment.
We use `chat.example.org` as the chatmail domain in the following steps.
Please substitute it with your own domain.
@@ -159,4 +161,27 @@ While this file is present, account creation will be blocked.
Delta Chat apps will, however, discover all ports and configurations
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail service.
## Email authentication
chatmail servers rely on [DKIM](https://www.rfc-editor.org/rfc/rfc6376)
to authenticate incoming emails.
Incoming emails must have a valid DKIM signature with
Signing Domain Identifier (SDID, `d=` parameter in the DKIM-Signature header)
equal to the `From:` header domain.
This property is checked by OpenDKIM screen policy script
before validating the signatures.
This correpsonds to strict [DMARC](https://www.rfc-editor.org/rfc/rfc7489) alignment (`adkim=s`),
but chatmail does not rely on DMARC and does not consult the sender policy published in DMARC records.
Other legacy authentication mechanisms such as [iprev](https://www.rfc-editor.org/rfc/rfc8601#section-2.7.3)
and [SPF](https://www.rfc-editor.org/rfc/rfc7208) are also not taken into account.
If there is no valid DKIM signature on the incoming email,
the sender receives a "5.7.1 No valid DKIM signature found" error.
Outgoing emails must be sent over authenticated connection
with envelope MAIL FROM (return path) corresponding to the login.
This is ensured by Postfix which maps login username
to MAIL FROM with
[`smtpd_sender_login_maps`](https://www.postfix.org/postconf.5.html#smtpd_sender_login_maps)
and rejects incorrectly authenticated emails with [`reject_sender_login_mismatch`](reject_sender_login_mismatch) policy.
`From:` header must correspond to envelope MAIL FROM,
this is ensured by `filtermail` proxy.

View File

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

View File

@@ -10,6 +10,8 @@ dependencies = [
"iniconfig",
"deltachat-rpc-server",
"deltachat-rpc-client",
"filelock",
"requests",
]
[tool.setuptools]
@@ -20,9 +22,11 @@ where = ['src']
[project.scripts]
doveauth = "chatmaild.doveauth:main"
chatmail-metadata = "chatmaild.metadata:main"
filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main"
rm_accounts = "chatmaild.rm_accounts:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
@@ -33,6 +37,16 @@ log_format = "%(asctime)s %(levelname)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
log_level = "INFO"
[tool.ruff]
lint.select = [
"F", # Pyflakes
"I", # isort
"PLC", # Pylint Convention
"PLE", # Pylint Error
"PLW", # Pylint Warning
]
[tool.tox]
legacy_tox_ini = """
[tox]
@@ -44,10 +58,9 @@ skipdist = True
skip_install = True
deps =
ruff
black
commands =
black --quiet --check --diff src/
ruff src/
ruff format --quiet --diff src/
ruff check src/
[testenv]
deps = pytest

View File

@@ -13,6 +13,7 @@ class Config:
self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
self.max_mailbox_size = params["max_mailbox_size"]
self.delete_mails_after = params["delete_mails_after"]
self.delete_accounts_after = int(params["delete_accounts_after"])
self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"])
@@ -20,6 +21,7 @@ class Config:
self.passthrough_recipients = params["passthrough_recipients"].split()
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.postfix_reinject_port = int(params["postfix_reinject_port"])
self.iroh_relay = params.get("iroh_relay")
self.privacy_postal = params.get("privacy_postal")
self.privacy_mail = params.get("privacy_mail")
self.privacy_pdo = params.get("privacy_pdo")

View File

@@ -1,5 +1,5 @@
import sqlite3
import contextlib
import sqlite3
import time
from pathlib import Path

View File

@@ -1,22 +1,26 @@
import crypt
import json
import logging
import os
import time
import sys
import json
import crypt
import time
from pathlib import Path
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
ThreadingMixIn,
UnixStreamServer,
)
import pwd
from .config import Config, read_config
from .database import Database
from .config import read_config, Config
NOCREATE_FILE = "/etc/chatmail-nocreate"
class UnknownCommand(ValueError):
"""dictproxy handler received an unkown command"""
def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
@@ -42,44 +46,71 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
return False
localpart, domain = parts
if localpart == "echo":
# echobot account should not be created in the database
return False
if (
len(localpart) > config.username_max_length
or len(localpart) < config.username_min_length
):
if localpart != "echo":
logging.warning(
"localpart %s has to be between %s and %s chars long",
localpart,
config.username_min_length,
config.username_max_length,
)
return False
logging.warning(
"localpart %s has to be between %s and %s chars long",
localpart,
config.username_min_length,
config.username_max_length,
)
return True
def get_user_data(db, user):
def get_user_data(db, config: Config, user):
if user == f"echo@{config.mail_domain}":
return dict(
home=f"/home/vmail/mail/{config.mail_domain}/echo@{config.mail_domain}",
uid="vmail",
gid="vmail",
)
with db.read_connection() as conn:
result = conn.get_user(user)
if result:
result["home"] = f"/home/vmail/mail/{config.mail_domain}/{user}"
result["uid"] = "vmail"
result["gid"] = "vmail"
return result
def lookup_userdb(db, user):
return get_user_data(db, user)
def lookup_userdb(db, config: Config, user):
return get_user_data(db, config, user)
def lookup_passdb(db, config: Config, user, cleartext_password):
if user == f"echo@{config.mail_domain}":
# Echobot writes password it wants to log in with into /run/echobot/password
try:
password = Path("/run/echobot/password").read_text()
except Exception:
logging.exception("Exception when trying to read /run/echobot/password")
return None
return dict(
home=f"/home/vmail/mail/{config.mail_domain}/echo@{config.mail_domain}",
uid="vmail",
gid="vmail",
password=encrypt_password(password),
)
with db.write_transaction() as conn:
userdata = conn.get_user(user)
if userdata:
# Update last login time.
conn.execute(
"UPDATE users SET last_login=? WHERE addr=?", (int(time.time()), user)
"UPDATE users SET last_login=? WHERE addr=?",
(int(time.time() // 86400), user),
)
userdata["home"] = f"/home/vmail/mail/{config.mail_domain}/{user}"
userdata["uid"] = "vmail"
userdata["gid"] = "vmail"
return userdata
@@ -125,8 +156,12 @@ def split_and_unescape(s):
def handle_dovecot_request(msg, db, config: Config):
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
short_command = msg[0]
if short_command == "L": # LOOKUP
if short_command == "H": # HELLO
# we don't do any checking on versions and just return
return
elif short_command == "L": # LOOKUP
parts = msg[1:].split("\t")
# Dovecot <2.3.17 has only one part,
@@ -142,7 +177,7 @@ def handle_dovecot_request(msg, db, config: Config):
if type == "userdb":
user = args[0]
if user.endswith(f"@{config.mail_domain}"):
res = lookup_userdb(db, user)
res = lookup_userdb(db, config, user)
if res:
reply_command = "O"
else:
@@ -157,7 +192,22 @@ def handle_dovecot_request(msg, db, config: Config):
reply_command = "N"
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
return None
raise UnknownCommand(msg)
def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config):
while True:
msg = rfile.readline().strip().decode()
if not msg:
break
try:
res = handle_dovecot_request(msg, db, config)
except UnknownCommand:
logging.warning("unknown command: %r", msg)
else:
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
@@ -166,23 +216,13 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
def main():
socket = sys.argv[1]
passwd_entry = pwd.getpwnam(sys.argv[2])
db = Database(sys.argv[3])
config = read_config(sys.argv[4])
db = Database(sys.argv[2])
config = read_config(sys.argv[3])
class Handler(StreamRequestHandler):
def handle(self):
try:
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, config)
if res:
self.wfile.write(res.encode("ascii"))
self.wfile.flush()
else:
logging.warn("request had no answer: %r", msg)
handle_dovecot_protocol(self.rfile, self.wfile, db, config)
except Exception:
logging.exception("Exception in the handler")
raise
@@ -193,7 +233,6 @@ def main():
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:

View File

@@ -3,14 +3,17 @@
it will echo back any message that has non-empty text and also supports the /help command.
"""
import logging
import os
import subprocess
import sys
from pathlib import Path
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
from chatmaild.newemail import create_newemail_dict
from chatmaild.config import read_config
from chatmaild.newemail import create_newemail_dict
hooks = events.HookCollection()
@@ -18,14 +21,14 @@ hooks = events.HookCollection()
@hooks.on(events.RawEvent)
def log_event(event):
if event.kind == EventType.INFO:
logging.info(event.msg)
logging.info("%s", event.msg)
elif event.kind == EventType.WARNING:
logging.warning(event.msg)
logging.warning("%s", event.msg)
@hooks.on(events.RawEvent(EventType.ERROR))
def log_error(event):
logging.error(event.msg)
logging.error("%s", event.msg)
@hooks.on(events.MemberListChanged)
@@ -48,6 +51,9 @@ def on_group_name_changed(event):
@hooks.on(events.NewMessage(func=lambda e: not e.command))
def echo(event):
snapshot = event.message_snapshot
if snapshot.is_info:
# Ignore info messages
return
if snapshot.text or snapshot.file:
snapshot.chat.send_message(text=snapshot.text, file=snapshot.file)
@@ -59,6 +65,7 @@ def help_command(event):
def main():
logging.basicConfig(level=logging.INFO)
path = os.environ.get("PATH")
venv_path = sys.argv[0].strip("echobot")
os.environ["PATH"] = path + ":" + venv_path
@@ -71,14 +78,27 @@ def main():
account = accounts[0] if accounts else deltachat.add_account()
bot = Bot(account, hooks)
config = read_config(sys.argv[1])
# Create password file
if bot.is_configured():
password = bot.account.get_config("mail_pw")
else:
password = create_newemail_dict(config)["password"]
Path("/run/echobot/password").write_text(password)
# Give the user which doveauth runs as access to the password file.
subprocess.run(
["/usr/bin/setfacl", "-m", "user:vmail:r", "/run/echobot/password"],
check=True,
)
if not bot.is_configured():
config = read_config(sys.argv[1])
password = create_newemail_dict(config).get("password")
email = "echo@" + config.mail_domain
bot.configure(email, password)
bot.run_forever()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()

View File

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

View File

@@ -0,0 +1,36 @@
import json
import logging
import os
from contextlib import contextmanager
import filelock
class FileDict:
"""Concurrency-safe multi-reader/single-writer persistent dict."""
def __init__(self, path):
self.path = path
self.lock_path = path.with_name(path.name + ".lock")
@contextmanager
def modify(self):
# the OS will release the lock if the process dies,
# and the contextmanager will otherwise guarantee release
with filelock.FileLock(self.lock_path):
data = self.read()
yield data
write_path = self.path.with_name(self.path.name + ".tmp")
with write_path.open("w") as f:
json.dump(data, f)
os.rename(write_path, self.path)
def read(self):
try:
with self.path.open("r") as f:
return json.load(f)
except FileNotFoundError:
return {}
except Exception:
logging.warning("corrupt serialization state at: %r", self.path)
return {}

View File

@@ -1,20 +1,111 @@
#!/usr/bin/env python3
import asyncio
import base64
import binascii
import logging
import time
import sys
from email.parser import BytesParser
import time
from email import policy
from email.parser import BytesParser
from email.utils import parseaddr
from smtplib import SMTP as SMTPClient
from aiosmtpd.controller import Controller
from smtplib import SMTP as SMTPClient
from .config import read_config
def check_openpgp_payload(payload: bytes):
"""Checks the OpenPGP payload.
OpenPGP payload must consist only of PKESK and SKESK packets
terminated by a single SEIPD packet.
Returns True if OpenPGP payload is correct,
False otherwise.
May raise IndexError while trying to read OpenPGP packet header
if it is truncated.
"""
i = 0
while i < len(payload):
# Only OpenPGP format is allowed.
if payload[i] & 0xC0 != 0xC0:
return False
packet_type_id = payload[i] & 0x3F
i += 1
if payload[i] < 192:
# One-octet length.
body_len = payload[i]
i += 1
elif payload[i] < 224:
# Two-octet length.
body_len = ((payload[i] - 192) << 8) + payload[i + 1] + 192
i += 2
elif payload[i] == 255:
# Five-octet length.
body_len = (
(payload[i + 1] << 24)
| (payload[i + 2] << 16)
| (payload[i + 3] << 8)
| payload[i + 4]
)
i += 5
else:
# Partial body length is not allowed.
return False
i += body_len
if i == len(payload):
if packet_type_id == 18:
# Last packet should be
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
return True
elif packet_type_id not in [1, 3]:
# All packets except the last one must be either
# Public-Key Encrypted Session Key Packet (PKESK)
# or
# Symmetric-Key Encrypted Session Key Packet (SKESK)
return False
if i > len(payload):
# Payload is truncated.
return False
return True
def check_armored_payload(payload: str):
prefix = "-----BEGIN PGP MESSAGE-----\r\n\r\n"
if not payload.startswith(prefix):
return False
payload = payload.removeprefix(prefix)
suffix = "-----END PGP MESSAGE-----\r\n\r\n"
if not payload.endswith(suffix):
return False
payload = payload.removesuffix(suffix)
# Remove CRC24.
payload = payload.rpartition("=")[0]
try:
payload = base64.b64decode(payload)
except binascii.Error:
return False
try:
return check_openpgp_payload(payload)
except IndexError:
return False
def check_encrypted(message):
"""Check that the message is an OpenPGP-encrypted message."""
"""Check that the message is an OpenPGP-encrypted message.
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
"""
if not message.is_multipart():
return False
if message.get("subject") != "...":
@@ -23,46 +114,30 @@ def check_encrypted(message):
return False
parts_count = 0
for part in message.iter_parts():
# We explicitly check Content-Type of each part later,
# but this is to be absolutely sure `get_payload()` returns string and not list.
if part.is_multipart():
return False
if parts_count == 0:
if part.get_content_type() != "application/pgp-encrypted":
return False
payload = part.get_payload()
if payload.strip() != "Version: 1":
return False
elif parts_count == 1:
if part.get_content_type() != "application/octet-stream":
return False
if not check_armored_payload(part.get_payload()):
return False
else:
return False
parts_count += 1
return True
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
async def asyncmain_beforequeue(config):
port = config.filtermail_smtp_port
Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
@@ -108,9 +183,6 @@ class BeforeQueueHandler:
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
if envelope.mail_from in self.config.passthrough_senders:
return

View File

@@ -18,7 +18,10 @@ max_user_send_per_minute = 60
max_mailbox_size = 100M
# days after which mails are unconditionally deleted
delete_mails_after = 40
delete_mails_after = 20
# days after which accounts are deleted if nobody logged in
delete_accounts_after = 25
# minimum length a username must have
username_min_length = 9

View File

@@ -0,0 +1,166 @@
import logging
import os
import sys
from pathlib import Path
from socketserver import (
StreamRequestHandler,
ThreadingMixIn,
UnixStreamServer,
)
from .config import read_config
from .filedict import FileDict
from .notifier import Notifier
DICTPROXY_HELLO_CHAR = "H"
DICTPROXY_LOOKUP_CHAR = "L"
DICTPROXY_ITERATE_CHAR = "I"
DICTPROXY_BEGIN_TRANSACTION_CHAR = "B"
DICTPROXY_SET_CHAR = "S"
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
DICTPROXY_TRANSACTION_CHARS = "BSC"
class Metadata:
# each SETMETADATA on this key appends to a list of unique device tokens
# which only ever get removed if the upstream indicates the token is invalid
DEVICETOKEN_KEY = "devicetoken"
def __init__(self, vmail_dir):
self.vmail_dir = vmail_dir
def get_metadata_dict(self, addr):
return FileDict(self.vmail_dir / addr / "metadata.json")
def add_token_to_addr(self, addr, token):
with self.get_metadata_dict(addr).modify() as data:
tokens = data.setdefault(self.DEVICETOKEN_KEY, [])
if token not in tokens:
tokens.append(token)
def remove_token_from_addr(self, addr, token):
with self.get_metadata_dict(addr).modify() as data:
tokens = data.get(self.DEVICETOKEN_KEY, [])
if token in tokens:
tokens.remove(token)
def get_tokens_for_addr(self, addr):
mdict = self.get_metadata_dict(addr).read()
return mdict.get(self.DEVICETOKEN_KEY, [])
def handle_dovecot_protocol(rfile, wfile, notifier, metadata, iroh_relay=None):
transactions = {}
while True:
msg = rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, transactions, notifier, metadata, iroh_relay)
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
def handle_dovecot_request(msg, transactions, notifier, metadata, iroh_relay=None):
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
short_command = msg[0]
parts = msg[1:].split("\t")
if short_command == DICTPROXY_LOOKUP_CHAR:
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
keyparts = parts[0].split("/", 2)
if keyparts[0] == "priv":
keyname = keyparts[2]
addr = parts[1]
if keyname == metadata.DEVICETOKEN_KEY:
res = " ".join(metadata.get_tokens_for_addr(addr))
return f"O{res}\n"
elif keyparts[0] == "shared":
keyname = keyparts[2]
if (
keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay"
and iroh_relay
):
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
return f"O{iroh_relay}\n"
logging.warning("lookup ignored: %r", msg)
return "N\n"
elif short_command == DICTPROXY_ITERATE_CHAR:
# Empty line means ITER_FINISHED.
# If we don't return empty line Dovecot will timeout.
return "\n"
elif short_command == DICTPROXY_HELLO_CHAR:
return # no version checking
if short_command not in (DICTPROXY_TRANSACTION_CHARS):
logging.warning("unknown dictproxy request: %r", msg)
return
transaction_id = parts[0]
if short_command == DICTPROXY_BEGIN_TRANSACTION_CHAR:
addr = parts[1]
transactions[transaction_id] = dict(addr=addr, res="O\n")
elif short_command == DICTPROXY_COMMIT_TRANSACTION_CHAR:
# each set devicetoken operation persists directly
# and does not wait until a "commit" comes
# because our dovecot config does not involve
# multiple set-operations in a single commit
return transactions.pop(transaction_id)["res"]
elif short_command == DICTPROXY_SET_CHAR:
# For documentation on key structure see
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else ""
addr = transactions[transaction_id]["addr"]
if keyname[0] == "priv" and keyname[2] == metadata.DEVICETOKEN_KEY:
metadata.add_token_to_addr(addr, value)
elif keyname[0] == "priv" and keyname[2] == "messagenew":
notifier.new_message_for_addr(addr, metadata)
else:
# Transaction failed.
transactions[transaction_id]["res"] = "F\n"
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
request_queue_size = 100
def main():
socket, vmail_dir, config_path = sys.argv[1:]
config = read_config(config_path)
iroh_relay = config.iroh_relay
vmail_dir = Path(vmail_dir)
if not vmail_dir.exists():
logging.error("vmail dir does not exist: %r", vmail_dir)
return 1
queue_dir = vmail_dir / "pending_notifications"
queue_dir.mkdir(exist_ok=True)
metadata = Metadata(vmail_dir)
notifier = Notifier(queue_dir)
notifier.start_notification_threads(metadata.remove_token_from_addr)
class Handler(StreamRequestHandler):
def handle(self):
try:
handle_dovecot_protocol(
self.rfile, self.wfile, notifier, metadata, iroh_relay
)
except Exception:
logging.exception("Exception in the dovecot dictproxy handler")
raise
try:
os.unlink(socket)
except FileNotFoundError:
pass
with ThreadedUnixStreamServer(socket, Handler) as server:
try:
server.serve_forever()
except KeyboardInterrupt:
pass

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
from pathlib import Path
import time
import sys
import time
from pathlib import Path
def main(vmail_dir=None):

View File

@@ -1,13 +1,13 @@
#!/usr/local/lib/chatmaild/venv/bin/python3
""" CGI script for creating new accounts. """
"""CGI script for creating new accounts."""
import json
import random
import secrets
import string
from chatmaild.config import read_config, Config
from chatmaild.config import Config, read_config
CONFIG_PATH = "/usr/local/lib/chatmaild/chatmail.ini"
ALPHANUMERIC = string.ascii_lowercase + string.digits

View File

@@ -0,0 +1,166 @@
"""
This modules provides notification machinery for transmitting device tokens to
a central notification server which in turn contacts a phone provider's notification server
to trigger Delta Chat apps to retrieve messages and provide instant notifications to users.
The Notifier class arranges the queuing of tokens in separate PriorityQueues
from which NotifyThreads take and transmit them via HTTPS
to the `notifications.delta.chat` service.
The current lack of proper HTTP/2-support in Python leads us
to use multiple threads and connections to the Rust-implemented `notifications.delta.chat`
which itself uses HTTP/2 and thus only a single connection to phone-notification providers.
If a token fails to cause a successful notification
it is moved to a retry-number specific PriorityQueue
which handles all tokens that failed a particular number of times
and which are scheduled for retry using exponential back-off timing.
If a token notification would be scheduled more than DROP_DEADLINE seconds
after its first attempt, it is dropped with a log error.
Note that tokens are completely opaque to the notification machinery here
and will in the future be encrypted foreclosing all ability to distinguish
which device token ultimately goes to which phone-provider notification service,
or to understand the relation of "device tokens" and chatmail addresses.
The meaning and format of tokens is basically a matter of Delta-Chat Core and
the `notification.delta.chat` service.
"""
import logging
import math
import os
import time
from dataclasses import dataclass
from pathlib import Path
from queue import PriorityQueue
from threading import Thread
from uuid import uuid4
import requests
@dataclass
class PersistentQueueItem:
path: Path
addr: str
start_ts: int
token: str
def delete(self):
self.path.unlink(missing_ok=True)
@classmethod
def create(cls, queue_dir, addr, start_ts, token):
queue_id = uuid4().hex
start_ts = int(start_ts)
path = queue_dir.joinpath(queue_id)
tmp_path = path.with_name(path.name + ".tmp")
tmp_path.write_text(f"{addr}\n{start_ts}\n{token}")
os.rename(tmp_path, path)
return cls(path, addr, start_ts, token)
@classmethod
def read_from_path(cls, path):
addr, start_ts, token = path.read_text().split("\n", maxsplit=2)
return cls(path, addr, int(start_ts), token)
def __lt__(self, other):
return self.start_ts < other.start_ts
class Notifier:
URL = "https://notifications.delta.chat/notify"
CONNECTION_TIMEOUT = 60.0 # seconds until http-request is given up
BASE_DELAY = 8.0 # base seconds for exponential back-off delay
DROP_DEADLINE = 5 * 60 * 60 # drop notifications after 5 hours
def __init__(self, queue_dir):
self.queue_dir = queue_dir
max_tries = int(math.log(self.DROP_DEADLINE, self.BASE_DELAY)) + 1
self.retry_queues = [PriorityQueue() for _ in range(max_tries)]
def compute_delay(self, retry_num):
return 0 if retry_num == 0 else pow(self.BASE_DELAY, retry_num)
def new_message_for_addr(self, addr, metadata):
start_ts = int(time.time())
for token in metadata.get_tokens_for_addr(addr):
queue_item = PersistentQueueItem.create(
self.queue_dir, addr, start_ts, token
)
self.queue_for_retry(queue_item)
def requeue_persistent_queue_items(self):
for queue_path in self.queue_dir.iterdir():
if queue_path.name.endswith(".tmp"):
logging.warning("removing spurious queue item: %r", queue_path)
queue_path.unlink()
continue
queue_item = PersistentQueueItem.read_from_path(queue_path)
self.queue_for_retry(queue_item)
def queue_for_retry(self, queue_item, retry_num=0):
delay = self.compute_delay(retry_num)
when = int(time.time()) + delay
deadline = queue_item.start_ts + self.DROP_DEADLINE
if retry_num >= len(self.retry_queues) or when > deadline:
queue_item.delete()
logging.error("notification exceeded deadline: %r", queue_item.token)
return
self.retry_queues[retry_num].put((when, queue_item))
def start_notification_threads(self, remove_token_from_addr):
self.requeue_persistent_queue_items()
threads = {}
for retry_num in range(len(self.retry_queues)):
# use 4 threads for first-try tokens and less for subsequent tries
num_threads = 4 if retry_num == 0 else 2
threads[retry_num] = []
for _ in range(num_threads):
thread = NotifyThread(self, retry_num, remove_token_from_addr)
threads[retry_num].append(thread)
thread.start()
return threads
class NotifyThread(Thread):
def __init__(self, notifier, retry_num, remove_token_from_addr):
super().__init__(daemon=True)
self.notifier = notifier
self.retry_num = retry_num
self.remove_token_from_addr = remove_token_from_addr
def stop(self):
self.notifier.retry_queues[self.retry_num].put((None, None))
def run(self):
requests_session = requests.Session()
while self.retry_one(requests_session):
pass
def retry_one(self, requests_session, sleep=time.sleep):
when, queue_item = self.notifier.retry_queues[self.retry_num].get()
if when is None:
return False
wait_time = when - int(time.time())
if wait_time > 0:
sleep(wait_time)
self.perform_request_to_notification_server(requests_session, queue_item)
return True
def perform_request_to_notification_server(self, requests_session, queue_item):
timeout = self.notifier.CONNECTION_TIMEOUT
token = queue_item.token
try:
res = requests_session.post(self.notifier.URL, data=token, timeout=timeout)
except requests.exceptions.RequestException as e:
res = e
else:
if res.status_code in (200, 410):
if res.status_code == 410:
self.remove_token_from_addr(queue_item.addr, token)
queue_item.delete()
return
logging.warning("Notification request failed: %r", res)
self.notifier.queue_for_retry(queue_item, retry_num=self.retry_num + 1)

View File

@@ -0,0 +1,37 @@
import sys
import time
import shutil
from pathlib import Path
from .config import read_config
from .database import Database
def remove_users(db: Database, cutoff_date: int):
with db.write_transaction() as conn:
delete_query = "DELETE FROM users WHERE last_login <?"
conn.execute(delete_query, (cutoff_date))
def remove_user_data(db: Database, cutoff_date: int, vmail_basedir: Path):
"""Collects all users where last_login < cutoff_date and deletes corresponding directories."""
with db.write_transaction() as conn:
select_query = "SELECT user FROM users WHERE last_login <?"
cursor = conn.execute(select_query, (cutoff_date,))
usernames = cursor.fetchall()
for username in usernames:
user_dir = vmail_basedir / username[0]
if user_dir.exists() and user_dir.is_dir():
shutil.rmtree(user_dir, ignore_errors=True)
print(f"Deleted directory: {user_dir}")
def main():
db = Database(sys.argv[2])
config = read_config(sys.argv[3])
today = int(time.time() // 86400)
cutoff_date = today - config.delete_accounts_after
remove_user_data(db, cutoff_date)

View File

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

View File

@@ -1,12 +1,14 @@
import random
import importlib.resources
import itertools
from email.parser import BytesParser
import os
import random
from email import policy
import pytest
from email.parser import BytesParser
from pathlib import Path
from chatmaild.database import Database
import pytest
from chatmaild.config import read_config, write_initial_config
from chatmaild.database import Database
@pytest.fixture
@@ -57,11 +59,18 @@ def db(tmpdir):
@pytest.fixture
def maildata(request):
datadir = importlib.resources.files(__package__).joinpath("mail-data")
try:
datadir = importlib.resources.files(__package__).joinpath("mail-data")
except TypeError:
# in python3.9 or lower, the above doesn't work, so we get datadir this way:
datadir = Path(os.getcwd()).joinpath("chatmaild/src/chatmaild/tests/mail-data")
assert datadir.exists(), datadir
def maildata(name, from_addr, to_addr):
data = datadir.joinpath(name).read_text()
# Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines.
data = datadir.joinpath(name).read_bytes().decode()
text = data.format(from_addr=from_addr, to_addr=to_addr)
return BytesParser(policy=policy.default).parsebytes(text.encode())

View File

@@ -24,7 +24,7 @@ def test_read_config_testrun(make_config):
assert config.postfix_reinject_port == 10025
assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "100M"
assert config.delete_mails_after == "40"
assert config.delete_mails_after == "20"
assert config.username_min_length == 9
assert config.username_max_length == 9
assert config.password_min_length == 9

View File

@@ -1,17 +1,23 @@
import io
import json
import pytest
import threading
import queue
import threading
import traceback
import chatmaild.doveauth
from chatmaild.doveauth import get_user_data, lookup_passdb, handle_dovecot_request
import pytest
from chatmaild.database import DBError
from chatmaild.doveauth import (
get_user_data,
handle_dovecot_protocol,
handle_dovecot_request,
lookup_passdb,
)
def test_basic(db, example_config):
lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue")
data = get_user_data(db, "asdf12345@chat.example.org")
data = get_user_data(db, example_config, "asdf12345@chat.example.org")
assert data
data2 = lookup_passdb(
db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue"
@@ -37,7 +43,7 @@ def test_nocreate_file(db, monkeypatch, tmpdir, example_config):
lookup_passdb(
db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik"
)
assert not get_user_data(db, "newuser12@chat.example.org")
assert not get_user_data(db, example_config, "newuser12@chat.example.org")
def test_db_version(db):
@@ -69,6 +75,23 @@ def test_handle_dovecot_request(db, example_config):
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(db, example_config, caplog):
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b""
assert not caplog.messages
def test_handle_dovecot_protocol(db, example_config):
rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b"N\n"
def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
num_threads = 50
req_per_thread = 5

View File

@@ -0,0 +1,19 @@
from chatmaild.filedict import FileDict
def test_basic(tmp_path):
fdict = FileDict(tmp_path.joinpath("metadata"))
assert fdict.read() == {}
with fdict.modify() as d:
d["devicetoken"] = [1, 2, 3]
d["456"] = 4.2
new = fdict.read()
assert new["devicetoken"] == [1, 2, 3]
assert new["456"] == 4.2
def test_bad_marshal_file(tmp_path, caplog):
fdict1 = FileDict(tmp_path.joinpath("metadata"))
fdict1.path.write_bytes(b"l12k3l12k3l")
assert fdict1.read() == {}
assert "corrupt" in caplog.records[0].msg

View File

@@ -1,12 +1,11 @@
import pytest
from chatmaild.filtermail import (
check_encrypted,
BeforeQueueHandler,
SendRateLimiter,
check_mdn,
check_armored_payload,
check_encrypted,
)
import pytest
@pytest.fixture
def maildomain():
@@ -63,34 +62,19 @@ def test_filtermail_encryption_detection(maildata):
assert not check_encrypted(msg)
def test_filtermail_is_mdn(maildata, gencreds, handler):
def test_filtermail_no_literal_packets(maildata):
"""Test that literal OpenPGP packet is not considered an encrypted mail."""
msg = maildata("literal.eml", from_addr="1@example.org", to_addr="2@example.org")
assert not check_encrypted(msg)
def test_filtermail_unencrypted_mdn(maildata, gencreds):
"""Unencrypted MDNs should not pass."""
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)
assert not check_encrypted(msg)
def test_send_rate_limiter():
@@ -143,3 +127,43 @@ def test_passthrough_senders(gencreds, handler, maildata):
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
def test_check_armored_payload():
payload = """-----BEGIN PGP MESSAGE-----\r
\r
wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r
Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r
755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r
pt14b4aC1VwtSnYhcRRELNLD/wE2TFif+g7poMmFY50VyMPLYjVP96Z5QCT4+z4H\r
Ikh/pRRN8S3JNMrRJHc6prooSJmLcx47Y5un7VFy390MsJ+LiUJuQMDdYWRAinfs\r
Ebm89Ezjm7F03qbFPXE0X4ZNzVXS/eKO0uhJQdiov/vmbn41rNtHmNpqjaO0vi5+\r
sS9tR7yDUrIXiCUCN78eBLVioxtktsPZm5cDORbQWzv+7nmCEz9/JowCUcBVdCGn\r
1ofOaH82JCAX/cRx08pLaDNj6iolVBsi56Dd+2bGxJOZOG2AMcEyz0pXY0dOAJCD\r
iUThcQeGIdRnU3j8UBcnIEsjLu2+C+rrwMZQESMWKnJ0rnqTk0pK5kXScr6F/L0L\r
UE49ccIexNm3xZvYr5drszr6wz3Tv5fdue87P4etBt90gF/Vzknck+g1LLlkzZkp\r
d8dI0k2tOSPjUbDPnSy1x+X73WGpPZmj0kWT+RGvq0nH6UkJj3AQTG2qf1T8jK+3\r
rTp3LR9vDkMwDjX4R8SA9c0wdnUzzr79OYQC9lTnzcx+fM6BBmgQ2GrS33jaFLp7\r
L6/DFpCl5zhnPjM/2dKvMkw/Kd6XS/vjwsO405FQdjSDiQEEAZA+ZvAfcjdccbbU\r
yCO+x0QNdeBsufDVnh3xvzuWy4CICdTQT4s1AWRPCzjOj+SGmx5WqCLWfsd8Ma0+\r
w/C7SfTYu1FDQILLM+llpq1M/9GPley4QZ8JQjo262AyPXsPF/OW48uuZz0Db1xT\r
Yh4iHBztj4VSdy7l2+IyaIf7cnL4EEBFxv/MwmVDXvDlxyvfAfIsd3D9SvJESzKZ\r
VWDYwaocgeCN+ojKu1p885lu1EfRbX3fr3YO02K5/c2JYDkc0Py0W3wUP/J1XUax\r
pbKpzwlkxEgtmzsGqsOfMJqBV3TNDrOA2uBsa+uBqP5MGYLZ49S/4v/bW9I01Cr1\r
D2ZkV510Y1Vgo66WlP8mRqOTyt/5WRhPD+MxXdk67BNN/PmO6tMlVoJDuk+XwWPR\r
t2TvNaND/yabT9eYI55Og4fzKD6RIjouUX8DvKLkm+7aXxVs2uuLQ3Jco3O82z55\r
dbShU1jYsrw9oouXUz06MHPbkdhNbF/2hfhZ2qA31sNeovJw65iUv7sDKX3LVWgJ\r
10jlywcDwqlU8CO7WC9lGixYTbnOkYZpXCGEl8e6Jbs79l42YFo4ogYpFK1NXFhV\r
kOXRmDf/wmfj+c/ld3L2PkvwlgofhCudOQknZbo3ub1gjiTn7L+lMGHIj/3suMIl\r
ID4EUxAXScIM1ZEz2fjtW5jATlqYcLjLTbf/olw6HFyPNH+9IssqXeZNKnGwPUB9\r
3lTXsg0tpzl+x7F/2WjEw1DSNhjC0KnHt1vEYNMkUGDGFdN9y3ERLqX/FIgiASUb\r
bTvAVupnAK3raBezGmhrs6LsQtLS9P0VvQiLU3uDhMqw8Z4SISLpcD+NnVBHzQqm\r
6W5Qn/8xsCL6av18yUVTi2G3igt3QCNoYx9evt2ZcIkNoyyagUVjfZe5GHXh8Dnz\r
GaBXW/hg3HlXLRGaQu4RYCzBMJILcO25OhZOg6jbkCLiEexQlm2e9krB5cXR49Al\r
UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
=b5Kp\r
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload) == True

View File

@@ -0,0 +1,312 @@
import io
import time
import pytest
import requests
from chatmaild.metadata import (
Metadata,
handle_dovecot_protocol,
handle_dovecot_request,
)
from chatmaild.notifier import (
Notifier,
NotifyThread,
PersistentQueueItem,
)
@pytest.fixture
def notifier(metadata):
queue_dir = metadata.vmail_dir.joinpath("pending_notifications")
queue_dir.mkdir()
return Notifier(queue_dir)
@pytest.fixture
def metadata(tmp_path):
vmail_dir = tmp_path.joinpath("vmaildir")
vmail_dir.mkdir()
return Metadata(vmail_dir)
@pytest.fixture
def testaddr():
return "user.name@example.org"
@pytest.fixture
def testaddr2():
return "user2@example.org"
@pytest.fixture
def token():
return "01234"
def get_mocked_requests(statuslist):
class ReqMock:
requests = []
def post(self, url, data, timeout):
self.requests.append((url, data, timeout))
res = statuslist.pop(0)
if isinstance(res, Exception):
raise res
class Result:
status_code = res
return Result()
return ReqMock()
def test_metadata_persistence(tmp_path, testaddr, testaddr2):
metadata1 = Metadata(tmp_path)
metadata2 = Metadata(tmp_path)
assert not metadata1.get_tokens_for_addr(testaddr)
assert not metadata2.get_tokens_for_addr(testaddr)
metadata1.add_token_to_addr(testaddr, "01234")
metadata1.add_token_to_addr(testaddr2, "456")
assert metadata2.get_tokens_for_addr(testaddr) == ["01234"]
assert metadata2.get_tokens_for_addr(testaddr2) == ["456"]
metadata2.remove_token_from_addr(testaddr, "01234")
assert not metadata1.get_tokens_for_addr(testaddr)
assert metadata1.get_tokens_for_addr(testaddr2) == ["456"]
def test_remove_nonexisting(metadata, tmp_path, testaddr):
metadata.add_token_to_addr(testaddr, "123")
metadata.remove_token_from_addr(testaddr, "1l23k1l2k3")
assert metadata.get_tokens_for_addr(testaddr) == ["123"]
def test_notifier_remove_without_set(metadata, testaddr):
metadata.remove_token_from_addr(testaddr, "123")
assert not metadata.get_tokens_for_addr(testaddr)
def test_handle_dovecot_request_lookup_fails(notifier, metadata, testaddr):
res = handle_dovecot_request(
f"Lpriv/123/chatmail\t{testaddr}", {}, notifier, metadata
)
assert res == "N\n"
def test_handle_dovecot_request_happy_path(notifier, metadata, testaddr, token):
transactions = {}
# set device token in a transaction
tx = "1111"
msg = f"B{tx}\t{testaddr}"
res = handle_dovecot_request(msg, transactions, notifier, metadata)
assert not res and not metadata.get_tokens_for_addr(testaddr)
assert transactions == {tx: dict(addr=testaddr, res="O\n")}
msg = f"S{tx}\tpriv/guid00/devicetoken\t{token}"
res = handle_dovecot_request(msg, transactions, notifier, metadata)
assert not res
assert len(transactions) == 1
assert metadata.get_tokens_for_addr(testaddr) == [token]
msg = f"C{tx}"
res = handle_dovecot_request(msg, transactions, notifier, metadata)
assert res == "O\n"
assert len(transactions) == 0
assert metadata.get_tokens_for_addr(testaddr) == [token]
# trigger notification for incoming message
tx2 = "2222"
assert (
handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions, notifier, metadata)
is None
)
msg = f"S{tx2}\tpriv/guid00/messagenew"
assert handle_dovecot_request(msg, transactions, notifier, metadata) is None
queue_item = notifier.retry_queues[0].get()[1]
assert queue_item.token == token
assert handle_dovecot_request(f"C{tx2}", transactions, notifier, metadata) == "O\n"
assert not transactions
assert queue_item.path.exists()
def test_handle_dovecot_protocol_set_devicetoken(metadata, notifier):
rfile = io.BytesIO(
b"\n".join(
[
b"HELLO",
b"Btx00\tuser@example.org",
b"Stx00\tpriv/guid00/devicetoken\t01234",
b"Ctx00",
]
)
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"O\n"
assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
def test_handle_dovecot_protocol_set_get_devicetoken(metadata, notifier):
rfile = io.BytesIO(
b"\n".join(
[
b"HELLO",
b"Btx00\tuser@example.org",
b"Stx00\tpriv/guid00/devicetoken\t01234",
b"Ctx00",
]
)
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
assert wfile.getvalue() == b"O\n"
rfile = io.BytesIO(
b"\n".join([b"HELLO", b"Lpriv/0123/devicetoken\tuser@example.org"])
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"O01234\n"
def test_handle_dovecot_protocol_iterate(metadata, notifier):
rfile = io.BytesIO(
b"\n".join(
[
b"H",
b"I9\t0\tpriv/5cbe730f146fea6535be0d003dd4fc98/\tci-2dzsrs@nine.testrun.org",
]
)
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"\n"
def test_notifier_thread_deletes_persistent_file(metadata, notifier, testaddr):
reqmock = get_mocked_requests([200])
metadata.add_token_to_addr(testaddr, "01234")
notifier.new_message_for_addr(testaddr, metadata)
NotifyThread(notifier, 0, None).retry_one(reqmock)
url, data, timeout = reqmock.requests[0]
assert data == "01234"
assert metadata.get_tokens_for_addr(testaddr) == ["01234"]
notifier.requeue_persistent_queue_items()
assert notifier.retry_queues[0].qsize() == 0
@pytest.mark.parametrize("status", [requests.exceptions.RequestException(), 404, 500])
def test_notifier_thread_connection_failures(
metadata, notifier, testaddr, status, caplog
):
"""test that tokens keep getting retried until they are given up."""
metadata.add_token_to_addr(testaddr, "01234")
notifier.new_message_for_addr(testaddr, metadata)
notifier.NOTIFICATION_RETRY_DELAY = 5
max_tries = len(notifier.retry_queues)
for i in range(max_tries):
caplog.clear()
reqmock = get_mocked_requests([status])
sleep_calls = []
NotifyThread(notifier, i, None).retry_one(reqmock, sleep=sleep_calls.append)
assert notifier.retry_queues[i].qsize() == 0
assert "request failed" in caplog.records[0].msg
if i > 0:
assert len(sleep_calls) == 1
if i + 1 < max_tries:
assert notifier.retry_queues[i + 1].qsize() == 1
assert len(caplog.records) == 1
else:
assert len(caplog.records) == 2
assert "deadline" in caplog.records[1].msg
notifier.requeue_persistent_queue_items()
assert notifier.retry_queues[0].qsize() == 0
def test_requeue_removes_tmp_files(notifier, metadata, testaddr, caplog):
metadata.add_token_to_addr(testaddr, "01234")
notifier.new_message_for_addr(testaddr, metadata)
p = notifier.queue_dir.joinpath("1203981203.tmp")
p.touch()
notifier2 = notifier.__class__(notifier.queue_dir)
notifier2.requeue_persistent_queue_items()
assert "spurious" in caplog.records[0].msg
assert not p.exists()
assert notifier2.retry_queues[0].qsize() == 1
when, queue_item = notifier2.retry_queues[0].get()
assert when <= int(time.time())
assert queue_item.addr == testaddr
def test_start_and_stop_notification_threads(notifier, testaddr):
threads = notifier.start_notification_threads(None)
for retry_num, threadlist in threads.items():
for t in threadlist:
t.stop()
t.join()
def test_multi_device_notifier(metadata, notifier, testaddr):
metadata.add_token_to_addr(testaddr, "01234")
metadata.add_token_to_addr(testaddr, "56789")
notifier.new_message_for_addr(testaddr, metadata)
reqmock = get_mocked_requests([200, 200])
NotifyThread(notifier, 0, None).retry_one(reqmock)
NotifyThread(notifier, 0, None).retry_one(reqmock)
assert notifier.retry_queues[0].qsize() == 0
assert notifier.retry_queues[1].qsize() == 0
url, data, timeout = reqmock.requests[0]
assert data == "01234"
url, data, timeout = reqmock.requests[1]
assert data == "56789"
assert metadata.get_tokens_for_addr(testaddr) == ["01234", "56789"]
def test_notifier_thread_run_gone_removes_token(metadata, notifier, testaddr):
metadata.add_token_to_addr(testaddr, "01234")
metadata.add_token_to_addr(testaddr, "45678")
notifier.new_message_for_addr(testaddr, metadata)
reqmock = get_mocked_requests([410, 200])
NotifyThread(notifier, 0, metadata.remove_token_from_addr).retry_one(reqmock)
NotifyThread(notifier, 0, None).retry_one(reqmock)
url, data, timeout = reqmock.requests[0]
assert data == "01234"
url, data, timeout = reqmock.requests[1]
assert data == "45678"
assert metadata.get_tokens_for_addr(testaddr) == ["45678"]
assert notifier.retry_queues[0].qsize() == 0
assert notifier.retry_queues[1].qsize() == 0
def test_persistent_queue_items(tmp_path, testaddr, token):
queue_item = PersistentQueueItem.create(tmp_path, testaddr, 432, token)
assert queue_item.addr == testaddr
assert queue_item.start_ts == 432
assert queue_item.token == token
item2 = PersistentQueueItem.read_from_path(queue_item.path)
assert item2.addr == testaddr
assert item2.start_ts == 432
assert item2.token == token
assert item2 == queue_item
item2.delete()
assert not item2.path.exists()
assert not queue_item < item2 and not item2 < queue_item
def test_iroh_relay(metadata):
rfile = io.BytesIO(
b"\n".join(
[
b"H",
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org",
]
)
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier, metadata, "https://example.org/")
assert wfile.getvalue() == b"Ohttps://example.org/\n"

View File

@@ -16,9 +16,9 @@ dependencies = [
"build",
"tox",
"ruff",
"black",
"pytest",
"pytest-xdist",
"imap_tools",
]
[project.scripts]
@@ -30,3 +30,13 @@ cmdeploy = "cmdeploy.cmdeploy:main"
[tool.pytest.ini_options]
addopts = "-v -ra --strict-markers"
[tool.ruff]
lint.select = [
"F", # Pyflakes
"I", # isort
"PLC", # Pylint Convention
"PLE", # Pylint Error
"PLW", # Pylint Warning
]

View File

@@ -1,20 +1,21 @@
"""
Chat Mail pyinfra deploy.
"""
import sys
import importlib.resources
import subprocess
import shutil
import io
import shutil
import subprocess
import sys
from pathlib import Path
from chatmaild.config import Config, read_config
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 pyinfra.operations import apt, files, pip, server, systemd
from chatmaild.config import read_config, Config
from .acmetool import deploy_acmetool
def _build_chatmaild(dist_dir) -> None:
@@ -101,13 +102,17 @@ def _install_remote_venv_with_chatmaild(config) -> None:
"doveauth",
"filtermail",
"echobot",
"chatmail-metadata",
):
params = dict(
execpath=f"{remote_venv_dir}/bin/{fn}",
config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir,
mail_domain=config.mail_domain,
)
source_path = importlib.resources.files(__package__).joinpath(
"service", f"{fn}.service.f"
)
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
content = source_path.read_text().format(**params).encode()
files.put(
@@ -126,6 +131,93 @@ def _install_remote_venv_with_chatmaild(config) -> None:
)
def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
"""Configures OpenDKIM"""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= main_config.changed
screen_script = files.put(
src=importlib.resources.files(__package__).joinpath("opendkim/screen.lua"),
dest="/etc/opendkim/screen.lua",
user="root",
group="root",
mode="644",
)
need_restart |= screen_script.changed
final_script = files.put(
src=importlib.resources.files(__package__).joinpath("opendkim/final.lua"),
dest="/etc/opendkim/final.lua",
user="root",
group="root",
mode="644",
)
need_restart |= final_script.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",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
apt.packages(
name="apt install opendkim opendkim-tools",
packages=["opendkim", "opendkim-tools"],
)
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
server.shell(
name="Generate OpenDKIM domain keys",
commands=[
f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
],
_sudo=True,
_sudo_user="opendkim",
)
return need_restart
def _install_mta_sts_daemon() -> bool:
need_restart = False
@@ -200,6 +292,16 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
)
need_restart |= header_cleanup.changed
# Login map that 1:1 maps email address to login.
login_map = files.put(
src=importlib.resources.files(__package__).joinpath("postfix/login_map"),
dest="/etc/postfix/login_map",
user="root",
group="root",
mode="644",
)
need_restart |= login_map.changed
return need_restart
@@ -225,6 +327,30 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
mode="644",
)
need_restart |= auth_config.changed
lua_push_notification_script = files.put(
src=importlib.resources.files(__package__).joinpath(
"dovecot/push_notification.lua"
),
dest="/etc/dovecot/push_notification.lua",
user="root",
group="root",
mode="644",
)
need_restart |= lua_push_notification_script.changed
sieve_script = files.put(
src=importlib.resources.files(__package__).joinpath("dovecot/default.sieve"),
dest="/etc/dovecot/default.sieve",
user="root",
group="root",
mode="644",
)
need_restart |= sieve_script.changed
if sieve_script.changed:
server.shell(
name="compile sieve script",
commands=["/usr/bin/sievec /etc/dovecot/default.sieve"],
)
files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/expunge.cron.j2"),
@@ -305,113 +431,20 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
return need_restart
def remove_opendkim() -> None:
"""Remove OpenDKIM, deprecated"""
files.file(
name="Remove legacy opendkim.conf",
path="/etc/opendkim.conf",
present=False,
)
files.directory(
name="Remove legacy opendkim socket directory from /var/spool/postfix",
path="/var/spool/postfix/opendkim",
present=False,
)
apt.packages(name="Remove openDKIM", packages="opendkim", present=False)
def _configure_rspamd(dkim_selector: str, mail_domain: str) -> bool:
"""Configures rspamd for Rate Limiting."""
need_restart = False
apt.packages(
name="apt install rspamd",
packages="rspamd",
)
for module in ["phishing", "rbl", "hfilter", "ratelimit"]:
disabled_module_conf = files.put(
name=f"disable {module} rspamd plugin",
src=importlib.resources.files(__package__).joinpath("rspamd/disabled.conf"),
dest=f"/etc/rspamd/local.d/{module}.conf",
user="root",
group="root",
mode="644",
)
need_restart |= disabled_module_conf.changed
options_inc = files.put(
name="disable fuzzy checks",
src=importlib.resources.files(__package__).joinpath("rspamd/options.inc"),
dest="/etc/rspamd/local.d/options.inc",
user="root",
group="root",
mode="644",
)
need_restart |= options_inc.changed
# https://rspamd.com/doc/modules/force_actions.html
force_actions_conf = files.put(
name="Set up rules to reject on DKIM, SPF and DMARC fails",
src=importlib.resources.files(__package__).joinpath(
"rspamd/force_actions.conf"
),
dest="/etc/rspamd/local.d/force_actions.conf",
user="root",
group="root",
mode="644",
)
need_restart |= force_actions_conf.changed
dkim_directory = "/var/lib/rspamd/dkim/"
dkim_key_path = f"{dkim_directory}{mail_domain}.{dkim_selector}.key"
dkim_dns_file = f"{dkim_directory}{mail_domain}.{dkim_selector}.zone"
dkim_config = files.template(
src=importlib.resources.files(__package__).joinpath(
"rspamd/dkim_signing.conf.j2"
),
dest="/etc/rspamd/local.d/dkim_signing.conf",
user="root",
group="root",
mode="644",
config={
"dkim_selector": str(dkim_selector),
"mail_domain": mail_domain,
"dkim_key_path": dkim_key_path,
},
)
need_restart |= dkim_config.changed
files.directory(
name="ensure DKIM key directory exists",
path=dkim_directory,
present=True,
user="_rspamd",
group="_rspamd",
)
if not host.get_fact(File, dkim_key_path):
server.shell(
name="Generate DKIM domain keys with rspamd",
commands=[
f"rspamadm dkim_keygen -b 2048 -s {dkim_selector} -d {mail_domain} -k {dkim_key_path} > {dkim_dns_file}"
],
_sudo=True,
_sudo_user="_rspamd",
)
return need_restart
def _remove_rspamd() -> None:
"""Remove rspamd"""
apt.packages(name="Remove rspamd", packages="rspamd", present=False)
def check_config(config):
mail_domain = config.mail_domain
if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"):
blocked_words = "merlinux schmieder testrun.org".split()
for value in config.__dict__.values():
if any(x in str(value) for x in blocked_words):
for key in config.__dict__:
value = config.__dict__[key]
if key.startswith("privacy") and any(
x in str(value) for x in blocked_words
):
raise ValueError(
f"please set your own privacy contacts/addresses in {config._inipath}"
)
@@ -429,9 +462,52 @@ def deploy_chatmail(config_path: Path) -> None:
from .www import build_webpages
apt.update(name="apt update", cache_time=24 * 3600)
server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Create opendkim user",
user="opendkim",
groups=["opendkim"],
system=True,
)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
server.user(name="Create echobot user", user="echobot", system=True)
server.shell(
name="Fix file owner in /home/vmail",
commands=["test -d /home/vmail && chown -R vmail:vmail /home/vmail"],
)
# Add our OBS repository for dovecot_no_delay
files.put(
name="Add Deltachat OBS GPG key to apt keyring",
src=importlib.resources.files(__package__).joinpath("obs-home-deltachat.gpg"),
dest="/etc/apt/keyrings/obs-home-deltachat.gpg",
user="root",
group="root",
mode="644",
)
files.line(
name="Add DeltaChat OBS home repository to sources.list",
path="/etc/apt/sources.list",
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
escape_regex_characters=True,
ensure_newline=True,
)
apt.update(name="apt update", cache_time=24 * 3600)
apt.packages(
name="Install rsync",
packages=["rsync"],
)
# Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf
@@ -456,10 +532,15 @@ def deploy_chatmail(config_path: Path) -> None:
# Deploy acmetool to have TLS certificates.
deploy_acmetool(
nginx_hook=True,
domains=[mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"],
)
apt.packages(
# required for setfacl for echobot
name="Install acl",
packages="acl",
)
apt.packages(
name="Install Postfix",
packages="postfix",
@@ -467,7 +548,7 @@ def deploy_chatmail(config_path: Path) -> None:
apt.packages(
name="Install Dovecot",
packages=["dovecot-imapd", "dovecot-lmtpd"],
packages=["dovecot-imapd", "dovecot-lmtpd", "dovecot-sieve"],
)
apt.packages(
@@ -494,15 +575,15 @@ def deploy_chatmail(config_path: Path) -> None:
mta_sts_need_restart = _install_mta_sts_daemon()
nginx_need_restart = _configure_nginx(mail_domain)
remove_opendkim()
rspamd_need_restart = _configure_rspamd("dkim", mail_domain)
_remove_rspamd()
opendkim_need_restart = _configure_opendkim(mail_domain, "opendkim")
systemd.service(
name="Start and enable rspamd",
service="rspamd.service",
name="Start and enable OpenDKIM",
service="opendkim.service",
running=True,
enabled=True,
restarted=rspamd_need_restart,
restarted=opendkim_need_restart,
)
systemd.service(
@@ -514,14 +595,9 @@ def deploy_chatmail(config_path: Path) -> None:
restarted=mta_sts_need_restart,
)
systemd.service(
name="Start and enable Postfix",
service="postfix.service",
running=True,
enabled=True,
restarted=postfix_need_restart,
)
# Dovecot should be started before Postfix
# because it creates authentication socket
# required by Postfix.
systemd.service(
name="Start and enable Dovecot",
service="dovecot.service",
@@ -530,6 +606,14 @@ def deploy_chatmail(config_path: Path) -> None:
restarted=dovecot_need_restart,
)
systemd.service(
name="Start and enable Postfix",
service="postfix.service",
running=True,
enabled=True,
restarted=postfix_need_restart,
)
systemd.service(
name="Start and enable nginx",
service="nginx.service",
@@ -558,5 +642,10 @@ def deploy_chatmail(config_path: Path) -> None:
service="systemd-journald.service",
running=True,
enabled=True,
restarted=journald_conf,
restarted=journald_conf.changed,
)
apt.packages(
name="Ensure cron is installed",
packages=["cron"],
)

View File

@@ -1,11 +1,11 @@
import importlib.resources
from pyinfra.operations import apt, files, systemd, server
from pyinfra import host
from pyinfra.facts.systemd import SystemdStatus
from pyinfra.operations import apt, files, server, systemd
def deploy_acmetool(nginx_hook=False, email="", domains=[]):
def deploy_acmetool(email="", domains=[]):
"""Deploy acmetool."""
apt.packages(
name="Install acmetool",
@@ -20,16 +20,13 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
mode="644",
)
if nginx_hook:
files.put(
src=importlib.resources.files(__package__)
.joinpath("acmetool.hook")
.open("rb"),
dest="/usr/lib/acme/hooks/nginx",
user="root",
group="root",
mode="744",
)
files.put(
src=importlib.resources.files(__package__).joinpath("acmetool.hook").open("rb"),
dest="/usr/lib/acme/hooks/nginx",
user="root",
group="root",
mode="744",
)
files.template(
src=importlib.resources.files(__package__).joinpath("response-file.yaml.j2"),
@@ -74,5 +71,5 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
server.shell(
name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want { ' '.join(domains)}"],
commands=[f"acmetool want --xlog.severity=debug { ' '.join(domains)}"],
)

View File

@@ -1,4 +1,4 @@
SHELL=/bin/sh
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
MAILTO=root
20 16 * * * root /usr/bin/acmetool --batch reconcile
20 16 * * * root /usr/bin/acmetool --batch reconcile && systemctl reload dovecot && systemctl reload postfix

View File

@@ -3,3 +3,5 @@ set -e
EVENT_NAME="$1"
[ "$EVENT_NAME" = "live-updated" ] || exit 42
systemctl restart nginx.service
systemctl reload dovecot.service
systemctl reload postfix.service

View File

@@ -1,2 +1,2 @@
"acme-enter-email": "{{ email }}"
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf": true
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.4-April-3-2024.pdf": true

View File

@@ -6,10 +6,10 @@ _submissions._tcp.{chatmail_domain}. SRV 0 1 465 {chatmail_domain}.
_imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}.
_imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}.
{chatmail_domain}. CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}"
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all"
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} ~all"
_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}"
mta-sts.{chatmail_domain}. CNAME {chatmail_domain}.
www.{chatmail_domain}. CNAME {chatmail_domain}.
_smtp._tls.{chatmail_domain}. TXT "v=TLSRPTv1;rua=mailto:{email}"
{dkim_entry}
_adsp._domainkey.{chatmail_domain}. TXT "dkim=discardable"

View File

@@ -2,20 +2,20 @@
Provides the `cmdeploy` entry point function,
along with command line option and subcommand parsing.
"""
import argparse
import shutil
import subprocess
import importlib.resources
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from termcolor import colored
from chatmaild.config import read_config, write_initial_config
from cmdeploy.dns import show_dns, check_necessary_dns
from termcolor import colored
from cmdeploy.dns import check_necessary_dns, show_dns
#
# cmdeploy sub commands and options
@@ -38,10 +38,6 @@ def init_cmd(args, out):
else:
write_initial_config(args.inipath, mail_domain)
out.green(f"created config file for {mail_domain} in {args.inipath}")
check_necessary_dns(
out,
mail_domain,
)
def run_cmd_options(parser):
@@ -156,26 +152,26 @@ def fmt_cmd_options(parser):
def fmt_cmd(args, out):
"""Run formattting fixes (ruff and black) on all chatmail source code."""
"""Run formattting fixes on all chatmail source code."""
sources = [str(importlib.resources.files(x)) for x in ("chatmaild", "cmdeploy")]
black_args = [shutil.which("black")]
ruff_args = [shutil.which("ruff")]
format_args = [shutil.which("ruff"), "format"]
check_args = [shutil.which("ruff"), "check"]
if args.check:
black_args.append("--check")
format_args.append("--diff")
else:
ruff_args.append("--fix")
check_args.append("--fix")
if not args.verbose:
black_args.append("-q")
ruff_args.append("-q")
check_args.append("--quiet")
format_args.append("--quiet")
black_args.extend(sources)
ruff_args.extend(sources)
format_args.extend(sources)
check_args.extend(sources)
out.check_call(" ".join(black_args), quiet=not args.verbose)
out.check_call(" ".join(ruff_args), quiet=not args.verbose)
out.check_call(" ".join(format_args), quiet=not args.verbose)
out.check_call(" ".join(check_args), quiet=not args.verbose)
return 0
@@ -231,7 +227,7 @@ class Out:
if not quiet:
cmdstring = " ".join(args)
self(f"[$ {cmdstring}]", file=sys.stderr)
proc = subprocess.run(args, env=env)
proc = subprocess.run(args, env=env, check=False)
return proc.returncode

View File

@@ -1,6 +1,8 @@
import os
import importlib.resources
import os
import pyinfra
from cmdeploy import deploy_chatmail

View File

@@ -1,9 +1,9 @@
import datetime
import importlib
import subprocess
import sys
import requests
import importlib
import subprocess
import datetime
class DNS:
@@ -11,6 +11,11 @@ class DNS:
self.session = requests.Session()
self.out = out
self.ssh = f"ssh root@{mail_domain} -- "
self.out.shell_output(
f"{ self.ssh }'apt-get update && apt-get install -y dnsutils'",
timeout=60,
no_print=True,
)
try:
self.shell(f"unbound-control flush_zone {mail_domain}")
except subprocess.CalledProcessError:
@@ -34,12 +39,11 @@ class DNS:
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
return self.shell(cmd).strip()
def get(self, typ: str, domain: str) -> str | None:
"""Get a DNS entry"""
def get(self, typ: str, domain: str) -> str:
"""Get a DNS entry or empty string if there is none."""
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
line = dig_result.partition("\n")[0]
if line:
return line
return line
def check_ptr_record(self, ip: str, mail_domain) -> bool:
"""Check the PTR record for an IPv4 or IPv6 address."""
@@ -54,27 +58,25 @@ def show_dns(args, out) -> int:
ssh = f"ssh root@{mail_domain}"
dns = DNS(out, mail_domain)
def read_dkim_entries(entry):
lines = []
for line in entry.split("\n"):
if line.startswith(";") or not line.strip():
continue
line = line.replace("\t", " ")
lines.append(line)
lines[0] = f"dkim._domainkey.{mail_domain}. IN TXT " + lines[0].strip(
"dkim._domainkey IN TXT "
)
return "\n".join(lines)
print("Checking your DKIM keys and DNS entries...")
try:
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
except subprocess.CalledProcessError:
print("Please run `cmdeploy run` first.")
return 1
dkim_entry = read_dkim_entries(
out.shell_output(f"{ssh} -- cat /var/lib/rspamd/dkim/{mail_domain}.dkim.zone")
dkim_selector = "opendkim"
dkim_pubkey = out.shell_output(
ssh + f" -- openssl rsa -in /etc/dkimkeys/{dkim_selector}.private"
" -pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
)
dkim_entry_value = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
dkim_entry_str = ""
while len(dkim_entry_value) >= 255:
dkim_entry_str += '"' + dkim_entry_value[:255] + '" '
dkim_entry_value = dkim_entry_value[255:]
dkim_entry_str += '"' + dkim_entry_value + '"'
dkim_entry = f"{dkim_selector}._domainkey.{mail_domain}. TXT {dkim_entry_str}"
ipv6 = dns.get_ipv6()
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
@@ -87,7 +89,6 @@ def show_dns(args, out) -> int:
f.read()
.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
@@ -103,11 +104,9 @@ def show_dns(args, out) -> int:
return 0
except TypeError:
pass
started_dkim_parsing = False
for line in zonefile.splitlines():
line = line.format(
for raw_line in zonefile.splitlines():
line = raw_line.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
@@ -131,28 +130,23 @@ def show_dns(args, out) -> int:
current = dns.get("SRV", domain[:-1])
if current != f"{prio} {weight} {port} {value}":
to_print.append(line)
if " TXT " in line:
if " TXT " in line:
domain, value = line.split(" TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if domain.startswith("_mta-sts."):
if current:
if current.split("id=")[0] == value.split("id=")[0]:
continue
if current != value:
# TXT records longer than 255 bytes
# are split into multiple <character-string>s.
# This typically happens with DKIM record
# which contains long RSA key.
#
# Removing `" "` before comparison
# to get back a single string.
if current.replace('" "', "") != value.replace('" "', ""):
to_print.append(line)
if " IN TXT ( " in line:
started_dkim_parsing = True
dkim_lines = [line]
if started_dkim_parsing and line.startswith('"'):
dkim_lines.append(" " + line)
domain, data = "\n".join(dkim_lines).split(" IN TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if current:
current = "( %s" % (current.replace('" "', '"\n "'))
if current != data:
to_print.append(dkim_entry)
else:
to_print.append(dkim_entry)
exit_code = 0
if to_print:
@@ -189,6 +183,7 @@ def show_dns(args, out) -> int:
def check_necessary_dns(out, mail_domain):
"""Check whether $mail_domain and mta-sts.$mail_domain resolve."""
print("Checking necessary DNS records... ")
dns = DNS(out, mail_domain)
ipv4 = dns.get("A", mail_domain)
ipv6 = dns.get("AAAA", mail_domain)
@@ -210,5 +205,5 @@ def check_necessary_dns(out, mail_domain):
print(line)
print()
else:
dns.out.green("\nAll necessary DNS entries seem to be set.")
dns.out.green("All necessary DNS records seem to be set.")
return True

View File

@@ -1,4 +1,4 @@
uri = proxy:/run/dovecot/doveauth.socket:auth
uri = proxy:/run/doveauth/doveauth.socket:auth
iterate_disable = yes
default_pass_scheme = plain
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).

View File

@@ -0,0 +1,7 @@
require ["imap4flags"];
# flag the message so it doesn't cause a push notification
if header :is ["Auto-Submitted"] ["auto-replied", "auto-generated"] {
addflag "$Auto";
}

View File

@@ -13,6 +13,12 @@ auth_cache_size = 100M
mail_debug = yes
{% endif %}
# Prevent warnings similar to:
# config: Warning: service auth { client_limit=1000 } is lower than required under max. load (10200). Counted for protocol services with service_count != 1: service lmtp { process_limit=100 } + service imap-urlauth-login { process_limit=100 } + service imap-login { process_limit=10000 }
# config: Warning: service anvil { client_limit=1000 } is lower than required under max. load (10103). Counted with: service imap-urlauth-login { process_limit=100 } + service imap-login { process_limit=10000 } + service auth { process_limit=1 }
# master: Warning: service(stats): client_limit (1000) reached, client connections are being dropped
default_client_limit = 20000
mail_server_admin = mailto:root@{{ config.mail_domain }}
mail_server_comment = Chatmail server
@@ -21,7 +27,7 @@ mail_plugins = quota
# these are the capabilities Delta Chat cares about actually
# so let's keep the network overhead per login small
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA XDELTAPUSH XCHATMAIL
# Authentication for system users.
@@ -71,6 +77,9 @@ mail_privileged_group = vmail
## Mail processes
##
# Pass all IMAP METADATA requests to the server implementing Dovecot's dict protocol.
mail_attribute_dict = proxy:/run/chatmail-metadata/metadata.socket:metadata
# Enable IMAP COMPRESS (RFC 4978).
# <https://datatracker.ietf.org/doc/html/rfc4978.html>
protocol imap {
@@ -79,7 +88,21 @@ protocol imap {
}
protocol lmtp {
mail_plugins = $mail_plugins quota
# quota plugin documentation:
# <https://doc.dovecot.org/configuration_manual/quota_plugin/>
#
# notify plugin is a dependency of push_notification plugin:
# <https://doc.dovecot.org/settings/plugin/notify-plugin/>
#
# push_notification plugin documentation:
# <https://doc.dovecot.org/configuration_manual/push_notification/>
#
# mail_lua and push_notification_lua are needed for Lua push notification handler.
# <https://doc.dovecot.org/configuration_manual/push_notification/#configuration>
#
# Sieve to mark messages that should not be notified as \Seen
# <https://doc.dovecot.org/configuration_manual/sieve/configuration/>
mail_plugins = $mail_plugins quota mail_lua notify push_notification push_notification_lua sieve
}
plugin {
@@ -95,7 +118,15 @@ plugin {
# quota_over_flag_value = TRUE
}
# push_notification configuration
plugin {
# <https://doc.dovecot.org/configuration_manual/push_notification/#lua-lua>
push_notification_driver = lua:file=/etc/dovecot/push_notification.lua
}
plugin {
sieve_default = file:/etc/dovecot/default.sieve
}
service lmtp {
user=vmail

View File

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

View File

@@ -0,0 +1,32 @@
function dovecot_lua_notify_begin_txn(user)
return user
end
function contains(v, needle)
for _, keyword in ipairs(v) do
if keyword == needle then
return true
end
end
return false
end
function dovecot_lua_notify_event_message_new(user, event)
local mbox = user:mailbox(event.mailbox)
mbox:sync()
if user.username ~= event.from_address then
-- Incoming message
if not contains(event.keywords, "$Auto") then
-- Not an Auto-Submitted message, notifying.
-- Notify METADATA server about new message.
mbox:metadata_set("/private/messagenew", "")
end
end
mbox:free()
end
function dovecot_lua_notify_end_txn(ctx, success)
end

View File

@@ -1,8 +1,9 @@
import importlib
import qrcode
import os
from PIL import ImageFont, ImageDraw, Image
import io
import os
import qrcode
from PIL import Image, ImageDraw, ImageFont
def gen_qr_png_data(maildomain):

View File

@@ -1,2 +1,3 @@
[Journal]
MaxRetentionSec=3d
Storage=volatile

View File

@@ -1,7 +1,7 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
error_log syslog:server=unix:/dev/log,facility=local3;
events {
worker_connections 768;
@@ -35,6 +35,8 @@ http {
server_name _;
access_log syslog:server=unix:/dev/log,facility=local7;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
@@ -80,5 +82,6 @@ http {
listen [::]:443 ssl;
server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.domain_name }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
}
}

Binary file not shown.

View File

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

View File

@@ -0,0 +1,32 @@
if odkim.internal_ip(ctx) == 1 then
-- Outgoing message will be signed,
-- no need to look for signatures.
return nil
end
nsigs = odkim.get_sigcount(ctx)
if nsigs == nil then
return nil
end
for i = 1, nsigs do
sig = odkim.get_sighandle(ctx, i - 1)
sigres = odkim.sig_result(sig)
-- All signatures that do not correspond to From:
-- were ignored in screen.lua and return sigres -1.
--
-- Any valid signature that was not ignored like this
-- means the message is acceptable.
if sigres == 0 then
-- Do not accept the signature if it does not cover the whole body
-- of the message by using `l=` tag.
if odkim.sig_canonlength(ctx, sig) < odkim.sig_bodylength(ctx, sig) then
return nil
end
end
end
odkim.set_reply(ctx, "554", "5.7.1", "No valid DKIM signature found")
odkim.set_result(ctx, SMFIS_REJECT)
return nil

View File

@@ -8,10 +8,12 @@ SyslogSuccess yes
# oversigned, because it is often the identity key used by reputation systems
# and thus somewhat security sensitive.
Canonicalization relaxed/simple
#Mode sv
#SubDomains no
OversignHeaders From
On-BadSignature reject
On-KeyNotFound reject
On-NoSignature reject
# Signing domain, selector, and key (required). For example, perform signing
# for domain "example.com" with selector "2020" (2020._domainkey.example.com),
# using the private key stored in /etc/dkimkeys/example.private. More granular
@@ -22,6 +24,15 @@ KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
KeyTable /etc/dkimkeys/KeyTable
SigningTable refile:/etc/dkimkeys/SigningTable
# Sign Autocrypt header in addition to the default specified in RFC 6376.
SignHeaders *,+autocrypt
# Script to ignore signatures that do not correspond to the From: domain.
ScreenPolicyScript /etc/opendkim/screen.lua
# Script to reject mails without a valid DKIM signature.
FinalPolicyScript /etc/opendkim/final.lua
# 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
# user (for example, Postfix). You may need to add user "postfix" to group
@@ -29,22 +40,10 @@ SigningTable refile:/etc/dkimkeys/SigningTable
UserID opendkim
UMask 007
# Socket for the MTA connection (required). If the MTA is inside a chroot jail,
# it must be ensured that the socket is accessible. In Debian, Postfix runs in
# a chroot in /var/spool/postfix, therefore a Unix socket would have to be
# configured as shown on the last line below.
#Socket local:/run/opendkim/opendkim.sock
#Socket inet:8891@localhost
#Socket inet:8891
Socket local:/var/spool/postfix/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
# Hosts for which to sign rather than verify, default is 127.0.0.1. See the
# OPERATION section of opendkim(8) for more information.
#InternalHosts 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12
# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
# by the package dns-root-data.
TrustAnchorFile /usr/share/dns/root.key
#Nameservers 127.0.0.1

View File

@@ -0,0 +1,21 @@
-- Ignore signatures that do not correspond to the From: domain.
from_domain = odkim.get_fromdomain(ctx)
if from_domain == nil then
return nil
end
n = odkim.get_sigcount(ctx)
if n == nil then
return nil
end
for i = 1, n do
sig = odkim.get_sighandle(ctx, i - 1)
sig_domain = odkim.sig_getdomain(sig)
if from_domain ~= sig_domain then
odkim.sig_ignore(sig)
end
end
return nil

View File

@@ -0,0 +1 @@
/^(.*)$/ ${1}

View File

@@ -23,6 +23,31 @@ 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_tls_protocols = >=TLSv1.2
# Disable anonymous cipher suites
# and known insecure algorithms.
#
# Disabling anonymous ciphers
# does not generally improve security
# because clients that want to verify certificate
# will not select them anyway,
# but makes cipher suite list shorter and security scanners happy.
# See <https://www.postfix.org/TLS_README.html> for discussion.
#
# Only ancient insecure ciphers should be disabled here
# as MTA clients that do not support more secure cipher
# likely do not support MTA-STS either and will
# otherwise fall back to using plaintext connection.
smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
# Override client's preference order.
# <https://www.postfix.org/postconf.5.html#tls_preempt_cipherlist>
#
# This is mostly to ensure cipher suites with forward secrecy
# are preferred over non cipher suites without forward secrecy.
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.mail_domain }}
@@ -46,5 +71,9 @@ inet_protocols = all
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }}
smtpd_milters = inet:127.0.0.1:11332
non_smtpd_milters = $smtpd_milters
mua_client_restrictions = permit_sasl_authenticated, reject
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
# 1:1 map MAIL FROM to SASL login name.
smtpd_sender_login_maps = regexp:/etc/postfix/login_map

View File

@@ -11,9 +11,10 @@
# ==========================================================================
{% if debug == true %}
smtp inet n - y - - smtpd -v
{% else %}
{%- else %}
smtp inet n - y - - smtpd
{% endif %}
{%- endif %}
-o smtpd_milters=unix:opendkim/opendkim.sock
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
@@ -78,6 +79,7 @@ filter unix - n n - - lmtp
# Local SMTP server for reinjecting filered mail.
localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
-o syslog_name=postfix/reinject
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean
# Cleanup `Received` headers for authenticated mail

View File

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

View File

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

View File

@@ -1,60 +0,0 @@
rules {
## Reject on missing or invalid DKIM signatures.
##
## We require DKIM signature on incoming mails regardless of DMARC policy.
# R_DKIM_REJECT: DKIM reject inserted by `dkim` module.
REJECT_INVALID_DKIM {
action = "reject";
expression = "R_DKIM_REJECT";
message = "Rejected due to invalid DKIM signature";
}
# R_DKIM_PERMFAIL: permanent failure inserted by `dkim` module e.g. no DKIM DNS record found.
REJECT_PERMFAIL_DKIM {
action = "reject";
expression = "R_DKIM_PERMFAIL";
message = "Rejected due to missing DKIM DNS entry";
}
# No DKIM signature (R_DKIM_NA symbol inserted by `dkim` module).
REJECT_MISSING_DKIM {
action = "reject";
expression = "R_DKIM_NA";
message = "Rejected due to missing DKIM signature";
}
## Reject on SPF failure.
# - SPF failure (R_SPF_FAIL)
# - SPF permanent failure, e.g. failed to resolve DNS record referenced from SPF (R_SPF_PERMFAIL)
REJECT_SPF {
action = "reject";
expression = "R_SPF_FAIL | R_SPF_PERMFAIL";
message = "Rejected due to failed SPF check";
}
# Reject on DMARC policy check failure.
REJECT_DMARC {
action = "reject";
expression = "DMARC_POLICY_REJECT";
message = "Rejected due to DMARC policy";
}
# Do not reject if:
# - R_DKIM_TEMPFAIL, it is a DNS resolution failure
# and we do not want to lose messages because of faulty network.
#
# - R_SPF_SOFTFAIL
# - R_SPF_NEUTRAL
# - R_SPF_DNSFAIL
# - R_SPF_NA
#
# - DMARC_DNSFAIL
# - DMARC_NA
# - DMARC_POLICY_SOFTFAIL
# - DMARC_POLICY_QUARANTINE
# - DMARC_BAD_POLICY
}

View File

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

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Chatmail dict proxy for IMAP METADATA
[Service]
ExecStart={execpath} /run/chatmail-metadata/metadata.socket /home/vmail/mail/{mail_domain} {config_path}
Restart=always
RestartSec=30
User=vmail
RuntimeDirectory=chatmail-metadata
[Install]
WantedBy=multi-user.target

View File

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

View File

@@ -0,0 +1,67 @@
[Unit]
Description=Chatmail echo bot for testing it works
[Service]
ExecStart={execpath} {config_path}
Environment="PATH={remote_venv_dir}:$PATH"
Restart=always
RestartSec=30
User=echobot
Group=echobot
# Create /var/lib/echobot
StateDirectory=echobot
# Create /run/echobot
#
# echobot stores /run/echobot/password
# with a password there, which doveauth then reads.
RuntimeDirectory=echobot
WorkingDirectory=/var/lib/echobot
# Apply security restrictions suggested by
# systemd-analyze security echobot.service
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
NoNewPrivileges=true
PrivateDevices=true
PrivateMounts=true
PrivateTmp=true
# We need to know about doveauth user to give it access to /run/echobot/password
PrivateUsers=false
ProtectClock=true
ProtectControlGroups=true
ProtectHostname=true
ProtectKernelLogs=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectProc=noaccess
# Should be "strict", but we currently write /accounts folder in a protected path
ProtectSystem=full
RemoveIPC=true
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
SystemCallArchitectures=native
SystemCallFilter=~@clock
SystemCallFilter=~@cpu-emulation
SystemCallFilter=~@debug
SystemCallFilter=~@module
SystemCallFilter=~@mount
SystemCallFilter=~@obsolete
SystemCallFilter=~@raw-io
SystemCallFilter=~@reboot
SystemCallFilter=~@resources
SystemCallFilter=~@swap
UMask=0077
[Install]
WantedBy=multi-user.target

View File

@@ -1,5 +1,5 @@
[Unit]
Description=Chatmail Postfix BeforeQeue filter
Description=Chatmail Postfix before queue filter
[Service]
ExecStart={execpath} {config_path}

View File

@@ -1,8 +1,10 @@
import pytest
import threading
import queue
import socket
import threading
import pytest
from chatmaild.config import read_config
from cmdeploy.cmdeploy import main
@@ -13,6 +15,13 @@ def test_init(tmp_path, maildomain):
assert config.mail_domain == maildomain
def test_capabilities(imap):
imap.connect()
capas = imap.conn.capabilities
assert "XCHATMAIL" in capas
assert "XDELTAPUSH" in capas
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
@@ -78,3 +87,24 @@ def test_concurrent_logins_same_account(
for _ in conns:
assert login_results.get()
def test_no_vrfy(chatmail_config):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((chatmail_config.mail_domain, 25))
banner = sock.recv(1024)
print(banner)
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
result = sock.recv(1024)
print(result)
sock.send(b"VRFY echo@%s\r\n" % (chatmail_config.mail_domain.encode(),))
result2 = sock.recv(1024)
print(result2)
assert result[0:10] == result2[0:10]
sock.send(b"VRFY wrongaddress\r\n")
result = sock.recv(1024)
print(result)
sock.send(b"VRFY echo\r\n")
result2 = sock.recv(1024)
print(result2)
assert result[0:10] == result2[0:10] == b"252 2.0.0 "

View File

@@ -1,4 +1,5 @@
import smtplib
import pytest
@@ -42,13 +43,25 @@ def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr):
assert "500" in str(e.value)
def test_authenticated_from(cmsetup, maildata):
"""Test that envelope FROM must be the same as login."""
user1, user2, user3 = cmsetup.gen_users(3)
msg = maildata("encrypted.eml", from_addr=user2.addr, to_addr=user3.addr)
with pytest.raises(smtplib.SMTPException) as e:
user1.smtp.sendmail(
from_addr=user2.addr, to_addrs=[user3.addr], msg=msg.as_string()
)
assert e.value.recipients[user3.addr][0] == 553
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr):
"""Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
recipient = cmsetup.gen_users(1)[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
with smtplib.SMTP(cmsetup.maildomain, 25) as s:
with pytest.raises(smtplib.SMTPDataError, match="missing DKIM signature"):
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
@@ -71,3 +84,19 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
assert b"4.7.1: Too much mail from" in outcome[1]
return
pytest.fail("Rate limit was not exceeded")
@pytest.mark.slow
def test_expunged(remote, chatmail_config):
outdated_days = int(chatmail_config.delete_mails_after) + 1
find_cmds = [
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/cur/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/cur/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/new/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/new/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/tmp/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
]
for cmd in find_cmds:
for line in remote.iter_output(cmd):
assert not line

View File

@@ -1,10 +1,50 @@
import time
import re
import ipaddress
import random
import re
import time
import imap_tools
import pytest
import requests
import ipaddress
@pytest.fixture
def imap_mailbox(cmfactory):
(ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr")
password = ac1.get_config("mail_pw")
mailbox = imap_tools.MailBox(user.split("@")[1])
mailbox.login(user, password)
return mailbox
class TestMetadataTokens:
"Tests that use Metadata extension for storing tokens"
def test_set_get_metadata(self, imap_mailbox):
"set and get metadata token for an account"
client = imap_mailbox.client
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n')
res = client.readline()
assert b"OK Setmetadata completed" in res
client.send(b"a02 GETMETADATA INBOX /private/devicetoken\n")
res = client.readline()
assert res[:1] == b"*"
res = client.readline().strip().rstrip(b")")
assert res == b"1111"
assert b"Getmetadata completed" in client.readline()
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "2222" )\n')
res = client.readline()
assert b"OK Setmetadata completed" in res
client.send(b"a02 GETMETADATA INBOX /private/devicetoken\n")
res = client.readline()
assert res[:1] == b"*"
res = client.readline().strip().rstrip(b")")
assert res == b"1111 2222"
assert b"Getmetadata completed" in client.readline()
class TestEndToEndDeltaChat:
@@ -63,7 +103,7 @@ class TestEndToEndDeltaChat:
addr = ac2.get_config("addr").lower()
saved_ok = 0
for line in remote.iter_output("journalctl -f -u dovecot"):
for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
if addr not in line:
# print(line)
continue
@@ -75,7 +115,10 @@ class TestEndToEndDeltaChat:
)
lp.indent("good, message sending failed because quota was exceeded")
return
if "saved mail to inbox" in line:
if (
"stored mail into mailbox 'inbox'" in line
or "saved mail to inbox" in line
):
saved_ok += 1
print(f"{saved_ok}: {line}")
if saved_ok >= num_to_send:
@@ -112,7 +155,7 @@ class TestEndToEndDeltaChat:
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 = ac2._evtracker.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")
@@ -132,7 +175,19 @@ def test_hide_senders_ip_address(cmfactory):
chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup")
user2.wait_next_incoming_message()
user2._evtracker.wait_next_incoming_message()
user2.direct_imap.select_folder("Inbox")
msg = user2.direct_imap.get_all_messages()[0]
assert public_ip not in msg.obj.as_string()
def test_echobot(cmfactory, chatmail_config, lp):
ac = cmfactory.get_online_accounts(1)[0]
lp.sec(f"Send message to echo@{chatmail_config.mail_domain}")
chat = ac.create_chat(f"echo@{chatmail_config.mail_domain}")
text = "hi, I hope you text me back"
chat.send_text(text)
lp.sec("Wait for reply from echobot")
reply = ac._evtracker.wait_next_incoming_message()
assert reply.text == text

View File

@@ -1,17 +1,16 @@
import os
import io
import time
import random
import subprocess
import imaplib
import smtplib
import io
import itertools
import os
import random
import smtplib
import subprocess
import time
from pathlib import Path
import pytest
from chatmaild.database import Database
from chatmaild.config import read_config
from chatmaild.database import Database
conftestdir = Path(__file__).parent

View File

@@ -1,6 +1,7 @@
import os
import pytest
from cmdeploy.cmdeploy import get_parser, main

View File

@@ -1,13 +1,14 @@
import importlib.resources
import webbrowser
import hashlib
import importlib.resources
import time
import traceback
import webbrowser
import markdown
from jinja2 import Template
from .genqr import gen_qr_png_data
from chatmaild.config import read_config
from jinja2 import Template
from .genqr import gen_qr_png_data
def snapshot_dir_stats(somedir):
@@ -120,7 +121,8 @@ def main():
print(f"watching {src_path} directory for changes")
changenum = 0
for count in range(0, 1000000):
count = 0
while True:
newstats = snapshot_dir_stats(src_path)
if newstats == stats and count % 60 != 0:
count += 1

80
scripts/dovecot/README.md Normal file
View File

@@ -0,0 +1,80 @@
## Introduction to custom Dovecot builds
Chatmail servers use a custom Debian build of the IMAP 'dovecot' server software because
a) Dovecot developers did not yet merge a [pull request](https://github.com/dovecot/core/pull/216)
which majorly speeds up message delivery by removing a hardcoded 0.5 second delay
on relaying incoming messages.
b) Even if merged, it would take years for it to reach Debian stable.
c) The modified dovecot has been successfully used since December 2023 without issues
and we see no noticeable downside (theoretically higher CPU usage but not measureable)
but a considerable upside as the delay-removal facilitates end-to-end message
delivery of 200 ms in real networks.
The modified forked dovecot code lives at
[https://github.com/chatmail/dovecot](https://github.com/chatmail/dovecot).
The remainder of this document describes the setup of the Debian repository
containing the patched dovecot version.
## Building Debian packages at build.opensuse.org
Delta Chat developers maintain an [account](https://build.opensuse.org/project/show/home:deltachat)
in the [Open Build Service (OBS)](https://openbuildservice.org/),
where the [resulting package](https://build.opensuse.org/package/show/home:deltachat/dovecot)
is now used in deploying chatmail servers.
The Open Build Service (OBS) is a platform for building and distributing software packages
across various operating systems and architectures.
It supports openSUSE, Fedora, Debian, Ubuntu and Arch.
It's [primary instance](https://build.opensuse.org/) is ran by the openSUSE project
and is part of the pipeline of the creation of SUSE Linux Enterprise.
The OBS provides a mercurial-like interface to create source repositories
that are then automatically built.
While in theory a package can be created entirely over the web interface,
the use of the cli-tool `osc` is more convenient and is described in the [official documentation](https://openbuildservice.org/help/manuals/obs-user-guide/art.obs.bg#sec.obsbg.obsconfig).
### How to build the dovecot debian package on the OBS via our script
In scripts/dovecot/ is a shell script that prepares the required files and pushes them to build.opensuse.org.
Before using the script, you should have osc set up as described in the [official documentation](https://openbuildservice.org/help/manuals/obs-user-guide/art.obs.bg#sec.obsbg.obsconfig).
The script assumes you are on Debian. It automatically installs any needed dependencies and creates the source package. To upload the resulting source package to the OBS you need to enter the username and password for deltachat on build.opensuse.org in the last step of the script.
Use `source build-obs.sh` to run it.
### Adding the resulting OBS repository to Debian 12
Our dovecot fork is automatically installed as part of the chatmail deployment. You can see it in cmdeploy/src/cmdeploy/__init__.py. If you want to add our fork manually to a system, you can do the following:
First add our signing key to your apt keyring:
```
sudo cp cmdeploy/src/cmdeploy/obs-home-deltachat.gpg /etc/apt/keyrings/obs-home-deltachat.gpg`
```
Now add our repository and key to /etc/apt/sources.list with a text editor of your choice:
```
deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./
```
You can now install dovecot like normal.
```
sudo apt update
sudo apt install dovecot-core
```
### Security concerns
The signing of the patched dovecot package is done in the OBS and
in theory SUSE could make changes to the package delivered.
It is probably reasonable to trust SUSE to not mess with the build
process because it would cause serious negative reputation damage for them
if they tried and someone finds out.
Our dovecot fork will receive the same security backports as the dovecot package in Debian Sid.

View File

@@ -0,0 +1,54 @@
#!/bin/bash
# Install dependencies
echo "Installing dependencies for this script:"
sudo apt install -y devscripts build-essential osc curl git debhelper-compat
# Define path of your local OBS repository
SCRIPT_DIR=$PWD
OBS_PATH=$SCRIPT_DIR/obs
REPO_PATH=$OBS_PATH/home:deltachat/dovecot/
# Download Debian Source Files
echo "Downloading precise files from Debian unstable repository..."
mkdir dovecot-build
cd dovecot-build
# taken May 6th 2024, from https://packages.debian.org/unstable/dovecot-core
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1-3.debian.tar.xz -O
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1.orig.tar.gz -O
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz -O
# Clone the Chatmail Dovecot Repo
echo "Cloning the Chatmail Dovecot fork..."
git clone https://github.com/chatmail/dovecot.git
# Build the source package
echo "Building the source package"
cd dovecot
dpkg-source -b .
# Setting up OSC
echo "Setting up OBS home repository"
mkdir $OBS_PATH
cd $OBS_PATH
rm -rf home:deltachat/dovecot
osc checkout home:deltachat/dovecot
# Copy Files to Your Local OBS Repository,
echo "Copying files to your local OBS repository..."
cd $SCRIPT_DIR/dovecot-build
cp -rf dovecot_2.3.21+dfsg1-3.debian.tar.xz $REPO_PATH
cp -rf dovecot_2.3.21+dfsg1.orig.tar.gz $REPO_PATH
cp -rf dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz $REPO_PATH
cp -rf dovecot_2.3.21+dfsg1-3.dsc $REPO_PATH
# Push Changes to OBS
echo "Pushing changes to OBS..."
cd $REPO_PATH
osc up
osc add dovecot_2.3.21+dfsg1-3.debian.tar.xz
osc add dovecot_2.3.21+dfsg1.orig.tar.gz
osc add dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz
osc add dovecot_2.3.21+dfsg1-3.dsc
osc commit

View File

@@ -1,11 +1,17 @@
<img class="banner" src="collage-top.png"/>
## Dear [Delta Chat](https://get.delta.chat) users and newcomers,
## Dear [Delta Chat](https://get.delta.chat) users and newcomers ...
{% if config.mail_domain != "nine.testrun.org" %}
Welcome to instant, interoperable and [privacy-preserving](privacy.html) messaging :)
{% else %}
Welcome to the default onboarding server ({{ config.mail_domain }})
for Delta Chat users. For details how it avoids storing personal information
please see our [privacy policy](privacy.html).
{% endif %}
👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address
👉 **Tap** or scan this QR code to get a `@{{config.mail_domain}}` chat profile
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>

View File

@@ -1,6 +1,4 @@
<img class="banner" src="collage-info.png"/>
## More information
{{ config.mail_domain }} provides a low-maintenance, resource efficient and
@@ -11,7 +9,7 @@ for the usage in chats, especially DeltaChat.
### Choosing a chatmail address instead of using a random one
In the Delta Chat account setup
you may tap `LOG INTO YOUR E-MAIL ACCOUNT`
you may tap `I already have a profile`
and fill the two fields like this:
- `Address`: invent a word with

View File

@@ -1,21 +1,41 @@
<img class="banner" 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 config.mail_domain == "nine.testrun.org" %}
Welcome to `{{config.mail_domain}}`, the default chatmail onboarding server for Delta Chat users.
It is operated on the side by a small sysops team employed by [merlinux](https://merlinux.eu),
an open-source R&D company also acting as the fiscal sponsor of Delta Chat app developments.
See [other chatmail servers](https://delta.chat/en/chatmail) for alternative server operators.
{% endif %}
## Summary: No personal data asked or collected
This chatmail server neither asks for nor retains personal information.
Chatmail servers exist to reliably transmit (store and deliver) end-to-end encrypted messages
between user's devices running the Delta Chat messenger app.
Technically, you may think of a Chatmail server as
an end-to-end encrypted "messaging router" at Internet-scale.
A chatmail server is very unlike classic e-mail servers (for example Google Mail servers)
that ask for personal data and permanently store messages.
A chatmail server behaves more like the Signal messaging server
but does not know about phone numbers and securely and automatically interoperates
with other chatmail and classic e-mail servers.
In particular, this chatmail server
- unconditionally removes messages after {{ config.delete_mails_after }} days,
- prohibits sending out un-encrypted messages,
- only has temporary log files used for debugging purposes.
Legally, authorities might still regard chatmail as a "classic e-mail" server
which collects and retains personal data.
We do not agree on this interpretation. Nevertheless, we provide more legal details below
to make life easier for data protection specialists and lawyers scrutinizing chatmail operations.
If you have any remaining questions about data protection, please contact us.
## 1. Name and contact information
@@ -178,8 +198,9 @@ for the purpose of drawing conclusions about your person.
## 4. Transfer of Data
Your personal data
will not be transferred to third parties
We do not retain any personal data but e-mail messages waiting to be delivered
may contain personal data.
Any such residual personal data will not be transferred to third parties
for purposes other than those listed below:
a) you have given your express consent