Compare commits

..

144 Commits

Author SHA1 Message Date
link2xt
158fb0b83e Add script to remove old seen messages 2024-07-05 16:46:36 +00:00
link2xt
b1d11d7747 Revert 57c29c14a4
Apparently this causes outlook.com messages to be rejected
even though they don't use `l=` tag.
2024-07-03 20:36:31 +00:00
link2xt
e948bdaea8 filtermail: do not allow ASCII armor without actual payload
Last line is removed as "optional checksum",
so it can contain anything.
Make sure that there is at least some actual payload
besides this line.
2024-07-03 19:36:07 +00:00
link2xt
17389b8667 Increase number of logged in IMAP sessions to 50000 2024-07-01 17:20:23 +00:00
link2xt
635b5de304 Replace bash with /bin/sh 2024-07-01 11:47:38 +02:00
holger krekel
67be981176 make a more complete test 2024-06-27 15:36:39 +02:00
missytake
0b8402c187 doveauth: ensure username length 2024-06-27 15:36:39 +02:00
missytake
7c98c1f8c9 test: ensure minimum username length 2024-06-27 15:36:39 +02:00
B. Petersen
0483603d4a fix headline ordering numbers, typo
before, the order was 2 - 3.1 - 3.2 - 3
i think, the gist was to have subheadlines under "2.";
this is fixed by this PR.

moreover, the PR contains a small typo fix.
2024-06-24 14:26:55 +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
56 changed files with 1398 additions and 481 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

View File

