mirror of
https://github.com/chatmail/relay.git
synced 2026-05-10 16:04:37 +00:00
Compare commits
149 Commits
prepare_11
...
hagi/#318-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eae1657de | ||
|
|
736c67ac1f | ||
|
|
295072e57b | ||
|
|
dc17088517 | ||
|
|
514a063142 | ||
|
|
2b96586e12 | ||
|
|
8fde4d929d | ||
|
|
683aefa37c | ||
|
|
b951ec12c5 | ||
|
|
3d8ac6b598 | ||
|
|
9515a37687 | ||
|
|
b5d0b0ad9a | ||
|
|
3f4989223d | ||
|
|
9be0408ab8 | ||
|
|
6b59b8be44 | ||
|
|
07ffc003e4 | ||
|
|
4cb62df33f | ||
|
|
ef58f011fb | ||
|
|
f7ef236ac8 | ||
|
|
dbe906a331 | ||
|
|
3899f41c61 | ||
|
|
57c29c14a4 | ||
|
|
2b5d903cc5 | ||
|
|
c8d270a853 | ||
|
|
72f4e9edbf | ||
|
|
1ce0a2b0ba | ||
|
|
044ebfb9a2 | ||
|
|
a41b034aa2 | ||
|
|
e00f0b852d | ||
|
|
501b12564c | ||
|
|
229ad15a28 | ||
|
|
e4f35d8dae | ||
|
|
4271573e15 | ||
|
|
b651a9046b | ||
|
|
6b84eaf8af | ||
|
|
1b076bcd22 | ||
|
|
30437f6c46 | ||
|
|
3171e40a26 | ||
|
|
61c915995b | ||
|
|
073bd86344 | ||
|
|
777a7addd2 | ||
|
|
4f28476c47 | ||
|
|
b05aec72c2 | ||
|
|
610675452e | ||
|
|
83387f5d08 | ||
|
|
142206529c | ||
|
|
c0f200b1a9 | ||
|
|
6d55f75bee | ||
|
|
c68cbf1806 | ||
|
|
9677617c7f | ||
|
|
d8cf282953 | ||
|
|
b959f57058 | ||
|
|
8768e6fd0b | ||
|
|
acbf370383 | ||
|
|
80dfdaee06 | ||
|
|
4d15ae9452 | ||
|
|
9a68d42ee8 | ||
|
|
d732d099ac | ||
|
|
582a2af799 | ||
|
|
fba3963d47 | ||
|
|
e80d33e2e0 | ||
|
|
6a3001bf22 | ||
|
|
368c41ba27 | ||
|
|
fa0d8432bc | ||
|
|
2811e08563 | ||
|
|
846a4066d8 | ||
|
|
6e1477666e | ||
|
|
013def94f9 | ||
|
|
468bb04149 | ||
|
|
30a23dad17 | ||
|
|
17af249f90 | ||
|
|
4e65291304 | ||
|
|
505ad36b36 | ||
|
|
dcb614911a | ||
|
|
e06c3631b2 | ||
|
|
da236e6e1b | ||
|
|
2796730a87 | ||
|
|
f32e18c32a | ||
|
|
1a5fd331b6 | ||
|
|
772b86a4b5 | ||
|
|
e0013b9bee | ||
|
|
127d9d6460 | ||
|
|
cb7de8019b | ||
|
|
2b5b06316d | ||
|
|
76b56d7b78 | ||
|
|
c1163228f6 | ||
|
|
8af825d7ea | ||
|
|
0a968aae93 | ||
|
|
879cffc056 | ||
|
|
462e92cca0 | ||
|
|
e1b1a945b1 | ||
|
|
0493e27312 | ||
|
|
e4f8c78efe | ||
|
|
e2cbf4e3e4 | ||
|
|
f35d98bb40 | ||
|
|
7ce1a5e841 | ||
|
|
0a72c2fba7 | ||
|
|
824f70f463 | ||
|
|
39f5f64998 | ||
|
|
1752803199 | ||
|
|
e372599ce7 | ||
|
|
ce9fb02a75 | ||
|
|
4526f5e772 | ||
|
|
616a42c8f3 | ||
|
|
ecb5ef8a10 | ||
|
|
824c3dc1d7 | ||
|
|
9b76d46558 | ||
|
|
cc4920ddc7 | ||
|
|
2af10175fa | ||
|
|
ae455fa9e1 | ||
|
|
60d7e516dd | ||
|
|
bf18905e02 | ||
|
|
4d6f520f18 | ||
|
|
9da626dfc8 | ||
|
|
1cca9aa441 | ||
|
|
3d054847a0 | ||
|
|
a31d998e67 | ||
|
|
d313bea97f | ||
|
|
da04226594 | ||
|
|
eb2de26638 | ||
|
|
f5652cdbc4 | ||
|
|
13172c92f3 | ||
|
|
09df636183 | ||
|
|
2b45ace3ba | ||
|
|
9e05a7d1eb | ||
|
|
21e7c09c43 | ||
|
|
14d96e0a9b | ||
|
|
459ffcabd6 | ||
|
|
75cc3fdab0 | ||
|
|
2d26a40c2b | ||
|
|
a78d4e6198 | ||
|
|
2a1e004962 | ||
|
|
5e55cc205d | ||
|
|
476c732373 | ||
|
|
71c50b7936 | ||
|
|
79cb390f16 | ||
|
|
c1452c9c6f | ||
|
|
6e903d7498 | ||
|
|
221f4a2b0c | ||
|
|
080ae058d8 | ||
|
|
edb84c0b3b | ||
|
|
04ef477d51 | ||
|
|
5696788d3a | ||
|
|
1c2bf919ed | ||
|
|
d15c22c1e8 | ||
|
|
9c6e90ae27 | ||
|
|
481791c277 | ||
|
|
a25c7981f9 | ||
|
|
53519f2865 |
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
name: isolated chatmaild tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: run chatmaild tests
|
||||
working-directory: chatmaild
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: deploy-chatmail tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: initenv
|
||||
run: scripts/initenv.sh
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
;; Zone file for staging.testrun.org
|
||||
;; Zone file for staging2.testrun.org
|
||||
|
||||
$ORIGIN staging.testrun.org.
|
||||
$ORIGIN staging2.testrun.org.
|
||||
$TTL 300
|
||||
|
||||
@ IN SOA ns.testrun.org. root.nine.testrun.org (
|
||||
@@ -15,6 +15,7 @@ $TTL 300
|
||||
@ IN NS ns.testrun.org.
|
||||
|
||||
;; DNS records.
|
||||
@ IN A 37.27.37.98
|
||||
mta-sts.staging.testrun.org. CNAME staging.testrun.org.
|
||||
www.staging.testrun.org. CNAME staging.testrun.org.
|
||||
@ IN A 37.27.24.139
|
||||
mta-sts.staging2.testrun.org. CNAME staging2.testrun.org.
|
||||
www.staging2.testrun.org. CNAME staging2.testrun.org.
|
||||
|
||||
|
||||
74
.github/workflows/test-and-deploy.yaml
vendored
74
.github/workflows/test-and-deploy.yaml
vendored
@@ -1,67 +1,87 @@
|
||||
name: deploy on staging.testrun.org, and run tests
|
||||
name: deploy on staging2.testrun.org, and run tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- staging-ci
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'scripts/**'
|
||||
- '**/README.md'
|
||||
- 'CHANGELOG.md'
|
||||
- 'LICENSE'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy on staging.testrun.org, and run tests
|
||||
name: deploy on staging2.testrun.org, and run tests
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: staging-deploy
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: prepare SSH
|
||||
run: |
|
||||
mkdir ~/.ssh
|
||||
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan staging.testrun.org > ~/.ssh/known_hosts
|
||||
# rsync -avz root@staging.testrun.org:/var/lib/acme . || true
|
||||
# rsync -avz root@staging.testrun.org:/var/lib/rspamd/dkim . || true
|
||||
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
|
||||
# save previous acme & dkim state
|
||||
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
|
||||
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
|
||||
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
|
||||
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
|
||||
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
|
||||
# make sure CAA record isn't set
|
||||
ssh -o StrictHostKeyChecking=accept-new root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org systemctl reload nsd
|
||||
|
||||
#- name: rebuild staging.testrun.org to have a clean VPS
|
||||
# run: |
|
||||
# curl -X POST \
|
||||
# -H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{"image":"debian-12"}' \
|
||||
# "https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
|
||||
- name: rebuild staging2.testrun.org to have a clean VPS
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"image":"debian-12"}' \
|
||||
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
|
||||
|
||||
- run: scripts/initenv.sh
|
||||
|
||||
- name: append venv/bin to PATH
|
||||
run: echo venv/bin >>$GITHUB_PATH
|
||||
|
||||
- name: upload TLS cert after rebuilding
|
||||
run: |
|
||||
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
|
||||
rm ~/.ssh/known_hosts
|
||||
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
|
||||
# download acme & dkim state from ns.testrun.org
|
||||
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
|
||||
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
|
||||
# restore acme & dkim state to staging2.testrun.org
|
||||
rsync -avz acme-restore/acme/ root@staging2.testrun.org:/var/lib/acme || true
|
||||
rsync -avz dkimkeys-restore/dkimkeys/ root@staging2.testrun.org:/etc/dkimkeys || true
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
|
||||
|
||||
- name: run formatting checks
|
||||
run: cmdeploy fmt -v
|
||||
|
||||
- name: run deploy-chatmail offline tests
|
||||
run: pytest --pyargs cmdeploy
|
||||
|
||||
#- name: upload TLS cert after rebuilding
|
||||
# run: |
|
||||
# echo " --- wait until staging.testrun.org VPS is rebuilt --- "
|
||||
# rm ~/.ssh/known_hosts
|
||||
# while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u ; do sleep 1 ; done
|
||||
# ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u
|
||||
# rsync -avz acme root@staging.testrun.org:/var/lib/ || true
|
||||
# rsync -avz dkim root@staging.testrun.org:/var/lib/rspamd/ || true
|
||||
|
||||
- run: cmdeploy init staging.testrun.org
|
||||
- run: cmdeploy init staging2.testrun.org
|
||||
|
||||
- run: cmdeploy run
|
||||
|
||||
- name: set DNS entries
|
||||
run: |
|
||||
#ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown _rspamd:_rspamd -R /var/lib/rspamd/dkim
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
|
||||
cmdeploy dns --zonefile staging-generated.zone
|
||||
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
|
||||
cat .github/workflows/staging.testrun.org-default.zone
|
||||
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging.testrun.org.zone
|
||||
ssh root@ns.testrun.org nsd-checkzone staging.testrun.org /etc/nsd/staging.testrun.org.zone
|
||||
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org systemctl reload nsd
|
||||
|
||||
- name: cmdeploy test
|
||||
|
||||
77
CHANGELOG.md
77
CHANGELOG.md
@@ -1,8 +1,81 @@
|
||||
# Changelog for chatmail deployment
|
||||
|
||||
## unreleased
|
||||
## untagged
|
||||
|
||||
### Changes since March 15th, 2024
|
||||
- replace crypt with passlib, as crypt will be deprecated in Python 3.13
|
||||
([#319](https://github.com/deltachat/chatmail/pull/319))
|
||||
|
||||
- Reject DKIM signatures that do not cover the whole message body.
|
||||
([#321](https://github.com/deltachat/chatmail/pull/321))
|
||||
|
||||
- check that OpenPGP has only PKESK, SKESK and SEIPD packets
|
||||
([#323](https://github.com/deltachat/chatmail/pull/323),
|
||||
[#324](https://github.com/deltachat/chatmail/pull/324))
|
||||
|
||||
- improve filtermail checks for encrypted messages and drop support for unencrypted MDNs
|
||||
([#320](https://github.com/deltachat/chatmail/pull/320))
|
||||
|
||||
## 1.3.0 - 2024-06-06
|
||||
|
||||
- don't check necessary DNS records on cmdeploy init anymore
|
||||
([#316](https://github.com/deltachat/chatmail/pull/316))
|
||||
|
||||
- ensure cron and acl are installed
|
||||
([#293](https://github.com/deltachat/chatmail/pull/293),
|
||||
[#310](https://github.com/deltachat/chatmail/pull/310))
|
||||
|
||||
- change default for delete_mails_after from 40 to 20 days
|
||||
([#300](https://github.com/deltachat/chatmail/pull/300))
|
||||
|
||||
- save journald logs only to memory and save nginx logs to journald instead of file
|
||||
([#299](https://github.com/deltachat/chatmail/pull/299))
|
||||
|
||||
- fix writing of multiple obs repositories in `/etc/apt/sources.list`
|
||||
([#290](https://github.com/deltachat/chatmail/pull/290))
|
||||
|
||||
- metadata: add support for `/shared/vendor/deltachat/irohrelay`
|
||||
([#284](https://github.com/deltachat/chatmail/pull/284))
|
||||
|
||||
- Emit "XCHATMAIL" capability from IMAP server
|
||||
([#278](https://github.com/deltachat/chatmail/pull/278))
|
||||
|
||||
- Move echobot `into /var/lib/echobot`
|
||||
([#281](https://github.com/deltachat/chatmail/pull/281))
|
||||
|
||||
- Accept Let's Encrypt's new Terms of Services
|
||||
([#275](https://github.com/deltachat/chatmail/pull/276))
|
||||
|
||||
- Reload Dovecot and Postfix when TLS certificate updates
|
||||
([#271](https://github.com/deltachat/chatmail/pull/271))
|
||||
|
||||
- Use forked version of dovecot without hardcoded delays
|
||||
([#270](https://github.com/deltachat/chatmail/pull/270))
|
||||
|
||||
## 1.2.0 - 2024-04-04
|
||||
|
||||
- Install dig on the server to resolve DNS records
|
||||
([#267](https://github.com/deltachat/chatmail/pull/267))
|
||||
|
||||
- preserve notification order and exponentially backoff with
|
||||
retries for tokens where we didn't get a successful return
|
||||
([#265](https://github.com/deltachat/chatmail/pull/263))
|
||||
|
||||
- Run chatmail-metadata and doveauth as vmail
|
||||
([#261](https://github.com/deltachat/chatmail/pull/261))
|
||||
|
||||
- Apply systemd restrictions to echobot
|
||||
([#259](https://github.com/deltachat/chatmail/pull/259))
|
||||
|
||||
- re-enable running the CI in pull requests, but not concurrently
|
||||
([#258](https://github.com/deltachat/chatmail/pull/258))
|
||||
|
||||
|
||||
## 1.1.0 - 2024-03-28
|
||||
|
||||
### The changelog starts to record changes from March 15th, 2024
|
||||
|
||||
- Move systemd unit templates to cmdeploy package
|
||||
([#255](https://github.com/deltachat/chatmail/pull/255))
|
||||
|
||||
- Persist push tokens and support multiple device per address
|
||||
([#254](https://github.com/deltachat/chatmail/pull/254))
|
||||
|
||||
25
README.md
25
README.md
@@ -15,6 +15,8 @@ after which the initially specified password is required for using them.
|
||||
|
||||
## Deploying your own chatmail server
|
||||
|
||||
To deploy chatmail on your own server, you must have set-up ssh authentication and need to use an ed25519 key, due to an [upstream bug in paramiko](https://github.com/paramiko/paramiko/issues/2191). You also need to add your private key to the local ssh-agent, because you can't type in your password during deployment.
|
||||
|
||||
We use `chat.example.org` as the chatmail domain in the following steps.
|
||||
Please substitute it with your own domain.
|
||||
|
||||
@@ -159,4 +161,27 @@ While this file is present, account creation will be blocked.
|
||||
Delta Chat apps will, however, discover all ports and configurations
|
||||
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail service.
|
||||
|
||||
## Email authentication
|
||||
|
||||
chatmail servers rely on [DKIM](https://www.rfc-editor.org/rfc/rfc6376)
|
||||
to authenticate incoming emails.
|
||||
Incoming emails must have a valid DKIM signature with
|
||||
Signing Domain Identifier (SDID, `d=` parameter in the DKIM-Signature header)
|
||||
equal to the `From:` header domain.
|
||||
This property is checked by OpenDKIM screen policy script
|
||||
before validating the signatures.
|
||||
This correpsonds to strict [DMARC](https://www.rfc-editor.org/rfc/rfc7489) alignment (`adkim=s`),
|
||||
but chatmail does not rely on DMARC and does not consult the sender policy published in DMARC records.
|
||||
Other legacy authentication mechanisms such as [iprev](https://www.rfc-editor.org/rfc/rfc8601#section-2.7.3)
|
||||
and [SPF](https://www.rfc-editor.org/rfc/rfc7208) are also not taken into account.
|
||||
If there is no valid DKIM signature on the incoming email,
|
||||
the sender receives a "5.7.1 No valid DKIM signature found" error.
|
||||
|
||||
Outgoing emails must be sent over authenticated connection
|
||||
with envelope MAIL FROM (return path) corresponding to the login.
|
||||
This is ensured by Postfix which maps login username
|
||||
to MAIL FROM with
|
||||
[`smtpd_sender_login_maps`](https://www.postfix.org/postconf.5.html#smtpd_sender_login_maps)
|
||||
and rejects incorrectly authenticated emails with [`reject_sender_login_mismatch`](reject_sender_login_mismatch) policy.
|
||||
`From:` header must correspond to envelope MAIL FROM,
|
||||
this is ensured by `filtermail` proxy.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
include src/chatmaild/*.f
|
||||
include src/chatmaild/ini/*.ini.f
|
||||
include src/chatmaild/ini/*.ini
|
||||
include src/chatmaild/tests/mail-data/*
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"deltachat-rpc-client",
|
||||
"filelock",
|
||||
"requests",
|
||||
"passlib",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
@@ -36,6 +37,16 @@ log_format = "%(asctime)s %(levelname)s %(message)s"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
log_level = "INFO"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
|
||||
"PLC", # Pylint Convention
|
||||
"PLE", # Pylint Error
|
||||
"PLW", # Pylint Warning
|
||||
]
|
||||
|
||||
[tool.tox]
|
||||
legacy_tox_ini = """
|
||||
[tox]
|
||||
@@ -47,10 +58,9 @@ skipdist = True
|
||||
skip_install = True
|
||||
deps =
|
||||
ruff
|
||||
black
|
||||
commands =
|
||||
black --quiet --check --diff src/
|
||||
ruff src/
|
||||
ruff format --quiet --diff src/
|
||||
ruff check src/
|
||||
|
||||
[testenv]
|
||||
deps = pytest
|
||||
|
||||
@@ -20,6 +20,7 @@ class Config:
|
||||
self.passthrough_recipients = params["passthrough_recipients"].split()
|
||||
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
|
||||
self.postfix_reinject_port = int(params["postfix_reinject_port"])
|
||||
self.iroh_relay = params.get("iroh_relay")
|
||||
self.privacy_postal = params.get("privacy_postal")
|
||||
self.privacy_mail = params.get("privacy_mail")
|
||||
self.privacy_pdo = params.get("privacy_pdo")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import sqlite3
|
||||
import contextlib
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
import json
|
||||
import crypt
|
||||
import time
|
||||
from pathlib import Path
|
||||
from socketserver import (
|
||||
UnixStreamServer,
|
||||
StreamRequestHandler,
|
||||
ThreadingMixIn,
|
||||
UnixStreamServer,
|
||||
)
|
||||
import pwd
|
||||
|
||||
import passlib.hash
|
||||
|
||||
from .config import Config, read_config
|
||||
from .database import Database
|
||||
from .config import read_config, Config
|
||||
|
||||
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
||||
|
||||
@@ -23,8 +24,9 @@ class UnknownCommand(ValueError):
|
||||
|
||||
def encrypt_password(password: str):
|
||||
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
|
||||
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
|
||||
return "{SHA512-CRYPT}" + passhash
|
||||
pw = passlib.hash.sha512_crypt.hash(password).split("$")
|
||||
|
||||
return "{SHA512-CRYPT}$" + pw[1] + "$" + pw[3] + "$" + pw[4]
|
||||
|
||||
|
||||
def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
||||
@@ -46,23 +48,32 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
||||
return False
|
||||
localpart, domain = parts
|
||||
|
||||
if localpart == "echo":
|
||||
# echobot account should not be created in the database
|
||||
return False
|
||||
|
||||
if (
|
||||
len(localpart) > config.username_max_length
|
||||
or len(localpart) < config.username_min_length
|
||||
):
|
||||
if localpart != "echo":
|
||||
logging.warning(
|
||||
"localpart %s has to be between %s and %s chars long",
|
||||
localpart,
|
||||
config.username_min_length,
|
||||
config.username_max_length,
|
||||
)
|
||||
return False
|
||||
logging.warning(
|
||||
"localpart %s has to be between %s and %s chars long",
|
||||
localpart,
|
||||
config.username_min_length,
|
||||
config.username_max_length,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_user_data(db, config: Config, user):
|
||||
if user == f"echo@{config.mail_domain}":
|
||||
return dict(
|
||||
home=f"/home/vmail/mail/{config.mail_domain}/echo@{config.mail_domain}",
|
||||
uid="vmail",
|
||||
gid="vmail",
|
||||
)
|
||||
|
||||
with db.read_connection() as conn:
|
||||
result = conn.get_user(user)
|
||||
if result:
|
||||
@@ -77,6 +88,21 @@ def lookup_userdb(db, config: Config, user):
|
||||
|
||||
|
||||
def lookup_passdb(db, config: Config, user, cleartext_password):
|
||||
if user == f"echo@{config.mail_domain}":
|
||||
# Echobot writes password it wants to log in with into /run/echobot/password
|
||||
try:
|
||||
password = Path("/run/echobot/password").read_text()
|
||||
except Exception:
|
||||
logging.exception("Exception when trying to read /run/echobot/password")
|
||||
return None
|
||||
|
||||
return dict(
|
||||
home=f"/home/vmail/mail/{config.mail_domain}/echo@{config.mail_domain}",
|
||||
uid="vmail",
|
||||
gid="vmail",
|
||||
password=encrypt_password(password),
|
||||
)
|
||||
|
||||
with db.write_transaction() as conn:
|
||||
userdata = conn.get_user(user)
|
||||
if userdata:
|
||||
@@ -191,9 +217,8 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
||||
|
||||
def main():
|
||||
socket = sys.argv[1]
|
||||
passwd_entry = pwd.getpwnam(sys.argv[2])
|
||||
db = Database(sys.argv[3])
|
||||
config = read_config(sys.argv[4])
|
||||
db = Database(sys.argv[2])
|
||||
config = read_config(sys.argv[3])
|
||||
|
||||
class Handler(StreamRequestHandler):
|
||||
def handle(self):
|
||||
@@ -209,7 +234,6 @@ def main():
|
||||
pass
|
||||
|
||||
with ThreadedUnixStreamServer(socket, Handler) as server:
|
||||
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
|
||||
it will echo back any message that has non-empty text and also supports the /help command.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
|
||||
|
||||
from chatmaild.newemail import create_newemail_dict
|
||||
from chatmaild.config import read_config
|
||||
from chatmaild.newemail import create_newemail_dict
|
||||
|
||||
hooks = events.HookCollection()
|
||||
|
||||
@@ -75,9 +78,23 @@ def main():
|
||||
account = accounts[0] if accounts else deltachat.add_account()
|
||||
|
||||
bot = Bot(account, hooks)
|
||||
|
||||
config = read_config(sys.argv[1])
|
||||
|
||||
# Create password file
|
||||
if bot.is_configured():
|
||||
password = bot.account.get_config("mail_pw")
|
||||
else:
|
||||
password = create_newemail_dict(config)["password"]
|
||||
Path("/run/echobot/password").write_text(password)
|
||||
|
||||
# Give the user which doveauth runs as access to the password file.
|
||||
subprocess.run(
|
||||
["/usr/bin/setfacl", "-m", "user:vmail:r", "/run/echobot/password"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
if not bot.is_configured():
|
||||
config = read_config(sys.argv[1])
|
||||
password = create_newemail_dict(config).get("password")
|
||||
email = "echo@" + config.mail_domain
|
||||
bot.configure(email, password)
|
||||
bot.run_forever()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import filelock
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
import filelock
|
||||
|
||||
|
||||
class FileDict:
|
||||
"""Concurrency-safe multi-reader/single-writer persistent dict."""
|
||||
|
||||
@@ -1,20 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import time
|
||||
import sys
|
||||
from email.parser import BytesParser
|
||||
import time
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from email.utils import parseaddr
|
||||
from smtplib import SMTP as SMTPClient
|
||||
|
||||
from aiosmtpd.controller import Controller
|
||||
from smtplib import SMTP as SMTPClient
|
||||
|
||||
from .config import read_config
|
||||
|
||||
|
||||
def check_openpgp_payload(payload: bytes):
|
||||
"""Checks the OpenPGP payload.
|
||||
|
||||
OpenPGP payload must consist only of PKESK and SKESK packets
|
||||
terminated by a single SEIPD packet.
|
||||
|
||||
Returns True if OpenPGP payload is correct,
|
||||
False otherwise.
|
||||
|
||||
May raise IndexError while trying to read OpenPGP packet header
|
||||
if it is truncated.
|
||||
"""
|
||||
i = 0
|
||||
while i < len(payload):
|
||||
# Only OpenPGP format is allowed.
|
||||
if payload[i] & 0xC0 != 0xC0:
|
||||
return False
|
||||
|
||||
packet_type_id = payload[i] & 0x3F
|
||||
i += 1
|
||||
if payload[i] < 192:
|
||||
# One-octet length.
|
||||
body_len = payload[i]
|
||||
i += 1
|
||||
elif payload[i] < 224:
|
||||
# Two-octet length.
|
||||
body_len = ((payload[i] - 192) << 8) + payload[i + 1] + 192
|
||||
i += 2
|
||||
elif payload[i] == 255:
|
||||
# Five-octet length.
|
||||
body_len = (
|
||||
(payload[i + 1] << 24)
|
||||
| (payload[i + 2] << 16)
|
||||
| (payload[i + 3] << 8)
|
||||
| payload[i + 4]
|
||||
)
|
||||
i += 5
|
||||
else:
|
||||
# Partial body length is not allowed.
|
||||
return False
|
||||
|
||||
i += body_len
|
||||
|
||||
if i == len(payload):
|
||||
if packet_type_id == 18:
|
||||
# Last packet should be
|
||||
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
|
||||
return True
|
||||
elif packet_type_id not in [1, 3]:
|
||||
# All packets except the last one must be either
|
||||
# Public-Key Encrypted Session Key Packet (PKESK)
|
||||
# or
|
||||
# Symmetric-Key Encrypted Session Key Packet (SKESK)
|
||||
return False
|
||||
|
||||
if i > len(payload):
|
||||
# Payload is truncated.
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_armored_payload(payload: str):
|
||||
prefix = "-----BEGIN PGP MESSAGE-----\r\n\r\n"
|
||||
if not payload.startswith(prefix):
|
||||
return False
|
||||
payload = payload.removeprefix(prefix)
|
||||
|
||||
suffix = "-----END PGP MESSAGE-----\r\n\r\n"
|
||||
if not payload.endswith(suffix):
|
||||
return False
|
||||
payload = payload.removesuffix(suffix)
|
||||
|
||||
# Remove CRC24.
|
||||
payload = payload.rpartition("=")[0]
|
||||
|
||||
try:
|
||||
payload = base64.b64decode(payload)
|
||||
except binascii.Error:
|
||||
return False
|
||||
|
||||
try:
|
||||
return check_openpgp_payload(payload)
|
||||
except IndexError:
|
||||
return False
|
||||
|
||||
|
||||
def check_encrypted(message):
|
||||
"""Check that the message is an OpenPGP-encrypted message."""
|
||||
"""Check that the message is an OpenPGP-encrypted message.
|
||||
|
||||
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
|
||||
"""
|
||||
if not message.is_multipart():
|
||||
return False
|
||||
if message.get("subject") != "...":
|
||||
@@ -23,46 +114,30 @@ def check_encrypted(message):
|
||||
return False
|
||||
parts_count = 0
|
||||
for part in message.iter_parts():
|
||||
# We explicitly check Content-Type of each part later,
|
||||
# but this is to be absolutely sure `get_payload()` returns string and not list.
|
||||
if part.is_multipart():
|
||||
return False
|
||||
|
||||
if parts_count == 0:
|
||||
if part.get_content_type() != "application/pgp-encrypted":
|
||||
return False
|
||||
|
||||
payload = part.get_payload()
|
||||
if payload.strip() != "Version: 1":
|
||||
return False
|
||||
elif parts_count == 1:
|
||||
if part.get_content_type() != "application/octet-stream":
|
||||
return False
|
||||
|
||||
if not check_armored_payload(part.get_payload()):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
parts_count += 1
|
||||
return True
|
||||
|
||||
|
||||
def check_mdn(message, envelope):
|
||||
if len(envelope.rcpt_tos) != 1:
|
||||
return False
|
||||
|
||||
for name in ["auto-submitted", "chat-version"]:
|
||||
if not message.get(name):
|
||||
return False
|
||||
|
||||
if message.get_content_type() != "multipart/report":
|
||||
return False
|
||||
|
||||
body = message.get_body()
|
||||
if body.get_content_type() != "text/plain":
|
||||
return False
|
||||
|
||||
if list(body.iter_attachments()) or list(body.iter_parts()):
|
||||
return False
|
||||
|
||||
# even with all mime-structural checks an attacker
|
||||
# could try to abuse the subject or body to contain links or other
|
||||
# annoyance -- we skip on checking subject/body for now as Delta Chat
|
||||
# should evolve to create E2E-encrypted read receipts anyway.
|
||||
# and then MDNs are just encrypted mail and can pass the border
|
||||
# to other instances.
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def asyncmain_beforequeue(config):
|
||||
port = config.filtermail_smtp_port
|
||||
Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
|
||||
@@ -108,9 +183,6 @@ class BeforeQueueHandler:
|
||||
if envelope.mail_from.lower() != from_addr.lower():
|
||||
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
|
||||
|
||||
if not mail_encrypted and check_mdn(message, envelope):
|
||||
return
|
||||
|
||||
if envelope.mail_from in self.config.passthrough_senders:
|
||||
return
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ max_user_send_per_minute = 60
|
||||
max_mailbox_size = 100M
|
||||
|
||||
# days after which mails are unconditionally deleted
|
||||
delete_mails_after = 40
|
||||
delete_mails_after = 20
|
||||
|
||||
# minimum length a username must have
|
||||
username_min_length = 9
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import pwd
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Thread, Event
|
||||
from socketserver import (
|
||||
UnixStreamServer,
|
||||
StreamRequestHandler,
|
||||
ThreadingMixIn,
|
||||
)
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from socketserver import (
|
||||
StreamRequestHandler,
|
||||
ThreadingMixIn,
|
||||
UnixStreamServer,
|
||||
)
|
||||
|
||||
from .config import read_config
|
||||
from .filedict import FileDict
|
||||
|
||||
from .notifier import Notifier
|
||||
|
||||
DICTPROXY_HELLO_CHAR = "H"
|
||||
DICTPROXY_LOOKUP_CHAR = "L"
|
||||
@@ -23,96 +20,69 @@ DICTPROXY_SET_CHAR = "S"
|
||||
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
|
||||
DICTPROXY_TRANSACTION_CHARS = "BSC"
|
||||
|
||||
# each SETMETADATA on this key appends to a list of unique device tokens
|
||||
# which only ever get removed if the upstream indicates the token is invalid
|
||||
METADATA_TOKEN_KEY = "devicetoken"
|
||||
|
||||
class Metadata:
|
||||
# each SETMETADATA on this key appends to a list of unique device tokens
|
||||
# which only ever get removed if the upstream indicates the token is invalid
|
||||
DEVICETOKEN_KEY = "devicetoken"
|
||||
|
||||
class Notifier:
|
||||
def __init__(self, vmail_dir):
|
||||
self.vmail_dir = vmail_dir
|
||||
self.notification_dir = vmail_dir / "pending_notifications"
|
||||
if not self.notification_dir.exists():
|
||||
self.notification_dir.mkdir()
|
||||
self.message_arrived_event = Event()
|
||||
|
||||
def get_metadata_dict(self, addr):
|
||||
return FileDict(self.vmail_dir / addr / "metadata.json")
|
||||
|
||||
def add_token(self, addr, token):
|
||||
def add_token_to_addr(self, addr, token):
|
||||
with self.get_metadata_dict(addr).modify() as data:
|
||||
tokens = data.get(METADATA_TOKEN_KEY)
|
||||
if tokens is None:
|
||||
data[METADATA_TOKEN_KEY] = [token]
|
||||
elif token not in tokens:
|
||||
tokens = data.setdefault(self.DEVICETOKEN_KEY, [])
|
||||
if token not in tokens:
|
||||
tokens.append(token)
|
||||
|
||||
def remove_token(self, addr, token):
|
||||
def remove_token_from_addr(self, addr, token):
|
||||
with self.get_metadata_dict(addr).modify() as data:
|
||||
tokens = data.get(METADATA_TOKEN_KEY, [])
|
||||
try:
|
||||
tokens = data.get(self.DEVICETOKEN_KEY, [])
|
||||
if token in tokens:
|
||||
tokens.remove(token)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def get_tokens(self, addr):
|
||||
return self.get_metadata_dict(addr).read().get(METADATA_TOKEN_KEY, [])
|
||||
|
||||
def new_message_for_addr(self, addr):
|
||||
self.notification_dir.joinpath(addr).touch()
|
||||
self.message_arrived_event.set()
|
||||
|
||||
def thread_run_loop(self):
|
||||
requests_session = requests.Session()
|
||||
while 1:
|
||||
self.message_arrived_event.wait()
|
||||
self.message_arrived_event.clear()
|
||||
self.thread_run_one(requests_session)
|
||||
|
||||
def thread_run_one(self, requests_session):
|
||||
for addr_path in self.notification_dir.iterdir():
|
||||
addr = addr_path.name
|
||||
if "@" not in addr:
|
||||
continue
|
||||
for token in self.get_tokens(addr):
|
||||
response = requests_session.post(
|
||||
"https://notifications.delta.chat/notify",
|
||||
data=token,
|
||||
timeout=60,
|
||||
)
|
||||
if response.status_code == 410:
|
||||
# 410 Gone status code
|
||||
# means the token is no longer valid.
|
||||
self.remove_token(addr, token)
|
||||
addr_path.unlink()
|
||||
def get_tokens_for_addr(self, addr):
|
||||
mdict = self.get_metadata_dict(addr).read()
|
||||
return mdict.get(self.DEVICETOKEN_KEY, [])
|
||||
|
||||
|
||||
def handle_dovecot_protocol(rfile, wfile, notifier):
|
||||
def handle_dovecot_protocol(rfile, wfile, notifier, metadata, iroh_relay=None):
|
||||
transactions = {}
|
||||
while True:
|
||||
msg = rfile.readline().strip().decode()
|
||||
if not msg:
|
||||
break
|
||||
|
||||
res = handle_dovecot_request(msg, transactions, notifier)
|
||||
res = handle_dovecot_request(msg, transactions, notifier, metadata, iroh_relay)
|
||||
if res:
|
||||
wfile.write(res.encode("ascii"))
|
||||
wfile.flush()
|
||||
|
||||
|
||||
def handle_dovecot_request(msg, transactions, notifier):
|
||||
def handle_dovecot_request(msg, transactions, notifier, metadata, iroh_relay=None):
|
||||
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
|
||||
short_command = msg[0]
|
||||
parts = msg[1:].split("\t")
|
||||
if short_command == DICTPROXY_LOOKUP_CHAR:
|
||||
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
|
||||
keyparts = parts[0].split("/")
|
||||
keyparts = parts[0].split("/", 2)
|
||||
if keyparts[0] == "priv":
|
||||
keyname = keyparts[2]
|
||||
addr = parts[1]
|
||||
if keyname == METADATA_TOKEN_KEY:
|
||||
res = " ".join(notifier.get_tokens(addr))
|
||||
if keyname == metadata.DEVICETOKEN_KEY:
|
||||
res = " ".join(metadata.get_tokens_for_addr(addr))
|
||||
return f"O{res}\n"
|
||||
elif keyparts[0] == "shared":
|
||||
keyname = keyparts[2]
|
||||
if (
|
||||
keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay"
|
||||
and iroh_relay
|
||||
):
|
||||
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
|
||||
return f"O{iroh_relay}\n"
|
||||
logging.warning("lookup ignored: %r", msg)
|
||||
return "N\n"
|
||||
elif short_command == DICTPROXY_ITERATE_CHAR:
|
||||
@@ -144,10 +114,10 @@ def handle_dovecot_request(msg, transactions, notifier):
|
||||
keyname = parts[1].split("/")
|
||||
value = parts[2] if len(parts) > 2 else ""
|
||||
addr = transactions[transaction_id]["addr"]
|
||||
if keyname[0] == "priv" and keyname[2] == METADATA_TOKEN_KEY:
|
||||
notifier.add_token(addr, value)
|
||||
if keyname[0] == "priv" and keyname[2] == metadata.DEVICETOKEN_KEY:
|
||||
metadata.add_token_to_addr(addr, value)
|
||||
elif keyname[0] == "priv" and keyname[2] == "messagenew":
|
||||
notifier.new_message_for_addr(addr)
|
||||
notifier.new_message_for_addr(addr, metadata)
|
||||
else:
|
||||
# Transaction failed.
|
||||
transactions[transaction_id]["res"] = "F\n"
|
||||
@@ -158,21 +128,28 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
||||
|
||||
|
||||
def main():
|
||||
socket, username, vmail_dir = sys.argv[1:]
|
||||
passwd_entry = pwd.getpwnam(username)
|
||||
socket, vmail_dir, config_path = sys.argv[1:]
|
||||
|
||||
config = read_config(config_path)
|
||||
iroh_relay = config.iroh_relay
|
||||
|
||||
vmail_dir = Path(vmail_dir)
|
||||
|
||||
if not vmail_dir.exists():
|
||||
logging.error("vmail dir does not exist: %r", vmail_dir)
|
||||
return 1
|
||||
|
||||
notifier = Notifier(vmail_dir)
|
||||
queue_dir = vmail_dir / "pending_notifications"
|
||||
queue_dir.mkdir(exist_ok=True)
|
||||
metadata = Metadata(vmail_dir)
|
||||
notifier = Notifier(queue_dir)
|
||||
notifier.start_notification_threads(metadata.remove_token_from_addr)
|
||||
|
||||
class Handler(StreamRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
handle_dovecot_protocol(self.rfile, self.wfile, notifier)
|
||||
handle_dovecot_protocol(
|
||||
self.rfile, self.wfile, notifier, metadata, iroh_relay
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Exception in the dovecot dictproxy handler")
|
||||
raise
|
||||
@@ -182,17 +159,7 @@ def main():
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# start notifier thread for signalling new messages to
|
||||
# Delta Chat notification server
|
||||
|
||||
t = Thread(target=notifier.thread_run_loop)
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
# let notifier thread run once for any pending notifications from last run
|
||||
notifier.message_arrived_event.set()
|
||||
|
||||
with ThreadedUnixStreamServer(socket, Handler) as server:
|
||||
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
from pathlib import Path
|
||||
import time
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(vmail_dir=None):
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/usr/local/lib/chatmaild/venv/bin/python3
|
||||
|
||||
""" CGI script for creating new accounts. """
|
||||
"""CGI script for creating new accounts."""
|
||||
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from chatmaild.config import read_config, Config
|
||||
from chatmaild.config import Config, read_config
|
||||
|
||||
CONFIG_PATH = "/usr/local/lib/chatmaild/chatmail.ini"
|
||||
ALPHANUMERIC = string.ascii_lowercase + string.digits
|
||||
|
||||
166
chatmaild/src/chatmaild/notifier.py
Normal file
166
chatmaild/src/chatmaild/notifier.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
This modules provides notification machinery for transmitting device tokens to
|
||||
a central notification server which in turn contacts a phone provider's notification server
|
||||
to trigger Delta Chat apps to retrieve messages and provide instant notifications to users.
|
||||
|
||||
The Notifier class arranges the queuing of tokens in separate PriorityQueues
|
||||
from which NotifyThreads take and transmit them via HTTPS
|
||||
to the `notifications.delta.chat` service.
|
||||
The current lack of proper HTTP/2-support in Python leads us
|
||||
to use multiple threads and connections to the Rust-implemented `notifications.delta.chat`
|
||||
which itself uses HTTP/2 and thus only a single connection to phone-notification providers.
|
||||
|
||||
If a token fails to cause a successful notification
|
||||
it is moved to a retry-number specific PriorityQueue
|
||||
which handles all tokens that failed a particular number of times
|
||||
and which are scheduled for retry using exponential back-off timing.
|
||||
If a token notification would be scheduled more than DROP_DEADLINE seconds
|
||||
after its first attempt, it is dropped with a log error.
|
||||
|
||||
Note that tokens are completely opaque to the notification machinery here
|
||||
and will in the future be encrypted foreclosing all ability to distinguish
|
||||
which device token ultimately goes to which phone-provider notification service,
|
||||
or to understand the relation of "device tokens" and chatmail addresses.
|
||||
The meaning and format of tokens is basically a matter of Delta-Chat Core and
|
||||
the `notification.delta.chat` service.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from queue import PriorityQueue
|
||||
from threading import Thread
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistentQueueItem:
|
||||
path: Path
|
||||
addr: str
|
||||
start_ts: int
|
||||
token: str
|
||||
|
||||
def delete(self):
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
@classmethod
|
||||
def create(cls, queue_dir, addr, start_ts, token):
|
||||
queue_id = uuid4().hex
|
||||
start_ts = int(start_ts)
|
||||
path = queue_dir.joinpath(queue_id)
|
||||
tmp_path = path.with_name(path.name + ".tmp")
|
||||
tmp_path.write_text(f"{addr}\n{start_ts}\n{token}")
|
||||
os.rename(tmp_path, path)
|
||||
return cls(path, addr, start_ts, token)
|
||||
|
||||
@classmethod
|
||||
def read_from_path(cls, path):
|
||||
addr, start_ts, token = path.read_text().split("\n", maxsplit=2)
|
||||
return cls(path, addr, int(start_ts), token)
|
||||
|
||||
def __lt__(self, other):
|
||||
return self.start_ts < other.start_ts
|
||||
|
||||
|
||||
class Notifier:
|
||||
URL = "https://notifications.delta.chat/notify"
|
||||
CONNECTION_TIMEOUT = 60.0 # seconds until http-request is given up
|
||||
BASE_DELAY = 8.0 # base seconds for exponential back-off delay
|
||||
DROP_DEADLINE = 5 * 60 * 60 # drop notifications after 5 hours
|
||||
|
||||
def __init__(self, queue_dir):
|
||||
self.queue_dir = queue_dir
|
||||
max_tries = int(math.log(self.DROP_DEADLINE, self.BASE_DELAY)) + 1
|
||||
self.retry_queues = [PriorityQueue() for _ in range(max_tries)]
|
||||
|
||||
def compute_delay(self, retry_num):
|
||||
return 0 if retry_num == 0 else pow(self.BASE_DELAY, retry_num)
|
||||
|
||||
def new_message_for_addr(self, addr, metadata):
|
||||
start_ts = int(time.time())
|
||||
for token in metadata.get_tokens_for_addr(addr):
|
||||
queue_item = PersistentQueueItem.create(
|
||||
self.queue_dir, addr, start_ts, token
|
||||
)
|
||||
self.queue_for_retry(queue_item)
|
||||
|
||||
def requeue_persistent_queue_items(self):
|
||||
for queue_path in self.queue_dir.iterdir():
|
||||
if queue_path.name.endswith(".tmp"):
|
||||
logging.warning("removing spurious queue item: %r", queue_path)
|
||||
queue_path.unlink()
|
||||
continue
|
||||
queue_item = PersistentQueueItem.read_from_path(queue_path)
|
||||
self.queue_for_retry(queue_item)
|
||||
|
||||
def queue_for_retry(self, queue_item, retry_num=0):
|
||||
delay = self.compute_delay(retry_num)
|
||||
when = int(time.time()) + delay
|
||||
deadline = queue_item.start_ts + self.DROP_DEADLINE
|
||||
if retry_num >= len(self.retry_queues) or when > deadline:
|
||||
queue_item.delete()
|
||||
logging.error("notification exceeded deadline: %r", queue_item.token)
|
||||
return
|
||||
|
||||
self.retry_queues[retry_num].put((when, queue_item))
|
||||
|
||||
def start_notification_threads(self, remove_token_from_addr):
|
||||
self.requeue_persistent_queue_items()
|
||||
threads = {}
|
||||
for retry_num in range(len(self.retry_queues)):
|
||||
# use 4 threads for first-try tokens and less for subsequent tries
|
||||
num_threads = 4 if retry_num == 0 else 2
|
||||
threads[retry_num] = []
|
||||
for _ in range(num_threads):
|
||||
thread = NotifyThread(self, retry_num, remove_token_from_addr)
|
||||
threads[retry_num].append(thread)
|
||||
thread.start()
|
||||
return threads
|
||||
|
||||
|
||||
class NotifyThread(Thread):
|
||||
def __init__(self, notifier, retry_num, remove_token_from_addr):
|
||||
super().__init__(daemon=True)
|
||||
self.notifier = notifier
|
||||
self.retry_num = retry_num
|
||||
self.remove_token_from_addr = remove_token_from_addr
|
||||
|
||||
def stop(self):
|
||||
self.notifier.retry_queues[self.retry_num].put((None, None))
|
||||
|
||||
def run(self):
|
||||
requests_session = requests.Session()
|
||||
while self.retry_one(requests_session):
|
||||
pass
|
||||
|
||||
def retry_one(self, requests_session, sleep=time.sleep):
|
||||
when, queue_item = self.notifier.retry_queues[self.retry_num].get()
|
||||
if when is None:
|
||||
return False
|
||||
wait_time = when - int(time.time())
|
||||
if wait_time > 0:
|
||||
sleep(wait_time)
|
||||
self.perform_request_to_notification_server(requests_session, queue_item)
|
||||
return True
|
||||
|
||||
def perform_request_to_notification_server(self, requests_session, queue_item):
|
||||
timeout = self.notifier.CONNECTION_TIMEOUT
|
||||
token = queue_item.token
|
||||
try:
|
||||
res = requests_session.post(self.notifier.URL, data=token, timeout=timeout)
|
||||
except requests.exceptions.RequestException as e:
|
||||
res = e
|
||||
else:
|
||||
if res.status_code in (200, 410):
|
||||
if res.status_code == 410:
|
||||
self.remove_token_from_addr(queue_item.addr, token)
|
||||
queue_item.delete()
|
||||
return
|
||||
|
||||
logging.warning("Notification request failed: %r", res)
|
||||
self.notifier.queue_for_retry(queue_item, retry_num=self.retry_num + 1)
|
||||
44
chatmaild/src/chatmaild/tests/mail-data/literal.eml
Normal file
44
chatmaild/src/chatmaild/tests/mail-data/literal.eml
Normal file
@@ -0,0 +1,44 @@
|
||||
From: {from_addr}
|
||||
|
||||
To: {to_addr}
|
||||
|
||||
Subject: ...
|
||||
|
||||
Date: Sun, 15 Oct 2023 16:43:21 +0000
|
||||
|
||||
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
|
||||
|
||||
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||
|
||||
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||
|
||||
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||
|
||||
Chat-Version: 1.0
|
||||
|
||||
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
|
||||
|
||||
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
|
||||
|
||||
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
|
||||
|
||||
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
|
||||
|
||||
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
|
||||
|
||||
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
|
||||
|
||||
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
|
||||
|
||||
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
|
||||
|
||||
MIME-Version: 1.0
|
||||
|
||||
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
|
||||
|
||||
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import random
|
||||
from pathlib import Path
|
||||
import os
|
||||
import importlib.resources
|
||||
import itertools
|
||||
from email.parser import BytesParser
|
||||
import os
|
||||
import random
|
||||
from email import policy
|
||||
import pytest
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
|
||||
from chatmaild.database import Database
|
||||
import pytest
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
from chatmaild.database import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -68,7 +68,9 @@ def maildata(request):
|
||||
assert datadir.exists(), datadir
|
||||
|
||||
def maildata(name, from_addr, to_addr):
|
||||
data = datadir.joinpath(name).read_text()
|
||||
# Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines.
|
||||
data = datadir.joinpath(name).read_bytes().decode()
|
||||
|
||||
text = data.format(from_addr=from_addr, to_addr=to_addr)
|
||||
return BytesParser(policy=policy.default).parsebytes(text.encode())
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_read_config_testrun(make_config):
|
||||
assert config.postfix_reinject_port == 10025
|
||||
assert config.max_user_send_per_minute == 60
|
||||
assert config.max_mailbox_size == "100M"
|
||||
assert config.delete_mails_after == "40"
|
||||
assert config.delete_mails_after == "20"
|
||||
assert config.username_min_length == 9
|
||||
assert config.username_max_length == 9
|
||||
assert config.password_min_length == 9
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import io
|
||||
import json
|
||||
import pytest
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
|
||||
import chatmaild.doveauth
|
||||
import pytest
|
||||
from chatmaild.database import DBError
|
||||
from chatmaild.doveauth import (
|
||||
get_user_data,
|
||||
lookup_passdb,
|
||||
handle_dovecot_request,
|
||||
handle_dovecot_protocol,
|
||||
handle_dovecot_request,
|
||||
lookup_passdb,
|
||||
)
|
||||
from chatmaild.database import DBError
|
||||
|
||||
|
||||
def test_basic(db, example_config):
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import pytest
|
||||
from chatmaild.filtermail import (
|
||||
check_encrypted,
|
||||
BeforeQueueHandler,
|
||||
SendRateLimiter,
|
||||
check_mdn,
|
||||
check_armored_payload,
|
||||
check_encrypted,
|
||||
)
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maildomain():
|
||||
@@ -63,34 +62,19 @@ def test_filtermail_encryption_detection(maildata):
|
||||
assert not check_encrypted(msg)
|
||||
|
||||
|
||||
def test_filtermail_is_mdn(maildata, gencreds, handler):
|
||||
def test_filtermail_no_literal_packets(maildata):
|
||||
"""Test that literal OpenPGP packet is not considered an encrypted mail."""
|
||||
msg = maildata("literal.eml", from_addr="1@example.org", to_addr="2@example.org")
|
||||
assert not check_encrypted(msg)
|
||||
|
||||
|
||||
def test_filtermail_unencrypted_mdn(maildata, gencreds):
|
||||
"""Unencrypted MDNs should not pass."""
|
||||
from_addr = gencreds()[0]
|
||||
to_addr = gencreds()[0] + ".other"
|
||||
msg = maildata("mdn.eml", from_addr, to_addr)
|
||||
|
||||
class env:
|
||||
mail_from = from_addr
|
||||
rcpt_tos = [to_addr]
|
||||
content = msg.as_bytes()
|
||||
|
||||
assert check_mdn(msg, env)
|
||||
print(msg.as_string())
|
||||
|
||||
assert not handler.check_DATA(env)
|
||||
|
||||
|
||||
def test_filtermail_to_multiple_recipients_no_mdn(maildata, gencreds):
|
||||
from_addr = gencreds()[0]
|
||||
to_addr = gencreds()[0] + ".other"
|
||||
thirdaddr = gencreds()[0]
|
||||
msg = maildata("mdn.eml", from_addr, to_addr)
|
||||
|
||||
class env:
|
||||
mail_from = from_addr
|
||||
rcpt_tos = [to_addr, thirdaddr]
|
||||
content = msg.as_bytes()
|
||||
|
||||
assert not check_mdn(msg, env)
|
||||
assert not check_encrypted(msg)
|
||||
|
||||
|
||||
def test_send_rate_limiter():
|
||||
@@ -143,3 +127,43 @@ def test_passthrough_senders(gencreds, handler, maildata):
|
||||
|
||||
# assert that None/no error is returned
|
||||
assert not handler.check_DATA(envelope=env)
|
||||
|
||||
|
||||
def test_check_armored_payload():
|
||||
payload = """-----BEGIN PGP MESSAGE-----\r
|
||||
\r
|
||||
wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r
|
||||
Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r
|
||||
755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r
|
||||
pt14b4aC1VwtSnYhcRRELNLD/wE2TFif+g7poMmFY50VyMPLYjVP96Z5QCT4+z4H\r
|
||||
Ikh/pRRN8S3JNMrRJHc6prooSJmLcx47Y5un7VFy390MsJ+LiUJuQMDdYWRAinfs\r
|
||||
Ebm89Ezjm7F03qbFPXE0X4ZNzVXS/eKO0uhJQdiov/vmbn41rNtHmNpqjaO0vi5+\r
|
||||
sS9tR7yDUrIXiCUCN78eBLVioxtktsPZm5cDORbQWzv+7nmCEz9/JowCUcBVdCGn\r
|
||||
1ofOaH82JCAX/cRx08pLaDNj6iolVBsi56Dd+2bGxJOZOG2AMcEyz0pXY0dOAJCD\r
|
||||
iUThcQeGIdRnU3j8UBcnIEsjLu2+C+rrwMZQESMWKnJ0rnqTk0pK5kXScr6F/L0L\r
|
||||
UE49ccIexNm3xZvYr5drszr6wz3Tv5fdue87P4etBt90gF/Vzknck+g1LLlkzZkp\r
|
||||
d8dI0k2tOSPjUbDPnSy1x+X73WGpPZmj0kWT+RGvq0nH6UkJj3AQTG2qf1T8jK+3\r
|
||||
rTp3LR9vDkMwDjX4R8SA9c0wdnUzzr79OYQC9lTnzcx+fM6BBmgQ2GrS33jaFLp7\r
|
||||
L6/DFpCl5zhnPjM/2dKvMkw/Kd6XS/vjwsO405FQdjSDiQEEAZA+ZvAfcjdccbbU\r
|
||||
yCO+x0QNdeBsufDVnh3xvzuWy4CICdTQT4s1AWRPCzjOj+SGmx5WqCLWfsd8Ma0+\r
|
||||
w/C7SfTYu1FDQILLM+llpq1M/9GPley4QZ8JQjo262AyPXsPF/OW48uuZz0Db1xT\r
|
||||
Yh4iHBztj4VSdy7l2+IyaIf7cnL4EEBFxv/MwmVDXvDlxyvfAfIsd3D9SvJESzKZ\r
|
||||
VWDYwaocgeCN+ojKu1p885lu1EfRbX3fr3YO02K5/c2JYDkc0Py0W3wUP/J1XUax\r
|
||||
pbKpzwlkxEgtmzsGqsOfMJqBV3TNDrOA2uBsa+uBqP5MGYLZ49S/4v/bW9I01Cr1\r
|
||||
D2ZkV510Y1Vgo66WlP8mRqOTyt/5WRhPD+MxXdk67BNN/PmO6tMlVoJDuk+XwWPR\r
|
||||
t2TvNaND/yabT9eYI55Og4fzKD6RIjouUX8DvKLkm+7aXxVs2uuLQ3Jco3O82z55\r
|
||||
dbShU1jYsrw9oouXUz06MHPbkdhNbF/2hfhZ2qA31sNeovJw65iUv7sDKX3LVWgJ\r
|
||||
10jlywcDwqlU8CO7WC9lGixYTbnOkYZpXCGEl8e6Jbs79l42YFo4ogYpFK1NXFhV\r
|
||||
kOXRmDf/wmfj+c/ld3L2PkvwlgofhCudOQknZbo3ub1gjiTn7L+lMGHIj/3suMIl\r
|
||||
ID4EUxAXScIM1ZEz2fjtW5jATlqYcLjLTbf/olw6HFyPNH+9IssqXeZNKnGwPUB9\r
|
||||
3lTXsg0tpzl+x7F/2WjEw1DSNhjC0KnHt1vEYNMkUGDGFdN9y3ERLqX/FIgiASUb\r
|
||||
bTvAVupnAK3raBezGmhrs6LsQtLS9P0VvQiLU3uDhMqw8Z4SISLpcD+NnVBHzQqm\r
|
||||
6W5Qn/8xsCL6av18yUVTi2G3igt3QCNoYx9evt2ZcIkNoyyagUVjfZe5GHXh8Dnz\r
|
||||
GaBXW/hg3HlXLRGaQu4RYCzBMJILcO25OhZOg6jbkCLiEexQlm2e9krB5cXR49Al\r
|
||||
UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
|
||||
=b5Kp\r
|
||||
-----END PGP MESSAGE-----\r
|
||||
\r
|
||||
"""
|
||||
|
||||
assert check_armored_payload(payload) == True
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import io
|
||||
import pytest
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from chatmaild.metadata import (
|
||||
handle_dovecot_request,
|
||||
Metadata,
|
||||
handle_dovecot_protocol,
|
||||
handle_dovecot_request,
|
||||
)
|
||||
from chatmaild.notifier import (
|
||||
Notifier,
|
||||
NotifyThread,
|
||||
PersistentQueueItem,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier(tmp_path):
|
||||
def notifier(metadata):
|
||||
queue_dir = metadata.vmail_dir.joinpath("pending_notifications")
|
||||
queue_dir.mkdir()
|
||||
return Notifier(queue_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metadata(tmp_path):
|
||||
vmail_dir = tmp_path.joinpath("vmaildir")
|
||||
vmail_dir.mkdir()
|
||||
return Notifier(vmail_dir)
|
||||
return Metadata(vmail_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -25,72 +39,100 @@ def testaddr2():
|
||||
return "user2@example.org"
|
||||
|
||||
|
||||
def test_notifier_persistence(tmp_path, testaddr, testaddr2):
|
||||
notifier1 = Notifier(tmp_path)
|
||||
notifier2 = Notifier(tmp_path)
|
||||
assert not notifier1.get_tokens(testaddr)
|
||||
assert not notifier2.get_tokens(testaddr)
|
||||
|
||||
notifier1.add_token(testaddr, "01234")
|
||||
notifier1.add_token(testaddr2, "456")
|
||||
assert notifier2.get_tokens(testaddr) == ["01234"]
|
||||
assert notifier2.get_tokens(testaddr2) == ["456"]
|
||||
notifier2.remove_token(testaddr, "01234")
|
||||
assert not notifier1.get_tokens(testaddr)
|
||||
assert notifier1.get_tokens(testaddr2) == ["456"]
|
||||
@pytest.fixture
|
||||
def token():
|
||||
return "01234"
|
||||
|
||||
|
||||
def test_remove_nonexisting(tmp_path, testaddr):
|
||||
notifier1 = Notifier(tmp_path)
|
||||
notifier1.add_token(testaddr, "123")
|
||||
notifier1.remove_token(testaddr, "1l23k1l2k3")
|
||||
assert notifier1.get_tokens(testaddr) == ["123"]
|
||||
def get_mocked_requests(statuslist):
|
||||
class ReqMock:
|
||||
requests = []
|
||||
|
||||
def post(self, url, data, timeout):
|
||||
self.requests.append((url, data, timeout))
|
||||
res = statuslist.pop(0)
|
||||
if isinstance(res, Exception):
|
||||
raise res
|
||||
|
||||
class Result:
|
||||
status_code = res
|
||||
|
||||
return Result()
|
||||
|
||||
return ReqMock()
|
||||
|
||||
|
||||
def test_notifier_delete_without_set(notifier, testaddr):
|
||||
notifier.remove_token(testaddr, "123")
|
||||
assert not notifier.get_tokens(testaddr)
|
||||
def test_metadata_persistence(tmp_path, testaddr, testaddr2):
|
||||
metadata1 = Metadata(tmp_path)
|
||||
metadata2 = Metadata(tmp_path)
|
||||
assert not metadata1.get_tokens_for_addr(testaddr)
|
||||
assert not metadata2.get_tokens_for_addr(testaddr)
|
||||
|
||||
metadata1.add_token_to_addr(testaddr, "01234")
|
||||
metadata1.add_token_to_addr(testaddr2, "456")
|
||||
assert metadata2.get_tokens_for_addr(testaddr) == ["01234"]
|
||||
assert metadata2.get_tokens_for_addr(testaddr2) == ["456"]
|
||||
metadata2.remove_token_from_addr(testaddr, "01234")
|
||||
assert not metadata1.get_tokens_for_addr(testaddr)
|
||||
assert metadata1.get_tokens_for_addr(testaddr2) == ["456"]
|
||||
|
||||
|
||||
def test_handle_dovecot_request_lookup_fails(notifier, testaddr):
|
||||
res = handle_dovecot_request(f"Lpriv/123/chatmail\t{testaddr}", {}, notifier)
|
||||
def test_remove_nonexisting(metadata, tmp_path, testaddr):
|
||||
metadata.add_token_to_addr(testaddr, "123")
|
||||
metadata.remove_token_from_addr(testaddr, "1l23k1l2k3")
|
||||
assert metadata.get_tokens_for_addr(testaddr) == ["123"]
|
||||
|
||||
|
||||
def test_notifier_remove_without_set(metadata, testaddr):
|
||||
metadata.remove_token_from_addr(testaddr, "123")
|
||||
assert not metadata.get_tokens_for_addr(testaddr)
|
||||
|
||||
|
||||
def test_handle_dovecot_request_lookup_fails(notifier, metadata, testaddr):
|
||||
res = handle_dovecot_request(
|
||||
f"Lpriv/123/chatmail\t{testaddr}", {}, notifier, metadata
|
||||
)
|
||||
assert res == "N\n"
|
||||
|
||||
|
||||
def test_handle_dovecot_request_happy_path(notifier, testaddr):
|
||||
def test_handle_dovecot_request_happy_path(notifier, metadata, testaddr, token):
|
||||
transactions = {}
|
||||
|
||||
# set device token in a transaction
|
||||
tx = "1111"
|
||||
msg = f"B{tx}\t{testaddr}"
|
||||
res = handle_dovecot_request(msg, transactions, notifier)
|
||||
assert not res and not notifier.get_tokens(testaddr)
|
||||
res = handle_dovecot_request(msg, transactions, notifier, metadata)
|
||||
assert not res and not metadata.get_tokens_for_addr(testaddr)
|
||||
assert transactions == {tx: dict(addr=testaddr, res="O\n")}
|
||||
|
||||
msg = f"S{tx}\tpriv/guid00/devicetoken\t01234"
|
||||
res = handle_dovecot_request(msg, transactions, notifier)
|
||||
msg = f"S{tx}\tpriv/guid00/devicetoken\t{token}"
|
||||
res = handle_dovecot_request(msg, transactions, notifier, metadata)
|
||||
assert not res
|
||||
assert len(transactions) == 1
|
||||
assert notifier.get_tokens(testaddr) == ["01234"]
|
||||
assert metadata.get_tokens_for_addr(testaddr) == [token]
|
||||
|
||||
msg = f"C{tx}"
|
||||
res = handle_dovecot_request(msg, transactions, notifier)
|
||||
res = handle_dovecot_request(msg, transactions, notifier, metadata)
|
||||
assert res == "O\n"
|
||||
assert len(transactions) == 0
|
||||
assert notifier.get_tokens(testaddr) == ["01234"]
|
||||
assert metadata.get_tokens_for_addr(testaddr) == [token]
|
||||
|
||||
# trigger notification for incoming message
|
||||
tx2 = "2222"
|
||||
assert handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions, notifier) is None
|
||||
assert (
|
||||
handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions, notifier, metadata)
|
||||
is None
|
||||
)
|
||||
msg = f"S{tx2}\tpriv/guid00/messagenew"
|
||||
assert handle_dovecot_request(msg, transactions, notifier) is None
|
||||
assert notifier.message_arrived_event.is_set()
|
||||
assert handle_dovecot_request(f"C{tx2}", transactions, notifier) == "O\n"
|
||||
assert handle_dovecot_request(msg, transactions, notifier, metadata) is None
|
||||
queue_item = notifier.retry_queues[0].get()[1]
|
||||
assert queue_item.token == token
|
||||
assert handle_dovecot_request(f"C{tx2}", transactions, notifier, metadata) == "O\n"
|
||||
assert not transactions
|
||||
assert notifier.notification_dir.joinpath(testaddr).exists()
|
||||
assert queue_item.path.exists()
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_set_devicetoken(notifier):
|
||||
def test_handle_dovecot_protocol_set_devicetoken(metadata, notifier):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
[
|
||||
@@ -102,12 +144,12 @@ def test_handle_dovecot_protocol_set_devicetoken(notifier):
|
||||
)
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
|
||||
assert wfile.getvalue() == b"O\n"
|
||||
assert notifier.get_tokens("user@example.org") == ["01234"]
|
||||
assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_set_get_devicetoken(notifier):
|
||||
def test_handle_dovecot_protocol_set_get_devicetoken(metadata, notifier):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
[
|
||||
@@ -119,19 +161,19 @@ def test_handle_dovecot_protocol_set_get_devicetoken(notifier):
|
||||
)
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||
assert notifier.get_tokens("user@example.org") == ["01234"]
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
|
||||
assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
|
||||
assert wfile.getvalue() == b"O\n"
|
||||
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join([b"HELLO", b"Lpriv/0123/devicetoken\tuser@example.org"])
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
|
||||
assert wfile.getvalue() == b"O01234\n"
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_iterate(notifier):
|
||||
def test_handle_dovecot_protocol_iterate(metadata, notifier):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
[
|
||||
@@ -141,90 +183,130 @@ def test_handle_dovecot_protocol_iterate(notifier):
|
||||
)
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
|
||||
assert wfile.getvalue() == b"\n"
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_messagenew(notifier):
|
||||
def test_notifier_thread_deletes_persistent_file(metadata, notifier, testaddr):
|
||||
reqmock = get_mocked_requests([200])
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
NotifyThread(notifier, 0, None).retry_one(reqmock)
|
||||
url, data, timeout = reqmock.requests[0]
|
||||
assert data == "01234"
|
||||
assert metadata.get_tokens_for_addr(testaddr) == ["01234"]
|
||||
notifier.requeue_persistent_queue_items()
|
||||
assert notifier.retry_queues[0].qsize() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [requests.exceptions.RequestException(), 404, 500])
|
||||
def test_notifier_thread_connection_failures(
|
||||
metadata, notifier, testaddr, status, caplog
|
||||
):
|
||||
"""test that tokens keep getting retried until they are given up."""
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
notifier.NOTIFICATION_RETRY_DELAY = 5
|
||||
max_tries = len(notifier.retry_queues)
|
||||
for i in range(max_tries):
|
||||
caplog.clear()
|
||||
reqmock = get_mocked_requests([status])
|
||||
sleep_calls = []
|
||||
NotifyThread(notifier, i, None).retry_one(reqmock, sleep=sleep_calls.append)
|
||||
assert notifier.retry_queues[i].qsize() == 0
|
||||
assert "request failed" in caplog.records[0].msg
|
||||
if i > 0:
|
||||
assert len(sleep_calls) == 1
|
||||
if i + 1 < max_tries:
|
||||
assert notifier.retry_queues[i + 1].qsize() == 1
|
||||
assert len(caplog.records) == 1
|
||||
else:
|
||||
assert len(caplog.records) == 2
|
||||
assert "deadline" in caplog.records[1].msg
|
||||
notifier.requeue_persistent_queue_items()
|
||||
assert notifier.retry_queues[0].qsize() == 0
|
||||
|
||||
|
||||
def test_requeue_removes_tmp_files(notifier, metadata, testaddr, caplog):
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
p = notifier.queue_dir.joinpath("1203981203.tmp")
|
||||
p.touch()
|
||||
notifier2 = notifier.__class__(notifier.queue_dir)
|
||||
notifier2.requeue_persistent_queue_items()
|
||||
assert "spurious" in caplog.records[0].msg
|
||||
assert not p.exists()
|
||||
assert notifier2.retry_queues[0].qsize() == 1
|
||||
when, queue_item = notifier2.retry_queues[0].get()
|
||||
assert when <= int(time.time())
|
||||
assert queue_item.addr == testaddr
|
||||
|
||||
|
||||
def test_start_and_stop_notification_threads(notifier, testaddr):
|
||||
threads = notifier.start_notification_threads(None)
|
||||
for retry_num, threadlist in threads.items():
|
||||
for t in threadlist:
|
||||
t.stop()
|
||||
t.join()
|
||||
|
||||
|
||||
def test_multi_device_notifier(metadata, notifier, testaddr):
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
metadata.add_token_to_addr(testaddr, "56789")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
reqmock = get_mocked_requests([200, 200])
|
||||
NotifyThread(notifier, 0, None).retry_one(reqmock)
|
||||
NotifyThread(notifier, 0, None).retry_one(reqmock)
|
||||
assert notifier.retry_queues[0].qsize() == 0
|
||||
assert notifier.retry_queues[1].qsize() == 0
|
||||
url, data, timeout = reqmock.requests[0]
|
||||
assert data == "01234"
|
||||
url, data, timeout = reqmock.requests[1]
|
||||
assert data == "56789"
|
||||
assert metadata.get_tokens_for_addr(testaddr) == ["01234", "56789"]
|
||||
|
||||
|
||||
def test_notifier_thread_run_gone_removes_token(metadata, notifier, testaddr):
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
metadata.add_token_to_addr(testaddr, "45678")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
|
||||
reqmock = get_mocked_requests([410, 200])
|
||||
NotifyThread(notifier, 0, metadata.remove_token_from_addr).retry_one(reqmock)
|
||||
NotifyThread(notifier, 0, None).retry_one(reqmock)
|
||||
url, data, timeout = reqmock.requests[0]
|
||||
assert data == "01234"
|
||||
url, data, timeout = reqmock.requests[1]
|
||||
assert data == "45678"
|
||||
assert metadata.get_tokens_for_addr(testaddr) == ["45678"]
|
||||
assert notifier.retry_queues[0].qsize() == 0
|
||||
assert notifier.retry_queues[1].qsize() == 0
|
||||
|
||||
|
||||
def test_persistent_queue_items(tmp_path, testaddr, token):
|
||||
queue_item = PersistentQueueItem.create(tmp_path, testaddr, 432, token)
|
||||
assert queue_item.addr == testaddr
|
||||
assert queue_item.start_ts == 432
|
||||
assert queue_item.token == token
|
||||
item2 = PersistentQueueItem.read_from_path(queue_item.path)
|
||||
assert item2.addr == testaddr
|
||||
assert item2.start_ts == 432
|
||||
assert item2.token == token
|
||||
assert item2 == queue_item
|
||||
item2.delete()
|
||||
assert not item2.path.exists()
|
||||
assert not queue_item < item2 and not item2 < queue_item
|
||||
|
||||
|
||||
def test_iroh_relay(metadata):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
[
|
||||
b"HELLO",
|
||||
b"Btx01\tuser@example.org",
|
||||
b"Stx01\tpriv/guid00/messagenew",
|
||||
b"Ctx01",
|
||||
b"H",
|
||||
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org",
|
||||
]
|
||||
)
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier)
|
||||
assert wfile.getvalue() == b"O\n"
|
||||
assert notifier.message_arrived_event.is_set()
|
||||
assert notifier.notification_dir.joinpath("user@example.org").exists()
|
||||
|
||||
|
||||
def test_notifier_thread_run(notifier, testaddr):
|
||||
requests = []
|
||||
|
||||
class ReqMock:
|
||||
def post(self, url, data, timeout):
|
||||
requests.append((url, data, timeout))
|
||||
|
||||
class Result:
|
||||
status_code = 200
|
||||
|
||||
return Result()
|
||||
|
||||
notifier.add_token(testaddr, "01234")
|
||||
notifier.new_message_for_addr(testaddr)
|
||||
notifier.thread_run_one(ReqMock())
|
||||
url, data, timeout = requests[0]
|
||||
assert data == "01234"
|
||||
assert notifier.get_tokens(testaddr) == ["01234"]
|
||||
|
||||
|
||||
def test_multi_device_notifier(notifier, testaddr):
|
||||
requests = []
|
||||
|
||||
class ReqMock:
|
||||
def post(self, url, data, timeout):
|
||||
requests.append((url, data, timeout))
|
||||
|
||||
class Result:
|
||||
status_code = 200
|
||||
|
||||
return Result()
|
||||
|
||||
notifier.add_token(testaddr, "01234")
|
||||
notifier.add_token(testaddr, "56789")
|
||||
notifier.new_message_for_addr(testaddr)
|
||||
notifier.thread_run_one(ReqMock())
|
||||
url, data, timeout = requests[0]
|
||||
assert data == "01234"
|
||||
url, data, timeout = requests[1]
|
||||
assert data == "56789"
|
||||
assert notifier.get_tokens(testaddr) == ["01234", "56789"]
|
||||
|
||||
|
||||
def test_notifier_thread_run_gone_removes_token(notifier, testaddr):
|
||||
requests = []
|
||||
|
||||
class ReqMock:
|
||||
def post(self, url, data, timeout):
|
||||
requests.append((url, data, timeout))
|
||||
|
||||
class Result:
|
||||
status_code = 410 if data == "01234" else 200
|
||||
|
||||
return Result()
|
||||
|
||||
notifier.add_token(testaddr, "01234")
|
||||
notifier.new_message_for_addr(testaddr)
|
||||
assert notifier.get_tokens(testaddr) == ["01234"]
|
||||
notifier.add_token(testaddr, "45678")
|
||||
notifier.thread_run_one(ReqMock())
|
||||
url, data, timeout = requests[0]
|
||||
assert data == "01234"
|
||||
url, data, timeout = requests[1]
|
||||
assert data == "45678"
|
||||
assert notifier.get_tokens(testaddr) == ["45678"]
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata, "https://example.org/")
|
||||
assert wfile.getvalue() == b"Ohttps://example.org/\n"
|
||||
|
||||
@@ -16,7 +16,6 @@ dependencies = [
|
||||
"build",
|
||||
"tox",
|
||||
"ruff",
|
||||
"black",
|
||||
"pytest",
|
||||
"pytest-xdist",
|
||||
"imap_tools",
|
||||
@@ -31,3 +30,13 @@ cmdeploy = "cmdeploy.cmdeploy:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-v -ra --strict-markers"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
|
||||
"PLC", # Pylint Convention
|
||||
"PLE", # Pylint Error
|
||||
"PLW", # Pylint Warning
|
||||
]
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
Chat Mail pyinfra deploy.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib.resources
|
||||
import subprocess
|
||||
import shutil
|
||||
import io
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from chatmaild.config import Config, read_config
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import apt, files, server, systemd, pip
|
||||
from pyinfra.facts.files import File
|
||||
from pyinfra.facts.systemd import SystemdEnabled
|
||||
from .acmetool import deploy_acmetool
|
||||
from pyinfra.operations import apt, files, pip, server, systemd
|
||||
|
||||
from chatmaild.config import read_config, Config
|
||||
from .acmetool import deploy_acmetool
|
||||
|
||||
|
||||
def _build_chatmaild(dist_dir) -> None:
|
||||
@@ -135,20 +135,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
|
||||
"""Configures OpenDKIM"""
|
||||
need_restart = False
|
||||
|
||||
server.group(name="Create opendkim group", group="opendkim", system=True)
|
||||
server.user(
|
||||
name="Create opendkim user",
|
||||
user="opendkim",
|
||||
groups=["opendkim"],
|
||||
system=True,
|
||||
)
|
||||
server.user(
|
||||
name="Add postfix user to opendkim group for socket access",
|
||||
user="postfix",
|
||||
groups=["opendkim"],
|
||||
system=True,
|
||||
)
|
||||
|
||||
main_config = files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"),
|
||||
dest="/etc/opendkim.conf",
|
||||
@@ -476,9 +462,48 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
|
||||
from .www import build_webpages
|
||||
|
||||
apt.update(name="apt update", cache_time=24 * 3600)
|
||||
server.group(name="Create vmail group", group="vmail", system=True)
|
||||
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
|
||||
server.group(name="Create opendkim group", group="opendkim", system=True)
|
||||
server.user(
|
||||
name="Create opendkim user",
|
||||
user="opendkim",
|
||||
groups=["opendkim"],
|
||||
system=True,
|
||||
)
|
||||
server.user(
|
||||
name="Add postfix user to opendkim group for socket access",
|
||||
user="postfix",
|
||||
groups=["opendkim"],
|
||||
system=True,
|
||||
)
|
||||
server.user(name="Create echobot user", user="echobot", system=True)
|
||||
|
||||
server.shell(
|
||||
name="Fix file owner in /home/vmail",
|
||||
commands=["test -d /home/vmail && chown -R vmail:vmail /home/vmail"],
|
||||
)
|
||||
|
||||
# Add our OBS repository for dovecot_no_delay
|
||||
files.put(
|
||||
name="Add Deltachat OBS GPG key to apt keyring",
|
||||
src=importlib.resources.files(__package__).joinpath("obs-home-deltachat.gpg"),
|
||||
dest="/etc/apt/keyrings/obs-home-deltachat.gpg",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
|
||||
files.line(
|
||||
name="Add DeltaChat OBS home repository to sources.list",
|
||||
path="/etc/apt/sources.list",
|
||||
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
|
||||
escape_regex_characters=True,
|
||||
ensure_newline=True,
|
||||
)
|
||||
|
||||
apt.update(name="apt update", cache_time=24 * 3600)
|
||||
|
||||
apt.packages(
|
||||
name="Install rsync",
|
||||
packages=["rsync"],
|
||||
@@ -507,10 +532,15 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
|
||||
# Deploy acmetool to have TLS certificates.
|
||||
deploy_acmetool(
|
||||
nginx_hook=True,
|
||||
domains=[mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"],
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
# required for setfacl for echobot
|
||||
name="Install acl",
|
||||
packages="acl",
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name="Install Postfix",
|
||||
packages="postfix",
|
||||
@@ -565,14 +595,9 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
restarted=mta_sts_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable Postfix",
|
||||
service="postfix.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=postfix_need_restart,
|
||||
)
|
||||
|
||||
# Dovecot should be started before Postfix
|
||||
# because it creates authentication socket
|
||||
# required by Postfix.
|
||||
systemd.service(
|
||||
name="Start and enable Dovecot",
|
||||
service="dovecot.service",
|
||||
@@ -581,6 +606,14 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
restarted=dovecot_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable Postfix",
|
||||
service="postfix.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=postfix_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable nginx",
|
||||
service="nginx.service",
|
||||
@@ -609,5 +642,10 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
service="systemd-journald.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=journald_conf,
|
||||
restarted=journald_conf.changed,
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name="Ensure cron is installed",
|
||||
packages=["cron"],
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import importlib.resources
|
||||
|
||||
from pyinfra.operations import apt, files, systemd, server
|
||||
from pyinfra import host
|
||||
from pyinfra.facts.systemd import SystemdStatus
|
||||
from pyinfra.operations import apt, files, server, systemd
|
||||
|
||||
|
||||
def deploy_acmetool(nginx_hook=False, email="", domains=[]):
|
||||
def deploy_acmetool(email="", domains=[]):
|
||||
"""Deploy acmetool."""
|
||||
apt.packages(
|
||||
name="Install acmetool",
|
||||
@@ -20,16 +20,13 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
|
||||
mode="644",
|
||||
)
|
||||
|
||||
if nginx_hook:
|
||||
files.put(
|
||||
src=importlib.resources.files(__package__)
|
||||
.joinpath("acmetool.hook")
|
||||
.open("rb"),
|
||||
dest="/usr/lib/acme/hooks/nginx",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="744",
|
||||
)
|
||||
files.put(
|
||||
src=importlib.resources.files(__package__).joinpath("acmetool.hook").open("rb"),
|
||||
dest="/usr/lib/acme/hooks/nginx",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="744",
|
||||
)
|
||||
|
||||
files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("response-file.yaml.j2"),
|
||||
@@ -74,5 +71,5 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
|
||||
|
||||
server.shell(
|
||||
name=f"Request certificate for: { ', '.join(domains) }",
|
||||
commands=[f"acmetool want { ' '.join(domains)}"],
|
||||
commands=[f"acmetool want --xlog.severity=debug { ' '.join(domains)}"],
|
||||
)
|
||||
|
||||
@@ -3,3 +3,5 @@ set -e
|
||||
EVENT_NAME="$1"
|
||||
[ "$EVENT_NAME" = "live-updated" ] || exit 42
|
||||
systemctl restart nginx.service
|
||||
systemctl reload dovecot.service
|
||||
systemctl reload postfix.service
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"acme-enter-email": "{{ email }}"
|
||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf": true
|
||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.4-April-3-2024.pdf": true
|
||||
|
||||
@@ -4,19 +4,18 @@ along with command line option and subcommand parsing.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import importlib.resources
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from termcolor import colored
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
from cmdeploy.dns import show_dns, check_necessary_dns
|
||||
from termcolor import colored
|
||||
|
||||
from cmdeploy.dns import check_necessary_dns, show_dns
|
||||
|
||||
#
|
||||
# cmdeploy sub commands and options
|
||||
@@ -39,10 +38,6 @@ def init_cmd(args, out):
|
||||
else:
|
||||
write_initial_config(args.inipath, mail_domain)
|
||||
out.green(f"created config file for {mail_domain} in {args.inipath}")
|
||||
check_necessary_dns(
|
||||
out,
|
||||
mail_domain,
|
||||
)
|
||||
|
||||
|
||||
def run_cmd_options(parser):
|
||||
@@ -157,26 +152,26 @@ def fmt_cmd_options(parser):
|
||||
|
||||
|
||||
def fmt_cmd(args, out):
|
||||
"""Run formattting fixes (ruff and black) on all chatmail source code."""
|
||||
"""Run formattting fixes on all chatmail source code."""
|
||||
|
||||
sources = [str(importlib.resources.files(x)) for x in ("chatmaild", "cmdeploy")]
|
||||
black_args = [shutil.which("black")]
|
||||
ruff_args = [shutil.which("ruff")]
|
||||
format_args = [shutil.which("ruff"), "format"]
|
||||
check_args = [shutil.which("ruff"), "check"]
|
||||
|
||||
if args.check:
|
||||
black_args.append("--check")
|
||||
format_args.append("--diff")
|
||||
else:
|
||||
ruff_args.append("--fix")
|
||||
check_args.append("--fix")
|
||||
|
||||
if not args.verbose:
|
||||
black_args.append("-q")
|
||||
ruff_args.append("-q")
|
||||
check_args.append("--quiet")
|
||||
format_args.append("--quiet")
|
||||
|
||||
black_args.extend(sources)
|
||||
ruff_args.extend(sources)
|
||||
format_args.extend(sources)
|
||||
check_args.extend(sources)
|
||||
|
||||
out.check_call(" ".join(black_args), quiet=not args.verbose)
|
||||
out.check_call(" ".join(ruff_args), quiet=not args.verbose)
|
||||
out.check_call(" ".join(format_args), quiet=not args.verbose)
|
||||
out.check_call(" ".join(check_args), quiet=not args.verbose)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -232,7 +227,7 @@ class Out:
|
||||
if not quiet:
|
||||
cmdstring = " ".join(args)
|
||||
self(f"[$ {cmdstring}]", file=sys.stderr)
|
||||
proc = subprocess.run(args, env=env)
|
||||
proc = subprocess.run(args, env=env, check=False)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
import importlib.resources
|
||||
import os
|
||||
|
||||
import pyinfra
|
||||
|
||||
from cmdeploy import deploy_chatmail
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import datetime
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import requests
|
||||
import importlib
|
||||
import subprocess
|
||||
import datetime
|
||||
|
||||
|
||||
class DNS:
|
||||
@@ -11,6 +11,11 @@ class DNS:
|
||||
self.session = requests.Session()
|
||||
self.out = out
|
||||
self.ssh = f"ssh root@{mail_domain} -- "
|
||||
self.out.shell_output(
|
||||
f"{ self.ssh }'apt-get update && apt-get install -y dnsutils'",
|
||||
timeout=60,
|
||||
no_print=True,
|
||||
)
|
||||
try:
|
||||
self.shell(f"unbound-control flush_zone {mail_domain}")
|
||||
except subprocess.CalledProcessError:
|
||||
@@ -99,8 +104,8 @@ def show_dns(args, out) -> int:
|
||||
return 0
|
||||
except TypeError:
|
||||
pass
|
||||
for line in zonefile.splitlines():
|
||||
line = line.format(
|
||||
for raw_line in zonefile.splitlines():
|
||||
line = raw_line.format(
|
||||
acme_account_url=acme_account_url,
|
||||
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
|
||||
chatmail_domain=args.config.mail_domain,
|
||||
@@ -178,6 +183,7 @@ def show_dns(args, out) -> int:
|
||||
|
||||
def check_necessary_dns(out, mail_domain):
|
||||
"""Check whether $mail_domain and mta-sts.$mail_domain resolve."""
|
||||
print("Checking necessary DNS records... ")
|
||||
dns = DNS(out, mail_domain)
|
||||
ipv4 = dns.get("A", mail_domain)
|
||||
ipv6 = dns.get("AAAA", mail_domain)
|
||||
@@ -199,5 +205,5 @@ def check_necessary_dns(out, mail_domain):
|
||||
print(line)
|
||||
print()
|
||||
else:
|
||||
dns.out.green("\nAll necessary DNS entries seem to be set.")
|
||||
dns.out.green("All necessary DNS records seem to be set.")
|
||||
return True
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
uri = proxy:/run/dovecot/doveauth.socket:auth
|
||||
uri = proxy:/run/doveauth/doveauth.socket:auth
|
||||
iterate_disable = yes
|
||||
default_pass_scheme = plain
|
||||
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
|
||||
|
||||
@@ -27,7 +27,7 @@ mail_plugins = quota
|
||||
# these are the capabilities Delta Chat cares about actually
|
||||
# so let's keep the network overhead per login small
|
||||
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
|
||||
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA XDELTAPUSH
|
||||
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA XDELTAPUSH XCHATMAIL
|
||||
|
||||
|
||||
# Authentication for system users.
|
||||
@@ -78,7 +78,7 @@ mail_privileged_group = vmail
|
||||
##
|
||||
|
||||
# Pass all IMAP METADATA requests to the server implementing Dovecot's dict protocol.
|
||||
mail_attribute_dict = proxy:/run/dovecot/metadata.socket:metadata
|
||||
mail_attribute_dict = proxy:/run/chatmail-metadata/metadata.socket:metadata
|
||||
|
||||
# Enable IMAP COMPRESS (RFC 4978).
|
||||
# <https://datatracker.ietf.org/doc/html/rfc4978.html>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import importlib
|
||||
import qrcode
|
||||
import os
|
||||
from PIL import ImageFont, ImageDraw, Image
|
||||
import io
|
||||
import os
|
||||
|
||||
import qrcode
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def gen_qr_png_data(maildomain):
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
[Journal]
|
||||
MaxRetentionSec=3d
|
||||
Storage=volatile
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
error_log /var/log/nginx/error.log;
|
||||
error_log syslog:server=unix:/dev/log,facility=local3;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
@@ -35,6 +35,8 @@ http {
|
||||
|
||||
server_name _;
|
||||
|
||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||
|
||||
location / {
|
||||
# First attempt to serve request as file, then
|
||||
# as directory, then fall back to displaying a 404.
|
||||
@@ -80,5 +82,6 @@ http {
|
||||
listen [::]:443 ssl;
|
||||
server_name www.{{ config.domain_name }};
|
||||
return 301 $scheme://{{ config.domain_name }}$request_uri;
|
||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
cmdeploy/src/cmdeploy/obs-home-deltachat.gpg
Normal file
BIN
cmdeploy/src/cmdeploy/obs-home-deltachat.gpg
Normal file
Binary file not shown.
@@ -19,7 +19,11 @@ for i = 1, nsigs do
|
||||
-- Any valid signature that was not ignored like this
|
||||
-- means the message is acceptable.
|
||||
if sigres == 0 then
|
||||
return nil
|
||||
-- Do not accept the signature if it does not cover the whole body
|
||||
-- of the message by using `l=` tag.
|
||||
if odkim.sig_canonlength(ctx, sig) < odkim.sig_bodylength(ctx, sig) then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
Description=Chatmail dict proxy for IMAP METADATA
|
||||
|
||||
[Service]
|
||||
ExecStart={execpath} /run/dovecot/metadata.socket vmail /home/vmail/mail/{mail_domain}
|
||||
ExecStart={execpath} /run/chatmail-metadata/metadata.socket /home/vmail/mail/{mail_domain} {config_path}
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
User=vmail
|
||||
RuntimeDirectory=chatmail-metadata
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
Description=Chatmail dict authentication proxy for dovecot
|
||||
|
||||
[Service]
|
||||
ExecStart={execpath} /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite {config_path}
|
||||
ExecStart={execpath} /run/doveauth/doveauth.socket /home/vmail/passdb.sqlite {config_path}
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
User=vmail
|
||||
RuntimeDirectory=doveauth
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -7,5 +7,61 @@ Environment="PATH={remote_venv_dir}:$PATH"
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
User=echobot
|
||||
Group=echobot
|
||||
|
||||
# Create /var/lib/echobot
|
||||
StateDirectory=echobot
|
||||
|
||||
# Create /run/echobot
|
||||
#
|
||||
# echobot stores /run/echobot/password
|
||||
# with a password there, which doveauth then reads.
|
||||
RuntimeDirectory=echobot
|
||||
|
||||
WorkingDirectory=/var/lib/echobot
|
||||
|
||||
# Apply security restrictions suggested by
|
||||
# systemd-analyze security echobot.service
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateMounts=true
|
||||
PrivateTmp=true
|
||||
|
||||
# We need to know about doveauth user to give it access to /run/echobot/password
|
||||
PrivateUsers=false
|
||||
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHostname=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectProc=noaccess
|
||||
|
||||
# Should be "strict", but we currently write /accounts folder in a protected path
|
||||
ProtectSystem=full
|
||||
|
||||
RemoveIPC=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=~@clock
|
||||
SystemCallFilter=~@cpu-emulation
|
||||
SystemCallFilter=~@debug
|
||||
SystemCallFilter=~@module
|
||||
SystemCallFilter=~@mount
|
||||
SystemCallFilter=~@obsolete
|
||||
SystemCallFilter=~@raw-io
|
||||
SystemCallFilter=~@reboot
|
||||
SystemCallFilter=~@resources
|
||||
SystemCallFilter=~@swap
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import pytest
|
||||
import threading
|
||||
import queue
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from chatmaild.config import read_config
|
||||
|
||||
from cmdeploy.cmdeploy import main
|
||||
|
||||
|
||||
@@ -14,6 +15,13 @@ def test_init(tmp_path, maildomain):
|
||||
assert config.mail_domain == maildomain
|
||||
|
||||
|
||||
def test_capabilities(imap):
|
||||
imap.connect()
|
||||
capas = imap.conn.capabilities
|
||||
assert "XCHATMAIL" in capas
|
||||
assert "XDELTAPUSH" in capas
|
||||
|
||||
|
||||
def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
|
||||
"""Test a) that an initial login creates a user automatically
|
||||
and b) verify we can also login a second time with the same password
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import smtplib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -85,6 +86,7 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
|
||||
pytest.fail("Rate limit was not exceeded")
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_expunged(remote, chatmail_config):
|
||||
outdated_days = int(chatmail_config.delete_mails_after) + 1
|
||||
find_cmds = [
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import time
|
||||
import re
|
||||
import ipaddress
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
|
||||
import imap_tools
|
||||
import pytest
|
||||
import requests
|
||||
import ipaddress
|
||||
import imap_tools
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import os
|
||||
import io
|
||||
import time
|
||||
import random
|
||||
import subprocess
|
||||
import imaplib
|
||||
import smtplib
|
||||
import io
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chatmaild.database import Database
|
||||
from chatmaild.config import read_config
|
||||
|
||||
from chatmaild.database import Database
|
||||
|
||||
conftestdir = Path(__file__).parent
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from cmdeploy.cmdeploy import get_parser, main
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import importlib.resources
|
||||
import webbrowser
|
||||
import hashlib
|
||||
import importlib.resources
|
||||
import time
|
||||
import traceback
|
||||
import webbrowser
|
||||
|
||||
import markdown
|
||||
from jinja2 import Template
|
||||
from .genqr import gen_qr_png_data
|
||||
from chatmaild.config import read_config
|
||||
from jinja2 import Template
|
||||
|
||||
from .genqr import gen_qr_png_data
|
||||
|
||||
|
||||
def snapshot_dir_stats(somedir):
|
||||
@@ -120,7 +121,8 @@ def main():
|
||||
print(f"watching {src_path} directory for changes")
|
||||
|
||||
changenum = 0
|
||||
for count in range(0, 1000000):
|
||||
count = 0
|
||||
while True:
|
||||
newstats = snapshot_dir_stats(src_path)
|
||||
if newstats == stats and count % 60 != 0:
|
||||
count += 1
|
||||
|
||||
80
scripts/dovecot/README.md
Normal file
80
scripts/dovecot/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
## Introduction to custom Dovecot builds
|
||||
|
||||
Chatmail servers use a custom Debian build of the IMAP 'dovecot' server software because
|
||||
|
||||
a) Dovecot developers did not yet merge a [pull request](https://github.com/dovecot/core/pull/216)
|
||||
which majorly speeds up message delivery by removing a hardcoded 0.5 second delay
|
||||
on relaying incoming messages.
|
||||
|
||||
b) Even if merged, it would take years for it to reach Debian stable.
|
||||
|
||||
c) The modified dovecot has been successfully used since December 2023 without issues
|
||||
and we see no noticeable downside (theoretically higher CPU usage but not measureable)
|
||||
but a considerable upside as the delay-removal facilitates end-to-end message
|
||||
delivery of 200 ms in real networks.
|
||||
|
||||
The modified forked dovecot code lives at
|
||||
[https://github.com/chatmail/dovecot](https://github.com/chatmail/dovecot).
|
||||
The remainder of this document describes the setup of the Debian repository
|
||||
containing the patched dovecot version.
|
||||
|
||||
## Building Debian packages at build.opensuse.org
|
||||
|
||||
Delta Chat developers maintain an [account](https://build.opensuse.org/project/show/home:deltachat)
|
||||
in the [Open Build Service (OBS)](https://openbuildservice.org/),
|
||||
where the [resulting package](https://build.opensuse.org/package/show/home:deltachat/dovecot)
|
||||
is now used in deploying chatmail servers.
|
||||
|
||||
The Open Build Service (OBS) is a platform for building and distributing software packages
|
||||
across various operating systems and architectures.
|
||||
It supports openSUSE, Fedora, Debian, Ubuntu and Arch.
|
||||
It's [primary instance](https://build.opensuse.org/) is ran by the openSUSE project
|
||||
and is part of the pipeline of the creation of SUSE Linux Enterprise.
|
||||
|
||||
The OBS provides a mercurial-like interface to create source repositories
|
||||
that are then automatically built.
|
||||
While in theory a package can be created entirely over the web interface,
|
||||
the use of the cli-tool `osc` is more convenient and is described in the [official documentation](https://openbuildservice.org/help/manuals/obs-user-guide/art.obs.bg#sec.obsbg.obsconfig).
|
||||
|
||||
### How to build the dovecot debian package on the OBS via our script
|
||||
|
||||
In scripts/dovecot/ is a shell script that prepares the required files and pushes them to build.opensuse.org.
|
||||
|
||||
Before using the script, you should have osc set up as described in the [official documentation](https://openbuildservice.org/help/manuals/obs-user-guide/art.obs.bg#sec.obsbg.obsconfig).
|
||||
|
||||
The script assumes you are on Debian. It automatically installs any needed dependencies and creates the source package. To upload the resulting source package to the OBS you need to enter the username and password for deltachat on build.opensuse.org in the last step of the script.
|
||||
|
||||
Use `source build-obs.sh` to run it.
|
||||
|
||||
### Adding the resulting OBS repository to Debian 12
|
||||
|
||||
Our dovecot fork is automatically installed as part of the chatmail deployment. You can see it in cmdeploy/src/cmdeploy/__init__.py. If you want to add our fork manually to a system, you can do the following:
|
||||
|
||||
First add our signing key to your apt keyring:
|
||||
|
||||
```
|
||||
sudo cp cmdeploy/src/cmdeploy/obs-home-deltachat.gpg /etc/apt/keyrings/obs-home-deltachat.gpg`
|
||||
```
|
||||
|
||||
Now add our repository and key to /etc/apt/sources.list with a text editor of your choice:
|
||||
|
||||
```
|
||||
deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./
|
||||
```
|
||||
|
||||
You can now install dovecot like normal.
|
||||
|
||||
```
|
||||
sudo apt update
|
||||
sudo apt install dovecot-core
|
||||
```
|
||||
|
||||
### Security concerns
|
||||
|
||||
The signing of the patched dovecot package is done in the OBS and
|
||||
in theory SUSE could make changes to the package delivered.
|
||||
It is probably reasonable to trust SUSE to not mess with the build
|
||||
process because it would cause serious negative reputation damage for them
|
||||
if they tried and someone finds out.
|
||||
|
||||
Our dovecot fork will receive the same security backports as the dovecot package in Debian Sid.
|
||||
54
scripts/dovecot/build-obs.sh
Normal file
54
scripts/dovecot/build-obs.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies for this script:"
|
||||
sudo apt install -y devscripts build-essential osc curl git debhelper-compat
|
||||
|
||||
# Define path of your local OBS repository
|
||||
SCRIPT_DIR=$PWD
|
||||
OBS_PATH=$SCRIPT_DIR/obs
|
||||
REPO_PATH=$OBS_PATH/home:deltachat/dovecot/
|
||||
|
||||
# Download Debian Source Files
|
||||
echo "Downloading precise files from Debian unstable repository..."
|
||||
mkdir dovecot-build
|
||||
cd dovecot-build
|
||||
|
||||
# taken May 6th 2024, from https://packages.debian.org/unstable/dovecot-core
|
||||
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1-3.debian.tar.xz -O
|
||||
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1.orig.tar.gz -O
|
||||
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz -O
|
||||
|
||||
# Clone the Chatmail Dovecot Repo
|
||||
echo "Cloning the Chatmail Dovecot fork..."
|
||||
git clone https://github.com/chatmail/dovecot.git
|
||||
|
||||
# Build the source package
|
||||
echo "Building the source package"
|
||||
cd dovecot
|
||||
dpkg-source -b .
|
||||
|
||||
# Setting up OSC
|
||||
echo "Setting up OBS home repository"
|
||||
mkdir $OBS_PATH
|
||||
cd $OBS_PATH
|
||||
rm -rf home:deltachat/dovecot
|
||||
osc checkout home:deltachat/dovecot
|
||||
|
||||
# Copy Files to Your Local OBS Repository,
|
||||
echo "Copying files to your local OBS repository..."
|
||||
cd $SCRIPT_DIR/dovecot-build
|
||||
cp -rf dovecot_2.3.21+dfsg1-3.debian.tar.xz $REPO_PATH
|
||||
cp -rf dovecot_2.3.21+dfsg1.orig.tar.gz $REPO_PATH
|
||||
cp -rf dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz $REPO_PATH
|
||||
cp -rf dovecot_2.3.21+dfsg1-3.dsc $REPO_PATH
|
||||
|
||||
# Push Changes to OBS
|
||||
echo "Pushing changes to OBS..."
|
||||
cd $REPO_PATH
|
||||
osc up
|
||||
osc add dovecot_2.3.21+dfsg1-3.debian.tar.xz
|
||||
osc add dovecot_2.3.21+dfsg1.orig.tar.gz
|
||||
osc add dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz
|
||||
osc add dovecot_2.3.21+dfsg1-3.dsc
|
||||
osc commit
|
||||
@@ -1,11 +1,17 @@
|
||||
|
||||
<img class="banner" src="collage-top.png"/>
|
||||
|
||||
## Dear [Delta Chat](https://get.delta.chat) users and newcomers,
|
||||
## Dear [Delta Chat](https://get.delta.chat) users and newcomers ...
|
||||
|
||||
{% if config.mail_domain != "nine.testrun.org" %}
|
||||
Welcome to instant, interoperable and [privacy-preserving](privacy.html) messaging :)
|
||||
{% else %}
|
||||
Welcome to the default onboarding server ({{ config.mail_domain }})
|
||||
for Delta Chat users. For details how it avoids storing personal information
|
||||
please see our [privacy policy](privacy.html).
|
||||
{% endif %}
|
||||
|
||||
👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address
|
||||
👉 **Tap** or scan this QR code to get a `@{{config.mail_domain}}` chat profile
|
||||
|
||||
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
|
||||
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
|
||||
<img class="banner" src="collage-info.png"/>
|
||||
|
||||
## More information
|
||||
|
||||
{{ config.mail_domain }} provides a low-maintenance, resource efficient and
|
||||
@@ -11,7 +9,7 @@ for the usage in chats, especially DeltaChat.
|
||||
### Choosing a chatmail address instead of using a random one
|
||||
|
||||
In the Delta Chat account setup
|
||||
you may tap `LOG INTO YOUR E-MAIL ACCOUNT`
|
||||
you may tap `I already have a profile`
|
||||
and fill the two fields like this:
|
||||
|
||||
- `Address`: invent a word with
|
||||
|
||||
@@ -1,21 +1,41 @@
|
||||
<img class="banner" src="collage-privacy.png"/>
|
||||
|
||||
# Privacy Policy for {{ config.mail_domain }}
|
||||
|
||||
We want to show you in a fair and transparent way
|
||||
what personal data is processed by us.
|
||||
We follow a strict privacy-by-design approach
|
||||
and try to avoid processing your data in the first place,
|
||||
but as you may know,
|
||||
the internet,
|
||||
and in particular sending e-mail messages,
|
||||
does not work without data.
|
||||
Still,
|
||||
it's only fair that you know at all times
|
||||
what personal data is processed
|
||||
when you use our service.
|
||||
{% if config.mail_domain == "nine.testrun.org" %}
|
||||
Welcome to `{{config.mail_domain}}`, the default chatmail onboarding server for Delta Chat users.
|
||||
It is operated on the side by a small sysops team employed by [merlinux](https://merlinux.eu),
|
||||
an open-source R&D company also acting as the fiscal sponsor of Delta Chat app developments.
|
||||
See [other chatmail servers](https://delta.chat/en/chatmail) for alternative server operators.
|
||||
{% endif %}
|
||||
|
||||
|
||||
## Summary: No personal data asked or collected
|
||||
|
||||
This chatmail server neither asks for nor retains personal information.
|
||||
Chatmail servers exist to reliably transmit (store and deliver) end-to-end encrypted messages
|
||||
between user's devices running the Delta Chat messenger app.
|
||||
Technically, you may think of a Chatmail server as
|
||||
an end-to-end encrypted "messaging router" at Internet-scale.
|
||||
|
||||
A chatmail server is very unlike classic e-mail servers (for example Google Mail servers)
|
||||
that ask for personal data and permanently store messages.
|
||||
A chatmail server behaves more like the Signal messaging server
|
||||
but does not know about phone numbers and securely and automatically interoperates
|
||||
with other chatmail and classic e-mail servers.
|
||||
|
||||
In particular, this chatmail server
|
||||
|
||||
- unconditionally removes messages after {{ config.delete_mails_after }} days,
|
||||
|
||||
- prohibits sending out un-encrypted messages,
|
||||
|
||||
- only has temporary log files used for debugging purposes.
|
||||
|
||||
Legally, authorities might still regard chatmail as a "classic e-mail" server
|
||||
which collects and retains personal data.
|
||||
We do not agree on this interpretation. Nevertheless, we provide more legal details below
|
||||
to make life easier for data protection specialists and lawyers scrutinizing chatmail operations.
|
||||
|
||||
If you have any remaining questions about data protection, please contact us.
|
||||
|
||||
## 1. Name and contact information
|
||||
|
||||
@@ -178,8 +198,9 @@ for the purpose of drawing conclusions about your person.
|
||||
|
||||
## 4. Transfer of Data
|
||||
|
||||
Your personal data
|
||||
will not be transferred to third parties
|
||||
We do not retain any personal data but e-mail messages waiting to be delivered
|
||||
may contain personal data.
|
||||
Any such residual personal data will not be transferred to third parties
|
||||
for purposes other than those listed below:
|
||||
|
||||
a) you have given your express consent
|
||||
|
||||
Reference in New Issue
Block a user