Compare commits

..

5 Commits

Author SHA1 Message Date
holger krekel
4f175eec94 add DNS tests, make remote ssh-exec errors show locally, cleanup ssh-bootstrap 2024-07-13 20:00:28 +02:00
holger krekel
1cb64b4777 restructure DNS checks 2024-07-13 19:20:30 +02:00
holger krekel
f88bc86c54 simplify remote zone-file checking and insist for "dns" subcommand that all records are present 2024-07-13 19:20:30 +02:00
holger krekel
db1054f4bd - better debugging for DNS queries
- don't try to guess IP addresses but insist on A and AAAA records
- try to allow ipv4 or ipv6 only zones
- move chatmail.zone generation to jinja so we can have conditionals
2024-07-13 19:20:30 +02:00
holger krekel
134f498778 report back on ip determination -- deal with failure to obtain ip address 2024-07-13 19:20:30 +02:00
84 changed files with 1253 additions and 3572 deletions

View File

@@ -1,33 +0,0 @@
---
name: Bug report
about: Report something that isn't working.
title: ''
assignees: ''
---
<!--
Please fill out as much of this form as you can (leaving out stuff that is not applicable is ok).
-->
- Server OS (Operating System) - preferably Debian 12:
- On which OS you run cmdeploy:
- chatmail/relay version: `git rev-parse HEAD`
## Expected behavior
*What did you try to achieve?*
## Actual behavior
*What happened instead?*
### Steps to reproduce the problem:
1.
2.
### Screenshots
### Logs

View File

@@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Mutual Help Chat Group
url: https://i.delta.chat/#C2846EB4C1CB8DF84B1818F5E3A638FC3FBDC981&a=stalebot1%40nine.testrun.org&g=Chatmail%20Mutual%20Help&x=7sFF7Ik50pWv6J1z7RVC5527&i=d7s1HvOsk5UrSf9AoqRZggg4&s=XmX_9BAW6-g5Ao5E8PyaeKNB
about: If you have troubles setting up the relay server, feel free to ask here.

View File

@@ -10,10 +10,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Checkout pull request HEAD commit instead of merge commit
# Otherwise `test_deployed_state` will be unhappy.
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild

View File

@@ -1,20 +0,0 @@
;; Zone file for staging-ipv4.testrun.org
$ORIGIN staging-ipv4.testrun.org.
$TTL 300
@ IN SOA ns.testrun.org. root.nine.testrun.org (
2023010101 ; Serial
7200 ; Refresh
3600 ; Retry
1209600 ; Expire
3600 ; Negative response caching TTL
)
;; Nameservers.
@ IN NS ns.testrun.org.
;; DNS records.
@ IN A 37.27.95.249
mta-sts.staging-ipv4.testrun.org. CNAME staging-ipv4.testrun.org.
www.staging-ipv4.testrun.org. CNAME staging-ipv4.testrun.org.

View File

@@ -1,100 +0,0 @@
name: deploy on staging-ipv4.testrun.org, and run tests
on:
push:
branches:
- main
pull_request:
paths-ignore:
- 'scripts/**'
- '**/README.md'
- 'CHANGELOG.md'
- 'LICENSE'
jobs:
deploy:
name: deploy on staging-ipv4.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: ci-ipv4-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !contains(github.ref, '$GITHUB_REF') }}
steps:
- uses: jsok/serialize-workflow-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- 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-ipv4.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging-ipv4.testrun.org:/var/lib/acme acme-ipv4 || true
rsync -avz root@staging-ipv4.testrun.org:/etc/dkimkeys dkimkeys-ipv4 || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys-ipv4/dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys-ipv4 root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme-ipv4/acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme-ipv4 root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging-ipv4.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_IPV4_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 staging-ipv4.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.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-ipv4/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys-ipv4/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging-ipv4.testrun.org:/var/lib/ || true
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.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
- run: |
cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
- run: cmdeploy run
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=nine.testrun.org cmdeploy test --slow
- name: cmdeploy dns
run: cmdeploy dns -v

View File

@@ -38,9 +38,7 @@ jobs:
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi 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 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 # make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone ssh -o StrictHostKeyChecking=accept-new root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /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 ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging2.testrun.org to have a clean VPS - name: rebuild staging2.testrun.org to have a clean VPS
@@ -66,8 +64,8 @@ jobs:
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true 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 rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org # restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging2.testrun.org:/var/lib/ || true rsync -avz acme-restore/acme/ root@staging2.testrun.org:/var/lib/acme || true
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || 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 ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
- name: run formatting checks - name: run formatting checks
@@ -93,6 +91,6 @@ jobs:
- name: cmdeploy test - name: cmdeploy test
run: CHATMAIL_DOMAIN2=nine.testrun.org cmdeploy test --slow run: CHATMAIL_DOMAIN2=nine.testrun.org cmdeploy test --slow
- name: cmdeploy dns - name: cmdeploy dns (try 3 times)
run: cmdeploy dns -v run: cmdeploy dns -v || cmdeploy dns -v || cmdeploy dns -v

View File