@@ -1,5 +1,81 @@
# Changelog for chatmail deployment
## untagged
- Test and fix for attempts to create inadmissible accounts
([#333](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))
- replace `bash` with `/bin/sh`
([#334](https://github.com/deltachat/chatmail/pull/334))
- Increase number of logged in IMAP sessions to 50000
([#335](https://github.com/deltachat/chatmail/pull/335))
- filtermail: do not allow ASCII armor without actual payload
([#325](https://github.com/deltachat/chatmail/pull/325))
## 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

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

@@ -36,6 +36,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]
@@ -47,10 +57,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

@@ -20,6 +20,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,18 +1,18 @@
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"
@@ -46,23 +46,33 @@ 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 False
return True
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:
@@ -77,6 +87,21 @@ def lookup_userdb(db, config: 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:
@@ -191,9 +216,8 @@ 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):
@@ -209,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()
@@ -75,9 +78,23 @@ 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()

View File

@@ -1,9 +1,10 @@
import os
import logging
import json
import filelock
import logging
import os
from contextlib import contextmanager
import filelock
class FileDict:
"""Concurrency-safe multi-reader/single-writer persistent dict."""

View File

@@ -1,20 +1,114 @@
#!/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 == 0:
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 +117,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 +186,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,7 @@ 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
# minimum length a username must have
username_min_length = 9

View File

@@ -1,19 +1,16 @@
import pwd
from pathlib import Path
from threading import Thread, Event
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
ThreadingMixIn,
)
import sys
import logging
import os
import requests
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"
@@ -23,96 +20,69 @@ DICTPROXY_SET_CHAR = "S"
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
DICTPROXY_TRANSACTION_CHARS = "BSC"
# 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
METADATA_TOKEN_KEY = "devicetoken"
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"
class Notifier:
def __init__(self, vmail_dir):
self.vmail_dir = vmail_dir
self.notification_dir = vmail_dir / "pending_notifications"
if not self.notification_dir.exists():
self.notification_dir.mkdir()
self.message_arrived_event = Event()
def get_metadata_dict(self, addr):
return FileDict(self.vmail_dir / addr / "metadata.json")
def add_token(self, addr, token):
def add_token_to_addr(self, addr, token):
with self.get_metadata_dict(addr).modify() as data:
tokens = data.get(METADATA_TOKEN_KEY)
if tokens is None:
data[METADATA_TOKEN_KEY] = [token]
elif token not in tokens:
tokens = data.setdefault(self.DEVICETOKEN_KEY, [])
if token not in tokens:
tokens.append(token)
def remove_token(self, addr, token):
def remove_token_from_addr(self, addr, token):
with self.get_metadata_dict(addr).modify() as data:
tokens = data.get(METADATA_TOKEN_KEY, [])
try:
tokens = data.get(self.DEVICETOKEN_KEY, [])
if token in tokens:
tokens.remove(token)
except ValueError:
pass
def get_tokens(self, addr):
return self.get_metadata_dict(addr).read().get(METADATA_TOKEN_KEY, [])
def new_message_for_addr(self, addr):
self.notification_dir.joinpath(addr).touch()
self.message_arrived_event.set()
def thread_run_loop(self):
requests_session = requests.Session()
while 1:
self.message_arrived_event.wait()
self.message_arrived_event.clear()
self.thread_run_one(requests_session)
def thread_run_one(self, requests_session):
for addr_path in self.notification_dir.iterdir():
addr = addr_path.name
if "@" not in addr:
continue
for token in self.get_tokens(addr):
response = requests_session.post(
"https://notifications.delta.chat/notify",
data=token,
timeout=60,
)
if response.status_code == 410:
# 410 Gone status code
# means the token is no longer valid.
self.remove_token(addr, token)
addr_path.unlink()
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):
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)
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):
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("/")
keyparts = parts[0].split("/", 2)
if keyparts[0] == "priv":
keyname = keyparts[2]
addr = parts[1]
if keyname == METADATA_TOKEN_KEY:
res = " ".join(notifier.get_tokens(addr))
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:
@@ -144,10 +114,10 @@ def handle_dovecot_request(msg, transactions, notifier):
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_TOKEN_KEY:
notifier.add_token(addr, value)
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)
notifier.new_message_for_addr(addr, metadata)
else:
# Transaction failed.
transactions[transaction_id]["res"] = "F\n"
@@ -158,21 +128,28 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
def main():
socket, username, vmail_dir = sys.argv[1:]
passwd_entry = pwd.getpwnam(username)
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
notifier = Notifier(vmail_dir)
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)
handle_dovecot_protocol(
self.rfile, self.wfile, notifier, metadata, iroh_relay
)
except Exception:
logging.exception("Exception in the dovecot dictproxy handler")
raise
@@ -182,17 +159,7 @@ def main():
except FileNotFoundError:
pass
# start notifier thread for signalling new messages to
# Delta Chat notification server
t = Thread(target=notifier.thread_run_loop)
t.setDaemon(True)
t.start()
# let notifier thread run once for any pending notifications from last run
notifier.message_arrived_event.set()
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

@@ -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,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,14 +1,14 @@
import random
from pathlib import Path
import os
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
@@ -68,7 +68,9 @@ def maildata(request):
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,18 +1,20 @@
import io
import json
import pytest
import queue
import threading
import traceback
import chatmaild.doveauth
import pytest
from chatmaild.database import DBError
from chatmaild.doveauth import (
get_user_data,
lookup_passdb,
handle_dovecot_request,
handle_dovecot_protocol,
handle_dovecot_request,
is_allowed_to_create,
lookup_passdb,
)
from chatmaild.database import DBError
from chatmaild.newemail import create_newemail_dict
def test_basic(db, example_config):
@@ -25,6 +27,20 @@ def test_basic(db, example_config):
assert data == data2
def test_invalid_username_length(example_config):
config = example_config
config.username_min_length = 6
config.username_max_length = 10
password = create_newemail_dict(config)["password"]
assert not is_allowed_to_create(config, f"a1234@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"012345@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"0123456@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"0123456789@{config.mail_domain}", password)
assert not is_allowed_to_create(
config, f"0123456789x@{config.mail_domain}", password
)
def test_dont_overwrite_password_on_wrong_login(db, example_config):
"""Test that logging in with a different password doesn't create a new user"""
res = lookup_passdb(

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,59 @@ 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
payload = """-----BEGIN PGP MESSAGE-----\r
\r
HELLOWORLD
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload) == False
payload = """-----BEGIN PGP MESSAGE-----\r
\r
=njUN
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload) == False

View File

@@ -1,18 +1,32 @@
import io
import pytest
import time
import pytest
import requests
from chatmaild.metadata import (
handle_dovecot_request,
Metadata,
handle_dovecot_protocol,
handle_dovecot_request,
)
from chatmaild.notifier import (
Notifier,
NotifyThread,
PersistentQueueItem,
)
@pytest.fixture
def notifier(tmp_path):
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 Notifier(vmail_dir)
return Metadata(vmail_dir)
@pytest.fixture
@@ -25,72 +39,100 @@ def testaddr2():
return "user2@example.org"
def test_notifier_persistence(tmp_path, testaddr, testaddr2):
notifier1 = Notifier(tmp_path)
notifier2 = Notifier(tmp_path)
assert not notifier1.get_tokens(testaddr)
assert not notifier2.get_tokens(testaddr)
notifier1.add_token(testaddr, "01234")
notifier1.add_token(testaddr2, "456")
assert notifier2.get_tokens(testaddr) == ["01234"]
assert notifier2.get_tokens(testaddr2) == ["456"]
notifier2.remove_token(testaddr, "01234")
assert not notifier1.get_tokens(testaddr)
assert notifier1.get_tokens(testaddr2) == ["456"]
@pytest.fixture
def token():
return "01234"
def test_remove_nonexisting(tmp_path, testaddr):
notifier1 = Notifier(tmp_path)
notifier1.add_token(testaddr, "123")
notifier1.remove_token(testaddr, "1l23k1l2k3")
assert notifier1.get_tokens(testaddr) == ["123"]
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_notifier_delete_without_set(notifier, testaddr):
notifier.remove_token(testaddr, "123")
assert not notifier.get_tokens(testaddr)
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_handle_dovecot_request_lookup_fails(notifier, testaddr):
res = handle_dovecot_request(f"Lpriv/123/chatmail\t{testaddr}", {}, notifier)
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, testaddr):
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)
assert not res and not notifier.get_tokens(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\t01234"
res = handle_dovecot_request(msg, transactions, notifier)
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 notifier.get_tokens(testaddr) == ["01234"]
assert metadata.get_tokens_for_addr(testaddr) == [token]
msg = f"C{tx}"
res = handle_dovecot_request(msg, transactions, notifier)
res = handle_dovecot_request(msg, transactions, notifier, metadata)
assert res == "O\n"
assert len(transactions) == 0
assert notifier.get_tokens(testaddr) == ["01234"]
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) is None
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) is None
assert notifier.message_arrived_event.is_set()
assert handle_dovecot_request(f"C{tx2}", transactions, notifier) == "O\n"
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 notifier.notification_dir.joinpath(testaddr).exists()
assert queue_item.path.exists()
def test_handle_dovecot_protocol_set_devicetoken(notifier):
def test_handle_dovecot_protocol_set_devicetoken(metadata, notifier):
rfile = io.BytesIO(
b"\n".join(
[
@@ -102,12 +144,12 @@ def test_handle_dovecot_protocol_set_devicetoken(notifier):
)
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier)
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"O\n"
assert notifier.get_tokens("user@example.org") == ["01234"]
assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
def test_handle_dovecot_protocol_set_get_devicetoken(notifier):
def test_handle_dovecot_protocol_set_get_devicetoken(metadata, notifier):
rfile = io.BytesIO(
b"\n".join(
[
@@ -119,19 +161,19 @@ def test_handle_dovecot_protocol_set_get_devicetoken(notifier):
)
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier)
assert notifier.get_tokens("user@example.org") == ["01234"]
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)
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"O01234\n"
def test_handle_dovecot_protocol_iterate(notifier):
def test_handle_dovecot_protocol_iterate(metadata, notifier):
rfile = io.BytesIO(
b"\n".join(
[
@@ -141,90 +183,130 @@ def test_handle_dovecot_protocol_iterate(notifier):
)
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, notifier)
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"\n"
def test_handle_dovecot_protocol_messagenew(notifier):
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"HELLO",
b"Btx01\tuser@example.org",
b"Stx01\tpriv/guid00/messagenew",
b"Ctx01",
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)
assert wfile.getvalue() == b"O\n"
assert notifier.message_arrived_event.is_set()
assert notifier.notification_dir.joinpath("user@example.org").exists()
def test_notifier_thread_run(notifier, testaddr):
requests = []
class ReqMock:
def post(self, url, data, timeout):
requests.append((url, data, timeout))
class Result:
status_code = 200
return Result()
notifier.add_token(testaddr, "01234")
notifier.new_message_for_addr(testaddr)
notifier.thread_run_one(ReqMock())
url, data, timeout = requests[0]
assert data == "01234"
assert notifier.get_tokens(testaddr) == ["01234"]
def test_multi_device_notifier(notifier, testaddr):
requests = []
class ReqMock:
def post(self, url, data, timeout):
requests.append((url, data, timeout))
class Result:
status_code = 200
return Result()
notifier.add_token(testaddr, "01234")
notifier.add_token(testaddr, "56789")
notifier.new_message_for_addr(testaddr)
notifier.thread_run_one(ReqMock())
url, data, timeout = requests[0]
assert data == "01234"
url, data, timeout = requests[1]
assert data == "56789"
assert notifier.get_tokens(testaddr) == ["01234", "56789"]
def test_notifier_thread_run_gone_removes_token(notifier, testaddr):
requests = []
class ReqMock:
def post(self, url, data, timeout):
requests.append((url, data, timeout))
class Result:
status_code = 410 if data == "01234" else 200
return Result()
notifier.add_token(testaddr, "01234")
notifier.new_message_for_addr(testaddr)
assert notifier.get_tokens(testaddr) == ["01234"]
notifier.add_token(testaddr, "45678")
notifier.thread_run_one(ReqMock())
url, data, timeout = requests[0]
assert data == "01234"
url, data, timeout = requests[1]
assert data == "45678"
assert notifier.get_tokens(testaddr) == ["45678"]
handle_dovecot_protocol(rfile, wfile, notifier, metadata, "https://example.org/")
assert wfile.getvalue() == b"Ohttps://example.org/\n"

View File

@@ -16,7 +16,6 @@ dependencies = [
"build",
"tox",
"ruff",
"black",
"pytest",
"pytest-xdist",
"imap_tools",
@@ -31,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

@@ -2,20 +2,20 @@
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:
@@ -135,20 +135,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
"""Configures OpenDKIM"""
need_restart = False
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,
)
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf",
@@ -375,6 +361,14 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
config=config,
)
files.put(
src=importlib.resources.files(__package__).joinpath("dovecot/remove-seen.py"),
dest="/usr/local/bin/remove-seen.py",
user="root",
group="root",
mode="755"
)
# as per https://doc.dovecot.org/configuration_manual/os/
# it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"):
@@ -476,9 +470,48 @@ 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"],
@@ -507,10 +540,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",
@@ -565,14 +603,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",
@@ -581,6 +614,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",
@@ -609,5 +650,12 @@ 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

@@ -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

@@ -4,19 +4,18 @@ 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
@@ -39,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):
@@ -157,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
@@ -232,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:
@@ -99,8 +104,8 @@ def show_dns(args, out) -> int:
return 0
except TypeError:
pass
for line in zonefile.splitlines():
line = line.format(
for raw_line in zonefile.splitlines():
line = raw_line.format(
acme_account_url=acme_account_url,
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
@@ -178,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)
@@ -199,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

@@ -19,6 +19,22 @@ mail_debug = yes
# master: Warning: service(stats): client_limit (1000) reached, client connections are being dropped
default_client_limit = 20000
# Increase number of logged in IMAP connections.
# Each connection is handled by a separate `imap` process.
# `imap` process should have `client_limit=1` as described in
# <https://doc.dovecot.org/configuration_manual/service_configuration/#service-limits>
# so each logged in IMAP session will need its own `imap` process.
#
# If this limit is reached,
# users will fail to LOGIN as `imap-login` process
# will accept them logging in but fail to transfer logged in
# connection to `imap` process until someone logs out and
# the following warning will be logged:
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped
service imap {
process_limit = 50000
}
mail_server_admin = mailto:root@{{ config.mail_domain }}
mail_server_comment = Chatmail server
@@ -27,7 +43,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 XDELTAPUSH
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA XDELTAPUSH XCHATMAIL
# Authentication for system users.
@@ -78,7 +94,7 @@ mail_privileged_group = vmail
##
# Pass all IMAP METADATA requests to the server implementing Dovecot's dict protocol.
mail_attribute_dict = proxy:/run/dovecot/metadata.socket:metadata
mail_attribute_dict = proxy:/run/chatmail-metadata/metadata.socket:metadata
# Enable IMAP COMPRESS (RFC 4978).
# <https://datatracker.ietf.org/doc/html/rfc4978.html>

View File

@@ -9,3 +9,4 @@
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
4 0 * * * vmail /usr/local/bin/remove-seen.py /home/vmail/mail/{{ config.mail_domain }}

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Remove seen messages that are older than two days
if maildir has more than 80 MB of messages."""
import sys
import time
from pathlib import Path
def getdirsize(path):
return sum(f.stat().st_size for f in path.glob("**/*") if f.is_file())
def parse_dovecot_seen(path):
return "S" in path.name.split(":2,")[-1]
def main():
now = time.time()
mailhome = Path(sys.argv[1])
for p in mailhome.iterdir():
dirsize = getdirsize(p / "cur") + getdirsize(p / "new")
if dirsize < 80000000:
continue
removed_bytes = 0
for mailpath in (p / "cur").iterdir():
seen = parse_dovecot_seen(mailpath)
stat = mailpath.stat()
size = stat.st_size
if seen and now > stat.st_mtime + 2 * 24 * 3600:
removed_bytes += size
mailpath.unlink(missing_ok=True)
if removed_bytes > 0:
(p / "maildirsize").unlink(missing_ok=True)
if __name__ == "__main__":
main()

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

@@ -2,9 +2,11 @@
Description=Chatmail dict proxy for IMAP METADATA
[Service]
ExecStart={execpath} /run/dovecot/metadata.socket vmail /home/vmail/mail/{mail_domain}
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

@@ -7,5 +7,61 @@ 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,9 +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
@@ -14,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

View File

@@ -1,4 +1,5 @@
import smtplib
import pytest
@@ -85,6 +86,7 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
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 = [

View File

@@ -1,11 +1,11 @@
import time
import re
import ipaddress
import random
import re
import time
import imap_tools
import pytest
import requests
import ipaddress
import imap_tools
@pytest.fixture

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

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
#
# Wrapper for cmdelpoy to run it in activated virtualenv.
set -e

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/sh
# 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,4 +1,4 @@
#!/bin/bash
#!/bin/sh
set -e
python3 -m venv --upgrade-deps venv

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
@@ -57,7 +77,7 @@ we process the following data and details:
- Users can retrieve or delete all stored messages
without intervention from the operators using standard IMAP client tools.
### 3.1 Account setup
### 2.1 Account setup
Creating an account happens in one of two ways on our mail servers:
@@ -78,7 +98,7 @@ Art. 6 (1) lit. b GDPR,
as you have a usage contract with us
by using our services.
## 3.2 Processing of E-Mail-Messages
### 2.2 Processing of E-Mail-Messages
In addition,
we will process data
@@ -104,7 +124,7 @@ Therefore, limits are enforced:
- message size limits
- any other limit neccessary for the whole server to function in a healthy way
- any other limit necessary for the whole server to function in a healthy way
and to prevent abuse.
The processing and use of the above permissions
@@ -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