@@ -2,362 +2,126 @@
## untagged ## untagged
- Check whether GCC is installed in initenv.sh - BREAKING: new required chatmail.ini values:
([#608](https://github.com/chatmail/relay/pull/608))
- Expire push notification tokens after 90 days mailboxes_dir = /home/vmail/mail/{mail_domain}
([#583](https://github.com/chatmail/relay/pull/583)) passdb = /home/vmail/passdb.sqlite
- Use official `mtail` binary instead of `mtail` package reducing hardcoding these two paths all over the files, also improving testability.
([#581](https://github.com/chatmail/relay/pull/581)) ([#351](https://github.com/deltachat/chatmail/pull/351))
- dovecot: install from download.delta.chat instead of openSUSE Build Service
([#590](https://github.com/chatmail/relay/pull/590))
- Reconfigure Dovecot imap-login service to high-performance mode
([#578](https://github.com/chatmail/relay/pull/578))
- Set timezone to improve dovecot performance
([#584](https://github.com/chatmail/relay/pull/584))
- Increase nginx connection limits
([#576](https://github.com/chatmail/relay/pull/576))
- If `dns-utils` needs to be installed before cmdeploy run, apt update to make sure it works
([#560](https://github.com/chatmail/relay/pull/560))
- filtermail: respect config message size limit
([#572](https://github.com/chatmail/relay/pull/572))
- Add config value after how many days large files are deleted
([#555](https://github.com/chatmail/relay/pull/555))
- cmdeploy: push relay version to /etc/chatmail-version
([#573](https://github.com/chatmail/relay/pull/573))
- filtermail: allow partial body length in OpenPGP payloads
([#570](https://github.com/chatmail/relay/pull/570))
- chatmaild: allow echobot to receive unencrypted messages by default
([#556](https://github.com/chatmail/relay/pull/556))
## 1.6.0 2025-04-11
- Handle Port-25 connect errors more gracefully (common with VPNs)
([#552](https://github.com/chatmail/relay/pull/552))
- Avoid "acmetool not found" during initial run
([#550](https://github.com/chatmail/relay/pull/550))
- Fix timezone handling such that client/servers do not need to use
same timezone.
([#553](https://github.com/chatmail/relay/pull/553))
- Enforce end-to-end encryption for incoming messages.
New user address mailboxes now get a `enforceE2EEincoming` file
which prohibits incoming cleartext messages from other domains.
An outside MTA trying to submit a cleartext message will
get a "523 Encryption Needed" response, see RFC5248.
If the file does not exist (as it the case for all existing accounts)
incoming cleartext messages are accepted.
([#538](https://github.com/chatmail/server/pull/538))
- Enforce end-to-end encryption between local addresses
([#535](https://github.com/chatmail/server/pull/535))
- unbound: check that port 53 is not occupied by a different process
([#537](https://github.com/chatmail/server/pull/537))
- unbound: before unbound is there, use 9.9.9.9 for resolving
([#518](https://github.com/chatmail/relay/pull/518))
- Limit the bind for the HTTPS server on 8443 to 127.0.0.1
([#522](https://github.com/chatmail/server/pull/522))
([#532](https://github.com/chatmail/server/pull/532))
- Send SNI when connecting to outside servers
([#524](https://github.com/chatmail/server/pull/524))
- postfix master.cf: use 127.0.0.1 for consistency
([#544](https://github.com/chatmail/relay/pull/544))
- Pass through `original_content` instead of `content` in filtermail
([#509](https://github.com/chatmail/server/pull/509))
- Document TLS requirements in the readme
([#514](https://github.com/chatmail/server/pull/514))
- Remove cleanup service from submission ports
([#512](https://github.com/chatmail/server/pull/512))
- cmdeploy dovecot: delete big messages after 7 days
([#504](https://github.com/chatmail/server/pull/504))
- mtail: fix getting logs from STDIN
([#502](https://github.com/chatmail/server/pull/502))
- filtermail: don't require exactly 2 lines after openPGP payload
([#497](https://github.com/chatmail/server/pull/497))
- cmdeploy dns: offer alternative DKIM record format for some web interfaces
([#470](https://github.com/chatmail/server/pull/470))
- journald: remove old logs from disk
([#490](https://github.com/chatmail/server/pull/490))
- opendkim: restart once every day to mend RAM leaks
([#498](https://github.com/chatmail/server/pull/498)
- migration guide: let opendkim own the DKIM keys directory
([#468](https://github.com/chatmail/server/pull/468))
- improve secure-join message detection
([#473](https://github.com/chatmail/server/pull/473))
- use old crypt lib in python < 3.11
([#483](https://github.com/chatmail/server/pull/483))
- chatmaild: set umask to 0700 for doveauth + metadata
([#490](https://github.com/chatmail/server/pull/492))
- remove MTA-STS daemon
([#488](https://github.com/chatmail/server/pull/488))
- replace `Subject` with `[...]` for all outgoing mails.
([#481](https://github.com/chatmail/server/pull/481))
- opendkim: use su instead of sudo
([#491](https://github.com/chatmail/server/pull/491))
## 1.5.0 2024-12-20
- cmdeploy dns: always show recommended DNS records
([#463](https://github.com/chatmail/server/pull/463))
- add `--all` to `cmdeploy dns`
([#462](https://github.com/chatmail/server/pull/462))
- fix `_mta-sts` TXT DNS record
([#461](https://github.com/chatmail/server/pull/461)
- deploy `iroh-relay` and also update "realtime relay services" in privacy policy.
([#434](https://github.com/chatmail/server/pull/434))
([#451](https://github.com/chatmail/server/pull/451))
- add guide to migrate chatmail to a new server
([#429](https://github.com/chatmail/server/pull/429))
- disable anvil authentication penalty
([#414](https://github.com/chatmail/server/pull/444)
- increase `request_queue_size` for UNIX sockets to 1000.
([#437](https://github.com/chatmail/server/pull/437))
- add argument to `cmdeploy run` for specifying
a different SSH host than `mail_domain`
([#439](https://github.com/chatmail/server/pull/439))
- query autoritative nameserver to bypass DNS cache
([#424](https://github.com/chatmail/server/pull/424))
- add mtail support (new optional `mtail_address` ini value)
This defines the address on which [`mtail`](https://google.github.io/mtail/)
exposes its metrics collected from the logs.
If you want to collect the metrics with Prometheus,
setup a private network (e.g. WireGuard interface)
and assign an IP address from this network to the host.
If you do not plan to collect metrics,
keep this setting unset.
([#388](https://github.com/chatmail/server/pull/388))
- fix checking for required DNS records
([#412](https://github.com/chatmail/server/pull/412))
- add support for specifying whole domains for recipient passthrough list
([#408](https://github.com/chatmail/server/pull/408))
- add a paragraph about "account deletion" to info page
([#405](https://github.com/chatmail/server/pull/405))
- avoid nginx listening on ipv6 if v6 is dsiabled
([#402](https://github.com/chatmail/server/pull/402))
- refactor ssh-based execution to allow organizing remote functions in
modules.
([#396](https://github.com/chatmail/server/pull/396))
- trigger "apt upgrade" during "cmdeploy run"
([#398](https://github.com/chatmail/server/pull/398))
- drop hispanilandia passthrough address
([#401](https://github.com/chatmail/server/pull/401))
- set CAA record flags to 0
- add IMAP capabilities instead of overwriting them
([#413](https://github.com/chatmail/server/pull/413))
- fix OpenPGP payload check
([#435](https://github.com/chatmail/server/pull/435))
- fix Dovecot quota_max_mail_size to use max_message_size config value
([#438](https://github.com/chatmail/server/pull/438))
## 1.4.1 2024-07-31
- fix metadata dictproxy which would confuse transactions
resulting in missed notifications and other issues.
([#393](https://github.com/chatmail/server/pull/393))
([#394](https://github.com/chatmail/server/pull/394))
- add optional "imap_rawlog" config option. If true,
.in/.out files are created in user home dirs
containing the imap protocol messages.
([#389](https://github.com/chatmail/server/pull/389))
## 1.4.0 2024-07-28
- Add `disable_ipv6` config option to chatmail.ini.
Required if the server doesn't have IPv6 connectivity.
([#312](https://github.com/chatmail/server/pull/312))
- allow current K9/Thunderbird-mail releases to send encrypted messages
outside by accepting their localized "encrypted subject" strings.
([#370](https://github.com/chatmail/server/pull/370))
- Migrate and remove sqlite database in favor of password/lastlogin tracking
in a user's maildir.
([#379](https://github.com/chatmail/server/pull/379))
- Require pyinfra V3 installed on the client side,
run `./scripts/initenv.sh` to upgrade locally.
([#378](https://github.com/chatmail/server/pull/378))
- don't hardcode "/home/vmail" paths but rather set them
once in the config object and use it everywhere else,
thereby also improving testability.
([#351](https://github.com/chatmail/server/pull/351))
temporarily introduced obligatory "passdb_path" and "mailboxes_dir"
settings but they were removed/obsoleted in
([#380](https://github.com/chatmail/server/pull/380))
- BREAKING: new required chatmail.ini value 'delete_inactive_users_after = 100' - BREAKING: new required chatmail.ini value 'delete_inactive_users_after = 100'
which removes users from database and mails after 100 days without any login. which removes users from database and mails after 100 days without any login.
([#350](https://github.com/chatmail/server/pull/350)) ([#350](https://github.com/deltachat/chatmail/pull/350))
- Refine DNS checking to distinguish between "required" and "recommended" settings
([#372](https://github.com/chatmail/server/pull/372))
- reload nginx in the acmetool cronjob - reload nginx in the acmetool cronjob
([#360](https://github.com/chatmail/server/pull/360)) ([#360](https://github.com/deltachat/chatmail/pull/360))
- remove checking of reverse-DNS PTR records. Chatmail-servers don't - remove checking of reverse-DNS PTR records. Chatmail-servers don't
depend on it and even in the wider e-mail system it's not common anymore. depend on it and even in the wider e-mail system it's not common anymore.
If it's an issue, a chatmail operator can still care to properly set reverse DNS. If it's an issue, a chatmail operator can still care to properly set reverse DNS.
([#348](https://github.com/chatmail/server/pull/348)) ([#348](https://github.com/deltachat/chatmail/pull/348))
- Make DNS-checking faster and more interactive, run it fully during "cmdeploy run", - Make DNS-checking faster and more interactive, run it fully during "cmdeploy run",
also introducing a generic mechanism for rapid remote ssh-based python function execution. also introducing a generic mechanism for rapid remote ssh-based python function execution.
([#346](https://github.com/chatmail/server/pull/346)) ([#346](https://github.com/deltachat/chatmail/pull/346))
- Don't fix file owner ship of /home/vmail - Don't fix file owner ship of /home/vmail
([#345](https://github.com/chatmail/server/pull/345)) ([#345](https://github.com/deltachat/chatmail/pull/345))
- Support iterating over all users with doveadm commands - Support iterating over all users with doveadm commands
([#344](https://github.com/chatmail/server/pull/344)) ([#344](https://github.com/deltachat/chatmail/pull/344))
- Test and fix for attempts to create inadmissible accounts - Test and fix for attempts to create inadmissible accounts
([#333](https://github.com/chatmail/server/pull/321)) ([#333](https://github.com/deltachat/chatmail/pull/321))
- check that OpenPGP has only PKESK, SKESK and SEIPD packets - check that OpenPGP has only PKESK, SKESK and SEIPD packets
([#323](https://github.com/chatmail/server/pull/323), ([#323](https://github.com/deltachat/chatmail/pull/323),
[#324](https://github.com/chatmail/server/pull/324)) [#324](https://github.com/deltachat/chatmail/pull/324))
- improve filtermail checks for encrypted messages and drop support for unencrypted MDNs - improve filtermail checks for encrypted messages and drop support for unencrypted MDNs
([#320](https://github.com/chatmail/server/pull/320)) ([#320](https://github.com/deltachat/chatmail/pull/320))
- replace `bash` with `/bin/sh` - replace `bash` with `/bin/sh`
([#334](https://github.com/chatmail/server/pull/334)) ([#334](https://github.com/deltachat/chatmail/pull/334))
- Increase number of logged in IMAP sessions to 50000 - Increase number of logged in IMAP sessions to 50000
([#335](https://github.com/chatmail/server/pull/335)) ([#335](https://github.com/deltachat/chatmail/pull/335))
- filtermail: do not allow ASCII armor without actual payload - filtermail: do not allow ASCII armor without actual payload
([#325](https://github.com/chatmail/server/pull/325)) ([#325](https://github.com/deltachat/chatmail/pull/325))
- Remove sieve to enable hardlink deduplication in LMTP - Remove sieve to enable hardlink deduplication in LMTP
([#343](https://github.com/chatmail/server/pull/343)) ([#343](https://github.com/deltachat/chatmail/pull/343))
- dovecot: enable gzip compression on disk - dovecot: enable gzip compression on disk
([#341](https://github.com/chatmail/server/pull/341)) ([#341](https://github.com/deltachat/chatmail/pull/341))
- DKIM-sign Content-Type and oversign all signed headers - DKIM-sign Content-Type and oversign all signed headers
([#296](https://github.com/chatmail/server/pull/296)) ([#296](https://github.com/deltachat/chatmail/pull/296))
- Add nonci_accounts metric - Add nonci_accounts metric
([#347](https://github.com/chatmail/server/pull/347)) ([#347](https://github.com/deltachat/chatmail/pull/347))
- doveauth: log when a new account is created - doveauth: log when a new account is created
([#349](https://github.com/chatmail/server/pull/349)) ([#349](https://github.com/deltachat/chatmail/pull/349))
- Multiplex HTTPS, IMAP and SMTP on port 443 - Multiplex HTTPS, IMAP and SMTP on port 443
([#357](https://github.com/chatmail/server/pull/357)) ([#357](https://github.com/deltachat/chatmail/pull/357))
## 1.3.0 - 2024-06-06 ## 1.3.0 - 2024-06-06
- don't check necessary DNS records on cmdeploy init anymore - don't check necessary DNS records on cmdeploy init anymore
([#316](https://github.com/chatmail/server/pull/316)) ([#316](https://github.com/deltachat/chatmail/pull/316))
- ensure cron and acl are installed - ensure cron and acl are installed
([#293](https://github.com/chatmail/server/pull/293), ([#293](https://github.com/deltachat/chatmail/pull/293),
[#310](https://github.com/chatmail/server/pull/310)) [#310](https://github.com/deltachat/chatmail/pull/310))
- change default for delete_mails_after from 40 to 20 days - change default for delete_mails_after from 40 to 20 days
([#300](https://github.com/chatmail/server/pull/300)) ([#300](https://github.com/deltachat/chatmail/pull/300))
- save journald logs only to memory and save nginx logs to journald instead of file - save journald logs only to memory and save nginx logs to journald instead of file
([#299](https://github.com/chatmail/server/pull/299)) ([#299](https://github.com/deltachat/chatmail/pull/299))
- fix writing of multiple obs repositories in `/etc/apt/sources.list` - fix writing of multiple obs repositories in `/etc/apt/sources.list`
([#290](https://github.com/chatmail/server/pull/290)) ([#290](https://github.com/deltachat/chatmail/pull/290))
- metadata: add support for `/shared/vendor/deltachat/irohrelay` - metadata: add support for `/shared/vendor/deltachat/irohrelay`
([#284](https://github.com/chatmail/server/pull/284)) ([#284](https://github.com/deltachat/chatmail/pull/284))
- Emit "XCHATMAIL" capability from IMAP server - Emit "XCHATMAIL" capability from IMAP server
([#278](https://github.com/chatmail/server/pull/278)) ([#278](https://github.com/deltachat/chatmail/pull/278))
- Move echobot `into /var/lib/echobot` - Move echobot `into /var/lib/echobot`
([#281](https://github.com/chatmail/server/pull/281)) ([#281](https://github.com/deltachat/chatmail/pull/281))
- Accept Let's Encrypt's new Terms of Services - Accept Let's Encrypt's new Terms of Services
([#275](https://github.com/chatmail/server/pull/276)) ([#275](https://github.com/deltachat/chatmail/pull/276))
- Reload Dovecot and Postfix when TLS certificate updates - Reload Dovecot and Postfix when TLS certificate updates
([#271](https://github.com/chatmail/server/pull/271)) ([#271](https://github.com/deltachat/chatmail/pull/271))
- Use forked version of dovecot without hardcoded delays - Use forked version of dovecot without hardcoded delays
([#270](https://github.com/chatmail/server/pull/270)) ([#270](https://github.com/deltachat/chatmail/pull/270))
## 1.2.0 - 2024-04-04 ## 1.2.0 - 2024-04-04
- Install dig on the server to resolve DNS records - Install dig on the server to resolve DNS records
([#267](https://github.com/chatmail/server/pull/267)) ([#267](https://github.com/deltachat/chatmail/pull/267))
- preserve notification order and exponentially backoff with - preserve notification order and exponentially backoff with
retries for tokens where we didn't get a successful return retries for tokens where we didn't get a successful return
([#265](https://github.com/chatmail/server/pull/263)) ([#265](https://github.com/deltachat/chatmail/pull/263))
- Run chatmail-metadata and doveauth as vmail - Run chatmail-metadata and doveauth as vmail
([#261](https://github.com/chatmail/server/pull/261)) ([#261](https://github.com/deltachat/chatmail/pull/261))
- Apply systemd restrictions to echobot - Apply systemd restrictions to echobot
([#259](https://github.com/chatmail/server/pull/259)) ([#259](https://github.com/deltachat/chatmail/pull/259))
- re-enable running the CI in pull requests, but not concurrently - re-enable running the CI in pull requests, but not concurrently
([#258](https://github.com/chatmail/server/pull/258)) ([#258](https://github.com/deltachat/chatmail/pull/258))
## 1.1.0 - 2024-03-28 ## 1.1.0 - 2024-03-28
@@ -365,27 +129,27 @@
### The changelog starts to record changes from March 15th, 2024 ### The changelog starts to record changes from March 15th, 2024
- Move systemd unit templates to cmdeploy package - Move systemd unit templates to cmdeploy package
([#255](https://github.com/chatmail/server/pull/255)) ([#255](https://github.com/deltachat/chatmail/pull/255))
- Persist push tokens and support multiple device per address - Persist push tokens and support multiple device per address
([#254](https://github.com/chatmail/server/pull/254)) ([#254](https://github.com/deltachat/chatmail/pull/254))
- Avoid warning for regular doveauth protocol's hello message. - Avoid warning for regular doveauth protocol's hello message.
([#250](https://github.com/chatmail/server/pull/250)) ([#250](https://github.com/deltachat/chatmail/pull/250))
- Fix various tests to pass again with "cmdeploy test". - Fix various tests to pass again with "cmdeploy test".
([#245](https://github.com/chatmail/server/pull/245), ([#245](https://github.com/deltachat/chatmail/pull/245),
[#242](https://github.com/chatmail/server/pull/242) [#242](https://github.com/deltachat/chatmail/pull/242)
- Ensure lets-encrypt certificates are reloaded after renewal - Ensure lets-encrypt certificates are reloaded after renewal
([#244]) https://github.com/chatmail/server/pull/244 ([#244]) https://github.com/deltachat/chatmail/pull/244
- Persist tokens to avoid iOS users loosing push-notifications when the - Persist tokens to avoid iOS users loosing push-notifications when the
chatmail metadata service is restarted (happens regularly during deploys) chatmail metadata service is restarted (happens regularly during deploys)
([#238](https://github.com/chatmail/server/pull/239) ([#238](https://github.com/deltachat/chatmail/pull/239)
- Fix failing sieve-script compile errors on incoming messages - Fix failing sieve-script compile errors on incoming messages
([#237](https://github.com/chatmail/server/pull/239) ([#237](https://github.com/deltachat/chatmail/pull/239)
- Fix quota reporting after expunging of old mails - Fix quota reporting after expunging of old mails
([#233](https://github.com/chatmail/server/pull/239) ([#233](https://github.com/deltachat/chatmail/pull/239)

478
README.md
View File

@@ -1,105 +1,57 @@
<img width="800px" src="www/src/collage-top.png"/> <img width="800px" src="www/src/collage-top.png"/>
# Chatmail relays for end-to-end encrypted e-mail # Chatmail services optimized for Delta Chat apps
Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed for: This repository helps to setup a ready-to-use chatmail server
- **Convenience:** Low friction instant onboarding
- **Privacy:** No name, phone numbers, email required or collected
- **End-to-End Encryption enforced**: only OpenPGP messages with metadata minimization allowed
- **Instant:** Privacy-preserving Push Notifications for Apple, Google, and Huawei
- **Speed:** Message delivery in half a second, with optional P2P realtime connections
- **Transport Security:** Strict TLS and DKIM enforced
- **Reliability:** No spam or IP reputation checks; rate-limits are suitable for realtime chats
- **Efficiency:** Messages are only stored for transit and removed automatically
This repository contains everything needed to setup a ready-to-use chatmail relay
comprised of a minimal setup of the battle-tested comprised of a minimal setup of the battle-tested
[Postfix SMTP](https://www.postfix.org) and [Dovecot IMAP](https://www.dovecot.org) MTAs/MDAs. [postfix smtp](https://www.postfix.org) and [dovecot imap](https://www.dovecot.org) services.
The automated setup is designed and optimized for providing chatmail addresses The setup is designed and optimized for providing chatmail accounts
for immediate permission-free onboarding through chat apps and bots. for use by [Delta Chat apps](https://delta.chat).
Chatmail addresses are automatically created at first login,
after which the initially specified password is required
for sending and receiving messages through them.
Please see [this list of known apps and client projects](https://chatmail.at/clients.html) Chatmail accounts are automatically created by a first login,
and [this list of known public 3rd party chatmail relay servers](https://chatmail.at/relays). after which the initially specified password is required for using them.
## Deploying your own chatmail server
## Minimal requirements, Prerequisites 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.
You will need the following: We use `chat.example.org` as the chatmail domain in the following steps.
- Control over a domain through a DNS provider of your choice.
- A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available.
Chatmail relay servers only require 1GB RAM, one CPU, and perhaps 10GB storage for a
few thousand active chatmail addresses.
- Key-based SSH authentication to the root user.
You must add a passphrase-protected private key to your local ssh-agent
because you can't type in your passphrase during deployment.
(An ed25519 private key is required due to an [upstream bug in paramiko](https://github.com/paramiko/paramiko/issues/2191))
## Getting started
We use `chat.example.org` as the chatmail domain in the following steps.
Please substitute it with your own domain. Please substitute it with your own domain.
1. Setup the initial DNS records. 1. Install the `cmdeploy` command in a virtualenv
The following is an example in the familiar BIND zone file format with
a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses.
``` ```
chat.example.com. 3600 IN A 198.51.100.5 git clone https://github.com/deltachat/chatmail
chat.example.com. 3600 IN AAAA 2001:db8::5 cd chatmail
www.chat.example.com. 3600 IN CNAME chat.example.com.
mta-sts.chat.example.com. 3600 IN CNAME chat.example.com.
```
2. On your local PC, clone the repository and bootstrap the Python virtualenv.
```
git clone https://github.com/chatmail/relay
cd relay
scripts/initenv.sh scripts/initenv.sh
``` ```
3. On your local PC, create chatmail configuration file `chatmail.ini`: 2. Create chatmail configuration file `chatmail.ini`:
``` ```
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
``` ```
4. Verify that SSH root login to your remote server works: 3. Setup first DNS records for your chatmail domain,
according to the hints provided by `cmdeploy init`.
Verify that SSH root login works:
``` ```
ssh root@chat.example.org # <-- use your domain ssh root@chat.example.org # <-- use your domain
``` ```
5. From your local PC, deploy the remote chatmail relay server: 4. Deploy to the remote chatmail server:
``` ```
scripts/cmdeploy run scripts/cmdeploy run
``` ```
This script will also check that you have all necessary DNS records. This script will also show you additional DNS records
If DNS records are missing, it will recommend
which you should configure at your DNS provider which you should configure at your DNS provider
(it can take some time until they are public). (it can take some time until they are public).
### Other helpful commands ### Other helpful commands:
To check the status of your remotely running chatmail service: To check the status of your remotely running chatmail service:
@@ -107,7 +59,7 @@ To check the status of your remotely running chatmail service:
scripts/cmdeploy status scripts/cmdeploy status
``` ```
To display and check all recommended DNS records: To check whether your DNS records are correct:
``` ```
scripts/cmdeploy dns scripts/cmdeploy dns
@@ -127,113 +79,43 @@ scripts/cmdeploy bench
## Overview of this repository ## Overview of this repository
This repository has four directories: This repository drives the development of chatmail services,
comprised of minimal setups of
- [cmdeploy](https://github.com/chatmail/relay/tree/main/cmdeploy) - [postfix smtp server](https://www.postfix.org)
is a collection of configuration files - [dovecot imap server](https://www.dovecot.org)
and a [pyinfra](https://pyinfra.com)-based deployment script.
- [chatmaild](https://github.com/chatmail/relay/tree/main/chatmaild) as well as custom services that are integrated with these two:
is a Python package containing several small services
which handle authentication,
trigger push notifications on new messages,
ensure that outbound mails are encrypted,
delete inactive users,
and some other minor things.
chatmaild can also be installed as a stand-alone Python package.
- [www](https://github.com/chatmail/relay/tree/main/www) - `chatmaild/src/chatmaild/doveauth.py` implements
contains the html, css, and markdown files create-on-login account creation semantics and is used
which make up a chatmail relay's web page. by Dovecot during login authentication and by Postfix
Edit them before deploying to make your chatmail relay stand out.
- [scripts](https://github.com/chatmail/relay/tree/main/scripts)
offers two convenience tools for beginners;
`initenv.sh` installs the necessary dependencies to a local virtual environment,
and the `scripts/cmdeploy` script enables you
to run the `cmdeploy` command line tool in the local virtual environment.
### cmdeploy
The `cmdeploy/src/cmdeploy/cmdeploy.py` command line tool
helps with setting up and managing the chatmail service.
`cmdeploy init` creates the `chatmail.ini` config file.
`cmdeploy run` uses a [pyinfra](https://pyinfra.com/)-based [`script`](cmdeploy/src/cmdeploy/__init__.py)
to automatically install or upgrade all chatmail components on a relay,
according to the `chatmail.ini` config.
The components of chatmail are:
- [Postfix SMTP MTA](https://www.postfix.org) accepts and relays messages
(both from your users and from the wider e-mail MTA network)
- [Dovecot IMAP MDA](https://www.dovecot.org) stores messages for your users until they download them
- [Nginx](https://nginx.org/) shows the web page with your privacy policy and additional information
- [acmetool](https://hlandau.github.io/acmetool/) manages TLS certificates for Dovecot, Postfix, and Nginx
- [OpenDKIM](http://www.opendkim.org/) for signing messages with DKIM and rejecting inbound messages without DKIM
- [mtail](https://google.github.io/mtail/) for collecting anonymized metrics in case you have monitoring
- [Iroh relay](https://www.iroh.computer/docs/concepts/relay)
which helps client devices to establish Peer-to-Peer connections
- and the chatmaild services, explained in the next section:
### chatmaild
`chatmaild` implements various systemd-controlled services
that integrate with Dovecot and Postfix to achieve instant-onboarding and
only relaying OpenPGP end-to-end messages encrypted messages.
A short overview of `chatmaild` services:
- [`doveauth`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/doveauth.py)
implements create-on-login address semantics and is used
by Dovecot during IMAP login and by Postfix during SMTP/SUBMISSION login
which in turn uses [Dovecot SASL](https://doc.dovecot.org/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket) which in turn uses [Dovecot SASL](https://doc.dovecot.org/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket)
to authenticate logins. to authenticate users
to send mails for them.
- [`filtermail`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/filtermail.py) - `chatmaild/src/chatmaild/filtermail.py` prevents
prevents unencrypted email from leaving or entering the chatmail service unencrypted e-mail from leaving the chatmail service
and is integrated into Postfix's outbound and inbound mail pipelines. and is integrated into postfix's outbound mail pipelines.
- [`chatmail-metadata`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py) is contacted by a There is also the `cmdeploy/src/cmdeploy/cmdeploy.py` command line tool
[Dovecot lua script](https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua) which helps with setting up and managing the chatmail service.
to store user-specific relay-side config. `cmdeploy run` uses [pyinfra-based scripting](https://pyinfra.com/)
On new messages, in `cmdeploy/src/cmdeploy/__init__.py`
it [passes the user's push notification token](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/notifier.py) to automatically install all chatmail components on a server.
to [notifications.delta.chat](https://delta.chat/help#instant-delivery)
so the push notifications on the user's phone can be triggered
by Apple/Google/Huawei.
- [`delete_inactive_users`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/delete_inactive_users.py)
deletes users if they have not logged in for a very long time.
The timeframe can be configured in `chatmail.ini`.
- [`lastlogin`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/lastlogin.py)
is contacted by Dovecot when a user logs in
and stores the date of the login.
- [`echobot`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/echo.py)
is a small bot for test purposes.
It simply echoes back messages from users.
- [`chatmail-metrics`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metrics.py)
collects some metrics and displays them at `https://example.org/metrics`.
### Home page and getting started for users ### Home page and getting started for users
`cmdeploy run` also creates default static web pages and deploys them `cmdeploy run` also creates default static Web pages and deploys them
to a Nginx web server with: to a nginx web server with:
- a default `index.html` along with a QR code that users can click to - a default `index.html` along with a QR code that users can click to
create an address on your chatmail relay create accounts on your chatmail provider,
- a default `info.html` that is linked from the home page - a default `info.html` that is linked from the home page,
- a default `policy.html` that is linked from the home page - a default `policy.html` that is linked from the home page.
All `.html` files are generated All `.html` files are generated
by the according markdown `.md` file in the `www/src` directory. by the according markdown `.md` file in the `www/src` directory.
@@ -241,64 +123,48 @@ by the according markdown `.md` file in the `www/src` directory.
### Refining the web pages ### Refining the web pages
``` ```
scripts/cmdeploy webdev scripts/cmdeploy webdev
``` ```
This starts a local live development cycle for chatmail web pages: This starts a local live development cycle for chatmail Web pages:
- uses the `www/src/page-layout.html` file for producing static - uses the `www/src/page-layout.html` file for producing static
HTML pages from `www/src/*.md` files HTML pages from `www/src/*.md` files
- continously builds the web presence reading files from `www/src` directory - continously builds the web presence reading files from `www/src` directory
and generating HTML files and copying assets to the `www/build` directory. and generating html files and copying assets to the `www/build` directory.
- Starts a browser window automatically where you can "refresh" as needed. - Starts a browser window automatically where you can "refresh" as needed.
## Mailbox directory layout
Fresh chatmail addresses have a mailbox directory that contains: ## Emergency Commands to disable automatic account creation
- a `password` file with the salted password required for authenticating If you need to stop account creation,
whether a login may use the address to send/receive messages. e.g. because some script is wildly creating accounts,
If you modify the password file manually, you effectively block the user. login to the server with ssh and run:
- `enforceE2EEincoming` is a default-created file with each address.
If present the file indicates that this chatmail address rejects incoming cleartext messages.
If absent the address accepts incoming cleartext messages.
- `dovecot*`, `cur`, `new` and `tmp` represent IMAP/mailbox state.
If the address is only used by one device, the Maildir directories
will typically be empty unless the user of that address hasn't been online
for a while.
## Emergency Commands to disable automatic address creation
If you need to stop address creation,
e.g. because some script is wildly creating addresses,
login with ssh and run:
``` ```
touch /etc/chatmail-nocreate touch /etc/chatmail-nocreate
``` ```
Chatmail address creation will be denied while this file is present. While this file is present, account creation will be blocked.
### Ports ### Ports
[Postfix](http://www.postfix.org/) listens on ports 25 (SMTP) and 587 (SUBMISSION) and 465 (SUBMISSIONS). [Postfix](http://www.postfix.org/) listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
[Dovecot](https://www.dovecot.org/) listens on ports 143 (IMAP) and 993 (IMAPS). [Dovecot](https://www.dovecot.org/) listens on ports 143 (imap) and 993 (imaps).
[Nginx](https://www.nginx.com/) listens on port 8443 (HTTPS-ALT) and 443 (HTTPS). [nginx](https://www.nginx.com/) listens on port 8443 (https-alt) and 443 (https).
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993. Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (HTTP). [acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (http).
chatmail-core based apps will, however, discover all ports and configurations 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 relay server. automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail service.
## Email authentication ## Email authentication
Chatmail relays enforce [DKIM](https://www.rfc-editor.org/rfc/rfc6376) chatmail servers rely on [DKIM](https://www.rfc-editor.org/rfc/rfc6376)
to authenticate incoming emails. to authenticate incoming emails.
Incoming emails must have a valid DKIM signature with Incoming emails must have a valid DKIM signature with
Signing Domain Identifier (SDID, `d=` parameter in the DKIM-Signature header) Signing Domain Identifier (SDID, `d=` parameter in the DKIM-Signature header)
@@ -320,227 +186,3 @@ to MAIL FROM with
and rejects incorrectly authenticated emails with [`reject_sender_login_mismatch`](reject_sender_login_mismatch) policy. and rejects incorrectly authenticated emails with [`reject_sender_login_mismatch`](reject_sender_login_mismatch) policy.
`From:` header must correspond to envelope MAIL FROM, `From:` header must correspond to envelope MAIL FROM,
this is ensured by `filtermail` proxy. this is ensured by `filtermail` proxy.
## TLS requirements
Postfix is configured to require valid TLS
by setting [`smtp_tls_security_level`](https://www.postfix.org/postconf.5.html#smtp_tls_security_level) to `verify`.
If emails don't arrive at your chatmail relay server,
the problem is likely that your relay does not have a valid TLS certificate.
You can test it by resolving `MX` records of your relay domain
and then connecting to MX relays (e.g `mx.example.org`) with
`openssl s_client -connect mx.example.org:25 -verify_hostname mx.example.org -verify_return_error -starttls smtp`
from the host that has open port 25 to verify that certificate is valid.
When providing a TLS certificate to your chatmail relay server,
make sure to provide the full certificate chain
and not just the last certificate.
If you are running an Exim server and don't see incoming connections
from a chatmail relay server in the logs,
make sure `smtp_no_mail` log item is enabled in the config
with `log_selector = +smtp_no_mail`.
By default Exim does not log sessions that are closed
before sending the `MAIL` command.
This happens if certificate is not recognized as valid by Postfix,
so you might think that connection is not established
while actually it is a problem with your TLS certificate.
## Migrating a chatmail relay to a new host
If you want to migrate chatmail relay from an old machine
to a new machine,
you can use these steps.
They were tested with a Linux laptop;
you might need to adjust some of the steps to your environment.
Let's assume that your `mail_domain` is `mail.example.org`,
all involved machines run Debian 12,
your old site's IP address is `13.37.13.37`,
and your new site's IP address is `13.12.23.42`.
Note, you should lower the TTLs of your DNS records to a value
such as 300 (5 minutes) so the migration happens as smoothly as possible.
During the guide you might get a warning about changed SSH Host keys;
in this case, just run `ssh-keygen -R "mail.example.org"` as recommended.
1. First, disable mail services on the old site.
```
cmdeploy run --disable-mail --ssh-host 13.37.13.37
```
Now your users will notice the migration
and will not be able to send or receive messages
until the migration is completed.
2. Now we want to copy `/home/vmail`, `/var/lib/acme`, `/etc/dkimkeys`, `/run/echobot`, and `/var/spool/postfix` to the new site.
Login to the old site while forwarding your SSH agent
so you can copy directly from the old to the new site with your SSH key:
```
ssh -A root@13.37.13.37
tar c - /home/vmail/mail /var/lib/acme /etc/dkimkeys /run/echobot /var/spool/postfix | ssh root@13.12.23.42 "tar x -C /"
```
This transfers all addresses, the TLS certificate, DKIM keys (so DKIM DNS record remains valid), and the echobot's password so it continues to function.
It also preserves the Postfix mail spool so any messages pending delivery will still be delivered.
3. Install chatmail on the new machine:
```
cmdeploy run --disable-mail --ssh-host 13.12.23.42
```
Postfix and Dovecot are disabled for now; we will enable them later.
We first need to make the new site fully operational.
3. On the new site, run the following to ensure the ownership is correct in case UIDs/GIDs changed:
```
chown root: -R /var/lib/acme
chown opendkim: -R /etc/dkimkeys
chown vmail: -R /home/vmail/mail
chown echobot: -R /run/echobot
```
4. Now, update DNS entries.
If other MTAs try to deliver messages to your chatmail domain they may fail intermittently,
as DNS catches up with the new site settings
but normally will retry delivering messages
for at least a week, so messages will not be lost.
5. Finally, you can execute `cmdeploy run --ssh-host 13.12.23.42` to turn on chatmail on the new relay.
Your users will be able to use the chatmail relay as soon as the DNS changes have propagated.
Voilà!
## Setting up a reverse proxy
A chatmail relay MTA does not track or depend on the client IP address
for its operation, so it can be run behind a reverse proxy.
This will not even affect incoming mail authentication
as DKIM only checks the cryptographic signature
of the message and does not use the IP address as the input.
For example, you may want to self-host your chatmail relay
and only use hosted VPS to provide a public IP address
for client connections and incoming mail.
You can connect chatmail relay to VPS
using a tunnel protocol
such as [WireGuard](https://www.wireguard.com/)
and setup a reverse proxy on a VPS
to forward connections to the chatmail relay
over the tunnel.
You can also setup multiple reverse proxies
for your chatmail relay in different networks
to ensure your relay is reachable even when
one of the IPs becomes inaccessible due to
hosting or routing problems.
Note that your chatmail relay still needs
to be able to make outgoing connections on port 25
to send messages outside.
To setup a reverse proxy
(or rather Destination NAT, DNAT)
for your chatmail relay,
put the following configuration in `/etc/nftables.conf`:
```
#!/usr/sbin/nft -f
flush ruleset
define wan = eth0
# Which ports to proxy.
#
# Note that SSH is not proxied
# so it is possible to log into the proxy server
# and not the original one.
define ports = { smtp, http, https, imap, imaps, submission, submissions }
# The host we want to proxy to.
define ipv4_address = AAA.BBB.CCC.DDD
define ipv6_address = [XXX::1]
table ip nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iif $wan tcp dport $ports dnat to $ipv4_address
}
chain postrouting {
type nat hook postrouting priority 0;
oifname $wan masquerade
}
}
table ip6 nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iif $wan tcp dport $ports dnat to $ipv6_address
}
chain postrouting {
type nat hook postrouting priority 0;
oifname $wan masquerade
}
}
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
# Accept ICMP.
# It is especially important to accept ICMPv6 ND messages,
# otherwise IPv6 connectivity breaks.
icmp type { echo-request } accept
icmpv6 type { echo-request, nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept
# Allow incoming SSH connections.
tcp dport { ssh } accept
ct state established accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established accept
ip daddr $ipv4_address counter accept
ip6 daddr $ipv6_address counter accept
}
chain output {
type filter hook output priority filter;
}
}
```
Run `systemctl enable nftables.service`
to ensure configuration is reloaded when the proxy relay reboots.
Uncomment in `/etc/sysctl.conf` the following two lines:
```
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
```
Then reboot the relay or do `sysctl -p` and `nft -f /etc/nftables.conf`.
Once proxy relay is set up,
you can add its IP address to the DNS.
## Neighbors and Acquaintances
Here are some related projects that you may be interested in:
- [Mox](https://github.com/mjl-/mox): A Golang email server. [Work is in
progress](https://github.com/mjl-/mox/issues/251) to modify it to support all
of the features and configuration settings required to operate as a chatmail
relay.
- [Maddy-Chatmail](https://github.com/sadraiiali/maddy_chatmail): a plugin for the
[Maddy email server](https://maddy.email/) which aims to implement the
chatmail relay features and configuration options.

View File

@@ -12,7 +12,6 @@ dependencies = [
"deltachat-rpc-client", "deltachat-rpc-client",
"filelock", "filelock",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
] ]
[tool.setuptools] [tool.setuptools]
@@ -28,7 +27,6 @@ filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main" echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main" chatmail-metrics = "chatmaild.metrics:main"
delete_inactive_users = "chatmaild.delete_inactive_users:main" delete_inactive_users = "chatmaild.delete_inactive_users:main"
lastlogin = "chatmaild.lastlogin:main"
[project.entry-points.pytest11] [project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin" "chatmaild.testplugin" = "chatmaild.tests.plugin"
@@ -48,9 +46,6 @@ lint.select = [
"PLE", # Pylint Error "PLE", # Pylint Error
"PLW", # Pylint Warning "PLW", # Pylint Warning
] ]
lint.ignore = [
"PLC0415" # import-outside-top-level
]
[tool.tox] [tool.tox]
legacy_tox_ini = """ legacy_tox_ini = """

View File

@@ -1 +0,0 @@

View File

@@ -2,20 +2,12 @@ from pathlib import Path
import iniconfig import iniconfig
from chatmaild.user import User
echobot_password_path = Path("/run/echobot/password")
def read_config(inipath): def read_config(inipath):
assert Path(inipath).exists(), inipath assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath) cfg = iniconfig.IniConfig(inipath)
params = cfg.sections["params"] params = cfg.sections["params"]
default_config_content = get_default_config_content(params["mail_domain"]) return Config(inipath, params=params)
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
class Config: class Config:
@@ -24,67 +16,36 @@ class Config:
self.mail_domain = params["mail_domain"] self.mail_domain = params["mail_domain"]
self.max_user_send_per_minute = int(params["max_user_send_per_minute"]) self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
self.max_mailbox_size = params["max_mailbox_size"] self.max_mailbox_size = params["max_mailbox_size"]
self.max_message_size = int(params.get("max_message_size", "31457280"))
self.delete_mails_after = params["delete_mails_after"] self.delete_mails_after = params["delete_mails_after"]
self.delete_large_after = params["delete_large_after"]
self.delete_inactive_users_after = int(params["delete_inactive_users_after"]) self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
self.username_min_length = int(params["username_min_length"]) self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"]) self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"]) self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split() self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split() self.passthrough_recipients = params["passthrough_recipients"].split()
self.mailboxes_dir = Path(params["mailboxes_dir"].strip())
self.passdb_path = Path(params["passdb_path"].strip())
self.filtermail_smtp_port = int(params["filtermail_smtp_port"]) self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.filtermail_smtp_port_incoming = int(
params["filtermail_smtp_port_incoming"]
)
self.postfix_reinject_port = int(params["postfix_reinject_port"]) self.postfix_reinject_port = int(params["postfix_reinject_port"])
self.postfix_reinject_port_incoming = int( self.iroh_relay = params.get("iroh_relay")
params["postfix_reinject_port_incoming"]
)
self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
if "iroh_relay" not in params:
self.iroh_relay = "https://" + params["mail_domain"]
self.enable_iroh_relay = True
else:
self.iroh_relay = params["iroh_relay"].strip()
self.enable_iroh_relay = False
self.privacy_postal = params.get("privacy_postal") self.privacy_postal = params.get("privacy_postal")
self.privacy_mail = params.get("privacy_mail") self.privacy_mail = params.get("privacy_mail")
self.privacy_pdo = params.get("privacy_pdo") self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.get("privacy_supervisor") self.privacy_supervisor = params.get("privacy_supervisor")
# deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
self.mailboxes_dir = Path(mbdir.strip())
# old unused option (except for first migration from sqlite to maildir store)
self.passdb_path = Path(params.get("passdb_path", "/home/vmail/passdb.sqlite"))
def _getbytefile(self): def _getbytefile(self):
return open(self._inipath, "rb") return open(self._inipath, "rb")
def get_user(self, addr) -> User: def get_user_maildir(self, addr):
if not addr or "@" not in addr or "/" in addr: if addr and addr != "." and "/" not in addr:
raise ValueError(f"invalid address {addr!r}") res = self.mailboxes_dir.joinpath(addr).resolve()
if res.is_relative_to(self.mailboxes_dir):
maildir = self.mailboxes_dir.joinpath(addr) return res
if addr.startswith("echo@"): raise ValueError(f"invalid address {addr!r}")
password_path = echobot_password_path
else:
password_path = maildir.joinpath("password")
return User(maildir, addr, password_path, uid="vmail", gid="vmail")
def write_initial_config(inipath, mail_domain, overrides): def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides.""" """Write out default config file, using the specified config value overrides."""
content = get_default_config_content(mail_domain, **overrides)
inipath.write_text(content)
def get_default_config_content(mail_domain, **overrides):
from importlib.resources import files from importlib.resources import files
inidir = files(__package__).joinpath("ini") inidir = files(__package__).joinpath("ini")
@@ -93,19 +54,14 @@ def get_default_config_content(mail_domain, **overrides):
# apply config overrides # apply config overrides
new_lines = [] new_lines = []
extra = overrides.copy()
for line in content.split("\n"): for line in content.split("\n"):
new_line = line.strip() new_line = line.strip()
if new_line and new_line[0] not in "#[": if new_line and new_line[0] not in "#[":
name, value = map(str.strip, new_line.split("=", maxsplit=1)) name, value = map(str.strip, new_line.split("=", maxsplit=1))
value = extra.pop(name, value) value = overrides.get(name, value)
new_line = f"{name} = {value}" new_line = f"{name} = {value}"
new_lines.append(new_line) new_lines.append(new_line)
for name, value in extra.items():
new_line = f"{name} = {value}"
new_lines.append(new_line)
content = "\n".join(new_lines) content = "\n".join(new_lines)
# apply testrun privacy overrides # apply testrun privacy overrides
@@ -116,7 +72,7 @@ def get_default_config_content(mail_domain, **overrides):
lines = [] lines = []
for line in content.split("\n"): for line in content.split("\n"):
for key, value in privacy.items(): for key, value in privacy.items():
value_lines = value.format(mail_domain=mail_domain).strip().split("\n") value_lines = value.strip().split("\n")
if not line.startswith(f"{key} =") or not value_lines: if not line.startswith(f"{key} =") or not value_lines:
continue continue
if len(value_lines) == 1: if len(value_lines) == 1:
@@ -129,4 +85,5 @@ def get_default_config_content(mail_domain, **overrides):
else: else:
lines.append(line) lines.append(line)
content = "\n".join(lines) content = "\n".join(lines)
return content
inipath.write_text(content)

View File

@@ -0,0 +1,133 @@
import contextlib
import sqlite3
import time
from pathlib import Path
class DBError(Exception):
"""error during an operation on the database."""
class Connection:
def __init__(self, sqlconn, write):
self._sqlconn = sqlconn
self._write = write
def close(self):
self._sqlconn.close()
def commit(self):
self._sqlconn.commit()
def rollback(self):
self._sqlconn.rollback()
def execute(self, query, params=()):
cur = self.cursor()
try:
cur.execute(query, params)
except sqlite3.IntegrityError as e:
raise DBError(e)
return cur
def cursor(self):
return self._sqlconn.cursor()
def get_user(self, addr: str) -> {}:
"""Get a row from the users table."""
q = "SELECT addr, password, last_login from users WHERE addr = ?"
row = self._sqlconn.execute(q, (addr,)).fetchone()
result = {}
if row:
result = dict(
user=row[0],
password=row[1],
last_login=row[2],
)
return result
class Database:
def __init__(self, path: str):
self.path = Path(path)
self.ensure_tables()
def _get_connection(
self, write=False, transaction=False, closing=False
) -> Connection:
# we let the database serialize all writers at connection time
# to play it very safe (we don't have massive amounts of writes).
mode = "ro"
if write:
mode = "rw"
if not self.path.exists():
mode = "rwc"
uri = "file:%s?mode=%s" % (self.path, mode)
sqlconn = sqlite3.connect(
uri,
timeout=60,
isolation_level=None if transaction else "DEFERRED",
uri=True,
)
# Enable Write-Ahead Logging to avoid readers blocking writers and vice versa.
if write:
sqlconn.execute("PRAGMA journal_mode=wal")
if transaction:
start_time = time.time()
while 1:
try:
sqlconn.execute("begin immediate")
break
except sqlite3.OperationalError:
# another thread may be writing, give it a chance to finish
time.sleep(0.1)
if time.time() - start_time > 5:
# if it takes this long, something is wrong
raise
conn = Connection(sqlconn, write=write)
if closing:
conn = contextlib.closing(conn)
return conn
@contextlib.contextmanager
def write_transaction(self):
conn = self._get_connection(closing=False, write=True, transaction=True)
try:
yield conn
except Exception:
conn.rollback()
conn.close()
raise
else:
conn.commit()
conn.close()
def read_connection(self, closing=True) -> Connection:
return self._get_connection(closing=closing, write=False)
def get_schema_version(self) -> int:
with self.read_connection() as conn:
dbversion = conn.execute("PRAGMA user_version").fetchone()[0]
return dbversion
CURRENT_DBVERSION = 1
def ensure_tables(self):
with self.write_transaction() as conn:
if self.get_schema_version() > 1:
raise DBError(
"version is %s; downgrading schema is not supported"
% (self.get_schema_version(),)
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS users (
addr TEXT PRIMARY KEY,
password TEXT,
last_login INTEGER
)
""",
)
conn.execute("PRAGMA user_version=%s" % (self.CURRENT_DBVERSION,))

View File

@@ -2,30 +2,32 @@
Remove inactive users Remove inactive users
""" """
import os
import shutil import shutil
import sys import sys
import time import time
from .config import read_config from .config import read_config
from .database import Database
from .doveauth import iter_userdb_lastlogin_before
def delete_inactive_users(config): def delete_inactive_users(db, config, CHUNK=100):
cutoff_date = time.time() - config.delete_inactive_users_after * 86400 cutoff_date = time.time() - config.delete_inactive_users_after * 86400
for addr in os.listdir(config.mailboxes_dir):
try:
user = config.get_user(addr)
except ValueError:
continue
read_timestamp = user.get_last_login_timestamp() old_users = iter_userdb_lastlogin_before(db, cutoff_date)
if read_timestamp and read_timestamp < cutoff_date: chunks = (old_users[i : i + CHUNK] for i in range(0, len(old_users), CHUNK))
path = config.mailboxes_dir.joinpath(addr) for sublist in chunks:
assert path == user.maildir for user in sublist:
shutil.rmtree(path, ignore_errors=True) user_mail_dir = config.get_user_maildir(user)
shutil.rmtree(user_mail_dir, ignore_errors=True)
with db.write_transaction() as conn:
for user in sublist:
conn.execute("DELETE FROM users WHERE addr = ?", (user,))
def main(): def main():
(cfgpath,) = sys.argv[1:] (cfgpath,) = sys.argv[1:]
config = read_config(cfgpath) config = read_config(cfgpath)
delete_inactive_users(config) db = Database(config.passdb_path)
delete_inactive_users(db, config)

View File

@@ -1,98 +0,0 @@
import logging
import os
from socketserver import StreamRequestHandler, ThreadingUnixStreamServer
class DictProxy:
def loop_forever(self, rfile, wfile):
# Transaction storage is local to each handler loop.
# Dovecot reuses transaction IDs across connections,
# starting transaction with the name `1`
# on two different connections to the same proxy sometimes.
transactions = {}
while True:
msg = rfile.readline().strip().decode()
if not msg:
break
res = self.handle_dovecot_request(msg, transactions)
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
def handle_dovecot_request(self, msg, transactions):
# see https://doc.dovecot.org/developer_manual/design/dict_protocol/#dovecot-dict-protocol
short_command = msg[0]
parts = msg[1:].split("\t")
if short_command == "L":
return self.handle_lookup(parts)
elif short_command == "I":
return self.handle_iterate(parts)
elif short_command == "H":
return # no version checking
if short_command not in ("BSC"):
logging.warning(f"unknown dictproxy request: {msg!r}")
return
transaction_id = parts[0]
if short_command == "B":
return self.handle_begin_transaction(transaction_id, parts, transactions)
elif short_command == "C":
return self.handle_commit_transaction(transaction_id, parts, transactions)
elif short_command == "S":
addr = transactions[transaction_id]["addr"]
if not self.handle_set(addr, parts):
transactions[transaction_id]["res"] = "F\n"
logging.error(f"dictproxy-set failed for {addr!r}: {msg!r}")
def handle_lookup(self, parts):
logging.warning(f"lookup ignored: {parts!r}")
return "N\n"
def handle_iterate(self, parts):
# Empty line means ITER_FINISHED.
# If we don't return empty line Dovecot will timeout.
return "\n"
def handle_begin_transaction(self, transaction_id, parts, transactions):
addr = parts[1]
transactions[transaction_id] = dict(addr=addr, res="O\n")
def handle_set(self, addr, parts):
# For documentation on key structure see
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
return False
def handle_commit_transaction(self, transaction_id, parts, transactions):
# return whatever "set" command(s) set as result.
return transactions.pop(transaction_id)["res"]
def serve_forever_from_socket(self, socket):
dictproxy = self
class Handler(StreamRequestHandler):
def handle(self):
try:
dictproxy.loop_forever(self.rfile, self.wfile)
except Exception:
logging.exception("Exception in the handler")
raise
try:
os.unlink(socket)
except FileNotFoundError:
pass
with CustomThreadingUnixStreamServer(socket, Handler) as server:
try:
server.serve_forever()
except KeyboardInterrupt:
pass
class CustomThreadingUnixStreamServer(ThreadingUnixStreamServer):
request_queue_size = 1000

View File

@@ -1,23 +1,29 @@
import crypt
import json import json
import logging import logging
import os import os
import sys import sys
import time
try: from pathlib import Path
import crypt_r from socketserver import (
except ImportError: StreamRequestHandler,
import crypt as crypt_r ThreadingMixIn,
UnixStreamServer,
)
from .config import Config, read_config from .config import Config, read_config
from .dictproxy import DictProxy from .database import Database
from .migrate_db import migrate_from_db_to_maildir
NOCREATE_FILE = "/etc/chatmail-nocreate" NOCREATE_FILE = "/etc/chatmail-nocreate"
class UnknownCommand(ValueError):
"""dictproxy handler received an unkown command"""
def encrypt_password(password: str): def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/ # https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt_r.crypt(password, crypt_r.METHOD_SHA512) passhash = crypt.crypt(password, crypt.METHOD_SHA512)
return "{SHA512-CRYPT}" + passhash return "{SHA512-CRYPT}" + passhash
@@ -59,6 +65,93 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
return True return True
def get_user_data(db, config: Config, user):
if user == f"echo@{config.mail_domain}":
return dict(
home=str(config.get_user_maildir(user)),
uid="vmail",
gid="vmail",
)
with db.read_connection() as conn:
result = conn.get_user(user)
if result:
result["home"] = str(config.get_user_maildir(user))
result["uid"] = "vmail"
result["gid"] = "vmail"
return result
def lookup_userdb(db, config: Config, user):
return get_user_data(db, config, user)
def lookup_passdb(db, config: Config, user, cleartext_password, last_login=None):
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=str(config.get_user_maildir(user)),
uid="vmail",
gid="vmail",
password=encrypt_password(password),
)
if last_login is None:
last_login = time.time()
last_login = int(last_login)
with db.write_transaction() as conn:
userdata = conn.get_user(user)
if userdata:
# Update last login time.
conn.execute(
"UPDATE users SET last_login=? WHERE addr=?", (last_login, user)
)
userdata["home"] = str(config.get_user_maildir(user))
userdata["uid"] = "vmail"
userdata["gid"] = "vmail"
return userdata
if not is_allowed_to_create(config, user, cleartext_password):
return
encrypted_password = encrypt_password(cleartext_password)
q = """INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)"""
conn.execute(q, (user, encrypted_password, last_login))
print(f"Created address: {user}", file=sys.stderr)
return dict(
home=str(config.get_user_maildir(user)),
uid="vmail",
gid="vmail",
password=encrypted_password,
)
def iter_userdb(db) -> list:
"""Get a list of all user addresses."""
with db.read_connection() as conn:
rows = conn.execute(
"SELECT addr from users",
).fetchall()
return [x[0] for x in rows]
def iter_userdb_lastlogin_before(db, cutoff_date):
"""Get a list of users where last login was before cutoff_date."""
with db.read_connection() as conn:
rows = conn.execute(
"SELECT addr FROM users WHERE last_login < ?", (cutoff_date,)
).fetchall()
return [x[0] for x in rows]
def split_and_unescape(s): def split_and_unescape(s):
"""Split strings using double quote as a separator and backslash as escape character """Split strings using double quote as a separator and backslash as escape character
into parts.""" into parts."""
@@ -85,12 +178,15 @@ def split_and_unescape(s):
yield out yield out
class AuthDictProxy(DictProxy): def handle_dovecot_request(msg, db, config: Config):
def __init__(self, config): # see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
super().__init__() short_command = msg[0]
self.config = config if short_command == "H": # HELLO
# we don't do any checking on versions and just return
return
elif short_command == "L": # LOOKUP
parts = msg[1:].split("\t")
def handle_lookup(self, parts):
# Dovecot <2.3.17 has only one part, # Dovecot <2.3.17 has only one part,
# do not attempt to read any other parts for compatibility. # do not attempt to read any other parts for compatibility.
keyname = parts[0] keyname = parts[0]
@@ -98,14 +194,13 @@ class AuthDictProxy(DictProxy):
namespace, type, args = keyname.split("/", 2) namespace, type, args = keyname.split("/", 2)
args = list(split_and_unescape(args)) args = list(split_and_unescape(args))
config = self.config
reply_command = "F" reply_command = "F"
res = "" res = ""
if namespace == "shared": if namespace == "shared":
if type == "userdb": if type == "userdb":
user = args[0] user = args[0]
if user.endswith(f"@{config.mail_domain}"): if user.endswith(f"@{config.mail_domain}"):
res = self.lookup_userdb(user) res = lookup_userdb(db, config, user)
if res: if res:
reply_command = "O" reply_command = "O"
else: else:
@@ -113,48 +208,62 @@ class AuthDictProxy(DictProxy):
elif type == "passdb": elif type == "passdb":
user = args[1] user = args[1]
if user.endswith(f"@{config.mail_domain}"): if user.endswith(f"@{config.mail_domain}"):
res = self.lookup_passdb(user, cleartext_password=args[0]) res = lookup_passdb(db, config, user, cleartext_password=args[0])
if res: if res:
reply_command = "O" reply_command = "O"
else: else:
reply_command = "N" reply_command = "N"
json_res = json.dumps(res) if res else "" json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n" return f"{reply_command}{json_res}\n"
elif short_command == "I": # ITERATE
def handle_iterate(self, parts):
# example: I0\t0\tshared/userdb/ # example: I0\t0\tshared/userdb/
parts = msg[1:].split("\t")
if parts[2] == "shared/userdb/": if parts[2] == "shared/userdb/":
result = "".join( result = "".join(f"Oshared/userdb/{user}\t\n" for user in iter_userdb(db))
f"Oshared/userdb/{user}\t\n" for user in self.iter_userdb()
)
return f"{result}\n" return f"{result}\n"
def iter_userdb(self) -> list: raise UnknownCommand(msg)
"""Get a list of all user addresses."""
return [x for x in os.listdir(self.config.mailboxes_dir) if "@" in x]
def lookup_userdb(self, addr):
return self.config.get_user(addr).get_userdb_dict()
def lookup_passdb(self, addr, cleartext_password): def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config):
user = self.config.get_user(addr) while True:
userdata = user.get_userdb_dict() msg = rfile.readline().strip().decode()
if userdata: if not msg:
return userdata break
if not is_allowed_to_create(self.config, addr, cleartext_password): try:
return res = handle_dovecot_request(msg, db, config)
except UnknownCommand:
logging.warning("unknown command: %r", msg)
else:
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
user.set_password(encrypt_password(cleartext_password))
print(f"Created address: {addr}", file=sys.stderr) class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
return user.get_userdb_dict() request_queue_size = 100
def main(): def main():
socket, cfgpath = sys.argv[1:] socket, cfgpath = sys.argv[1:]
config = read_config(cfgpath) config = read_config(cfgpath)
db = Database(config.passdb_path)
migrate_from_db_to_maildir(config) class Handler(StreamRequestHandler):
def handle(self):
try:
handle_dovecot_protocol(self.rfile, self.wfile, db, config)
except Exception:
logging.exception("Exception in the handler")
raise
dictproxy = AuthDictProxy(config=config) try:
os.unlink(socket)
except FileNotFoundError:
pass
dictproxy.serve_forever_from_socket(socket) with ThreadedUnixStreamServer(socket, Handler) as server:
try:
server.serve_forever()
except KeyboardInterrupt:
pass

View File

@@ -12,8 +12,7 @@ from pathlib import Path
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
from chatmaild.config import echobot_password_path, read_config from chatmaild.config import read_config
from chatmaild.doveauth import encrypt_password
from chatmaild.newemail import create_newemail_dict from chatmaild.newemail import create_newemail_dict
hooks = events.HookCollection() hooks = events.HookCollection()
@@ -22,9 +21,9 @@ hooks = events.HookCollection()
@hooks.on(events.RawEvent) @hooks.on(events.RawEvent)
def log_event(event): def log_event(event):
if event.kind == EventType.INFO: if event.kind == EventType.INFO:
logging.info(event.msg) logging.info("%s", event.msg)
elif event.kind == EventType.WARNING: elif event.kind == EventType.WARNING:
logging.warning(event.msg) logging.warning("%s", event.msg)
@hooks.on(events.RawEvent(EventType.ERROR)) @hooks.on(events.RawEvent(EventType.ERROR))
@@ -46,7 +45,7 @@ def on_group_image_changed(event):
@hooks.on(events.GroupNameChanged) @hooks.on(events.GroupNameChanged)
def on_group_name_changed(event): def on_group_name_changed(event):
logging.info(f"group name changed, old name: {event.old_name}") logging.info("group name changed, old name: %s", event.old_name)
@hooks.on(events.NewMessage(func=lambda e: not e.command)) @hooks.on(events.NewMessage(func=lambda e: not e.command))
@@ -73,7 +72,7 @@ def main():
with Rpc() as rpc: with Rpc() as rpc:
deltachat = DeltaChat(rpc) deltachat = DeltaChat(rpc)
system_info = deltachat.get_system_info() system_info = deltachat.get_system_info()
logging.info(f"Running deltachat core {system_info.deltachat_core_version}") logging.info("Running deltachat core %s", system_info.deltachat_core_version)
accounts = deltachat.get_all_accounts() accounts = deltachat.get_all_accounts()
account = accounts[0] if accounts else deltachat.add_account() account = accounts[0] if accounts else deltachat.add_account()
@@ -81,27 +80,23 @@ def main():
bot = Bot(account, hooks) bot = Bot(account, hooks)
config = read_config(sys.argv[1]) config = read_config(sys.argv[1])
addr = "echo@" + config.mail_domain
# Create password file # Create password file
if bot.is_configured(): if bot.is_configured():
password = bot.account.get_config("mail_pw") password = bot.account.get_config("mail_pw")
else: else:
password = create_newemail_dict(config)["password"] password = create_newemail_dict(config)["password"]
Path("/run/echobot/password").write_text(password)
echobot_password_path.write_text(encrypt_password(password))
# Give the user which doveauth runs as access to the password file. # Give the user which doveauth runs as access to the password file.
subprocess.check_call( subprocess.run(
["/usr/bin/setfacl", "-m", "user:vmail:r", echobot_password_path], ["/usr/bin/setfacl", "-m", "user:vmail:r", "/run/echobot/password"],
check=True,
) )
if not bot.is_configured(): if not bot.is_configured():
bot.configure(addr, password) email = "echo@" + config.mail_domain
bot.configure(email, password)
# write invite link to working directory
invitelink = bot.account.get_qr_code()
Path("invite-link.txt").write_text(invitelink)
bot.run_forever() bot.run_forever()

View File

@@ -2,7 +2,6 @@ import json
import logging import logging
import os import os
from contextlib import contextmanager from contextlib import contextmanager
from random import randint
import filelock import filelock
@@ -33,12 +32,5 @@ class FileDict:
except FileNotFoundError: except FileNotFoundError:
return {} return {}
except Exception: except Exception:
logging.warning(f"corrupt serialization state at: {self.path!r}") logging.warning("corrupt serialization state at: %r", self.path)
return {} return {}
def write_bytes_atomic(path, content):
rint = randint(0, 10000000)
tmp = path.with_name(path.name + f".tmp-{rint}")
tmp.write_bytes(content)
os.rename(tmp, path)

View File

@@ -11,12 +11,9 @@ from email.utils import parseaddr
from smtplib import SMTP as SMTPClient from smtplib import SMTP as SMTPClient
from aiosmtpd.controller import Controller from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP
from .config import read_config from .config import read_config
ENCRYPTION_NEEDED_523 = "523 Encryption Needed: Invalid Unencrypted Mail"
def check_openpgp_payload(payload: bytes): def check_openpgp_payload(payload: bytes):
"""Checks the OpenPGP payload. """Checks the OpenPGP payload.
@@ -38,12 +35,6 @@ def check_openpgp_payload(payload: bytes):
packet_type_id = payload[i] & 0x3F packet_type_id = payload[i] & 0x3F
i += 1 i += 1
while payload[i] >= 224 and payload[i] < 255:
# Partial body length.
partial_length = 1 << (payload[i] & 0x1F)
i += 1 + partial_length
if payload[i] < 192: if payload[i] < 192:
# One-octet length. # One-octet length.
body_len = payload[i] body_len = payload[i]
@@ -62,17 +53,16 @@ def check_openpgp_payload(payload: bytes):
) )
i += 5 i += 5
else: else:
# Impossible, partial body length was processed above. # Partial body length is not allowed.
return False return False
i += body_len i += body_len
if i == len(payload): if i == len(payload):
# Last packet should be if packet_type_id == 18:
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD) # Last packet should be
# # Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
# This is the only place where this function may return `True`. return True
return packet_type_id == 18
elif packet_type_id not in [1, 3]: elif packet_type_id not in [1, 3]:
# All packets except the last one must be either # All packets except the last one must be either
# Public-Key Encrypted Session Key Packet (PKESK) # Public-Key Encrypted Session Key Packet (PKESK)
@@ -80,7 +70,13 @@ def check_openpgp_payload(payload: bytes):
# Symmetric-Key Encrypted Session Key Packet (SKESK) # Symmetric-Key Encrypted Session Key Packet (SKESK)
return False return False
return False if i == 0:
return False
if i > len(payload):
# Payload is truncated.
return False
return True
def check_armored_payload(payload: str): def check_armored_payload(payload: str):
@@ -89,9 +85,7 @@ def check_armored_payload(payload: str):
return False return False
payload = payload.removeprefix(prefix) payload = payload.removeprefix(prefix)
while payload.endswith("\r\n"): suffix = "-----END PGP MESSAGE-----\r\n\r\n"
payload = payload.removesuffix("\r\n")
suffix = "-----END PGP MESSAGE-----"
if not payload.endswith(suffix): if not payload.endswith(suffix):
return False return False
payload = payload.removesuffix(suffix) payload = payload.removesuffix(suffix)
@@ -110,27 +104,6 @@ def check_armored_payload(payload: str):
return False return False
def is_securejoin(message):
if message.get("secure-join") not in ["vc-request", "vg-request"]:
return False
if not message.is_multipart():
return False
parts_count = 0
for part in message.iter_parts():
parts_count += 1
if parts_count > 1:
return False
if part.is_multipart():
return False
if part.get_content_type() != "text/plain":
return False
payload = part.get_payload().strip().lower()
if payload not in ("secure-join: vc-request", "secure-join: vg-request"):
return False
return True
def check_encrypted(message): def check_encrypted(message):
"""Check that the message is an OpenPGP-encrypted message. """Check that the message is an OpenPGP-encrypted message.
@@ -138,6 +111,8 @@ def check_encrypted(message):
""" """
if not message.is_multipart(): if not message.is_multipart():
return False return False
if message.get("subject") not in {"...", "[...]"}:
return False
if message.get_content_type() != "multipart/encrypted": if message.get_content_type() != "multipart/encrypted":
return False return False
parts_count = 0 parts_count = 0
@@ -166,51 +141,18 @@ def check_encrypted(message):
return True return True
async def asyncmain_beforequeue(config, mode): async def asyncmain_beforequeue(config):
if mode == "outgoing": port = config.filtermail_smtp_port
port = config.filtermail_smtp_port Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
handler = OutgoingBeforeQueueHandler(config)
else:
port = config.filtermail_smtp_port_incoming
handler = IncomingBeforeQueueHandler(config)
HackedController(
handler,
hostname="127.0.0.1",
port=port,
data_size_limit=config.max_message_size,
).start()
def recipient_matches_passthrough(recipient, passthrough_recipients): class BeforeQueueHandler:
for addr in passthrough_recipients:
if recipient == addr:
return True
if addr[0] == "@" and recipient.endswith(addr):
return True
return False
class HackedController(Controller):
def factory(self):
return SMTPDiscardRCPTO_options(self.handler, **self.SMTP_kwargs)
class SMTPDiscardRCPTO_options(SMTP):
def _getparams(self, params):
# aiosmtpd's SMTP daemon fails to handle a request if there are RCPT TO options
# We just ignore them for our incoming filtermail purposes
if len(params) == 1 and params[0].startswith("ORCPT"):
return {}
return super()._getparams(params)
class OutgoingBeforeQueueHandler:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.send_rate_limiter = SendRateLimiter() self.send_rate_limiter = SendRateLimiter()
async def handle_MAIL(self, server, session, envelope, address, mail_options): async def handle_MAIL(self, server, session, envelope, address, mail_options):
logging.info(f"handle_MAIL from {address}") logging.info("handle_MAIL from %s", address)
envelope.mail_from = address envelope.mail_from = address
max_sent = self.config.max_user_send_per_minute max_sent = self.config.max_user_send_per_minute
if not self.send_rate_limiter.is_sending_allowed(address, max_sent): if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
@@ -229,99 +171,45 @@ class OutgoingBeforeQueueHandler:
return error return error
logging.info("re-injecting the mail that passed checks") logging.info("re-injecting the mail that passed checks")
client = SMTPClient("localhost", self.config.postfix_reinject_port) client = SMTPClient("localhost", self.config.postfix_reinject_port)
client.sendmail( client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
)
return "250 OK" return "250 OK"
def check_DATA(self, envelope): def check_DATA(self, envelope):
"""the central filtering function for e-mails.""" """the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {envelope.mail_from}") logging.info("Processing DATA message from %s", envelope.mail_from)
message = BytesParser(policy=policy.default).parsebytes(envelope.content) message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message) mail_encrypted = check_encrypted(message)
_, from_addr = parseaddr(message.get("from").strip()) _, from_addr = parseaddr(message.get("from").strip())
logging.info("mime-from: %s envelope-from: %r", from_addr, envelope.mail_from)
if envelope.mail_from.lower() != from_addr.lower(): if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>" return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if mail_encrypted or is_securejoin(message):
print("Outgoing: Filtering encrypted mail.", file=sys.stderr)
return
print("Outgoing: Filtering unencrypted mail.", file=sys.stderr)
if envelope.mail_from in self.config.passthrough_senders: if envelope.mail_from in self.config.passthrough_senders:
return return
# allow self-sent Autocrypt Setup Message
if envelope.rcpt_tos == [from_addr]:
if message.get("subject") == "Autocrypt Setup Message":
if message.get_content_type() == "multipart/mixed":
return
passthrough_recipients = self.config.passthrough_recipients passthrough_recipients = self.config.passthrough_recipients
envelope_from_domain = from_addr.split("@").pop()
for recipient in envelope.rcpt_tos: for recipient in envelope.rcpt_tos:
if recipient_matches_passthrough(recipient, passthrough_recipients): if envelope.mail_from == recipient:
# Always allow sending emails to self.
continue continue
if recipient in passthrough_recipients:
print("Rejected unencrypted mail.", file=sys.stderr)
return ENCRYPTION_NEEDED_523
class IncomingBeforeQueueHandler:
def __init__(self, config):
self.config = config
async def handle_DATA(self, server, session, envelope):
logging.info("handle_DATA before-queue")
error = self.check_DATA(envelope)
if error:
return error
logging.info("re-injecting the mail that passed checks")
# the smtp daemon on reinject_port_incoming gives it to dkim milter
# which looks at source address to determine whether to verify or sign
client = SMTPClient(
"localhost",
self.config.postfix_reinject_port_incoming,
source_address=("127.0.0.2", 0),
)
client.sendmail(
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
)
return "250 OK"
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
if mail_encrypted or is_securejoin(message):
print("Incoming: Filtering encrypted mail.", file=sys.stderr)
return
print("Incoming: Filtering unencrypted mail.", file=sys.stderr)
# we want cleartext mailer-daemon messages to pass through
# chatmail core will typically not display them as normal messages
if message.get("auto-submitted"):
_, from_addr = parseaddr(message.get("from").strip())
if from_addr.lower().startswith("mailer-daemon@"):
if message.get_content_type() == "multipart/report":
return
for recipient in envelope.rcpt_tos:
user = self.config.get_user(recipient)
if user is None or user.is_incoming_cleartext_ok():
continue continue
res = recipient.split("@")
if len(res) != 2:
return f"500 Invalid address <{recipient}>"
_recipient_addr, recipient_domain = res
print("Rejected unencrypted mail.", file=sys.stderr) is_outgoing = recipient_domain != envelope_from_domain
return ENCRYPTION_NEEDED_523 if is_outgoing and not mail_encrypted:
is_securejoin = message.get("secure-join") in [
"vc-request",
"vg-request",
]
if not is_securejoin:
return f"500 Invalid unencrypted mail to <{recipient}>"
class SendRateLimiter: class SendRateLimiter:
@@ -340,14 +228,11 @@ class SendRateLimiter:
def main(): def main():
args = sys.argv[1:] args = sys.argv[1:]
assert len(args) == 2 assert len(args) == 1
config = read_config(args[0]) config = read_config(args[0])
mode = args[1]
logging.basicConfig(level=logging.WARN) logging.basicConfig(level=logging.WARN)
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
assert mode in ["incoming", "outgoing"] task = asyncmain_beforequeue(config)
task = asyncmain_beforequeue(config, mode)
loop.create_task(task) loop.create_task(task)
logging.info("entering serving loop")
loop.run_forever() loop.run_forever()

View File

@@ -17,17 +17,11 @@ max_user_send_per_minute = 60
# maximum mailbox size of a chatmail address # maximum mailbox size of a chatmail address
max_mailbox_size = 100M max_mailbox_size = 100M
# maximum message size for an e-mail in bytes
max_message_size = 31457280
# days after which mails are unconditionally deleted # days after which mails are unconditionally deleted
delete_mails_after = 20 delete_mails_after = 20
# days after which large messages (>200k) are unconditionally deleted # days after which users without a login are deleted (database and mails)
delete_large_after = 7 delete_inactive_users_after = 100
# days after which users without a successful login are deleted (database and mails)
delete_inactive_users_after = 90
# minimum length a username must have # minimum length a username must have
username_min_length = 9 username_min_length = 9
@@ -42,58 +36,24 @@ password_min_length = 9
passthrough_senders = passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails # list of e-mail recipients for which to accept outbound un-encrypted mails
# (space-separated, item may start with "@" to whitelist whole recipient domains) passthrough_recipients = xstore@testrun.org groupsbot@hispanilandia.net
passthrough_recipients = xstore@testrun.org echo@{mail_domain}
# #
# Deployment Details # Deployment Details
# #
# SMTP outgoing filtermail and reinjection # Directory where user mailboxes are stored
mailboxes_dir = /home/vmail/mail/{mail_domain}
# user address sqlite database path
passdb_path = /home/vmail/passdb.sqlite
# where the filtermail SMTP service listens
filtermail_smtp_port = 10080 filtermail_smtp_port = 10080
# postfix accepts on the localhost reinject SMTP port
postfix_reinject_port = 10025 postfix_reinject_port = 10025
# SMTP incoming filtermail and reinjection
filtermail_smtp_port_incoming = 10081
postfix_reinject_port_incoming = 10026
# if set to "True" IPv6 is disabled
disable_ipv6 = False
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
# service.
# If you set it to anything else, the service will be disabled
# and users will be directed to use the given iroh relay URL.
# Set it to empty string if you want users to use their default iroh relay.
# iroh_relay =
# Address on which `mtail` listens,
# e.g. 127.0.0.1 or some private network
# address like 192.168.10.1.
# You can point Prometheus
# or some other OpenMetrics-compatible
# collector to
# http://{{mtail_address}}:3903/metrics
# and display collected metrics with Grafana.
#
# WARNING: do not expose this service
# to the public IP address.
#
# `mtail is not running if the setting is not set.
# mtail_address = 127.0.0.1
#
# Debugging options
#
# set to True if you want to track imap protocol execution
# in per-maildir ".in/.out" files.
# Note that you need to manually cleanup these files
# so use this option with caution on production servers.
imap_rawlog = false
# #
# Privacy Policy # Privacy Policy
# #

View File

@@ -1,7 +1,7 @@
[privacy] [privacy]
passthrough_recipients = privacy@testrun.org xstore@testrun.org echo@{mail_domain} passthrough_recipients = privacy@testrun.org xstore@testrun.org groupsbot@hispanilandia.net
privacy_postal = privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel, Merlinux GmbH, Represented by the managing director H. Krekel,

View File

@@ -1,31 +0,0 @@
import sys
from .config import read_config
from .dictproxy import DictProxy
class LastLoginDictProxy(DictProxy):
def __init__(self, config):
super().__init__()
self.config = config
def handle_set(self, addr, parts):
keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else ""
if keyname[0] == "shared" and keyname[1] == "last-login":
if addr.startswith("echo@"):
return True
addr = keyname[2]
timestamp = int(value)
user = self.config.get_user(addr)
user.set_last_login_timestamp(timestamp)
return True
return False
def main():
socket, config_path = sys.argv[1:]
config = read_config(config_path)
dictproxy = LastLoginDictProxy(config=config)
dictproxy.serve_forever_from_socket(socket)

View File

@@ -1,23 +1,27 @@
import logging import logging
import os
import sys import sys
import time from socketserver import (
from contextlib import contextmanager StreamRequestHandler,
ThreadingMixIn,
UnixStreamServer,
)
from .config import read_config from .config import read_config
from .dictproxy import DictProxy
from .filedict import FileDict from .filedict import FileDict
from .notifier import Notifier from .notifier import Notifier
DICTPROXY_HELLO_CHAR = "H"
def _is_valid_token_timestamp(timestamp, now): DICTPROXY_LOOKUP_CHAR = "L"
# Token if invalid after 90 days DICTPROXY_ITERATE_CHAR = "I"
# or if the timestamp is in the future. DICTPROXY_BEGIN_TRANSACTION_CHAR = "B"
return timestamp > now - 3600 * 24 * 90 and timestamp < now + 60 DICTPROXY_SET_CHAR = "S"
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
DICTPROXY_TRANSACTION_CHARS = "BSC"
class Metadata: class Metadata:
# each SETMETADATA on this key appends to dictionary # each SETMETADATA on this key appends to a list of unique device tokens
# mapping of unique device tokens
# which only ever get removed if the upstream indicates the token is invalid # which only ever get removed if the upstream indicates the token is invalid
DEVICETOKEN_KEY = "devicetoken" DEVICETOKEN_KEY = "devicetoken"
@@ -27,93 +31,99 @@ class Metadata:
def get_metadata_dict(self, addr): def get_metadata_dict(self, addr):
return FileDict(self.vmail_dir / addr / "metadata.json") return FileDict(self.vmail_dir / addr / "metadata.json")
@contextmanager
def _modify_tokens(self, addr):
with self.get_metadata_dict(addr).modify() as data:
tokens = data.setdefault(self.DEVICETOKEN_KEY, {})
now = int(time.time())
if isinstance(tokens, list):
data[self.DEVICETOKEN_KEY] = tokens = {t: now for t in tokens}
expired_tokens = [
token
for token, timestamp in tokens.items()
if not _is_valid_token_timestamp(tokens[token], now)
]
for expired_token in expired_tokens:
del tokens[expired_token]
yield tokens
def add_token_to_addr(self, addr, token): def add_token_to_addr(self, addr, token):
with self._modify_tokens(addr) as tokens: with self.get_metadata_dict(addr).modify() as data:
tokens[token] = int(time.time()) tokens = data.setdefault(self.DEVICETOKEN_KEY, [])
if token not in tokens:
tokens.append(token)
def remove_token_from_addr(self, addr, token): def remove_token_from_addr(self, addr, token):
with self._modify_tokens(addr) as tokens: with self.get_metadata_dict(addr).modify() as data:
tokens = data.get(self.DEVICETOKEN_KEY, [])
if token in tokens: if token in tokens:
del tokens[token] tokens.remove(token)
def get_tokens_for_addr(self, addr): def get_tokens_for_addr(self, addr):
mdict = self.get_metadata_dict(addr).read() mdict = self.get_metadata_dict(addr).read()
tokens = mdict.get(self.DEVICETOKEN_KEY, {}) return mdict.get(self.DEVICETOKEN_KEY, [])
now = int(time.time())
if isinstance(tokens, dict):
token_list = [
token
for token, timestamp in tokens.items()
if _is_valid_token_timestamp(timestamp, now)
]
if len(token_list) < len(tokens):
# Some tokens have expired, remove them.
with self._modify_tokens(addr) as _tokens:
pass
else:
token_list = []
return token_list
class MetadataDictProxy(DictProxy): def handle_dovecot_protocol(rfile, wfile, notifier, metadata, iroh_relay=None):
def __init__(self, notifier, metadata, iroh_relay=None): transactions = {}
super().__init__() while True:
self.notifier = notifier msg = rfile.readline().strip().decode()
self.metadata = metadata if not msg:
self.iroh_relay = iroh_relay break
def handle_lookup(self, parts): res = handle_dovecot_request(msg, transactions, notifier, metadata, iroh_relay)
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
def handle_dovecot_request(msg, transactions, notifier, metadata, iroh_relay=None):
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
short_command = msg[0]
parts = msg[1:].split("\t")
if short_command == DICTPROXY_LOOKUP_CHAR:
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
keyparts = parts[0].split("/", 2) keyparts = parts[0].split("/", 2)
if keyparts[0] == "priv": if keyparts[0] == "priv":
keyname = keyparts[2] keyname = keyparts[2]
addr = parts[1] addr = parts[1]
if keyname == self.metadata.DEVICETOKEN_KEY: if keyname == metadata.DEVICETOKEN_KEY:
res = " ".join(self.metadata.get_tokens_for_addr(addr)) res = " ".join(metadata.get_tokens_for_addr(addr))
return f"O{res}\n" return f"O{res}\n"
elif keyparts[0] == "shared": elif keyparts[0] == "shared":
keyname = keyparts[2] keyname = keyparts[2]
if ( if (
keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay" keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay"
and self.iroh_relay and iroh_relay
): ):
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay` # Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
return f"O{self.iroh_relay}\n" return f"O{iroh_relay}\n"
logging.warning(f"lookup ignored: {parts!r}") logging.warning("lookup ignored: %r", msg)
return "N\n" return "N\n"
elif short_command == DICTPROXY_ITERATE_CHAR:
# Empty line means ITER_FINISHED.
# If we don't return empty line Dovecot will timeout.
return "\n"
elif short_command == DICTPROXY_HELLO_CHAR:
return # no version checking
def handle_set(self, addr, parts): if short_command not in (DICTPROXY_TRANSACTION_CHARS):
logging.warning("unknown dictproxy request: %r", msg)
return
transaction_id = parts[0]
if short_command == DICTPROXY_BEGIN_TRANSACTION_CHAR:
addr = parts[1]
transactions[transaction_id] = dict(addr=addr, res="O\n")
elif short_command == DICTPROXY_COMMIT_TRANSACTION_CHAR:
# each set devicetoken operation persists directly
# and does not wait until a "commit" comes
# because our dovecot config does not involve
# multiple set-operations in a single commit
return transactions.pop(transaction_id)["res"]
elif short_command == DICTPROXY_SET_CHAR:
# For documentation on key structure see # For documentation on key structure see
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h # https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
keyname = parts[1].split("/") keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else "" value = parts[2] if len(parts) > 2 else ""
if keyname[0] == "priv" and keyname[2] == self.metadata.DEVICETOKEN_KEY: addr = transactions[transaction_id]["addr"]
self.metadata.add_token_to_addr(addr, value) if keyname[0] == "priv" and keyname[2] == metadata.DEVICETOKEN_KEY:
return True metadata.add_token_to_addr(addr, value)
elif keyname[0] == "priv" and keyname[2] == "messagenew": elif keyname[0] == "priv" and keyname[2] == "messagenew":
self.notifier.new_message_for_addr(addr, self.metadata) notifier.new_message_for_addr(addr, metadata)
return True else:
# Transaction failed.
transactions[transaction_id]["res"] = "F\n"
return False
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
request_queue_size = 100
def main(): def main():
@@ -133,8 +143,23 @@ def main():
notifier = Notifier(queue_dir) notifier = Notifier(queue_dir)
notifier.start_notification_threads(metadata.remove_token_from_addr) notifier.start_notification_threads(metadata.remove_token_from_addr)
dictproxy = MetadataDictProxy( class Handler(StreamRequestHandler):
notifier=notifier, metadata=metadata, iroh_relay=iroh_relay def handle(self):
) try:
handle_dovecot_protocol(
self.rfile, self.wfile, notifier, metadata, iroh_relay
)
except Exception:
logging.exception("Exception in the dovecot dictproxy handler")
raise
dictproxy.serve_forever_from_socket(socket) try:
os.unlink(socket)
except FileNotFoundError:
pass
with ThreadedUnixStreamServer(socket, Handler) as server:
try:
server.serve_forever()
except KeyboardInterrupt:
pass

View File

@@ -11,8 +11,6 @@ def main(vmail_dir=None):
ci_accounts = 0 ci_accounts = 0
for path in Path(vmail_dir).iterdir(): for path in Path(vmail_dir).iterdir():
if not path.joinpath("cur").is_dir():
continue
accounts += 1 accounts += 1
if path.name[:3] in ("ci-", "ac_"): if path.name[:3] in ("ci-", "ac_"):
ci_accounts += 1 ci_accounts += 1

View File

@@ -1,63 +0,0 @@
"""
migration code from old sqlite databases into per-maildir "password" files
where mtime reflects and is updated to be the "last-login" time.
"""
import logging
import os
import sqlite3
import sys
from chatmaild.config import read_config
def get_all_rows(path):
assert path.exists()
uri = f"file:{path}?mode=ro"
sqlconn = sqlite3.connect(uri, timeout=60, isolation_level="DEFERRED", uri=True)
cur = sqlconn.cursor()
cur.execute("SELECT * from users")
rows = cur.fetchall()
sqlconn.close()
return rows
def migrate_from_db_to_maildir(config, chunking=10000):
path = config.passdb_path
if not path.exists():
return
all_rows = get_all_rows(path)
# don't transfer special/CI accounts
rows = [row for row in all_rows if row[0][:3] not in ("ci-", "ac_")]
logging.info(f"ignoring {len(all_rows) - len(rows)} CI accounts")
logging.info(f"migrating {len(rows)} sqlite database passwords to user dirs")
for i, row in enumerate(rows):
addr = row[0]
enc_password = row[1]
user = config.get_user(addr)
user.set_password(enc_password)
if len(row) == 3 and row[2]:
timestamp = int(row[2])
user.set_last_login_timestamp(timestamp)
if i > 0 and i % chunking == 0:
logging.info(f"migration-progress: {i} passwords transferred")
logging.info("migration: all passwords migrated")
oldpath = config.passdb_path.with_suffix(config.passdb_path.suffix + ".old")
os.rename(config.passdb_path, oldpath)
for path in config.passdb_path.parent.iterdir():
if path.name.startswith(config.passdb_path.name + "-"):
path.unlink()
logging.info(f"migration: moved database to {oldpath!r}")
if __name__ == "__main__":
config = read_config(sys.argv[1])
logging.basicConfig(level=logging.INFO)
migrate_from_db_to_maildir(config)

View File

@@ -17,11 +17,11 @@ and which are scheduled for retry using exponential back-off timing.
If a token notification would be scheduled more than DROP_DEADLINE seconds If a token notification would be scheduled more than DROP_DEADLINE seconds
after its first attempt, it is dropped with a log error. after its first attempt, it is dropped with a log error.
Note that tokens are opaque to the notification machinery here Note that tokens are completely opaque to the notification machinery here
and are encrypted foreclosing all ability to distinguish and will in the future be encrypted foreclosing all ability to distinguish
which device token ultimately goes to which phone-provider notification service, which device token ultimately goes to which phone-provider notification service,
or to understand the relation of "device tokens" and chatmail addresses. or to understand the relation of "device tokens" and chatmail addresses.
The meaning and format of tokens is basically a matter of chatmail Core and The meaning and format of tokens is basically a matter of Delta-Chat Core and
the `notification.delta.chat` service. the `notification.delta.chat` service.
""" """
@@ -92,15 +92,10 @@ class Notifier:
def requeue_persistent_queue_items(self): def requeue_persistent_queue_items(self):
for queue_path in self.queue_dir.iterdir(): for queue_path in self.queue_dir.iterdir():
if queue_path.name.endswith(".tmp"): if queue_path.name.endswith(".tmp"):
logging.warning(f"removing spurious queue item: {queue_path!r}") logging.warning("removing spurious queue item: %r", queue_path)
queue_path.unlink()
continue
try:
queue_item = PersistentQueueItem.read_from_path(queue_path)
except ValueError:
logging.warning(f"removing spurious queue item: {queue_path!r}")
queue_path.unlink() queue_path.unlink()
continue continue
queue_item = PersistentQueueItem.read_from_path(queue_path)
self.queue_for_retry(queue_item) self.queue_for_retry(queue_item)
def queue_for_retry(self, queue_item, retry_num=0): def queue_for_retry(self, queue_item, retry_num=0):
@@ -109,7 +104,7 @@ class Notifier:
deadline = queue_item.start_ts + self.DROP_DEADLINE deadline = queue_item.start_ts + self.DROP_DEADLINE
if retry_num >= len(self.retry_queues) or when > deadline: if retry_num >= len(self.retry_queues) or when > deadline:
queue_item.delete() queue_item.delete()
logging.error(f"notification exceeded deadline: {queue_item.token!r}") logging.error("notification exceeded deadline: %r", queue_item.token)
return return
self.retry_queues[retry_num].put((when, queue_item)) self.retry_queues[retry_num].put((when, queue_item))
@@ -167,5 +162,5 @@ class NotifyThread(Thread):
queue_item.delete() queue_item.delete()
return return
logging.warning(f"Notification request failed: {res!r}") logging.warning("Notification request failed: %r", res)
self.notifier.queue_for_retry(queue_item, retry_num=self.retry_num + 1) self.notifier.queue_for_retry(queue_item, retry_num=self.retry_num + 1)

View File

@@ -1,56 +0,0 @@
From: {from_addr}
To: {to_addr}
Autocrypt-Setup-Message: v1
Subject: Autocrypt Setup Message
Date: Tue, 22 Jan 2019 12:56:29 +0100
Content-type: multipart/mixed; boundary="Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ"
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ
Content-Type: text/plain
This message contains all information to transfer your Autocrypt
settings along with your secret key securely from your original
device.
To set up your new device for Autocrypt, please follow the
instuctions that should be presented by your new device.
You can keep this message and use it as a backup for your secret
key. If you want to do this, you should write down the Setup Code
and store it securely.
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ
Content-Type: application/autocrypt-setup
Content-Disposition: attachment; filename="autocrypt-setup-message.html"
<html><body>
<p>
This is the Autocrypt setup file used to transfer settings and
keys between clients. You can decrypt it using the Setup Code
presented on your old device, and then import the contained key
into your keyring.
</p>
<pre>
-----BEGIN PGP MESSAGE-----
Passphrase-Format: numeric9x4
Passphrase-Begin: 17
jA0EBwMCFAxADoCdzeX/0ukBlqI5+pfpKb751qd/7nLNbkpy3gVcaf1QwRPZYt40
Ynp08UqRQ2g48ZlnzHLSwlTGOPTuv2Jt8ka+pgZ45xzvJSG2gau03xP4VsC271kR
VmCjdb0Y6Rk96mAwfGzrkbaRQ9Z7fIoL866GOv6h9neiVIkp+JYlTV6ISD0ZQJ4Q
I6dOQkB/TWZyVjtiJDOQHdfNWliA6NtqaLq19wlu9L5xXjuNpY95KwR8EJXWe0+o
Y3d2U/KxOAkXKghP2Qg1GtlPVeGC5T4p03TGI6pzKT+kHX6Rrm9wK6sM9aTquMmF
Vok84Jg1DFnwivWC2RILR81rXi7k/+Y6MUbveFgJ9cQduqpxnmD7TjOblYu7M6zp
YGAUxh8DRKlIMn2QsA++DBYQ6ACZvwuY8qTDLkqPDo4WqM313dsMJbyGjDdVE7EM
PESS+RlABETpZXz8g/ycr6DIUNdlbPcmYlsBfHWDOuR2GFFTwmlv5slWS39dJv38
E0eIe1CwdxI801Se7t7dUUS/ZF8wb6GlmxOcqGbF8eko1Z0S64IAm7/h13MRQCxI
geQnHfGYVJ2FOimoCMEKwfa9x++RFTDW0u7spDC2uWvK/1viV8OfRppFhLr/kmKb
18lWXuAz80DAjUDUsVqEq2MvJBJGoCJUEyjuRsLkHYRM5jYk4v50LyyR0Om73nWF
nZBqmqNzdr7Xb9PHHdFhnEc0VvoYbrcM0RVYcEMW3YbmejM891j1d6Iv+/n/qND/
NdebGrfWJMmFLf/iEkzTZ3/v5inW9LpWoRc94ioCjJTaEo8Rib6ARRFaJVIsmNXi
YicFGO98D+zX+a2t9Yz6IpPajVslnOp6ScpmXgts/2XWD7oE+JgxSAqo/dLVsHgP
Ufo=
=pulM
-----END PGP MESSAGE-----
</pre></body></html>
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ--

View File

@@ -1,44 +1,44 @@
From: {from_addr} From: {from_addr}
To: {to_addr}
Subject: ... To: {to_addr}
Date: Sun, 15 Oct 2023 16:43:21 +0000
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org> Subject: ...
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org> Date: Sun, 15 Oct 2023 16:43:21 +0000
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
Chat-Version: 1.0 Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO Chat-Version: 1.0
MIME-Version: 1.0
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
--YFrteb74qSXmggbOxZL9dRnhymywAi rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
Content-Description: PGP/MIME version identification
Content-Type: application/pgp-encrypted FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
Version: 1 XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: OpenPGP encrypted message JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
Content-Disposition: inline; filename="encrypted.asc";
Content-Type: application/octet-stream; name="encrypted.asc" IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
-----BEGIN PGP MESSAGE----- MIME-Version: 1.0
yxJiAAAAAABIZWxsbyB3b3JsZCE= Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
=1I/B
-----END PGP MESSAGE----- boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
--YFrteb74qSXmggbOxZL9dRnhymywAi--

View File

@@ -1,46 +0,0 @@
Date: Fri, 8 Jul 1994 09:21:47 -0400
From: Mail Delivery Subsystem <MAILER-DAEMON@example.org>
Subject: Returned mail: User unknown
To: <owner-ups-mib@CS.UTK.EDU>
Auto-Submitted: auto-replied
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status;
boundary="JAA13167.773673707/CS.UTK.EDU"
--JAA13167.773673707/CS.UTK.EDU
content-type: text/plain; charset=us-ascii
----- The following addresses had delivery problems -----
<arathib@vnet.ibm.com> (unrecoverable error)
<wsnell@sdcc13.ucsd.edu> (unrecoverable error)
--JAA13167.773673707/CS.UTK.EDU
content-type: message/delivery-status
Reporting-MTA: dns; cs.utk.edu
Original-Recipient: rfc822;arathib@vnet.ibm.com
Final-Recipient: rfc822;arathib@vnet.ibm.com
Action: failed
Status: 5.0.0 (permanent failure)
Diagnostic-Code: smtp;
550 'arathib@vnet.IBM.COM' is not a registered gateway user
Remote-MTA: dns; vnet.ibm.com
Original-Recipient: rfc822;johnh@hpnjld.njd.hp.com
Final-Recipient: rfc822;johnh@hpnjld.njd.hp.com
Action: delayed
Status: 4.0.0 (hpnjld.njd.jp.com: host name lookup failure)
Original-Recipient: rfc822;wsnell@sdcc13.ucsd.edu
Final-Recipient: rfc822;wsnell@sdcc13.ucsd.edu
Action: failed
Status: 5.0.0
Diagnostic-Code: smtp; 550 user unknown
Remote-MTA: dns; sdcc13.ucsd.edu
--JAA13167.773673707/CS.UTK.EDU
content-type: message/rfc822
[original message goes here]
--JAA13167.773673707/CS.UTK.EDU--

View File

@@ -1,21 +0,0 @@
Subject: Message from {from_addr}
From: <{from_addr}>
To: <{to_addr}>
Date: Sun, 15 Oct 2023 16:43:25 +0000
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
Chat-Version: 1.0
Secure-Join: vc-request
Secure-Join-Invitenumber: RANDOM-TOKEN
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
Content-Type: text/plain; charset=utf-8
Buy viagra!
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--

View File

@@ -1,21 +0,0 @@
Subject: Message from {from_addr}
From: <{from_addr}>
To: <{to_addr}>
Date: Sun, 15 Oct 2023 16:43:25 +0000
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
Chat-Version: 1.0
Secure-Join: vc-request
Secure-Join-Invitenumber: RANDOM-TOKEN
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
Content-Type: text/plain; charset=utf-8
Secure-Join: vc-request
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--

View File

@@ -7,19 +7,19 @@ from email.parser import BytesParser
from pathlib import Path from pathlib import Path
import pytest import pytest
from chatmaild.config import read_config, write_initial_config from chatmaild.config import read_config, write_initial_config
from chatmaild.database import Database
@pytest.fixture @pytest.fixture
def make_config(tmp_path): def make_config(tmp_path):
inipath = tmp_path.joinpath("chatmail.ini") inipath = tmp_path.joinpath("chatmail.ini")
def make_conf(mail_domain, settings=None): def make_conf(mail_domain):
basedir = tmp_path.joinpath(f"vmail/{mail_domain}") basedir = tmp_path.joinpath(f"vmail/{mail_domain}")
basedir.mkdir(parents=True, exist_ok=True) basedir.mkdir(parents=True, exist_ok=True)
overrides = settings.copy() if settings else {} passdb = tmp_path.joinpath("vmail/passdb.sqlite")
overrides["mailboxes_dir"] = str(basedir) overrides = dict(mailboxes_dir=str(basedir), passdb_path=str(passdb))
write_initial_config(inipath, mail_domain, overrides=overrides) write_initial_config(inipath, mail_domain, overrides=overrides)
return read_config(inipath) return read_config(inipath)
@@ -36,11 +36,6 @@ def maildomain(example_config):
return example_config.mail_domain return example_config.mail_domain
@pytest.fixture
def testaddr(maildomain):
return f"user.name@{maildomain}"
@pytest.fixture @pytest.fixture
def gencreds(maildomain): def gencreds(maildomain):
count = itertools.count() count = itertools.count()
@@ -59,6 +54,13 @@ def gencreds(maildomain):
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
@pytest.fixture @pytest.fixture
def maildata(request): def maildata(request):
try: try:
@@ -69,29 +71,11 @@ def maildata(request):
assert datadir.exists(), datadir assert datadir.exists(), datadir
def maildata(name, from_addr, to_addr, subject="[...]"): def maildata(name, from_addr, to_addr, subject="..."):
# Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines. # Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines.
data = datadir.joinpath(name).read_bytes().decode() data = datadir.joinpath(name).read_bytes().decode()
text = data.format(from_addr=from_addr, to_addr=to_addr, subject=subject) text = data.format(from_addr=from_addr, to_addr=to_addr, subject=subject)
return BytesParser(policy=policy.SMTP).parsebytes(text.encode()) return BytesParser(policy=policy.default).parsebytes(text.encode())
return maildata return maildata
@pytest.fixture
def mockout():
class MockOut:
captured_red = []
captured_green = []
captured_plain = []
def red(self, msg):
self.captured_red.append(msg)
def green(self, msg):
self.captured_green.append(msg)
def __call__(self, msg):
self.captured_plain.append(msg)
return MockOut()

View File

@@ -1,5 +1,4 @@
import pytest import pytest
from chatmaild.config import read_config from chatmaild.config import read_config
@@ -15,14 +14,6 @@ def test_read_config_basic(example_config):
assert example_config.mail_domain == "chat.example.org" assert example_config.mail_domain == "chat.example.org"
def test_read_config_basic_using_defaults(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini")
inipath.write_text(f"[params]\nmail_domain = {maildomain}")
example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 60
assert example_config.filtermail_smtp_port_incoming == 10081
def test_read_config_testrun(make_config): def test_read_config_testrun(make_config):
config = make_config("something.testrun.org") config = make_config("something.testrun.org")
assert config.mail_domain == "something.testrun.org" assert config.mail_domain == "something.testrun.org"
@@ -35,7 +26,6 @@ def test_read_config_testrun(make_config):
assert config.max_user_send_per_minute == 60 assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "100M" assert config.max_mailbox_size == "100M"
assert config.delete_mails_after == "20" assert config.delete_mails_after == "20"
assert config.delete_large_after == "7"
assert config.username_min_length == 9 assert config.username_min_length == 9
assert config.username_max_length == 9 assert config.username_max_length == 9
assert config.password_min_length == 9 assert config.password_min_length == 9
@@ -48,28 +38,24 @@ def test_config_userstate_paths(make_config, tmp_path):
mailboxes_dir = config.mailboxes_dir mailboxes_dir = config.mailboxes_dir
passdb_path = config.passdb_path passdb_path = config.passdb_path
assert mailboxes_dir.name == "something.testrun.org" assert mailboxes_dir.name == "something.testrun.org"
assert str(passdb_path) == "/home/vmail/passdb.sqlite" assert passdb_path.name == "passdb.sqlite"
assert passdb_path.is_relative_to(tmp_path)
assert config.mail_domain == "something.testrun.org" assert config.mail_domain == "something.testrun.org"
path = config.get_user("user1@something.testrun.org").maildir path = config.get_user_maildir("user1@something.testrun.org")
assert not path.exists() assert not path.exists()
assert path == mailboxes_dir.joinpath("user1@something.testrun.org") assert path == mailboxes_dir.joinpath("user1@something.testrun.org")
with pytest.raises(ValueError): with pytest.raises(ValueError):
config.get_user("") config.get_user_maildir("")
with pytest.raises(ValueError): with pytest.raises(ValueError):
config.get_user(None) config.get_user_maildir(None)
with pytest.raises(ValueError): with pytest.raises(ValueError):
config.get_user("../some@something.testrun.org").maildir config.get_user_maildir("../some@something.testrun.org")
with pytest.raises(ValueError): with pytest.raises(ValueError):
config.get_user("..").maildir config.get_user_maildir("..")
with pytest.raises(ValueError): with pytest.raises(ValueError):
config.get_user(".") config.get_user_maildir(".")
def test_config_max_message_size(make_config, tmp_path):
config = make_config("something.testrun.org", dict(max_message_size="10000"))
assert config.max_message_size == 10000

View File

@@ -1,37 +1,27 @@
import time import time
from chatmaild.delete_inactive_users import delete_inactive_users from chatmaild.delete_inactive_users import delete_inactive_users
from chatmaild.doveauth import AuthDictProxy from chatmaild.doveauth import lookup_passdb
def test_login_timestamps(example_config): def test_remove_stale_users(db, example_config):
testaddr = "someuser@chat.example.org"
user = example_config.get_user(testaddr)
# password file needs to be set because it's mtime tracks last-login time
user.set_password("1l2k3j1l2k3j123")
for i in range(10):
user.set_last_login_timestamp(86400 * 4 + i)
assert user.get_last_login_timestamp() == 86400 * 4
def test_delete_inactive_users(example_config):
new = time.time() new = time.time()
old = new - (example_config.delete_inactive_users_after * 86400) - 1 old = new - (example_config.delete_inactive_users_after * 86400) - 1
dictproxy = AuthDictProxy(example_config)
def create_user(addr, last_login): def create_user(addr, last_login):
dictproxy.lookup_passdb(addr, "q9mr3faue") lookup_passdb(db, example_config, addr, "q9mr3faue", last_login=last_login)
user = example_config.get_user(addr) md = example_config.get_user_maildir(addr)
user.maildir.joinpath("cur").mkdir() md.mkdir(parents=True)
user.maildir.joinpath("cur", "something").mkdir() md.joinpath("cur").mkdir()
user.set_last_login_timestamp(timestamp=last_login) md.joinpath("cur", "something").mkdir()
# create some stale and some new accounts # create some stale and some new accounts
to_remove = [] to_remove = []
for i in range(150): for i in range(150):
addr = f"oldold{i:03}@chat.example.org" addr = f"oldold{i:03}@chat.example.org"
create_user(addr, last_login=old) create_user(addr, last_login=old)
with db.read_connection() as conn:
assert conn.get_user(addr)
to_remove.append(addr) to_remove.append(addr)
remain = [] remain = []
@@ -43,17 +33,19 @@ def test_delete_inactive_users(example_config):
# check pre and post-conditions for delete_inactive_users() # check pre and post-conditions for delete_inactive_users()
for addr in to_remove: for addr in to_remove:
assert example_config.get_user(addr).maildir.exists() assert example_config.get_user_maildir(addr).exists()
delete_inactive_users(example_config) delete_inactive_users(db, example_config)
for p in example_config.mailboxes_dir.iterdir(): for p in example_config.mailboxes_dir.iterdir():
assert not p.name.startswith("old") assert not p.name.startswith("old")
for addr in to_remove: for addr in to_remove:
assert not example_config.get_user(addr).maildir.exists() assert not example_config.get_user_maildir(addr).exists()
with db.read_connection() as conn:
assert not conn.get_user(addr)
for addr in remain: for addr in remain:
userdir = example_config.get_user(addr).maildir assert example_config.get_user_maildir(addr).exists()
assert userdir.exists() with db.read_connection() as conn:
assert userdir.joinpath("password").read_text() assert conn.get_user(addr)

View File

@@ -4,39 +4,58 @@ import queue
import threading import threading
import traceback import traceback
import pytest
import chatmaild.doveauth import chatmaild.doveauth
import pytest
from chatmaild.database import DBError
from chatmaild.doveauth import ( from chatmaild.doveauth import (
AuthDictProxy, get_user_data,
handle_dovecot_protocol,
handle_dovecot_request,
is_allowed_to_create, is_allowed_to_create,
iter_userdb,
iter_userdb_lastlogin_before,
lookup_passdb,
) )
from chatmaild.newemail import create_newemail_dict from chatmaild.newemail import create_newemail_dict
@pytest.fixture def test_basic(db, example_config):
def dictproxy(example_config): lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue")
return AuthDictProxy(config=example_config) data = get_user_data(db, example_config, "asdf12345@chat.example.org")
def test_basic(dictproxy, gencreds):
addr, password = gencreds()
dictproxy.lookup_passdb(addr, password)
data = dictproxy.lookup_userdb(addr)
assert data assert data
data2 = dictproxy.lookup_passdb(addr, password) data2 = lookup_passdb(
db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue"
)
assert data == data2 assert data == data2
def test_iterate_addresses(dictproxy): def test_iterate_addresses(db, example_config):
addresses = [] addresses = []
for i in range(10): for i in range(10):
addresses.append(f"asdf1234{i}@chat.example.org") addresses.append(f"asdf1234{i}@chat.example.org")
dictproxy.lookup_passdb(addresses[-1], "q9mr3faue") lookup_passdb(db, example_config, addresses[-1], "q9mr3faue")
res = iter_userdb(db)
assert res == addresses
res = dictproxy.iter_userdb()
assert set(res) == set(addresses) def test_iterate_addresses_lastlogin_before(db, example_config):
addresses = []
cutoff_date = 1000
for i in range(10):
addr = f"oldold{i:03}@chat.example.org"
lookup_passdb(
db, example_config, addr, "q9mr3faue", last_login=cutoff_date - 10
)
addresses.append(addr)
for i in range(5):
addr = f"newnew{i:03}@chat.example.org"
lookup_passdb(db, example_config, addr, "q9mr3faue", last_login=cutoff_date + i)
res = iter_userdb_lastlogin_before(db, cutoff_date)
assert sorted(res) == sorted(addresses)
def test_invalid_username_length(example_config): def test_invalid_username_length(example_config):
@@ -53,33 +72,45 @@ def test_invalid_username_length(example_config):
) )
def test_dont_overwrite_password_on_wrong_login(dictproxy): 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""" """Test that logging in with a different password doesn't create a new user"""
res = dictproxy.lookup_passdb( res = lookup_passdb(
"newuser12@chat.example.org", "kajdlkajsldk12l3kj1983" db, example_config, "newuser12@chat.example.org", "kajdlkajsldk12l3kj1983"
) )
assert res["password"] assert res["password"]
res2 = dictproxy.lookup_passdb("newuser12@chat.example.org", "kajdslqwe") res2 = lookup_passdb(db, example_config, "newuser12@chat.example.org", "kajdslqwe")
# this function always returns a password hash, which is actually compared by dovecot. # this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"] assert res["password"] == res2["password"]
def test_nocreate_file(monkeypatch, tmpdir, dictproxy): def test_nocreate_file(db, monkeypatch, tmpdir, example_config):
p = tmpdir.join("nocreate") p = tmpdir.join("nocreate")
p.write("") p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
dictproxy.lookup_passdb("newuser12@chat.example.org", "zequ0Aimuchoodaechik") lookup_passdb(
assert not dictproxy.lookup_userdb("newuser12@chat.example.org") db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik"
)
assert not get_user_data(db, example_config, "newuser12@chat.example.org")
def test_handle_dovecot_request(dictproxy): def test_db_version(db):
transactions = {} assert db.get_schema_version() == 1
def test_too_high_db_version(db):
with db.write_transaction() as conn:
conn.execute("PRAGMA user_version=%s;" % (999,))
with pytest.raises(DBError):
db.ensure_tables()
def test_handle_dovecot_request(db, example_config):
# Test that password can contain ", ', \ and / # Test that password can contain ", ', \ and /
msg = ( msg = (
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"' 'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
"some42123@chat.example.org\tsome42123@chat.example.org" "some42123@chat.example.org\tsome42123@chat.example.org"
) )
res = dictproxy.handle_dovecot_request(msg, transactions) res = handle_dovecot_request(msg, db, example_config)
assert res assert res
assert res[0] == "O" and res.endswith("\n") assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip()) userdata = json.loads(res[1:].strip())
@@ -88,48 +119,45 @@ def test_handle_dovecot_request(dictproxy):
assert userdata["password"].startswith("{SHA512-CRYPT}") assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(example_config, caplog): def test_handle_dovecot_protocol_hello_is_skipped(db, example_config, caplog):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n") rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b"" assert wfile.getvalue() == b""
assert not caplog.messages assert not caplog.messages
def test_handle_dovecot_protocol_user_not_exists(example_config): def test_handle_dovecot_protocol(db, example_config):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO( rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n" b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
) )
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b"N\n" assert wfile.getvalue() == b"N\n"
def test_handle_dovecot_protocol_iterate(gencreds, example_config): def test_handle_dovecot_protocol_iterate(db, gencreds, example_config):
dictproxy = AuthDictProxy(config=example_config) lookup_passdb(db, example_config, "asdf00000@chat.example.org", "q9mr3faue")
dictproxy.lookup_passdb("asdf00000@chat.example.org", "q9mr3faue") lookup_passdb(db, example_config, "asdf11111@chat.example.org", "q9mr3faue")
dictproxy.lookup_passdb("asdf11111@chat.example.org", "q9mr3faue")
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/") rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) handle_dovecot_protocol(rfile, wfile, db, example_config)
lines = wfile.getvalue().decode("ascii").split("\n") lines = wfile.getvalue().decode("ascii").split("\n")
assert "Oshared/userdb/asdf00000@chat.example.org\t" in lines assert lines[0] == "Oshared/userdb/asdf00000@chat.example.org\t"
assert "Oshared/userdb/asdf11111@chat.example.org\t" in lines assert lines[1] == "Oshared/userdb/asdf11111@chat.example.org\t"
assert not lines[2] assert not lines[2]
def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy): def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
num_threads = 50 num_threads = 50
req_per_thread = 5 req_per_thread = 5
results = queue.Queue() results = queue.Queue()
def lookup(): def lookup(db):
for i in range(req_per_thread): for i in range(req_per_thread):
addr, password = gencreds() addr, password = gencreds()
try: try:
dictproxy.lookup_passdb(addr, password) lookup_passdb(db, example_config, addr, password)
except Exception: except Exception:
results.put(traceback.format_exc()) results.put(traceback.format_exc())
else: else:
@@ -137,7 +165,7 @@ def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
threads = [] threads = []
for i in range(num_threads): for i in range(num_threads):
thread = threading.Thread(target=lookup, daemon=True) thread = threading.Thread(target=lookup, args=(db,), daemon=True)
threads.append(thread) threads.append(thread)
print(f"created {num_threads} threads, starting them and waiting for results") print(f"created {num_threads} threads, starting them and waiting for results")

View File

@@ -1,6 +1,4 @@
import threading from chatmaild.filedict import FileDict
from chatmaild.filedict import FileDict, write_bytes_atomic
def test_basic(tmp_path): def test_basic(tmp_path):
@@ -19,21 +17,3 @@ def test_bad_marshal_file(tmp_path, caplog):
fdict1.path.write_bytes(b"l12k3l12k3l") fdict1.path.write_bytes(b"l12k3l12k3l")
assert fdict1.read() == {} assert fdict1.read() == {}
assert "corrupt" in caplog.records[0].msg assert "corrupt" in caplog.records[0].msg
def test_write_bytes_atomic_concurrent(tmp_path):
p = tmp_path.joinpath("somefile.ext")
write_bytes_atomic(p, b"hello")
threads = []
for i in range(30):
content = f"hello{i}".encode("ascii")
t = threading.Thread(target=lambda: write_bytes_atomic(p, content))
t.start()
threads.append(t)
for t in threads:
t.join()
assert p.read_text().strip() != "hello"
assert len(list(p.parent.iterdir())) == 1

View File

@@ -1,12 +1,9 @@
import pytest import pytest
from chatmaild.filtermail import ( from chatmaild.filtermail import (
IncomingBeforeQueueHandler, BeforeQueueHandler,
OutgoingBeforeQueueHandler,
SendRateLimiter, SendRateLimiter,
check_armored_payload, check_armored_payload,
check_encrypted, check_encrypted,
is_securejoin,
) )
@@ -19,13 +16,7 @@ def maildomain():
@pytest.fixture @pytest.fixture
def handler(make_config, maildomain): def handler(make_config, maildomain):
config = make_config(maildomain) config = make_config(maildomain)
return OutgoingBeforeQueueHandler(config) return BeforeQueueHandler(config)
@pytest.fixture
def inhandler(make_config, maildomain):
config = make_config(maildomain)
return IncomingBeforeQueueHandler(config)
def test_reject_forged_from(maildata, gencreds, handler): def test_reject_forged_from(maildata, gencreds, handler):
@@ -36,14 +27,14 @@ def test_reject_forged_from(maildata, gencreds, handler):
# test that the filter lets good mail through # test that the filter lets good mail through
to_addr = gencreds()[0] to_addr = gencreds()[0]
env.content = maildata( env.content = maildata(
"encrypted.eml", from_addr=env.mail_from, to_addr=to_addr "plain.eml", from_addr=env.mail_from, to_addr=to_addr
).as_bytes() ).as_bytes()
assert not handler.check_DATA(envelope=env) assert not handler.check_DATA(envelope=env)
# test that the filter rejects forged mail # test that the filter rejects forged mail
env.content = maildata( env.content = maildata(
"encrypted.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr "plain.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr
).as_bytes() ).as_bytes()
error = handler.check_DATA(envelope=env) error = handler.check_DATA(envelope=env)
assert "500" in error assert "500" in error
@@ -62,28 +53,19 @@ def test_filtermail_no_encryption_detection(maildata):
assert not check_encrypted(msg) assert not check_encrypted(msg)
def test_filtermail_securejoin_detection(maildata):
msg = maildata(
"securejoin-vc.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert is_securejoin(msg)
msg = maildata(
"securejoin-vc-fake.eml",
from_addr="some@example.org",
to_addr="other@example.org",
)
assert not is_securejoin(msg)
def test_filtermail_encryption_detection(maildata): def test_filtermail_encryption_detection(maildata):
msg = maildata( for subject in ("...", "[...]"):
"encrypted.eml", msg = maildata(
from_addr="1@example.org", "encrypted.eml",
to_addr="2@example.org", from_addr="1@example.org",
subject="Subject does not matter, will be replaced anyway", to_addr="2@example.org",
) subject=subject,
assert check_encrypted(msg) )
assert check_encrypted(msg)
# if the subject is not a known encrypted subject value, it is not considered ac-encrypted
msg.replace_header("Subject", "Click this link")
assert not check_encrypted(msg)
def test_filtermail_no_literal_packets(maildata): def test_filtermail_no_literal_packets(maildata):
@@ -113,7 +95,7 @@ def test_send_rate_limiter():
break break
def test_cleartext_excempt_privacy(maildata, gencreds, handler): def test_excempt_privacy(maildata, gencreds, handler):
from_addr = gencreds()[0] from_addr = gencreds()[0]
to_addr = "privacy@testrun.org" to_addr = "privacy@testrun.org"
handler.config.passthrough_recipients = [to_addr] handler.config.passthrough_recipients = [to_addr]
@@ -134,97 +116,10 @@ def test_cleartext_excempt_privacy(maildata, gencreds, handler):
rcpt_tos = [to_addr, false_to] rcpt_tos = [to_addr, false_to]
content = msg.as_bytes() content = msg.as_bytes()
assert "523" in handler.check_DATA(envelope=env2) assert "500" in handler.check_DATA(envelope=env2)
def test_cleartext_self_send_autocrypt_setup_message(maildata, gencreds, handler): def test_passthrough_senders(gencreds, handler, maildata):
from_addr = gencreds()[0]
to_addr = from_addr
msg = maildata("asm.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert not handler.check_DATA(envelope=env)
def test_cleartext_send_fails(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = gencreds()[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
res = handler.check_DATA(envelope=env)
assert "523 Encryption Needed" in res
def test_cleartext_incoming_fails(maildata, gencreds, inhandler):
from_addr = gencreds()[0]
to_addr, password = gencreds()
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
user = inhandler.config.get_user(to_addr)
user.set_password(password)
res = inhandler.check_DATA(envelope=env)
assert "523 Encryption Needed" in res
user.allow_incoming_cleartext()
assert not inhandler.check_DATA(envelope=env)
def test_cleartext_incoming_mailer_daemon(maildata, gencreds, inhandler):
from_addr = "mailer-daemon@example.org"
to_addr = gencreds()[0]
msg = maildata("mailer-daemon.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert not inhandler.check_DATA(envelope=env)
def test_cleartext_passthrough_domains(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = "privacy@x.y.z"
handler.config.passthrough_recipients = ["@x.y.z"]
false_to = "something@x.y"
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
class env2:
mail_from = from_addr
rcpt_tos = [to_addr, false_to]
content = msg.as_bytes()
assert "523" in handler.check_DATA(envelope=env2)
def test_cleartext_passthrough_senders(gencreds, handler, maildata):
acc1 = gencreds()[0] acc1 = gencreds()[0]
to_addr = "recipient@something.org" to_addr = "recipient@something.org"
handler.config.passthrough_senders = [acc1] handler.config.passthrough_senders = [acc1]
@@ -275,20 +170,10 @@ UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
=b5Kp\r =b5Kp\r
-----END PGP MESSAGE-----\r -----END PGP MESSAGE-----\r
\r \r
\r
""" """
assert check_armored_payload(payload) == True assert check_armored_payload(payload) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload) == True
payload = """-----BEGIN PGP MESSAGE-----\r payload = """-----BEGIN PGP MESSAGE-----\r
\r \r
HELLOWORLD HELLOWORLD
@@ -304,45 +189,3 @@ HELLOWORLD
\r \r
""" """
assert check_armored_payload(payload) == False assert check_armored_payload(payload) == False
# Test payload using partial body length
# as generated by GopenPGP.
payload = """-----BEGIN PGP MESSAGE-----\r
\r
wV4DdCVjRfOT3TQSAQdAY5+pjT6mlCxPGdR3be4w7oJJRUGIPI/Vnh+mJxGSm34w\r
LNlVc89S1g22uQYFif2sUJsQWbpoHpNkuWpkSgOaHmNvrZiY/YU5iv+cZ3LbmtUG\r
0uoBisSHh9O1c+5sYZSbrvYZ1NOwlD7Fv/U5/Mw4E5+CjxfdgNGp5o3DDddzPK78\r
jseDhdSXxnaiIJC93hxNX6R1RPt3G2gukyzx69wciPQShcF8zf3W3o75Ed7B8etV\r
QEeB16xzdFhKa9JxdjTu3osgCs21IO7wpcFkjc7nZzlW6jPnELJJaNmv4yOOCjMp\r
6YAkaN/BkL+jHTznHDuDsT5ilnTXpwHDU1Cm9PIx/KFcNCQnIB+2DcdIHPHUH1ci\r
jvqoeXAVWjKXEjS7PqPFuP/xGbrWG2ugs+toXJOKbgRkExvKs1dwPFKrgghvCVbW\r
AcKejQKAPArLwpkA7aD875TZQShvGt74fNs45XBlGOYOnNOAJ1KAmzrXLIDViyyB\r
kDsmTBk785xofuCkjBpXSe6vsMprPzCteDfaUibh8FHeJjucxPerwuOPEmnogNaf\r
YyL4+iy8H8I9/p7pmUqILprxTG0jTOtlk0bTVzeiF56W1xbtSEMuOo4oFbQTyOM2\r
bKXaYo774Jm+rRtKAnnI2dtf9RpK19cog6YNzfYjesLKbXDsPZbN5rmwyFiCvvxC\r
kQ6JLob+B2fPdY2gzy7LypxktS8Zi1HJcWDHJGVmQodaDLqKUObb4M26bXDe6oxI\r
NS8PJz5exVbM3KhZnUOEn6PJRBBf5a/ZqxlhZPcQo/oBuhKpBRpO5kSDwPIUByu3\r
UlXLSkpMqe9pUarAOEuQjfl2RVY7U+RrQYp4YP5keMO+i8NCefAFbowTTufO1JIq\r
2nVgCi/QVnxZyEc9OYt/8AE3g4cdojE+vsSDifZLSWYIetpfrohHv3dT3StD1QRG\r
0QE6qq6oKpg/IL0cjvuX4c7a7bslv2fXp8t75y37RU6253qdIebhxc/cRhPbc/yu\r
p0YLyD4SrvKTLP2ZV95jT4IPEpqm4AN3QmiOzdtqR2gLyb62L8QfqI/FdwsIiRiM\r
hqydwoqt/lfSqG1WKPh+6EkMkH+TDiCC1BQdbN1MNcyUtcjb35PR2c8Ld2TF3guA\r
jLIqMt/Vb7hBoMb2FcsOYY25ka9oV62OwgKWLXnFzk+modMR5fzb4kxVVAYEqP+D\r
T5KO1Vs76v1fyPGOq6BbBCvLwTqe/e6IZInJles4v5jrhnLcGKmNGivCUDe6X6NY\r
UKNt5RsZllwDQpaAb5dMNhyrk8SgIE7TBI7rvqIdUCE52Vy+0JDxFg5olRpFUfO6\r
/MyTW3Yo/ekk/npHr7iYYqJTCc21bDGLWQcIo/XO7WPxrKNWGBNPFnkRdw0MaKr4\r
+cEM3V8NFnSEpC12xA+RX/CezuJtwXZK5MpG76eYqMO6qyC+c25YcFecEufDZDxx\r
ZLqRszVRyxyWPtk/oIeQK2v9wOqY6N9/ff01gHz69vqYqN5bUw/QKZsmx1zW+gPw\r
6x2tDK2BHeYl182gCbhlKISRFwCtbjqZSkiKWao/VtygHkw0fK34avJuyQ/X9YaN\r
BRy+7Lf3VA53pnB5WJ1xwRXN8VDvmZeXzv2krHveCMemj0OjnRoCLu117xN0A5m9\r
Fm/RoDix5PolDHtWTtr2m1n2hp2LHnj8at9lFEd0SKhAYHVL9KjzycwWODZRXt+x\r
zGDDuooEeTvdY5NLyKcl4gETz1ZP4Ez5jGGjhPSwSpq1mU7UaJ9ZXXdr4KHyifW6\r
ggNzNsGhXTap7IWZpTtqXABydfiBshmH2NjqtNDwBweJVSgP10+r0WhMWlaZs6xl\r
V3o5yskJt6GlkwpJxZrTvN6Tiww/eW7HFV6NGf7IRSWY5tJc/iA7/92tOmkdvJ1q\r
myLbG7cJB787QjplEyVe2P/JBO6xYvbkJLf9Q+HaviTO25rugRSrYsoKMDfO8VlQ\r
1CcnTPVtApPZJEQzAWJEgVAM8uIlkqWJJMgyWT34sTkdBeCUFGloXQFs9Yxd0AGf\r
/zHEkYZSTKpVSvAIGu4=\r
=6iHb\r
-----END PGP MESSAGE-----\r
"""
assert check_armored_payload(payload) == True

View File

@@ -1,64 +0,0 @@
import time
from chatmaild.doveauth import AuthDictProxy
from chatmaild.lastlogin import (
LastLoginDictProxy,
)
def test_handle_dovecot_request_last_login(testaddr, example_config):
dictproxy = LastLoginDictProxy(config=example_config)
authproxy = AuthDictProxy(config=example_config)
authproxy.lookup_passdb(testaddr, "1l2k3j1l2k3jl123")
dictproxy_transactions = {}
# Begin transaction
tx = "1111"
msg = f"B{tx}\t{testaddr}"
res = dictproxy.handle_dovecot_request(msg, dictproxy_transactions)
assert not res
assert dictproxy_transactions == {tx: dict(addr=testaddr, res="O\n")}
# set last-login info for user
user = dictproxy.config.get_user(testaddr)
timestamp = int(time.time())
msg = f"S{tx}\tshared/last-login/{testaddr}\t{timestamp}"
res = dictproxy.handle_dovecot_request(msg, dictproxy_transactions)
assert not res
assert len(dictproxy_transactions) == 1
read_timestamp = user.get_last_login_timestamp()
assert read_timestamp == timestamp // 86400 * 86400
# finish transaction
msg = f"C{tx}"
res = dictproxy.handle_dovecot_request(msg, dictproxy_transactions)
assert res == "O\n"
assert len(dictproxy_transactions) == 0
def test_handle_dovecot_request_last_login_echobot(example_config):
dictproxy = LastLoginDictProxy(config=example_config)
authproxy = AuthDictProxy(config=example_config)
testaddr = f"echo@{example_config.mail_domain}"
authproxy.lookup_passdb(testaddr, "ignore")
user = dictproxy.config.get_user(testaddr)
transactions = {}
# set last-login info for user
tx = "1111"
msg = f"B{tx}\t{testaddr}"
res = dictproxy.handle_dovecot_request(msg, transactions)
assert not res
assert transactions == {tx: dict(addr=testaddr, res="O\n")}
timestamp = int(time.time())
msg = f"S{tx}\tshared/last-login/{testaddr}\t{timestamp}"
res = dictproxy.handle_dovecot_request(msg, transactions)
assert not res
assert len(transactions) == 1
read_timestamp = user.get_last_login_timestamp()
assert read_timestamp is None

View File

@@ -3,10 +3,10 @@ import time
import pytest import pytest
import requests import requests
from chatmaild.metadata import ( from chatmaild.metadata import (
Metadata, Metadata,
MetadataDictProxy, handle_dovecot_protocol,
handle_dovecot_request,
) )
from chatmaild.notifier import ( from chatmaild.notifier import (
Notifier, Notifier,
@@ -30,8 +30,8 @@ def metadata(tmp_path):
@pytest.fixture @pytest.fixture
def dictproxy(notifier, metadata): def testaddr():
return MetadataDictProxy(notifier=notifier, metadata=metadata) return "user.name@example.org"
@pytest.fixture @pytest.fixture
@@ -88,51 +88,51 @@ def test_notifier_remove_without_set(metadata, testaddr):
assert not metadata.get_tokens_for_addr(testaddr) assert not metadata.get_tokens_for_addr(testaddr)
def test_handle_dovecot_request_lookup_fails(dictproxy, testaddr): def test_handle_dovecot_request_lookup_fails(notifier, metadata, testaddr):
transactions = {} res = handle_dovecot_request(
res = dictproxy.handle_dovecot_request( f"Lpriv/123/chatmail\t{testaddr}", {}, notifier, metadata
f"Lpriv/123/chatmail\t{testaddr}", transactions
) )
assert res == "N\n" assert res == "N\n"
def test_handle_dovecot_request_happy_path(dictproxy, testaddr, token): def test_handle_dovecot_request_happy_path(notifier, metadata, testaddr, token):
metadata = dictproxy.metadata
transactions = {} transactions = {}
notifier = dictproxy.notifier
# set device token in a transaction # set device token in a transaction
tx = "1111" tx = "1111"
msg = f"B{tx}\t{testaddr}" msg = f"B{tx}\t{testaddr}"
res = dictproxy.handle_dovecot_request(msg, transactions) res = handle_dovecot_request(msg, transactions, notifier, metadata)
assert not res and not metadata.get_tokens_for_addr(testaddr) assert not res and not metadata.get_tokens_for_addr(testaddr)
assert transactions == {tx: dict(addr=testaddr, res="O\n")} assert transactions == {tx: dict(addr=testaddr, res="O\n")}
msg = f"S{tx}\tpriv/guid00/devicetoken\t{token}" msg = f"S{tx}\tpriv/guid00/devicetoken\t{token}"
res = dictproxy.handle_dovecot_request(msg, transactions) res = handle_dovecot_request(msg, transactions, notifier, metadata)
assert not res assert not res
assert len(transactions) == 1 assert len(transactions) == 1
assert metadata.get_tokens_for_addr(testaddr) == [token] assert metadata.get_tokens_for_addr(testaddr) == [token]
msg = f"C{tx}" msg = f"C{tx}"
res = dictproxy.handle_dovecot_request(msg, transactions) res = handle_dovecot_request(msg, transactions, notifier, metadata)
assert res == "O\n" assert res == "O\n"
assert len(transactions) == 0 assert len(transactions) == 0
assert metadata.get_tokens_for_addr(testaddr) == [token] assert metadata.get_tokens_for_addr(testaddr) == [token]
# trigger notification for incoming message # trigger notification for incoming message
tx2 = "2222" tx2 = "2222"
assert dictproxy.handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions) is None assert (
handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions, notifier, metadata)
is None
)
msg = f"S{tx2}\tpriv/guid00/messagenew" msg = f"S{tx2}\tpriv/guid00/messagenew"
assert dictproxy.handle_dovecot_request(msg, transactions) is None assert handle_dovecot_request(msg, transactions, notifier, metadata) is None
queue_item = notifier.retry_queues[0].get()[1] queue_item = notifier.retry_queues[0].get()[1]
assert queue_item.token == token assert queue_item.token == token
assert dictproxy.handle_dovecot_request(f"C{tx2}", transactions) == "O\n" assert handle_dovecot_request(f"C{tx2}", transactions, notifier, metadata) == "O\n"
assert not transactions assert not transactions
assert queue_item.path.exists() assert queue_item.path.exists()
def test_handle_dovecot_protocol_set_devicetoken(dictproxy): def test_handle_dovecot_protocol_set_devicetoken(metadata, notifier):
rfile = io.BytesIO( rfile = io.BytesIO(
b"\n".join( b"\n".join(
[ [
@@ -144,12 +144,12 @@ def test_handle_dovecot_protocol_set_devicetoken(dictproxy):
) )
) )
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"O\n" assert wfile.getvalue() == b"O\n"
assert dictproxy.metadata.get_tokens_for_addr("user@example.org") == ["01234"] assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
def test_handle_dovecot_protocol_set_get_devicetoken(dictproxy): def test_handle_dovecot_protocol_set_get_devicetoken(metadata, notifier):
rfile = io.BytesIO( rfile = io.BytesIO(
b"\n".join( b"\n".join(
[ [
@@ -161,19 +161,19 @@ def test_handle_dovecot_protocol_set_get_devicetoken(dictproxy):
) )
) )
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert dictproxy.metadata.get_tokens_for_addr("user@example.org") == ["01234"] assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
assert wfile.getvalue() == b"O\n" assert wfile.getvalue() == b"O\n"
rfile = io.BytesIO( rfile = io.BytesIO(
b"\n".join([b"HELLO", b"Lpriv/0123/devicetoken\tuser@example.org"]) b"\n".join([b"HELLO", b"Lpriv/0123/devicetoken\tuser@example.org"])
) )
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"O01234\n" assert wfile.getvalue() == b"O01234\n"
def test_handle_dovecot_protocol_iterate(dictproxy): def test_handle_dovecot_protocol_iterate(metadata, notifier):
rfile = io.BytesIO( rfile = io.BytesIO(
b"\n".join( b"\n".join(
[ [
@@ -183,7 +183,7 @@ def test_handle_dovecot_protocol_iterate(dictproxy):
) )
) )
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile) handle_dovecot_protocol(rfile, wfile, notifier, metadata)
assert wfile.getvalue() == b"\n" assert wfile.getvalue() == b"\n"
@@ -242,22 +242,6 @@ def test_requeue_removes_tmp_files(notifier, metadata, testaddr, caplog):
assert queue_item.addr == testaddr assert queue_item.addr == testaddr
def test_requeue_removes_invalid_files(notifier, metadata, testaddr, caplog):
metadata.add_token_to_addr(testaddr, "01234")
notifier.new_message_for_addr(testaddr, metadata)
# empty/invalid files should be ignored
p = notifier.queue_dir.joinpath("1203981203")
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): def test_start_and_stop_notification_threads(notifier, testaddr):
threads = notifier.start_notification_threads(None) threads = notifier.start_notification_threads(None)
for retry_num, threadlist in threads.items(): for retry_num, threadlist in threads.items():
@@ -314,7 +298,7 @@ def test_persistent_queue_items(tmp_path, testaddr, token):
assert not queue_item < item2 and not item2 < queue_item assert not queue_item < item2 and not item2 < queue_item
def test_iroh_relay(dictproxy): def test_iroh_relay(metadata):
rfile = io.BytesIO( rfile = io.BytesIO(
b"\n".join( b"\n".join(
[ [
@@ -324,6 +308,5 @@ def test_iroh_relay(dictproxy):
) )
) )
wfile = io.BytesIO() wfile = io.BytesIO()
dictproxy.iroh_relay = "https://example.org/" handle_dovecot_protocol(rfile, wfile, notifier, metadata, "https://example.org/")
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"Ohttps://example.org/\n" assert wfile.getvalue() == b"Ohttps://example.org/\n"

View File

@@ -2,15 +2,8 @@ from chatmaild.metrics import main
def test_main(tmp_path, capsys): def test_main(tmp_path, capsys):
paths = []
for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"): for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"):
p = tmp_path.joinpath(x) tmp_path.joinpath(x).mkdir()
p.mkdir()
p.joinpath("cur").mkdir()
paths.append(p)
tmp_path.joinpath("nomailbox").mkdir()
main(tmp_path) main(tmp_path)
out, _ = capsys.readouterr() out, _ = capsys.readouterr()
d = {} d = {}

View File

@@ -1,67 +0,0 @@
import sqlite3
from chatmaild.migrate_db import migrate_from_db_to_maildir
def test_migration_not_exists(tmp_path, example_config):
example_config.passdb_path = tmp_path.joinpath("sqlite")
def test_migration(tmp_path, example_config, caplog):
passdb_path = tmp_path.joinpath("passdb.sqlite")
uri = f"file:{passdb_path}?mode=rwc"
sqlconn = sqlite3.connect(uri, timeout=60, uri=True)
sqlconn.execute(
"""
CREATE TABLE users (
addr TEXT PRIMARY KEY,
password TEXT,
last_login INTEGER
)
"""
)
all = {}
for i in range(500):
values = (f"somsom{i:03}@example.org", f"passwo{i:03}", i * 86400)
sqlconn.execute(
"""
INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)""",
values,
)
all[values[0]] = values[1:]
for i in range(500):
values = (f"pompom{i:03}@example.org", f"wopass{i:03}", "")
sqlconn.execute(
"""
INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)""",
values,
)
all[values[0]] = values[1:]
sqlconn.commit()
sqlconn.close()
assert passdb_path.stat().st_size > 10000
example_config.passdb_path = passdb_path
assert not caplog.records
migrate_from_db_to_maildir(example_config, chunking=500)
assert len(caplog.records) > 3
for path in example_config.mailboxes_dir.iterdir():
if "@" not in path.name:
continue
password, last_login = all.pop(path.name)
user = example_config.get_user(path.name)
if last_login:
assert user.get_last_login_timestamp() == last_login
assert password == user.get_userdb_dict()["password"]
assert not all
assert not example_config.passdb_path.exists()

View File

@@ -1,56 +0,0 @@
def test_login_timestamp(testaddr, example_config):
user = example_config.get_user(testaddr)
user.set_password("someeqkjwelkqwjleqwe")
user.set_last_login_timestamp(100000)
assert user.get_last_login_timestamp() == 86400
user.set_last_login_timestamp(200000)
assert user.get_last_login_timestamp() == 86400 * 2
def test_get_user_dict_not_set(testaddr, example_config, caplog):
user = example_config.get_user(testaddr)
assert not caplog.records
assert user.get_userdb_dict() == {}
assert len(caplog.records) == 0
user.set_password("")
assert user.get_userdb_dict() == {}
assert len(caplog.records) == 1
def test_get_user_dict(make_config, tmp_path):
config = make_config("something.testrun.org")
addr = "user1@something.org"
user = config.get_user(addr)
enc_password = "l1k2j31lk2j3l1k23j123"
user.set_password(enc_password)
data = user.get_userdb_dict()
assert addr in str(data["home"])
assert data["uid"] == "vmail"
assert data["gid"] == "vmail"
assert data["password"] == enc_password
def test_no_mailboxes_dir(testaddr, example_config, tmp_path):
p = tmp_path.joinpath("a", "mailboxes")
example_config.mailboxes_dir = p
user = example_config.get_user(testaddr)
user.set_password("someeqkjwelkqwjleqwe")
user.set_last_login_timestamp(100000)
assert user.get_last_login_timestamp() == 86400
def test_set_get_cleartext_flag(testaddr, example_config, tmp_path):
p = tmp_path.joinpath("a", "mailboxes")
example_config.mailboxes_dir = p
user = example_config.get_user(testaddr)
user.set_password("someeqkjwelkqwjleqwe")
user.set_last_login_timestamp(100000)
assert user.get_last_login_timestamp() == 86400
assert not user.is_incoming_cleartext_ok()
user.allow_incoming_cleartext()
assert user.is_incoming_cleartext_ok()

View File

@@ -1,84 +0,0 @@
import logging
import os
from chatmaild.filedict import write_bytes_atomic
def get_daytimestamp(timestamp) -> int:
return int(timestamp) // 86400 * 86400
class User:
def __init__(self, maildir, addr, password_path, uid, gid):
self.maildir = maildir
self.addr = addr
self.password_path = password_path
self.enforce_E2EE_path = maildir.joinpath("enforceE2EEincoming")
self.uid = uid
self.gid = gid
@property
def can_track(self):
return "@" in self.addr and not self.addr.startswith("echo@")
def get_userdb_dict(self):
"""Return a non-empty dovecot 'userdb' style dict
if the user has an existing non-empty password"""
try:
pw = self.password_path.read_text()
except FileNotFoundError:
return {}
if not pw:
logging.error(f"password is empty for: {self.addr}")
return {}
home = str(self.maildir)
return dict(addr=self.addr, home=home, uid=self.uid, gid=self.gid, password=pw)
def is_incoming_cleartext_ok(self):
return not self.enforce_E2EE_path.exists()
def allow_incoming_cleartext(self):
if self.enforce_E2EE_path.exists():
self.enforce_E2EE_path.unlink()
def set_password(self, enc_password):
"""Set the specified password for this user.
This method can be called concurrently
but there is no guarantee which of the password-set calls will win.
"""
self.maildir.mkdir(exist_ok=True, parents=True)
password = enc_password.encode("ascii")
try:
write_bytes_atomic(self.password_path, password)
except PermissionError:
if not self.addr.startswith("echo@"):
logging.error(f"could not write password for: {self.addr}")
raise
if not self.addr.startswith("echo@"):
self.enforce_E2EE_path.touch()
def set_last_login_timestamp(self, timestamp):
"""Track login time with daily granularity
to minimize touching files and to minimize metadata leakage."""
if not self.can_track:
return
try:
mtime = int(os.stat(self.password_path).st_mtime)
except FileNotFoundError:
logging.error(f"Can not get last login timestamp for {self.addr}")
return
timestamp = get_daytimestamp(timestamp)
if mtime != timestamp:
os.utime(self.password_path, (timestamp, timestamp))
def get_last_login_timestamp(self):
if self.can_track:
try:
return int(self.password_path.stat().st_mtime)
except FileNotFoundError:
pass

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "cmdeploy" name = "cmdeploy"
version = "0.2" version = "0.2"
dependencies = [ dependencies = [
"pyinfra>=3", "pyinfra",
"pillow", "pillow",
"qrcode", "qrcode",
"markdown", "markdown",
@@ -41,6 +41,3 @@ lint.select = [
"PLE", # Pylint Error "PLE", # Pylint Error
"PLW", # Pylint Warning "PLW", # Pylint Warning
] ]
lint.ignore = [
"PLC0415" # import-outside-top-level
]

View File

@@ -7,35 +7,17 @@ import io
import shutil import shutil
import subprocess import subprocess
import sys import sys
from io import StringIO
from pathlib import Path from pathlib import Path
from chatmaild.config import Config, read_config from chatmaild.config import Config, read_config
from pyinfra import facts, host from pyinfra import host
from pyinfra.api import FactBase
from pyinfra.facts.files import File from pyinfra.facts.files import File
from pyinfra.facts.server import Sysctl
from pyinfra.facts.systemd import SystemdEnabled from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd from pyinfra.operations import apt, files, pip, server, systemd
from .acmetool import deploy_acmetool from .acmetool import deploy_acmetool
class Port(FactBase):
"""
Returns the process occuping a port.
"""
def command(self, port: int) -> str:
return (
"ss -lptn 'src :%d' | awk 'NR>1 {print $6,$7}' | sed 's/users:((\"//;s/\".*//'"
% (port,)
)
def process(self, output: [str]) -> str:
return output[0]
def _build_chatmaild(dist_dir) -> None: def _build_chatmaild(dist_dir) -> None:
dist_dir = Path(dist_dir).resolve() dist_dir = Path(dist_dir).resolve()
if dist_dir.exists(): if dist_dir.exists():
@@ -96,11 +78,6 @@ def _install_remote_venv_with_chatmaild(config) -> None:
always_copy=True, always_copy=True,
) )
apt.packages(
name="install gcc and headers to build crypt_r source package",
packages=["gcc", "python3-dev"],
)
server.shell( server.shell(
name=f"forced pip-install {dist_file.name}", name=f"forced pip-install {dist_file.name}",
commands=[ commands=[
@@ -124,14 +101,11 @@ def _install_remote_venv_with_chatmaild(config) -> None:
for fn in ( for fn in (
"doveauth", "doveauth",
"filtermail", "filtermail",
"filtermail-incoming",
"echobot", "echobot",
"chatmail-metadata", "chatmail-metadata",
"lastlogin",
): ):
execpath = fn if fn != "filtermail-incoming" else "filtermail"
params = dict( params = dict(
execpath=f"{remote_venv_dir}/bin/{execpath}", execpath=f"{remote_venv_dir}/bin/{fn}",
config_path=remote_chatmail_inipath, config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir, remote_venv_dir=remote_venv_dir,
mail_domain=config.mail_domain, mail_domain=config.mail_domain,
@@ -235,37 +209,51 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
server.shell( server.shell(
name="Generate OpenDKIM domain keys", name="Generate OpenDKIM domain keys",
commands=[ commands=[
f"/usr/sbin/opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}" f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
], ],
_use_su_login=True, _sudo=True,
_su_user="opendkim", _sudo_user="opendkim",
) )
service_file = files.put(
name="Configure opendkim to restart once a day",
src=importlib.resources.files(__package__).joinpath("opendkim/systemd.conf"),
dest="/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
)
need_restart |= service_file.changed
return need_restart return need_restart
def _uninstall_mta_sts_daemon() -> None: def _install_mta_sts_daemon() -> bool:
# Remove configuration. need_restart = False
files.file("/etc/mta-sts-daemon.yml", present=False)
files.directory("/usr/local/lib/postfix-mta-sts-resolver", present=False) config = files.put(
name="upload postfix-mta-sts-resolver config",
files.file("/etc/systemd/system/mta-sts-daemon.service", present=False) src=importlib.resources.files(__package__).joinpath(
"postfix/mta-sts-daemon.yml"
systemd.service( ),
name="Stop MTA-STS daemon", dest="/etc/mta-sts-daemon.yml",
service="mta-sts-daemon.service", user="root",
daemon_reload=True, group="root",
running=False, mode="644",
enabled=False,
) )
need_restart |= config.changed
server.shell(
name="install postfix-mta-sts-resolver with pip",
commands=[
"python3 -m virtualenv /usr/local/lib/postfix-mta-sts-resolver",
"/usr/local/lib/postfix-mta-sts-resolver/bin/pip install postfix-mta-sts-resolver",
],
)
systemd_unit = files.put(
name="upload mta-sts-daemon systemd unit",
src=importlib.resources.files(__package__).joinpath(
"postfix/mta-sts-daemon.service"
),
dest="/etc/systemd/system/mta-sts-daemon.service",
user="root",
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
return need_restart
def _configure_postfix(config: Config, debug: bool = False) -> bool: def _configure_postfix(config: Config, debug: bool = False) -> bool:
@@ -279,7 +267,6 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
group="root", group="root",
mode="644", mode="644",
config=config, config=config,
disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed need_restart |= main_config.changed
@@ -318,40 +305,6 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
return need_restart return need_restart
def _install_dovecot_package(package: str, arch: str):
arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch
url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
deb_filename = "/root/" + url.split("/")[-1]
match (package, arch):
case ("core", "amd64"):
sha256 = "43f593332e22ac7701c62d58b575d2ca409e0f64857a2803be886c22860f5587"
case ("core", "arm64"):
sha256 = "4d21eba1a83f51c100f08f2e49f0c9f8f52f721ebc34f75018e043306da993a7"
case ("imapd", "amd64"):
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
case ("imapd", "arm64"):
sha256 = "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f"
case ("lmtpd", "amd64"):
sha256 = "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab"
case ("lmtpd", "arm64"):
sha256 = "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f"
case _:
apt.packages(packages=[f"dovecot-{package}"])
return
files.download(
name=f"Download dovecot-{package}",
src=url,
dest=deb_filename,
sha256sum=sha256,
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
)
apt.deb(name=f"Install dovecot-{package}", src=deb_filename)
def _configure_dovecot(config: Config, debug: bool = False) -> bool: def _configure_dovecot(config: Config, debug: bool = False) -> bool:
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
need_restart = False need_restart = False
@@ -364,7 +317,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
mode="644", mode="644",
config=config, config=config,
debug=debug, debug=debug,
disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed need_restart |= main_config.changed
auth_config = files.put( auth_config = files.put(
@@ -399,10 +351,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
# it is recommended to set the following inotify limits # it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"): for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}" key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] > 65535:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl( server.sysctl(
name=f"Change {key}", name=f"Change {key}",
key=key, key=key,
@@ -410,17 +358,10 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
persist=True, persist=True,
) )
timezone_env = files.line(
name="Set TZ environment variable",
path="/etc/environment",
line="TZ=:/etc/localtime",
)
need_restart |= timezone_env.changed
return need_restart return need_restart
def _configure_nginx(config: Config, debug: bool = False) -> bool: def _configure_nginx(domain: str, debug: bool = False) -> bool:
"""Configures nginx HTTP server.""" """Configures nginx HTTP server."""
need_restart = False need_restart = False
@@ -430,8 +371,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config={"domain_name": domain},
disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed need_restart |= main_config.changed
@@ -441,7 +381,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config={"domain_name": domain},
) )
need_restart |= autoconfig.changed need_restart |= autoconfig.changed
@@ -451,7 +391,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config={"domain_name": domain},
) )
need_restart |= mta_sts_config.changed need_restart |= mta_sts_config.changed
@@ -497,122 +437,10 @@ def check_config(config):
return config return config
def deploy_mtail(config): def deploy_chatmail(config_path: Path) -> None:
# Uninstall mtail package, we are going to install a static binary.
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False)
(url, sha256sum) = {
"x86_64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz",
"123c2ee5f48c3eff12ebccee38befd2233d715da736000ccde49e3d5607724e4",
),
"aarch64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz",
"aa04811c0929b6754408676de520e050c45dddeb3401881888a092c9aea89cae",
),
}[host.get_fact(facts.server.Arch)]
server.shell(
name="Download mtail",
commands=[
f"(echo '{sha256sum} /usr/local/bin/mtail' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - mtail -O >/usr/local/bin/mtail.new && mv /usr/local/bin/mtail.new /usr/local/bin/mtail)",
"chmod 755 /usr/local/bin/mtail",
],
)
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
# This allows to read from journalctl instead of log files.
files.template(
src=importlib.resources.files(__package__).joinpath("mtail/mtail.service.j2"),
dest="/etc/systemd/system/mtail.service",
user="root",
group="root",
mode="644",
address=config.mtail_address or "127.0.0.1",
port=3903,
)
mtail_conf = files.put(
name="Mtail configuration",
src=importlib.resources.files(__package__).joinpath(
"mtail/delivered_mail.mtail"
),
dest="/etc/mtail/delivered_mail.mtail",
user="root",
group="root",
mode="644",
)
systemd.service(
name="Start and enable mtail",
service="mtail.service",
running=bool(config.mtail_address),
enabled=bool(config.mtail_address),
restarted=mtail_conf.changed,
)
def deploy_iroh_relay(config) -> None:
(url, sha256sum) = {
"x86_64": (
"https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-x86_64-unknown-linux-musl.tar.gz",
"2ffacf7c0622c26b67a5895ee8e07388769599f60e5f52a3bd40a3258db89b2c",
),
"aarch64": (
"https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-aarch64-unknown-linux-musl.tar.gz",
"b915037bcc1ff1110cc9fcb5de4a17c00ff576fd2f568cd339b3b2d54c420dc4",
),
}[host.get_fact(facts.server.Arch)]
apt.packages(
name="Install curl",
packages=["curl"],
)
server.shell(
name="Download iroh-relay",
commands=[
f"(echo '{sha256sum} /usr/local/bin/iroh-relay' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
"chmod 755 /usr/local/bin/iroh-relay",
],
)
need_restart = False
systemd_unit = files.put(
name="Upload iroh-relay systemd unit",
src=importlib.resources.files(__package__).joinpath("iroh-relay.service"),
dest="/etc/systemd/system/iroh-relay.service",
user="root",
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
iroh_config = files.put(
name="Upload iroh-relay config",
src=importlib.resources.files(__package__).joinpath("iroh-relay.toml"),
dest="/etc/iroh-relay.toml",
user="root",
group="root",
mode="644",
)
need_restart |= iroh_config.changed
systemd.service(
name="Start and enable iroh-relay",
service="iroh-relay.service",
running=True,
enabled=config.enable_iroh_relay,
restarted=need_restart,
)
def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
"""Deploy a chat-mail instance. """Deploy a chat-mail instance.
:param config_path: path to chatmail.ini :param config_path: path to chatmail.ini
:param disable_mail: whether to disable postfix & dovecot
""" """
config = read_config(config_path) config = read_config(config_path)
check_config(config) check_config(config)
@@ -636,7 +464,6 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
system=True, system=True,
) )
server.user(name="Create echobot user", user="echobot", system=True) server.user(name="Create echobot user", user="echobot", system=True)
server.user(name="Create iroh user", user="iroh", system=True)
# Add our OBS repository for dovecot_no_delay # Add our OBS repository for dovecot_no_delay
files.put( files.put(
@@ -653,17 +480,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
path="/etc/apt/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/ ./", line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
escape_regex_characters=True, escape_regex_characters=True,
present=False, ensure_newline=True,
) )
if host.get_fact(Port, port=53) != "unbound":
files.line(
name="Add 9.9.9.9 to resolv.conf",
path="/etc/resolv.conf",
line="nameserver 9.9.9.9",
)
apt.update(name="apt update", cache_time=24 * 3600) apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True)
apt.packages( apt.packages(
name="Install rsync", name="Install rsync",
@@ -673,12 +493,6 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
# Run local DNS resolver `unbound`. # Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf # `resolvconf` takes care of setting up /etc/resolv.conf
# to use 127.0.0.1 as the resolver. # to use 127.0.0.1 as the resolver.
from cmdeploy.cmdeploy import Out
process_on_53 = host.get_fact(Port, port=53)
if process_on_53 not in (None, "unbound"):
Out().red(f"Can't install unbound: port 53 is occupied by: {process_on_53}")
exit(1)
apt.packages( apt.packages(
name="Install unbound", name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"], packages=["unbound", "unbound-anchor", "dnsutils"],
@@ -697,12 +511,9 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
enabled=True, enabled=True,
) )
deploy_iroh_relay(config)
# Deploy acmetool to have TLS certificates. # Deploy acmetool to have TLS certificates.
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool( deploy_acmetool(
domains=tls_domains, domains=[mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"],
) )
apt.packages( apt.packages(
@@ -716,10 +527,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
packages="postfix", packages="postfix",
) )
if not "dovecot.service" in host.get_fact(SystemdEnabled): apt.packages(
_install_dovecot_package("core", host.get_fact(facts.server.Arch)) name="Install Dovecot",
_install_dovecot_package("imapd", host.get_fact(facts.server.Arch)) packages=["dovecot-imapd", "dovecot-lmtpd"],
_install_dovecot_package("lmtpd", host.get_fact(facts.server.Arch)) )
apt.packages( apt.packages(
name="Install nginx", name="Install nginx",
@@ -742,8 +553,8 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
debug = False debug = False
dovecot_need_restart = _configure_dovecot(config, debug=debug) dovecot_need_restart = _configure_dovecot(config, debug=debug)
postfix_need_restart = _configure_postfix(config, debug=debug) postfix_need_restart = _configure_postfix(config, debug=debug)
nginx_need_restart = _configure_nginx(config) mta_sts_need_restart = _install_mta_sts_daemon()
_uninstall_mta_sts_daemon() nginx_need_restart = _configure_nginx(mail_domain)
_remove_rspamd() _remove_rspamd()
opendkim_need_restart = _configure_opendkim(mail_domain, "opendkim") opendkim_need_restart = _configure_opendkim(mail_domain, "opendkim")
@@ -753,27 +564,35 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
service="opendkim.service", service="opendkim.service",
running=True, running=True,
enabled=True, enabled=True,
daemon_reload=opendkim_need_restart,
restarted=opendkim_need_restart, restarted=opendkim_need_restart,
) )
systemd.service(
name="Start and enable MTA-STS daemon",
service="mta-sts-daemon.service",
daemon_reload=True,
running=True,
enabled=True,
restarted=mta_sts_need_restart,
)
# Dovecot should be started before Postfix # Dovecot should be started before Postfix
# because it creates authentication socket # because it creates authentication socket
# required by Postfix. # required by Postfix.
systemd.service( systemd.service(
name="disable dovecot for now" if disable_mail else "Start and enable Dovecot", name="Start and enable Dovecot",
service="dovecot.service", service="dovecot.service",
running=False if disable_mail else True, running=True,
enabled=False if disable_mail else True, enabled=True,
restarted=dovecot_need_restart if not disable_mail else False, restarted=dovecot_need_restart,
) )
systemd.service( systemd.service(
name="disable postfix for now" if disable_mail else "Start and enable Postfix", name="Start and enable Postfix",
service="postfix.service", service="postfix.service",
running=False if disable_mail else True, running=True,
enabled=False if disable_mail else True, enabled=True,
restarted=postfix_need_restart if not disable_mail else False, restarted=postfix_need_restart,
) )
systemd.service( systemd.service(
@@ -806,29 +625,8 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
enabled=True, enabled=True,
restarted=journald_conf.changed, restarted=journald_conf.changed,
) )
files.directory(
name="Ensure old logs on disk are deleted",
path="/var/log/journal/",
present=False,
)
apt.packages( apt.packages(
name="Ensure cron is installed", name="Ensure cron is installed",
packages=["cron"], packages=["cron"],
) )
try:
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
except Exception:
git_hash = "unknown\n"
try:
git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception:
git_diff = ""
files.put(
name="Upload chatmail relay git commiit hash",
src=StringIO(git_hash + git_diff),
dest="/etc/chatmail-version",
mode="700",
)
deploy_mtail(config)

View File

@@ -70,6 +70,6 @@ def deploy_acmetool(email="", domains=[]):
) )
server.shell( server.shell(
name=f"Request certificate for: {', '.join(domains)}", name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want --xlog.severity=debug {' '.join(domains)}"], commands=[f"acmetool want --xlog.severity=debug { ' '.join(domains)}"],
) )

View File

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

View File

@@ -1,30 +1,21 @@
; {% if ipv4 %}
; Required DNS entries for chatmail servers {{ chatmail_domain }}. A {{ ipv4 }}
;
{% if A %}
{{ mail_domain }}. A {{ A }}
{% endif %} {% endif %}
{% if AAAA %} {% if ipv6 %}
{{ mail_domain }}. AAAA {{ AAAA }} {{ chatmail_domain }}. AAAA {{ ipv6 }}
{% endif %} {% endif %}
{{ mail_domain }}. MX 10 {{ mail_domain }}. {{ chatmail_domain }}. MX 10 {{ chatmail_domain }}.
_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}" _submission._tcp.{{ chatmail_domain }}. SRV 0 1 587 {{ chatmail_domain }}.
mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}. _submissions._tcp.{{ chatmail_domain }}. SRV 0 1 465 {{ chatmail_domain }}.
www.{{ mail_domain }}. CNAME {{ mail_domain }}. _imap._tcp.{{ chatmail_domain }}. SRV 0 1 143 {{ chatmail_domain }}.
{{ dkim_entry }} _imaps._tcp.{{ chatmail_domain }}. SRV 0 1 993 {{ chatmail_domain }}.
;
; Recommended DNS entries for interoperability and security-hardening
;
{{ mail_domain }}. TXT "v=spf1 a ~all"
_dmarc.{{ mail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
{% if acme_account_url %} {% if acme_account_url %}
{{ mail_domain }}. CAA 0 issue "letsencrypt.org;accounturi={{ acme_account_url }}" {{ chatmail_domain }}. CAA 128 issue "letsencrypt.org;accounturi={{ acme_account_url }}"
{% endif %} {% endif %}
_adsp._domainkey.{{ mail_domain }}. TXT "dkim=discardable" {{ chatmail_domain }}. TXT "v=spf1 a:{{ chatmail_domain }} ~all"
_dmarc.{{ chatmail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_submission._tcp.{{ mail_domain }}. SRV 0 1 587 {{ mail_domain }}. _mta-sts.{{ chatmail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
_submissions._tcp.{{ mail_domain }}. SRV 0 1 465 {{ mail_domain }}. mta-sts.{{ chatmail_domain }}. CNAME {{ chatmail_domain }}.
_imap._tcp.{{ mail_domain }}. SRV 0 1 143 {{ mail_domain }}. www.{{ chatmail_domain }}. CNAME {{ chatmail_domain }}.
_imaps._tcp.{{ mail_domain }}. SRV 0 1 993 {{ mail_domain }}. {{ dkim_entry }}
_adsp._domainkey.{{ chatmail_domain }}. TXT "dkim=discardable"

View File

@@ -7,19 +7,16 @@ import argparse
import importlib.resources import importlib.resources
import importlib.util import importlib.util
import os import os
import pathlib
import shutil import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import pyinfra
from chatmaild.config import read_config, write_initial_config from chatmaild.config import read_config, write_initial_config
from packaging import version
from termcolor import colored from termcolor import colored
from . import dns, remote from . import dns, remote_funcs
from .sshexec import SSHExec, Local from .sshexec import SSHExec
# #
# cmdeploy sub commands and options # cmdeploy sub commands and options
@@ -52,58 +49,30 @@ def run_cmd_options(parser):
action="store_true", action="store_true",
help="don't actually modify the server", help="don't actually modify the server",
) )
parser.add_argument(
"--disable-mail",
dest="disable_mail",
action="store_true",
help="install/upgrade the server, but disable postfix & dovecot for now",
)
parser.add_argument(
"--ssh-host",
dest="ssh_host",
help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default",
default=None,
)
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain remote_data = dns.get_initial_remote_data(args, out)
if ssh_host == "localhost":
sshexec = Local(ssh_host)
else:
sshexec = args.get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red): if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1 return 1
env = os.environ.copy() env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath env["CHATMAIL_INI"] = args.inipath
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve() deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
ssh_host = "@local" if ssh_host == "localhost" else f"--ssh-host {ssh_host}" cmd = f"{pyinf} --ssh-user root {args.config.mail_domain} {deploy_path}"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"):
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
return 1
try: retcode = out.check_call(cmd, env=env)
retcode = out.check_call(cmd, env=env) if retcode == 0:
if retcode == 0: out.green("Deploy completed, call `cmdeploy dns` next.")
out.green("Deploy completed, call `cmdeploy dns` next.") elif not remote_data["acme_account_url"]:
elif not remote_data["acme_account_url"]: out.red("Deploy completed but letsencrypt not configured")
out.red("Deploy completed but letsencrypt not configured") out.red("Run 'cmdeploy run' again")
out.red("Run 'cmdeploy run' again") retcode = 0
retcode = 0 else:
else:
out.red("Deploy failed")
except subprocess.CalledProcessError:
out.red("Deploy failed") out.red("Deploy failed")
retcode = 1
return retcode return retcode
@@ -111,37 +80,16 @@ def dns_cmd_options(parser):
parser.add_argument( parser.add_argument(
"--zonefile", "--zonefile",
dest="zonefile", dest="zonefile",
type=pathlib.Path, help="print the whole zonefile for deploying directly",
default=None,
help="write out a zonefile",
) )
def dns_cmd(args, out): def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
sshexec = args.get_sshexec() remote_data = dns.get_initial_remote_data(args, out)
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not remote_data: if not remote_data:
return 1 return 1
retcode = dns.show_dns(args, out, remote_data)
if not remote_data["acme_account_url"]:
out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
return 1
if not remote_data["dkim_entry"]:
out.red("could not determine dkim_entry, please run 'cmdeploy run'")
return 1
zonefile = dns.get_filled_zone_file(remote_data)
if args.zonefile:
args.zonefile.write_text(zonefile)
out.green(f"DNS records successfully written to: {args.zonefile}")
return 0
retcode = dns.check_full_zone(
sshexec, remote_data=remote_data, zonefile=zonefile, out=out
)
return retcode return retcode
@@ -156,7 +104,7 @@ def status_cmd(args, out):
else: else:
out.red("no privacy settings") out.red("no privacy settings")
for line in sshexec(remote.rshell.get_systemd_running): for line in sshexec(remote_funcs.get_systemd_running):
print(line) print(line)
@@ -335,9 +283,14 @@ def main(args=None):
if not hasattr(args, "func"): if not hasattr(args, "func"):
return parser.parse_args(["-h"]) return parser.parse_args(["-h"])
def get_sshexec(host): ssh_cache = []
print(f"[ssh] login to {host}")
return SSHExec(host, verbose=args.verbose) def get_sshexec():
if not ssh_cache:
print(f"[ssh] login to {args.config.mail_domain}")
ssh = SSHExec(args.config.mail_domain, remote_funcs, verbose=args.verbose)
ssh_cache.append(ssh)
return ssh_cache[0]
args.get_sshexec = get_sshexec args.get_sshexec = get_sshexec

View File

@@ -11,9 +11,8 @@ def main():
"CHATMAIL_INI", "CHATMAIL_INI",
importlib.resources.files("cmdeploy").joinpath("../../../chatmail.ini"), importlib.resources.files("cmdeploy").joinpath("../../../chatmail.ini"),
) )
disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL"))
deploy_chatmail(config_path, disable_mail) deploy_chatmail(config_path)
if pyinfra.is_cli: if pyinfra.is_cli:

View File

@@ -3,69 +3,75 @@ import importlib
from jinja2 import Template from jinja2 import Template
from . import remote from . import remote_funcs
def get_initial_remote_data(sshexec, mail_domain): def get_initial_remote_data(args, out):
sshexec = args.get_sshexec()
mail_domain = args.config.mail_domain
return sshexec.logged( return sshexec.logged(
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain) call=remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
) )
def check_initial_remote_data(remote_data, *, print=print): def check_initial_remote_data(remote_data, print=print):
mail_domain = remote_data["mail_domain"] mail_domain = remote_data["mail_domain"]
if not remote_data["A"] and not remote_data["AAAA"]: if not remote_data["A"] and not remote_data["AAAA"]:
print(f"Missing A and/or AAAA DNS records for {mail_domain}!") print("Missing A and/or AAAA DNS records for {mail_domain}!")
elif remote_data["MTA_STS"] != f"{mail_domain}.": elif not remote_data["MTA_STS"]:
print("Missing MTA-STS CNAME record:") print("Missing MTA-STS CNAME record:")
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.") print(f"mta-sts.{mail_domain}. CNAME {mail_domain}")
elif remote_data["WWW"] != f"{mail_domain}.":
print("Missing www CNAME record:")
print(f"www.{mail_domain}. CNAME {mail_domain}.")
else: else:
return remote_data return remote_data
def get_filled_zone_file(remote_data): def show_dns(args, out, remote_data) -> int:
sts_id = remote_data.get("sts_id")
if not sts_id:
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
content = template.read_text()
zonefile = Template(content).render(**remote_data)
lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
lines.append("")
zonefile = "\n".join(lines)
return zonefile
def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
"""Check existing DNS records, optionally write them to zone file """Check existing DNS records, optionally write them to zone file
and return (exitcode, remote_data) tuple.""" and return (exitcode, remote_data) tuple."""
required_diff, recommended_diff = sshexec.logged( sshexec = args.get_sshexec()
remote.rdns.check_zonefile,
kwargs=dict(zonefile=zonefile, mail_domain=remote_data["mail_domain"]), if not remote_data["acme_account_url"]:
out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
return 1
if not remote_data["dkim_entry"]:
out.red("could not determine dkim_entry, please run 'cmdeploy run'")
return 1
sts_id = remote_data.get("sts_id")
if not sts_id:
sts_id = datetime.datetime.now().strftime("%Y%m%d%H%M")
template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
content = template.read_text()
zonefile = Template(content).render(
acme_account_url=remote_data.get("acme_account_url"),
dkim_entry=remote_data["dkim_entry"],
ipv4=remote_data["A"],
ipv6=remote_data["AAAA"],
sts_id=sts_id,
chatmail_domain=args.config.mail_domain,
)
lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
lines.append("")
zonefile = "\n".join(lines)
diff_records = sshexec.logged(
remote_funcs.check_zonefile, kwargs=dict(zonefile=zonefile)
) )
returncode = 0 if getattr(args, "zonefile", None):
if required_diff: with open(args.zonefile, "w+") as zf:
out.red("Please set required DNS entries at your DNS provider:\n") zf.write(zonefile)
for line in required_diff: out.green(f"DNS records successfully written to: {args.zonefile}")
out(line) return 0
out("")
returncode = 1
if remote_data.get("dkim_entry") in required_diff:
out(
"If the DKIM entry above does not work with your DNS provider, you can try this one:\n"
)
out(remote_data.get("web_dkim_entry") + "\n")
if recommended_diff:
out("WARNING: these recommended DNS entries are not set:\n")
for line in recommended_diff:
out(line)
if not (recommended_diff or required_diff): if diff_records:
out.red("Please set the following DNS entries at your DNS provider:\n")
for line in diff_records:
out(line)
return 1
else:
out.green("Great! All your DNS entries are verified and correct.") out.green("Great! All your DNS entries are verified and correct.")
return returncode return 0

View File

@@ -1,9 +1,5 @@
## Dovecot configuration file ## Dovecot configuration file
{% if disable_ipv6 %}
listen = *
{% endif %}
protocols = imap lmtp protocols = imap lmtp
auth_mechanisms = plain auth_mechanisms = plain
@@ -51,7 +47,10 @@ mail_server_comment = Chatmail server
# <https://doc.dovecot.org/configuration_manual/quota_plugin/> # <https://doc.dovecot.org/configuration_manual/quota_plugin/>
mail_plugins = zlib quota mail_plugins = zlib quota
imap_capability = +XDELTAPUSH XCHATMAIL # 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 XCHATMAIL
# Authentication for system users. # Authentication for system users.
@@ -107,16 +106,10 @@ mail_attribute_dict = proxy:/run/chatmail-metadata/metadata.socket:metadata
# `imap_zlib` enables IMAP COMPRESS (RFC 4978). # `imap_zlib` enables IMAP COMPRESS (RFC 4978).
# <https://datatracker.ietf.org/doc/html/rfc4978.html> # <https://datatracker.ietf.org/doc/html/rfc4978.html>
protocol imap { protocol imap {
mail_plugins = $mail_plugins imap_zlib imap_quota last_login mail_plugins = $mail_plugins imap_zlib imap_quota
imap_metadata = yes imap_metadata = yes
} }
plugin {
last_login_dict = proxy:/run/chatmail-lastlogin/lastlogin.socket:lastlogin
#last_login_key = last-login/%u # default
last_login_precision = s
}
protocol lmtp { protocol lmtp {
# notify plugin is a dependency of push_notification plugin: # notify plugin is a dependency of push_notification plugin:
# <https://doc.dovecot.org/settings/plugin/notify-plugin/> # <https://doc.dovecot.org/settings/plugin/notify-plugin/>
@@ -141,7 +134,7 @@ plugin {
# for now we define static quota-rules for all users # for now we define static quota-rules for all users
quota = maildir:User quota quota = maildir:User quota
quota_rule = *:storage={{ config.max_mailbox_size }} quota_rule = *:storage={{ config.max_mailbox_size }}
quota_max_mail_size={{ config.max_message_size }} quota_max_mail_size=30M
quota_grace = 0 quota_grace = 0
# quota_over_flag_value = TRUE # quota_over_flag_value = TRUE
} }
@@ -177,72 +170,26 @@ service auth-worker {
} }
service imap-login { service imap-login {
# High-performance mode as described in # High-security mode.
# <https://doc.dovecot.org/2.3/admin_manual/login_processes/#high-performance-mode> # Each process serves a single connection and exits afterwards.
# # This is the default, but we set it explicitly to be sure.
# So-called high-security mode described in # See <https://doc.dovecot.org/admin_manual/login_processes/#high-security-mode> for details.
# <https://doc.dovecot.org/2.3/admin_manual/login_processes/#high-security-mode> service_count = 1
# and enabled by default with `service_count = 1` starts one process per connection
# and has problems logging in thousands of users after Dovecot restart.
service_count = 0
# Increase virtual memory size limit. # Inrease the number of simultaneous connections.
# Since imap-login processes handle TLS connections
# even after logging users in
# and many connections are handled by each process,
# memory size limit should be increased.
# #
# Otherwise the whole process eventually dies # As of Dovecot 2.3.19.1 the default is 100 processes.
# with an error similar to # Combined with `service_count = 1` it means only 100 connections
# imap-login: Fatal: master: service(imap-login): # can be handled simultaneously.
# child 1422951 returned error 83 process_limit = 10000
# (Out of memory (service imap-login { vsz_limit=256 MB },
# you may need to increase it)
# and takes down all its TLS connections at once.
vsz_limit = 1G
# Avoid startup latency for new connections. # Avoid startup latency for new connections.
#
# Should be set to at least the number of CPU cores
# according to the documentation.
process_min_avail = 10 process_min_avail = 10
} }
service anvil {
# We are disabling anvil penalty on failed login attempts
# because it can only detect brute forcing by IP address
# not by username. As the correct IP address is not handed
# to dovecot anyway, it is more of hindrance than of use.
# See <https://www.dovecot.org/list/dovecot/2012-May/135485.html> for details.
unix_listener anvil-auth-penalty {
mode = 0
}
}
ssl = required ssl = required
ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain
ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey
ssl_dh = </usr/share/dovecot/dh.pem ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.3 ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes ssl_prefer_server_ciphers = yes
{% if config.imap_rawlog %}
service postlogin {
executable = script-login -d rawlog
unix_listener postlogin {
}
}
service imap {
executable = imap postlogin
}
protocol imap {
#rawlog_dir = /tmp/rawlog/%u
# Put .in and .out imap protocol logging files into per-user homedir
# You can use a command like this to combine into one protocol stream:
# sort -sn <(sed 's/ / C: /' *.in) <(sed 's/ / S: /' cat *.out)
rawlog_dir = %h
}
{% endif %}

View File

@@ -1,5 +1,3 @@
# delete already seen big mails after 7 days, in the INBOX
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_large_after }} -size +200k -type f -delete
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox # delete all mails after {{ config.delete_mails_after }} days, in the Inbox
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete 2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# or in any IMAP subfolder # or in any IMAP subfolder

View File

@@ -2,6 +2,15 @@ function dovecot_lua_notify_begin_txn(user)
return user return user
end end
function contains(v, needle)
for _, keyword in ipairs(v) do
if keyword == needle then
return true
end
end
return false
end
function dovecot_lua_notify_event_message_new(user, event) function dovecot_lua_notify_event_message_new(user, event)
local mbox = user:mailbox(event.mailbox) local mbox = user:mailbox(event.mailbox)
mbox:sync() mbox:sync()

View File

@@ -1,12 +0,0 @@
[Unit]
Description=Iroh relay
[Service]
ExecStart=/usr/local/bin/iroh-relay --config-path /etc/iroh-relay.toml
Restart=on-failure
RestartSec=5s
User=iroh
Group=iroh
[Install]
WantedBy=multi-user.target

View File

@@ -1,5 +0,0 @@
enable_relay = true
http_bind_addr = "[::]:3340"
enable_stun = true
enable_metrics = false
metrics_bind_addr = "127.0.0.1:9092"

View File

@@ -1,64 +0,0 @@
counter delivered_mail
/saved mail to INBOX$/ {
delivered_mail++
}
counter quota_exceeded
/Quota exceeded \(mailbox for user is full\)$/ {
quota_exceeded++
}
# Essentially the number of outgoing messages.
counter dkim_signed
/DKIM-Signature field added/ {
dkim_signed++
}
counter created_accounts
counter created_ci_accounts
counter created_nonci_accounts
/: Created address: (?P<addr>.*)$/ {
created_accounts++
$addr =~ /ci-/ {
created_ci_accounts++
} else {
created_nonci_accounts++
}
}
counter postfix_timeouts
/timeout after DATA/ {
postfix_timeouts++
}
counter postfix_noqueue
/postfix\/.*NOQUEUE/ {
postfix_noqueue++
}
counter warning_count
/warning/ {
warning_count++
}
counter filtered_mail_count
counter encrypted_mail_count
/Filtering encrypted mail\./ {
encrypted_mail_count++
filtered_mail_count++
}
counter unencrypted_mail_count
/Filtering unencrypted mail\./ {
unencrypted_mail_count++
filtered_mail_count++
}
counter rejected_unencrypted_mail_count
/Rejected unencrypted mail\./ {
rejected_unencrypted_mail_count++
}

View File

@@ -1,10 +0,0 @@
[Unit]
Description=mtail
[Service]
Type=simple
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -"
Restart=on-failure
[Install]
WantedBy=multi-user.target

View File

@@ -2,40 +2,24 @@ load_module modules/ngx_stream_module.so;
user www-data; user www-data;
worker_processes auto; worker_processes auto;
# Increase the number of connections
# that a worker process can open
# to avoid errors such as
# accept4() failed (24: Too many open files)
# and
# socket() failed (24: Too many open files) while connecting to upstream
# in the logs.
# <https://nginx.org/en/docs/ngx_core_module.html#worker_rlimit_nofile>
worker_rlimit_nofile 2048;
pid /run/nginx.pid; pid /run/nginx.pid;
error_log syslog:server=unix:/dev/log,facility=local3; error_log syslog:server=unix:/dev/log,facility=local3;
events { events {
# Increase to avoid errors such as worker_connections 768;
# 768 worker_connections are not enough while connecting to upstream
# in the logs.
# <https://nginx.org/en/docs/ngx_core_module.html#worker_connections>
worker_connections 2048;
# multi_accept on; # multi_accept on;
} }
stream { stream {
map $ssl_preread_alpn_protocols $proxy { map $ssl_preread_alpn_protocols $proxy {
default 127.0.0.1:8443; default 127.0.0.1:8443;
~\bsmtp\b 127.0.0.1:465; ~\bsmtp\b 127.0.0.1:submissions;
~\bimap\b 127.0.0.1:993; ~\bimap\b 127.0.0.1:imaps;
} }
server { server {
listen 443; listen 443;
{% if not disable_ipv6 %}
listen [::]:443; listen [::]:443;
{% endif %}
proxy_pass $proxy; proxy_pass $proxy;
ssl_preread on; ssl_preread on;
} }
@@ -59,8 +43,8 @@ http {
gzip on; gzip on;
server { server {
listen 8443 ssl default_server;
listen 127.0.0.1:8443 ssl default_server; listen [::]:8443 ssl default_server;
root /var/www/html; root /var/www/html;
@@ -107,31 +91,12 @@ http {
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py; fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
} }
# Proxy to iroh-relay service.
location /relay {
proxy_pass http://127.0.0.1:3340;
proxy_http_version 1.1;
# Upgrade header is normally set to "iroh derp http" or "websocket".
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /relay/probe {
proxy_pass http://127.0.0.1:3340;
proxy_http_version 1.1;
}
location /generate_204 {
proxy_pass http://127.0.0.1:3340;
proxy_http_version 1.1;
}
} }
# Redirect www. to non-www # Redirect www. to non-www
server { server {
listen 127.0.0.1:8443 ssl; listen 8443 ssl;
listen [::]:8443 ssl;
server_name www.{{ config.domain_name }}; server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.domain_name }}$request_uri; return 301 $scheme://{{ config.domain_name }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;

View File

@@ -1,3 +0,0 @@
[Service]
Restart=always
RuntimeMaxSec=1d

View File

@@ -20,12 +20,9 @@ smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey
smtpd_tls_security_level=may smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=verify smtp_tls_security_level=may
# Send SNI extension when connecting to other servers.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = inline:{nauta.cu=may} smtp_tls_policy_maps = socketmap:inet:127.0.0.1:8461:postfix
smtpd_tls_protocols = >=TLSv1.2 smtpd_tls_protocols = >=TLSv1.2
# Disable anonymous cipher suites # Disable anonymous cipher suites
@@ -65,14 +62,11 @@ mydestination =
relayhost = relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0 mailbox_size_limit = 0
message_size_limit = {{config.max_message_size}} # maximum 30MB sized messages
message_size_limit = 31457280
recipient_delimiter = + recipient_delimiter = +
inet_interfaces = all inet_interfaces = all
{% if disable_ipv6 %}
inet_protocols = ipv4
{% else %}
inet_protocols = all inet_protocols = all
{% endif %}
virtual_transport = lmtp:unix:private/dovecot-lmtp virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }} virtual_mailbox_domains = {{ config.mail_domain }}

View File

@@ -14,11 +14,10 @@ smtp inet n - y - - smtpd -v
{%- else %} {%- else %}
smtp inet n - y - - smtpd smtp inet n - y - - smtpd
{%- endif %} {%- endif %}
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }} -o smtpd_milters=unix:opendkim/opendkim.sock
submission inet n - y - 5000 smtpd submission inet n - y - 5000 smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.3
-o smtpd_sasl_auth_enable=yes -o smtpd_sasl_auth_enable=yes
-o smtpd_sasl_type=dovecot -o smtpd_sasl_type=dovecot
-o smtpd_sasl_path=private/auth -o smtpd_sasl_path=private/auth
@@ -32,11 +31,11 @@ submission inet n - y - 5000 smtpd
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
-o cleanup_service_name=authclean
smtps inet n - y - 5000 smtpd smtps inet n - y - 5000 smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.3
-o smtpd_sasl_auth_enable=yes -o smtpd_sasl_auth_enable=yes
-o smtpd_sasl_type=dovecot -o smtpd_sasl_type=dovecot
-o smtpd_sasl_path=private/auth -o smtpd_sasl_path=private/auth
@@ -49,6 +48,7 @@ smtps inet n - y - 5000 smtpd
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit=1000
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
-o cleanup_service_name=authclean
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup cleanup unix n - y - 0 cleanup
@@ -76,27 +76,17 @@ anvil unix - - y - 1 anvil
scache unix - - y - 1 scache scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp filter unix - n n - - lmtp
# Local SMTP server for reinjecting outgoing filtered mail. # Local SMTP server for reinjecting filered mail.
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o smtpd_milters=unix:opendkim/opendkim.sock -o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean -o cleanup_service_name=authclean
# Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 10 smtpd
-o syslog_name=postfix/reinject_incoming
-o smtpd_milters=unix:opendkim/opendkim.sock
# Cleanup `Received` headers for authenticated mail # Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP. # to avoid leaking client IP.
# #
# We do not do this for received mails # We do not do this for received mails
# as this will break DKIM signatures # as this will break DKIM signatures
# if `Received` header is signed. # if `Received` header is signed.
#
# This service also rewrites
# Subject with `[...]`
# to make sure the users
# cannot send unprotected Subject.
authclean unix n - - - 0 cleanup authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup -o header_checks=regexp:/etc/postfix/submission_header_cleanup

View File

@@ -2,4 +2,3 @@
/^X-Originating-IP:/ IGNORE /^X-Originating-IP:/ IGNORE
/^X-Mailer:/ IGNORE /^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE /^User-Agent:/ IGNORE
/^Subject:/ REPLACE Subject: [...]

View File

@@ -1,12 +0,0 @@
"""
The 'cmdeploy.remote' sub package contains modules with remotely executing functions.
Its "_sshexec_bootstrap" module is executed remotely through `SSHExec`
and its main() loop there stays connected via a command channel,
ready to receive function invocations ("command") and return results.
"""
from . import rdns, rshell
__all__ = ["rdns", "rshell"]

View File

@@ -1,30 +0,0 @@
import builtins
import importlib
import traceback
## Function Execution server
def _run_loop(cmd_channel):
while cmd := cmd_channel.receive():
cmd_channel.send(_handle_one_request(cmd))
def _handle_one_request(cmd):
pymod_path, func_name, kwargs = cmd
try:
mod = importlib.import_module(pymod_path)
func = getattr(mod, func_name)
res = func(**kwargs)
return ("finish", res)
except:
data = traceback.format_exc()
return ("error", data)
def main(channel):
# enable simple "print" logging
builtins.print = lambda x="": channel.send(("log", x))
_run_loop(channel)

View File

@@ -1,38 +0,0 @@
from subprocess import DEVNULL, CalledProcessError, check_output
def shell(command, fail_ok=False):
print(f"$ {command}")
args = dict(shell=True)
if fail_ok:
args["stderr"] = DEVNULL
try:
return check_output(command, **args).decode().rstrip()
except CalledProcessError:
if not fail_ok:
raise
return ""
def get_systemd_running():
lines = shell("systemctl --type=service --state=running").split("\n")
return [line for line in lines if line.startswith(" ")]
def write_numbytes(path, num):
with open(path, "w") as f:
f.write("x" * num)
def dovecot_recalc_quota(user):
shell(f"doveadm quota recalc -u {user}")
output = shell(f"doveadm quota get -u {user}")
#
# Quota name Type Value Limit %
# User quota STORAGE 5 102400 0
# User quota MESSAGE 2 - 0
#
for line in output.split("\n"):
parts = line.split()
if parts[2] == "STORAGE":
return dict(value=int(parts[3]), limit=int(parts[4]), percent=int(parts[5]))

View File

@@ -11,29 +11,42 @@ All functions of this module
""" """
import re import re
import traceback
from subprocess import CalledProcessError, check_output
from .rshell import CalledProcessError, shell
def shell(command, fail_ok=False):
print(f"$ {command}")
try:
return check_output(command, shell=True).decode().rstrip()
except CalledProcessError:
if not fail_ok:
raise
return ""
def get_systemd_running():
lines = shell("systemctl --type=service --state=running").split("\n")
return [line for line in lines if line.startswith(" ")]
def perform_initial_checks(mail_domain): def perform_initial_checks(mail_domain):
"""Collecting initial DNS settings.""" """Collecting initial DNS zone content."""
assert mail_domain assert mail_domain
if not shell("dig", fail_ok=True):
shell("apt-get update && apt-get install -y dnsutils")
A = query_dns("A", mail_domain) A = query_dns("A", mail_domain)
AAAA = query_dns("AAAA", mail_domain) AAAA = query_dns("AAAA", mail_domain)
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}") MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
WWW = query_dns("CNAME", f"www.{mail_domain}")
res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS, WWW=WWW) res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS)
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True) if not MTA_STS or (not A and not AAAA):
res["dkim_entry"], res["web_dkim_entry"] = get_dkim_entry(
mail_domain, dkim_selector="opendkim"
)
if not MTA_STS or not WWW or (not A and not AAAA):
return res return res
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
if not shell("dig", fail_ok=True):
shell("apt-get install -y dnsutils")
shell(f"unbound-control flush_zone {mail_domain}", fail_ok=True)
res["dkim_entry"] = get_dkim_entry(mail_domain, dkim_selector="opendkim")
# parse out sts-id if exists, example: "v=STSv1; id=2090123" # parse out sts-id if exists, example: "v=STSv1; id=2090123"
parts = query_dns("TXT", f"_mta-sts.{mail_domain}").split("id=") parts = query_dns("TXT", f"_mta-sts.{mail_domain}").split("id=")
res["sts_id"] = parts[1].rstrip('"') if len(parts) == 2 else "" res["sts_id"] = parts[1].rstrip('"') if len(parts) == 2 else ""
@@ -50,45 +63,22 @@ def get_dkim_entry(mail_domain, dkim_selector):
return return
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s" dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw)) dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw)) return f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"'
return (
f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"',
f'{dkim_selector}._domainkey.{mail_domain}. TXT "{web_dkim_value}"',
)
def query_dns(typ, domain): def query_dns(typ, domain):
# Get autoritative nameserver from the SOA record. res = shell(f"dig -r -q {domain} -t {typ} +short")
soa_answers = [ print(res)
x.split()
for x in shell(f"dig -r -q {domain} -t SOA +noall +authority +answer").split(
"\n"
)
]
soa = [a for a in soa_answers if len(a) >= 3 and a[3] == "SOA"]
if not soa:
return
ns = soa[0][4]
# Query authoritative nameserver directly to bypass DNS cache.
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short")
if res: if res:
return res.split("\n")[0] return res.split("\n")[0]
return "" return ""
def check_zonefile(zonefile, mail_domain): def check_zonefile(zonefile):
"""Check expected zone file entries.""" """Check expected zone file entries."""
required = True diff = []
required_diff = []
recommended_diff = []
for zf_line in zonefile.splitlines(): for zf_line in zonefile.splitlines():
if "; Recommended" in zf_line:
required = False
continue
if not zf_line.strip() or zf_line.startswith(";"):
continue
print(f"dns-checking {zf_line!r}") print(f"dns-checking {zf_line!r}")
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2) zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".") zf_domain = zf_domain.rstrip(".")
@@ -96,9 +86,40 @@ def check_zonefile(zonefile, mail_domain):
query_value = query_dns(zf_typ, zf_domain) query_value = query_dns(zf_typ, zf_domain)
if zf_value != query_value: if zf_value != query_value:
assert zf_typ in ("A", "AAAA", "CNAME", "CAA", "SRV", "MX", "TXT"), zf_line assert zf_typ in ("A", "AAAA", "CNAME", "CAA", "SRV", "MX", "TXT"), zf_line
if required: diff.append(zf_line)
required_diff.append(zf_line)
else:
recommended_diff.append(zf_line)
return required_diff, recommended_diff return diff
## Function Execution server
def _run_loop(cmd_channel):
while 1:
cmd = cmd_channel.receive()
if cmd is None:
break
cmd_channel.send(_handle_one_request(cmd))
def _handle_one_request(cmd):
func_name, kwargs = cmd
try:
res = globals()[func_name](**kwargs)
return ("finish", res)
except:
data = traceback.format_exc()
return ("error", data)
# check if this module is executed remotely
# and setup a simple serialized function-execution loop
if __name__ == "__channelexec__":
channel = channel # noqa (channel object gets injected)
# enable simple "print" debugging for anyone changing this module
globals()["print"] = lambda x="": channel.send(("log", x))
_run_loop(channel)

View File

@@ -7,7 +7,6 @@ Restart=always
RestartSec=30 RestartSec=30
User=vmail User=vmail
RuntimeDirectory=chatmail-metadata RuntimeDirectory=chatmail-metadata
UMask=0077
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -7,7 +7,6 @@ Restart=always
RestartSec=30 RestartSec=30
User=vmail User=vmail
RuntimeDirectory=doveauth RuntimeDirectory=doveauth
UMask=0077
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -1,12 +0,0 @@
[Unit]
Description=Incoming Chatmail Postfix before queue filter
[Service]
ExecStart={execpath} {config_path} incoming
Restart=always
RestartSec=30
User=vmail
[Install]
WantedBy=multi-user.target

View File

@@ -1,11 +1,10 @@
[Unit] [Unit]
Description=Outgoing Chatmail Postfix before queue filter Description=Chatmail Postfix before queue filter
[Service] [Service]
ExecStart={execpath} {config_path} outgoing ExecStart={execpath} {config_path}
Restart=always Restart=always
RestartSec=30 RestartSec=30
User=vmail
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -1,12 +0,0 @@
[Unit]
Description=Dict proxy for last-login tracking
[Service]
ExecStart={execpath} /run/chatmail-lastlogin/lastlogin.socket {config_path}
Restart=always
RestartSec=30
User=vmail
RuntimeDirectory=chatmail-lastlogin
[Install]
WantedBy=multi-user.target

View File

@@ -1,99 +1,26 @@
import inspect
import os
import subprocess
import sys import sys
from queue import Queue
import execnet import execnet
from . import remote
class FuncError(Exception): class FuncError(Exception):
pass pass
def bootstrap_remote(gateway, remote=remote): class SSHExec:
"""Return a command channel which can execute remote functions.""" RemoteError = execnet.RemoteError
source_init_path = inspect.getfile(remote)
basedir = os.path.dirname(source_init_path)
name = os.path.basename(basedir)
# rsync sourcedir to remote host
remote_pkg_path = f"/root/from-cmdeploy/{name}"
q = Queue()
finish = lambda: q.put(None)
rsync = execnet.RSync(sourcedir=basedir, verbose=False)
rsync.add_target(gateway, remote_pkg_path, finishedcallback=finish, delete=True)
rsync.send()
q.get()
# start sshexec bootstrap and return its command channel
remote_sys_path = os.path.dirname(remote_pkg_path)
channel = gateway.remote_exec(
f"""
import sys
sys.path.insert(0, {remote_sys_path!r})
from remote._sshexec_bootstrap import main
main(channel)
"""
)
return channel
def print_stderr(item="", end="\n"):
print(item, file=sys.stderr, end=end)
class Exec:
FuncError = FuncError FuncError = FuncError
def __init__(self, host, verbose, timeout): def __init__(self, host, remote_funcs, verbose=False, python="python3", timeout=60):
self.host = host self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}")
self._remote_cmdloop_channel = self.gateway.remote_exec(remote_funcs)
self.timeout = timeout self.timeout = timeout
self.verbose = verbose self.verbose = verbose
def __call__(self, call, kwargs=None, log_callback=None):
return subprocess.check_output(call)
def logged(self, call, kwargs):
def log_progress(data):
sys.stderr.write(".")
sys.stderr.flush()
title = call.__doc__
if not title:
title = call.__name__
if self.verbose:
print_stderr("[ssh] " + title)
return self(call, kwargs, log_callback=print_stderr)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=log_progress)
print_stderr()
return res
class Local(Exec):
def __init__(self, host, verbose=False, timeout=60):
super().__init__(host, verbose, timeout)
class SSHExec(Exec):
RemoteError = execnet.RemoteError
def __init__(self, host, verbose=False, timeout=60):
super().__init__(host, verbose, timeout)
self.gateway = execnet.makegateway(f"ssh=root@{host}//python=python3")
self._remote_cmdloop_channel = bootstrap_remote(self.gateway, remote)
def __call__(self, call, kwargs=None, log_callback=None): def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None: if kwargs is None:
kwargs = {} kwargs = {}
assert call.__module__.startswith("cmdeploy.remote") self._remote_cmdloop_channel.send((call.__name__, kwargs))
modname = call.__module__.replace("cmdeploy.", "")
self._remote_cmdloop_channel.send((modname, call.__name__, kwargs))
while 1: while 1:
code, data = self._remote_cmdloop_channel.receive(timeout=self.timeout) code, data = self._remote_cmdloop_channel.receive(timeout=self.timeout)
if log_callback is not None and code == "log": if log_callback is not None and code == "log":
@@ -102,3 +29,20 @@ class SSHExec(Exec):
return data return data
elif code == "error": elif code == "error":
raise self.FuncError(data) raise self.FuncError(data)
def logged(self, call, kwargs):
def log_progress(data):
sys.stdout.write(".")
sys.stdout.flush()
title = call.__doc__
if not title:
title = call.__name__
if self.verbose:
print("[ssh] " + title)
return self(call, kwargs, log_callback=print)
else:
print(title, end="")
res = self(call, kwargs, log_callback=log_progress)
print()
return res

View File

@@ -1,17 +0,0 @@
; Required DNS entries for chatmail servers
zftest.testrun.org. A 135.181.204.127
zftest.testrun.org. AAAA 2a01:4f9:c012:52f4::1
zftest.testrun.org. MX 10 zftest.testrun.org.
_mta-sts.zftest.testrun.org. TXT "v=STSv1; id=202403211706"
mta-sts.zftest.testrun.org. CNAME zftest.testrun.org.
www.zftest.testrun.org. CNAME zftest.testrun.org.
opendkim._domainkey.zftest.testrun.org. TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s"
; Recommended DNS entries
_submission._tcp.zftest.testrun.org. SRV 0 1 587 zftest.testrun.org.
_submissions._tcp.zftest.testrun.org. SRV 0 1 465 zftest.testrun.org.
_imap._tcp.zftest.testrun.org. SRV 0 1 143 zftest.testrun.org.
_imaps._tcp.zftest.testrun.org. SRV 0 1 993 zftest.testrun.org.
zftest.testrun.org. CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
zftest.testrun.org. TXT "v=spf1 a:zftest.testrun.org ~all"
_dmarc.zftest.testrun.org. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_adsp._domainkey.zftest.testrun.org. TXT "dkim=discardable"

View File

@@ -37,24 +37,24 @@ class TestDC:
def test_ping_pong(self, benchmark, cmfactory): def test_ping_pong(self, benchmark, cmfactory):
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2) chat = cmfactory.get_accepted_chat(ac1, ac2)
def dc_ping_pong(): def dc_ping_pong():
chat.send_text("ping") chat.send_text("ping")
msg = ac2._evtracker.wait_next_incoming_message() msg = ac2.wait_next_incoming_message()
msg.chat.send_text("pong") msg.chat.send_text("pong")
ac1._evtracker.wait_next_incoming_message() ac1.wait_next_incoming_message()
benchmark(dc_ping_pong, 5) benchmark(dc_ping_pong, 5)
def test_send_10_receive_10(self, benchmark, cmfactory, lp): def test_send_10_receive_10(self, benchmark, cmfactory, lp):
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2) chat = cmfactory.get_accepted_chat(ac1, ac2)
def dc_send_10_receive_10(): def dc_send_10_receive_10():
for i in range(10): for i in range(10):
chat.send_text(f"hello {i}") chat.send_text(f"hello {i}")
for i in range(10): for i in range(10):
ac2._evtracker.wait_next_incoming_message() ac2.wait_next_incoming_message()
benchmark(dc_send_10_receive_10, 5) benchmark(dc_send_10_receive_10, 5)

View File

@@ -90,13 +90,8 @@ def test_concurrent_logins_same_account(
def test_no_vrfy(chatmail_config): def test_no_vrfy(chatmail_config):
domain = chatmail_config.mail_domain
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10) sock.connect((chatmail_config.mail_domain, 25))
try:
sock.connect((domain, 25))
except socket.timeout:
pytest.skip(f"port 25 not reachable for {domain}")
banner = sock.recv(1024) banner = sock.recv(1024)
print(banner) print(banner)
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),)) sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))

View File

@@ -1,77 +1,57 @@
import datetime
import smtplib import smtplib
import socket
import subprocess
import pytest import pytest
from cmdeploy import remote from cmdeploy import remote_funcs
from cmdeploy.sshexec import SSHExec from cmdeploy.sshexec import SSHExec
class TestSSHExecutor: class TestSSHExecutor:
@pytest.fixture(scope="class") @pytest.fixture(scope="class")
def sshexec(self, sshdomain): def sshexec(self, sshdomain):
return SSHExec(sshdomain) return SSHExec(sshdomain, remote_funcs)
def test_ls(self, sshexec): def test_ls(self, sshexec):
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls")) out = sshexec(call=remote_funcs.shell, kwargs=dict(command="ls"))
out2 = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls")) out2 = sshexec(call=remote_funcs.shell, kwargs=dict(command="ls"))
assert out == out2 assert out == out2
def test_perform_initial(self, sshexec, maildomain): def test_perform_initial(self, sshexec, maildomain):
res = sshexec( res = sshexec(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
assert res["A"] or res["AAAA"] assert res["A"] or res["AAAA"]
def test_logged(self, sshexec, maildomain, capsys): def test_logged(self, sshexec, maildomain, capsys):
sshexec.logged( sshexec.logged(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert err.startswith("Collecting") assert out.startswith("Collecting")
assert err.endswith("....\n") assert out.endswith("....\n")
assert err.count("\n") == 1 assert out.count("\n") == 1
sshexec.verbose = True sshexec.verbose = True
sshexec.logged( sshexec.logged(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
out, err = capsys.readouterr() out, err = capsys.readouterr()
lines = err.split("\n") lines = out.split("\n")
assert len(lines) > 4 assert len(lines) > 4
assert remote.rdns.perform_initial_checks.__doc__ in lines[0] assert remote_funcs.perform_initial_checks.__doc__ in lines[0]
def test_exception(self, sshexec, capsys): def test_exception(self, sshexec, capsys):
try: try:
sshexec.logged( sshexec.logged(
remote.rdns.perform_initial_checks, remote_funcs.perform_initial_checks,
kwargs=dict(mail_domain=None), kwargs=dict(mail_domain=None),
) )
except sshexec.FuncError as e: except sshexec.FuncError as e:
assert "rdns.py" in str(e) assert "remote_funcs.py" in str(e)
assert "AssertionError" in str(e) assert "AssertionError" in str(e)
else: else:
pytest.fail("didn't raise exception") pytest.fail("didn't raise exception")
def test_opendkim_restarted(self, sshexec):
"""check that opendkim is not running for longer than a day."""
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
datestring = out.split("=")[1]
since_date = datetime.datetime.strptime(datestring, "%a %Y-%m-%d %H:%M:%S %Z")
now = datetime.datetime.now(since_date.tzinfo)
assert (now - since_date).total_seconds() < 60 * 60 * 51
def test_timezone_env(remote):
for line in remote.iter_output("env"):
print(line)
if line == "tz=:/etc/localtime":
return True
pytest.fail("TZ is not set")
def test_remote(remote, imap_or_smtp): def test_remote(remote, imap_or_smtp):
lineproducer = remote.iter_output(imap_or_smtp.logcmd) lineproducer = remote.iter_output(imap_or_smtp.logcmd)
@@ -127,46 +107,14 @@ def test_authenticated_from(cmsetup, maildata):
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"]) @pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr): def test_reject_missing_dkim(cmsetup, maildata, from_addr):
domain = cmsetup.maildomain """Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
try:
sock.connect((domain, 25))
except socket.timeout:
pytest.skip(f"port 25 not reachable for {domain}")
recipient = cmsetup.gen_users(1)[0] recipient = cmsetup.gen_users(1)[0]
msg = maildata( msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr with smtplib.SMTP(cmsetup.maildomain, 25) as s:
).as_string()
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
with conn as s:
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"): with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg) s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
def test_rewrite_subject(cmsetup, maildata):
"""Test that subject gets replaced with [...]."""
user1, user2 = cmsetup.gen_users(2)
sent_msg = maildata(
"encrypted.eml",
from_addr=user1.addr,
to_addr=user2.addr,
subject="Unencrypted subject",
).as_string()
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user2.addr], msg=sent_msg)
messages = user2.imap.fetch_all_messages()
assert len(messages) == 1
rcvd_msg = messages[0]
assert "Subject: [...]" not in sent_msg
assert "Subject: [...]" in rcvd_msg
assert "Subject: Unencrypted subject" in sent_msg
assert "Subject: Unencrypted subject" not in rcvd_msg
@pytest.mark.slow @pytest.mark.slow
def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config): def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
"""Test that the per-account send-mail limit is exceeded.""" """Test that the per-account send-mail limit is exceeded."""
@@ -199,25 +147,6 @@ def test_expunged(remote, chatmail_config):
f"find {chatmail_config.mailboxes_dir} -path '*/tmp/*' -mtime +{outdated_days} -type f", f"find {chatmail_config.mailboxes_dir} -path '*/tmp/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f", f"find {chatmail_config.mailboxes_dir} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
] ]
outdated_days = int(chatmail_config.delete_large_after) + 1
find_cmds.append(
"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
)
for cmd in find_cmds: for cmd in find_cmds:
for line in remote.iter_output(cmd): for line in remote.iter_output(cmd):
assert not line assert not line
def test_deployed_state(remote):
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
git_diff = subprocess.check_output(["git", "diff"]).decode()
git_status = [git_hash.strip()]
for line in git_diff.splitlines():
git_status.append(line.strip().lower())
remote_version = []
for line in remote.iter_output("cat /etc/chatmail-version"):
print(line)
remote_version.append(line)
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
for i in range(len(remote_version)):
assert git_status[i] == remote_version[i], "You have undeployed changes."

View File

@@ -1,4 +1,5 @@
import ipaddress import ipaddress
import random
import re import re
import time import time
@@ -6,9 +7,6 @@ import imap_tools
import pytest import pytest
import requests import requests
from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec
@pytest.fixture @pytest.fixture
def imap_mailbox(cmfactory): def imap_mailbox(cmfactory):
@@ -56,23 +54,22 @@ class TestEndToEndDeltaChat:
"""Test that a DC account can send a message to a second DC account """Test that a DC account can send a message to a second DC account
on the same chat-mail instance.""" on the same chat-mail instance."""
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2) chat = cmfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: prepare and send text message to ac2")
chat.send_text("message0") chat.send_text("message0")
lp.sec("wait for ac2 to receive message") lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message() msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0" assert msg2.text == "message0"
def test_exceed_quota( @pytest.mark.slow
self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain def test_exceed_quota(self, cmfactory, lp, tmpdir, remote, chatmail_config):
):
"""This is a very slow test as it needs to upload >100MB of mail data """This is a very slow test as it needs to upload >100MB of mail data
before quota is exceeded, and thus depends on the speed of the upload. before quota is exceeded, and thus depends on the speed of the upload.
""" """
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2) chat = cmfactory.get_accepted_chat(ac1, ac2)
user = ac2.get_config("configured_addr")
def parse_size_limit(limit: str) -> int: def parse_size_limit(limit: str) -> int:
"""Parse a size limit and return the number of bytes as integer. """Parse a size limit and return the number of bytes as integer.
@@ -85,27 +82,49 @@ class TestEndToEndDeltaChat:
return int(float(number) * units[unit]) return int(float(number) * units[unit])
quota = parse_size_limit(chatmail_config.max_mailbox_size) quota = parse_size_limit(chatmail_config.max_mailbox_size)
attachsize = 1 * 1024 * 1024
num_to_send = quota // attachsize + 2
lp.sec(f"ac1: send {num_to_send} large files to ac2")
lp.indent(f"per-user quota is assumed to be: {quota/(1024*1024)}MB")
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
msgs = []
for i in range(num_to_send):
attachment = tmpdir / f"attachment{i}"
data = "".join(random.choice(alphanumeric) for i in range(1024))
with open(attachment, "w+") as f:
for j in range(attachsize // len(data)):
f.write(data)
lp.sec(f"filling remote inbox for {user}") msg = chat.send_file(str(attachment))
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2," msgs.append(msg)
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn) lp.indent(f"Sent out msg {i}, size {attachsize/(1024*1024)}MB")
sshexec = SSHExec(sshdomain)
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
assert res["percent"] >= 100
lp.sec("ac2: check quota is triggered") lp.sec("ac2: check messages are arriving until quota is reached")
starting = True addr = ac2.get_config("addr").lower()
saved_ok = 0
for line in remote.iter_output("journalctl -n0 -f -u dovecot"): for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
if starting: if addr not in line:
chat.send_text("hello")
starting = False
if user not in line:
# print(line) # print(line)
continue continue
if "quota exceeded" in line: if "quota" in line:
return if "quota exceeded" in line:
if saved_ok < num_to_send // 2:
pytest.fail(
f"quota exceeded too early: after {saved_ok} messages already"
)
lp.indent("good, message sending failed because quota was exceeded")
return
if (
"stored mail into mailbox 'inbox'" in line
or "saved mail to inbox" in line
):
saved_ok += 1
print(f"{saved_ok}: {line}")
if saved_ok >= num_to_send:
break
pytest.fail("sending succeeded although messages should exceed quota")
def test_securejoin(self, cmfactory, lp, maildomain2): def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False) ac1 = cmfactory.new_online_configuring_account(cache=False)
@@ -153,7 +172,7 @@ def test_hide_senders_ip_address(cmfactory):
assert ipaddress.ip_address(public_ip) assert ipaddress.ip_address(public_ip)
user1, user2 = cmfactory.get_online_accounts(2) user1, user2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(user1, user2) chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message() user2._evtracker.wait_next_incoming_message()
@@ -162,18 +181,11 @@ def test_hide_senders_ip_address(cmfactory):
assert public_ip not in msg.obj.as_string() assert public_ip not in msg.obj.as_string()
def test_echobot(cmfactory, chatmail_config, lp, sshdomain): def test_echobot(cmfactory, chatmail_config, lp):
ac = cmfactory.get_online_accounts(1)[0] ac = cmfactory.get_online_accounts(1)[0]
# establish contact with echobot lp.sec(f"Send message to echo@{chatmail_config.mail_domain}")
sshexec = SSHExec(sshdomain) chat = ac.create_chat(f"echo@{chatmail_config.mail_domain}")
command = "cat /var/lib/echobot/invite-link.txt"
echo_invite_link = sshexec(call=rshell.shell, kwargs=dict(command=command))
chat = ac.qr_setup_contact(echo_invite_link)
ac._evtracker.wait_securejoin_joiner_progress(1000)
# send message and check it gets replied back
lp.sec("Send message to echobot")
text = "hi, I hope you text me back" text = "hi, I hope you text me back"
chat.send_text(text) chat.send_text(text)
lp.sec("Wait for reply from echobot") lp.sec("Wait for reply from echobot")

View File

@@ -10,6 +10,7 @@ from pathlib import Path
import pytest import pytest
from chatmaild.config import read_config from chatmaild.config import read_config
from chatmaild.database import Database
conftestdir = Path(__file__).parent conftestdir = Path(__file__).parent
@@ -62,7 +63,7 @@ def sshdomain(maildomain):
def maildomain2(): def maildomain2():
domain = os.environ.get("CHATMAIL_DOMAIN2") domain = os.environ.get("CHATMAIL_DOMAIN2")
if not domain: if not domain:
pytest.skip("set CHATMAIL_DOMAIN2 to a second chatmail server") pytest.skip("set CHATMAIL_DOMAIN2 to a ssh-reachable chatmail instance")
return domain return domain
@@ -78,17 +79,6 @@ def pytest_report_header():
return ["-" * len(text), text, "-" * len(text)] return ["-" * len(text), text, "-" * len(text)]
@pytest.fixture
def cm_data(request):
datadir = request.fspath.dirpath("data")
class CMData:
def get(self, name):
return datadir.join(name).read()
return CMData()
@pytest.fixture @pytest.fixture
def benchmark(request): def benchmark(request):
def bench(func, num, name=None, reportfunc=None): def bench(func, num, name=None, reportfunc=None):
@@ -261,6 +251,13 @@ def gencreds(chatmail_config):
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
# #
# Delta Chat testplugin re-use # Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts # use the cmfactory fixture to get chatmail instance accounts
@@ -302,13 +299,10 @@ def cmfactory(request, gencreds, tmpdir, maildomain):
pytest.importorskip("deltachat") pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory from deltachat.testplugin import ACFactory
data = request.getfixturevalue("data")
testproc = ChatmailTestProcess(request.config, maildomain, gencreds) testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=data)
class Data:
def read_path(self, path):
return
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
# nb. a bit hacky # nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support # would probably be better if deltachat's test machinery grows native support

View File

@@ -1,50 +1,36 @@
import pytest import pytest
from cmdeploy import remote from cmdeploy import remote_funcs
from cmdeploy.dns import check_full_zone, check_initial_remote_data from cmdeploy.dns import check_initial_remote_data
@pytest.fixture
def mockdns_base(monkeypatch):
qdict = {}
def query_dns(typ, domain):
try:
return qdict[typ][domain]
except KeyError:
return ""
monkeypatch.setattr(remote.rdns, query_dns.__name__, query_dns)
return qdict
@pytest.fixture
def mockdns(mockdns_base):
mockdns_base.update(
{
"A": {"some.domain": "1.1.1.1"},
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
"CNAME": {
"mta-sts.some.domain": "some.domain.",
"www.some.domain": "some.domain.",
},
}
)
return mockdns_base
class TestPerformInitialChecks: class TestPerformInitialChecks:
@pytest.fixture
def mockdns(self, monkeypatch):
qdict = {
"A": {"some.domain": "1.1.1.1"},
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
"CNAME": {"mta-sts.some.domain": "some.domain"},
}.copy()
def query_dns(typ, domain):
try:
return qdict[typ][domain]
except KeyError:
return ""
monkeypatch.setattr(remote_funcs, query_dns.__name__, query_dns)
return qdict
def test_perform_initial_checks_ok1(self, mockdns): def test_perform_initial_checks_ok1(self, mockdns):
remote_data = remote.rdns.perform_initial_checks("some.domain") remote_data = remote_funcs.perform_initial_checks("some.domain")
assert remote_data["A"] == mockdns["A"]["some.domain"] assert len(remote_data) == 7
assert remote_data["AAAA"] == mockdns["AAAA"]["some.domain"]
assert remote_data["MTA_STS"] == mockdns["CNAME"]["mta-sts.some.domain"]
assert remote_data["WWW"] == mockdns["CNAME"]["www.some.domain"]
@pytest.mark.parametrize("drop", ["A", "AAAA"]) @pytest.mark.parametrize("drop", ["A", "AAAA"])
def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop): def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop):
del mockdns[drop] del mockdns[drop]
remote_data = remote.rdns.perform_initial_checks("some.domain") remote_data = remote_funcs.perform_initial_checks("some.domain")
assert len(remote_data) == 7
assert not remote_data[drop] assert not remote_data[drop]
l = [] l = []
@@ -53,75 +39,12 @@ class TestPerformInitialChecks:
assert not l assert not l
def test_perform_initial_checks_no_mta_sts(self, mockdns): def test_perform_initial_checks_no_mta_sts(self, mockdns):
del mockdns["CNAME"]["mta-sts.some.domain"] del mockdns["CNAME"]
remote_data = remote.rdns.perform_initial_checks("some.domain") remote_data = remote_funcs.perform_initial_checks("some.domain")
assert len(remote_data) == 4
assert not remote_data["MTA_STS"] assert not remote_data["MTA_STS"]
l = [] l = []
res = check_initial_remote_data(remote_data, print=l.append) res = check_initial_remote_data(remote_data, print=l.append)
assert not res assert not res
assert len(l) == 2 assert len(l) == 2
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
for zf_line in zonefile.split("\n"):
if zf_line.startswith("#"):
if "Recommended" in zf_line and only_required:
return
continue
if not zf_line.strip():
continue
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip()
mockdns_base.setdefault(zf_typ, {})[zf_domain] = zf_value
class MockSSHExec:
def logged(self, func, kwargs):
return func(**kwargs)
def call(self, func, kwargs):
return func(**kwargs)
class TestZonefileChecks:
def test_check_zonefile_all_ok(self, cm_data, mockdns_base):
zonefile = cm_data.get("zftest.zone")
parse_zonefile_into_dict(zonefile, mockdns_base)
required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile, "some.domain"
)
assert not required_diff and not recommended_diff
def test_check_zonefile_recommended_not_set(self, cm_data, mockdns_base):
zonefile = cm_data.get("zftest.zone")
zonefile_mocked = zonefile.split("; Recommended")[0]
parse_zonefile_into_dict(zonefile_mocked, mockdns_base)
required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile, "some.domain"
)
assert not required_diff
assert len(recommended_diff) == 8
def test_check_zonefile_output_required_fine(self, cm_data, mockdns_base, mockout):
zonefile = cm_data.get("zftest.zone")
zonefile_mocked = zonefile.split("; Recommended")[0]
parse_zonefile_into_dict(zonefile_mocked, mockdns_base, only_required=True)
mssh = MockSSHExec()
mockdns_base["mail_domain"] = "some.domain"
res = check_full_zone(mssh, mockdns_base, out=mockout, zonefile=zonefile)
assert res == 0
assert "WARNING" in mockout.captured_plain[0]
assert len(mockout.captured_plain) == 9
def test_check_zonefile_output_full(self, cm_data, mockdns_base, mockout):
zonefile = cm_data.get("zftest.zone")
parse_zonefile_into_dict(zonefile, mockdns_base)
mssh = MockSSHExec()
mockdns_base["mail_domain"] = "some.domain"
res = check_full_zone(mssh, mockdns_base, out=mockout, zonefile=zonefile)
assert res == 0
assert not mockout.captured_red
assert "correct" in mockout.captured_green[0]
assert not mockout.captured_red

View File

@@ -1,23 +1,5 @@
#!/bin/sh #!/bin/sh
set -e set -e
if command -v lsb_release 2>&1 >/dev/null; then
case "$(lsb_release -is)" in
Ubuntu | Debian )
if ! dpkg -l | grep python3-dev 2>&1 >/dev/null
then
echo "You need to install python3-dev for installing the other dependencies."
exit 1
fi
if ! gcc --version 2>&1 >/dev/null
then
echo "You need to install gcc for building Python dependencies."
exit 1
fi
;;
esac
fi
python3 -m venv --upgrade-deps venv python3 -m venv --upgrade-deps venv
venv/bin/pip install -e chatmaild venv/bin/pip install -e chatmaild

View File

@@ -11,11 +11,7 @@ for Delta Chat users. For details how it avoids storing personal information
please see our [privacy policy](privacy.html). please see our [privacy policy](privacy.html).
{% endif %} {% endif %}
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a> 👉 **Tap** or scan this QR code to get a `@{{config.mail_domain}}` chat profile
If you are viewing this page on a different device
without a Delta Chat app,
you can also **scan this QR code** with Delta Chat:
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new"> <a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a> <img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>

View File

@@ -6,6 +6,31 @@ interoperable e-mail service for everyone. What's behind a `chatmail` is
effectively a normal e-mail address just like any other but optimized effectively a normal e-mail address just like any other but optimized
for the usage in chats, especially DeltaChat. 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 `I already have a profile`
and fill the two fields like this:
- `Address`: invent a word with
{% if username_min_length == username_max_length %}
*exactly* {{ username_min_length }}
{% else %}
{{ username_min_length}}
{% if username_max_length == "more" %}
or more
{% else %}
to {{ username_max_length }}
{% endif %}
{% endif %}
characters
and append `@{{config.mail_domain}}` to it.
- `Password`: invent at least {{ password_min_length }} characters.
If the e-mail address is not yet taken, you'll get that account.
The first login sets your password.
### Rate and storage limits ### Rate and storage limits
@@ -15,25 +40,10 @@ for the usage in chats, especially DeltaChat.
- You may send up to {{ config.max_user_send_per_minute }} messages per minute. - You may send up to {{ config.max_user_send_per_minute }} messages per minute.
- Messages are unconditionally removed {{ config.delete_mails_after }} days after arriving on the server.
- You can store up to [{{ config.max_mailbox_size }} messages on the server](https://delta.chat/en/help#what-happens-if-i-turn-on-delete-old-messages-from-server). - You can store up to [{{ config.max_mailbox_size }} messages on the server](https://delta.chat/en/help#what-happens-if-i-turn-on-delete-old-messages-from-server).
- Messages are unconditionally removed latest {{ config.delete_mails_after }} days after arriving on the server.
Earlier, if storage may exceed otherwise.
### <a name="account-deletion"></a> Account deletion
If you remove a {{ config.mail_domain }} profile from within the Delta Chat app,
then the according account on the server, along with all associated data,
is automatically deleted {{ config.delete_inactive_users_after }} days afterwards.
If you use multiple devices
then you need to remove the according chat profile from each device
in order for all account data to be removed on the server side.
If you have any further questions or requests regarding account deletion
please send a message from your account to {{ config.privacy_mail }}.
### Who are the operators? Which software is running? ### Who are the operators? Which software is running?

View File

@@ -72,15 +72,3 @@ code {
color: red; color: red;
font-weight: bold; font-weight: bold;
} }
.cta-button, .cta-button:hover, .cta-button:visited {
border: 1.5px solid #a4c2d0;
border-radius: 5px;
padding: 10px;
display: inline-block;
margin: 10px 0;
background: linear-gradient(120deg, #77888f, #364e59);
color: white !important;
font-weight: bold;
}

View File

@@ -3,8 +3,8 @@
{% if config.mail_domain == "nine.testrun.org" %} {% if config.mail_domain == "nine.testrun.org" %}
Welcome to `{{config.mail_domain}}`, the default chatmail onboarding server for Delta Chat users. 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 It is operated on the side by a small sysops team employed by [merlinux](https://merlinux.eu),
on a voluntary basis. 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. See [other chatmail servers](https://delta.chat/en/chatmail) for alternative server operators.
{% endif %} {% endif %}
@@ -23,22 +23,18 @@ A chatmail server behaves more like the Signal messaging server
but does not know about phone numbers and securely and automatically interoperates but does not know about phone numbers and securely and automatically interoperates
with other chatmail and classic e-mail servers. with other chatmail and classic e-mail servers.
Unlike classic e-mail servers, this chatmail server In particular, this chatmail server
- unconditionally removes messages after {{ config.delete_mails_after }} days, - unconditionally removes messages after {{ config.delete_mails_after }} days,
- prohibits sending out un-encrypted messages, - prohibits sending out un-encrypted messages,
- does not store Internet addresses ("IP addresses"), - only has temporary log files used for debugging purposes.
- does not process IP addresses in relation to email addresses.
Due to the resulting lack of personal data processing
this chatmail server may not require a privacy policy.
Nevertheless, we provide legal details below to make life easier
for data protection specialists and lawyers scrutinizing chatmail operations.
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.
## 1. Name and contact information ## 1. Name and contact information
@@ -58,18 +54,18 @@ We have appointed a data protection officer:
## 2. Processing when using chat e-mail services ## 2. Processing when using chat e-mail services
We provide services optimized for the use from [Delta Chat](https://delta.chat) apps We provide e-mail services optimized for the use from [Delta Chat](https://delta.chat) apps
and process only the data necessary and process only the data necessary
for the setup and technical execution of message delivery. for the setup and technical execution of the e-mail dispatch.
The purpose of the processing is that users can The purpose of the processing is to
read, write, manage, delete, send, and receive chat messages. read, write, manage, delete, send, and receive emails.
For this purpose, For this purpose,
we operate server-side software we operate server-side software
that enables us to send and receive messages. that enables us to send and receive e-mail messages.
Allowing the use of the e-mail service,
we process the following data and details:
We process the following data and details: - Outgoing and incoming messages (SMTP) are stored for transit
- Outgoing and incoming messages (SMTP) are stored for transit
on behalf of their users until the message can be delivered. on behalf of their users until the message can be delivered.
- E-Mail-Messages are stored for the recipient and made accessible via IMAP protocols, - E-Mail-Messages are stored for the recipient and made accessible via IMAP protocols,
@@ -78,15 +74,9 @@ We process the following data and details:
- IMAP and SMTP protocols are password protected with unique credentials for each account. - IMAP and SMTP protocols are password protected with unique credentials for each account.
- Users can retrieve or delete all stored messages - Users can retrieve or delete all stored messages
without intervention from the operators using standard IMAP client tools. without intervention from the operators using standard IMAP client tools.
- Users can connect to a "realtime relay service"
to establish Peer-to-Peer connection between user devices,
allowing them to send and retrieve ephemeral messages
which are never stored on the chatmail server, also not in encrypted form.
### 2.1 Account setup ### 2.1 Account setup
Creating an account happens in one of two ways on our mail servers: Creating an account happens in one of two ways on our mail servers:
@@ -263,7 +253,7 @@ is the `{{ config.privacy_supervisor }}`.
## 6. Validity of this privacy policy ## 6. Validity of this privacy policy
This data protection declaration is valid This data protection declaration is valid
as of *October 2024*. as of *December 2023*.
Due to the further development of our service and offers Due to the further development of our service and offers
or due to changed legal or official requirements, or due to changed legal or official requirements,
it may become necessary to revise this data protection declaration from time to time. it may become necessary to revise this data protection declaration from time to time.