Compare commits

..

1 Commits

Author SHA1 Message Date
link2xt
41b8ec0421 Dovecot: setup METADATA
There is no dictionary to set additional attributes,
but admin email can already be retrieved:

? GETMETADATA "" (/shared/admin)
* METADATA "" (/shared/admin {27}
mailto:root@c20.testrun.org)
? OK Getmetadata completed (0.001 + 0.000 secs).
2024-01-20 23:20:50 +00:00
80 changed files with 781 additions and 2771 deletions

View File

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

View File

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

View File

@@ -1,96 +1,72 @@
name: deploy on staging2.testrun.org, and run tests
name: deploy on staging.testrun.org, and run tests
on:
push:
branches:
- main
pull_request:
paths-ignore:
- 'scripts/**'
- '**/README.md'
- 'CHANGELOG.md'
- 'LICENSE'
- staging-ci
jobs:
deploy:
name: deploy on staging2.testrun.org, and run tests
name: deploy on staging.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !contains(github.ref, '$GITHUB_REF') }}
steps:
- uses: jsok/serialize-workflow-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
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
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
- 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"
#- 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"
- 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
- run: cmdeploy init staging2.testrun.org
#- 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 run --verbose
- run: cmdeploy init staging.testrun.org
- run: cmdeploy run
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone --verbose
#ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown _rspamd:_rspamd -R /var/lib/rspamd/dkim
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 .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
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
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=nine.testrun.org cmdeploy test --slow
- name: cmdeploy dns (try 3 times)
run: cmdeploy dns -v || cmdeploy dns -v || cmdeploy dns -v
run: cmdeploy dns || cmdeploy dns || cmdeploy dns

View File

@@ -1,155 +0,0 @@
# Changelog for chatmail deployment
## untagged
- BREAKING: new required chatmail.ini values:
mailboxes_dir = /home/vmail/mail/{mail_domain}
passdb = /home/vmail/passdb.sqlite
reducing hardcoding these two paths all over the files, also improving testability.
([#351](https://github.com/deltachat/chatmail/pull/351))
- BREAKING: new required chatmail.ini value 'delete_inactive_users_after = 100'
which removes users from database and mails after 100 days without any login.
([#350](https://github.com/deltachat/chatmail/pull/350))
- reload nginx in the acmetool cronjob
([#360](https://github.com/deltachat/chatmail/pull/360))
- remove checking of reverse-DNS PTR records. Chatmail-servers don't
depend on it and even in the wider e-mail system it's not common anymore.
If it's an issue, a chatmail operator can still care to properly set reverse DNS.
([#348](https://github.com/deltachat/chatmail/pull/348))
- Make DNS-checking faster and more interactive, run it fully during "cmdeploy run",
also introducing a generic mechanism for rapid remote ssh-based python function execution.
([#346](https://github.com/deltachat/chatmail/pull/346))
- Don't fix file owner ship of /home/vmail
([#345](https://github.com/deltachat/chatmail/pull/345))
- Support iterating over all users with doveadm commands
([#344](https://github.com/deltachat/chatmail/pull/344))
- Test and fix for attempts to create inadmissible accounts
([#333](https://github.com/deltachat/chatmail/pull/321))
- check that OpenPGP has only PKESK, SKESK and SEIPD packets
([#323](https://github.com/deltachat/chatmail/pull/323),
[#324](https://github.com/deltachat/chatmail/pull/324))
- improve filtermail checks for encrypted messages and drop support for unencrypted MDNs
([#320](https://github.com/deltachat/chatmail/pull/320))
- replace `bash` with `/bin/sh`
([#334](https://github.com/deltachat/chatmail/pull/334))
- Increase number of logged in IMAP sessions to 50000
([#335](https://github.com/deltachat/chatmail/pull/335))
- filtermail: do not allow ASCII armor without actual payload
([#325](https://github.com/deltachat/chatmail/pull/325))
- Remove sieve to enable hardlink deduplication in LMTP
([#343](https://github.com/deltachat/chatmail/pull/343))
- dovecot: enable gzip compression on disk
([#341](https://github.com/deltachat/chatmail/pull/341))
- DKIM-sign Content-Type and oversign all signed headers
([#296](https://github.com/deltachat/chatmail/pull/296))
- Add nonci_accounts metric
([#347](https://github.com/deltachat/chatmail/pull/347))
- doveauth: log when a new account is created
([#349](https://github.com/deltachat/chatmail/pull/349))
- Multiplex HTTPS, IMAP and SMTP on port 443
([#357](https://github.com/deltachat/chatmail/pull/357))
## 1.3.0 - 2024-06-06
- don't check necessary DNS records on cmdeploy init anymore
([#316](https://github.com/deltachat/chatmail/pull/316))
- ensure cron and acl are installed
([#293](https://github.com/deltachat/chatmail/pull/293),
[#310](https://github.com/deltachat/chatmail/pull/310))
- change default for delete_mails_after from 40 to 20 days
([#300](https://github.com/deltachat/chatmail/pull/300))
- save journald logs only to memory and save nginx logs to journald instead of file
([#299](https://github.com/deltachat/chatmail/pull/299))
- fix writing of multiple obs repositories in `/etc/apt/sources.list`
([#290](https://github.com/deltachat/chatmail/pull/290))
- metadata: add support for `/shared/vendor/deltachat/irohrelay`
([#284](https://github.com/deltachat/chatmail/pull/284))
- Emit "XCHATMAIL" capability from IMAP server
([#278](https://github.com/deltachat/chatmail/pull/278))
- Move echobot `into /var/lib/echobot`
([#281](https://github.com/deltachat/chatmail/pull/281))
- Accept Let's Encrypt's new Terms of Services
([#275](https://github.com/deltachat/chatmail/pull/276))
- Reload Dovecot and Postfix when TLS certificate updates
([#271](https://github.com/deltachat/chatmail/pull/271))
- Use forked version of dovecot without hardcoded delays
([#270](https://github.com/deltachat/chatmail/pull/270))
## 1.2.0 - 2024-04-04
- Install dig on the server to resolve DNS records
([#267](https://github.com/deltachat/chatmail/pull/267))
- preserve notification order and exponentially backoff with
retries for tokens where we didn't get a successful return
([#265](https://github.com/deltachat/chatmail/pull/263))
- Run chatmail-metadata and doveauth as vmail
([#261](https://github.com/deltachat/chatmail/pull/261))
- Apply systemd restrictions to echobot
([#259](https://github.com/deltachat/chatmail/pull/259))
- re-enable running the CI in pull requests, but not concurrently
([#258](https://github.com/deltachat/chatmail/pull/258))
## 1.1.0 - 2024-03-28
### The changelog starts to record changes from March 15th, 2024
- Move systemd unit templates to cmdeploy package
([#255](https://github.com/deltachat/chatmail/pull/255))
- Persist push tokens and support multiple device per address
([#254](https://github.com/deltachat/chatmail/pull/254))
- Avoid warning for regular doveauth protocol's hello message.
([#250](https://github.com/deltachat/chatmail/pull/250))
- Fix various tests to pass again with "cmdeploy test".
([#245](https://github.com/deltachat/chatmail/pull/245),
[#242](https://github.com/deltachat/chatmail/pull/242)
- Ensure lets-encrypt certificates are reloaded after renewal
([#244]) https://github.com/deltachat/chatmail/pull/244
- Persist tokens to avoid iOS users loosing push-notifications when the
chatmail metadata service is restarted (happens regularly during deploys)
([#238](https://github.com/deltachat/chatmail/pull/239)
- Fix failing sieve-script compile errors on incoming messages
([#237](https://github.com/deltachat/chatmail/pull/239)
- Fix quota reporting after expunging of old mails
([#233](https://github.com/deltachat/chatmail/pull/239)

View File

@@ -15,8 +15,6 @@ 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.
@@ -155,34 +153,10 @@ While this file is present, account creation will be blocked.
[Postfix](http://www.postfix.org/) listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
[Dovecot](https://www.dovecot.org/) listens on ports 143 (imap) and 993 (imaps).
[nginx](https://www.nginx.com/) listens on port 8443 (https-alt) and 443 (https).
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
[nginx](https://www.nginx.com/) listens on port 443 (https).
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (http).
Delta Chat apps will, however, discover all ports and configurations
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail service.
## Email authentication
chatmail servers rely on [DKIM](https://www.rfc-editor.org/rfc/rfc6376)
to authenticate incoming emails.
Incoming emails must have a valid DKIM signature with
Signing Domain Identifier (SDID, `d=` parameter in the DKIM-Signature header)
equal to the `From:` header domain.
This property is checked by OpenDKIM screen policy script
before validating the signatures.
This correpsonds to strict [DMARC](https://www.rfc-editor.org/rfc/rfc7489) alignment (`adkim=s`),
but chatmail does not rely on DMARC and does not consult the sender policy published in DMARC records.
Other legacy authentication mechanisms such as [iprev](https://www.rfc-editor.org/rfc/rfc8601#section-2.7.3)
and [SPF](https://www.rfc-editor.org/rfc/rfc7208) are also not taken into account.
If there is no valid DKIM signature on the incoming email,
the sender receives a "5.7.1 No valid DKIM signature found" error.
Outgoing emails must be sent over authenticated connection
with envelope MAIL FROM (return path) corresponding to the login.
This is ensured by Postfix which maps login username
to MAIL FROM with
[`smtpd_sender_login_maps`](https://www.postfix.org/postconf.5.html#smtpd_sender_login_maps)
and rejects incorrectly authenticated emails with [`reject_sender_login_mismatch`](reject_sender_login_mismatch) policy.
`From:` header must correspond to envelope MAIL FROM,
this is ensured by `filtermail` proxy.

View File

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

View File

@@ -10,8 +10,6 @@ dependencies = [
"iniconfig",
"deltachat-rpc-server",
"deltachat-rpc-client",
"filelock",
"requests",
]
[tool.setuptools]
@@ -22,11 +20,9 @@ where = ['src']
[project.scripts]
doveauth = "chatmaild.doveauth:main"
chatmail-metadata = "chatmaild.metadata:main"
filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main"
delete_inactive_users = "chatmaild.delete_inactive_users:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
@@ -37,16 +33,6 @@ 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]
@@ -58,9 +44,10 @@ skipdist = True
skip_install = True
deps =
ruff
black
commands =
ruff format --quiet --diff src/
ruff check src/
black --quiet --check --diff src/
ruff src/
[testenv]
deps = pytest

View File

@@ -1,13 +1,9 @@
from pathlib import Path
import iniconfig
def read_config(inipath):
assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath)
params = cfg.sections["params"]
return Config(inipath, params=params)
return Config(inipath, params=cfg.sections["params"])
class Config:
@@ -17,17 +13,13 @@ class Config:
self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
self.max_mailbox_size = params["max_mailbox_size"]
self.delete_mails_after = params["delete_mails_after"]
self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.mailboxes_dir = Path(params["mailboxes_dir"].strip())
self.passdb_path = Path(params["passdb_path"].strip())
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.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")
@@ -36,36 +28,14 @@ class Config:
def _getbytefile(self):
return open(self._inipath, "rb")
def get_user_maildir(self, addr):
if addr and addr != "." and "/" not in addr:
res = self.mailboxes_dir.joinpath(addr).resolve()
if res.is_relative_to(self.mailboxes_dir):
return res
raise ValueError(f"invalid address {addr!r}")
def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides."""
def write_initial_config(inipath, mail_domain):
from importlib.resources import files
inidir = files(__package__).joinpath("ini")
source_inipath = inidir.joinpath("chatmail.ini.f")
content = source_inipath.read_text().format(mail_domain=mail_domain)
# apply config overrides
new_lines = []
for line in content.split("\n"):
new_line = line.strip()
if new_line and new_line[0] not in "#[":
name, value = map(str.strip, new_line.split("=", maxsplit=1))
value = overrides.get(name, value)
new_line = f"{name} = {value}"
new_lines.append(new_line)
content = "\n".join(new_lines)
# apply testrun privacy overrides
content = (
inidir.joinpath("chatmail.ini.f").read_text().format(mail_domain=mail_domain)
)
if mail_domain.endswith(".testrun.org"):
override_inipath = inidir.joinpath("override-testrun.ini")
privacy = iniconfig.IniConfig(override_inipath)["privacy"]

View File

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

View File

@@ -1,33 +0,0 @@
"""
Remove inactive users
"""
import shutil
import sys
import time
from .config import read_config
from .database import Database
from .doveauth import iter_userdb_lastlogin_before
def delete_inactive_users(db, config, CHUNK=100):
cutoff_date = time.time() - config.delete_inactive_users_after * 86400
old_users = iter_userdb_lastlogin_before(db, cutoff_date)
chunks = (old_users[i : i + CHUNK] for i in range(0, len(old_users), CHUNK))
for sublist in chunks:
for user in sublist:
user_mail_dir = config.get_user_maildir(user)
shutil.rmtree(user_mail_dir, ignore_errors=True)
with db.write_transaction() as conn:
for user in sublist:
conn.execute("DELETE FROM users WHERE addr = ?", (user,))
def main():
(cfgpath,) = sys.argv[1:]
config = read_config(cfgpath)
db = Database(config.passdb_path)
delete_inactive_users(db, config)

View File

@@ -1,26 +1,22 @@
import crypt
import json
import logging
import os
import sys
import time
from pathlib import Path
import sys
import json
import crypt
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
ThreadingMixIn,
UnixStreamServer,
)
import pwd
from .config import Config, read_config
from .database import Database
from .config import read_config, Config
NOCREATE_FILE = "/etc/chatmail-nocreate"
class UnknownCommand(ValueError):
"""dictproxy handler received an unkown command"""
def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
@@ -46,75 +42,44 @@ 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
):
logging.warning(
"localpart %s has to be between %s and %s chars long",
localpart,
config.username_min_length,
config.username_max_length,
)
return False
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
return True
def get_user_data(db, config: Config, user):
if user == f"echo@{config.mail_domain}":
return dict(
home=str(config.get_user_maildir(user)),
uid="vmail",
gid="vmail",
)
def get_user_data(db, user):
with db.read_connection() as conn:
result = conn.get_user(user)
if result:
result["home"] = str(config.get_user_maildir(user))
result["uid"] = "vmail"
result["gid"] = "vmail"
return result
def lookup_userdb(db, config: Config, user):
return get_user_data(db, config, user)
def lookup_userdb(db, user):
return get_user_data(db, user)
def lookup_passdb(db, config: Config, user, cleartext_password, last_login=None):
if user == f"echo@{config.mail_domain}":
# Echobot writes password it wants to log in with into /run/echobot/password
try:
password = Path("/run/echobot/password").read_text()
except Exception:
logging.exception("Exception when trying to read /run/echobot/password")
return None
return dict(
home=str(config.get_user_maildir(user)),
uid="vmail",
gid="vmail",
password=encrypt_password(password),
)
if last_login is None:
last_login = time.time()
last_login = int(last_login)
def lookup_passdb(db, config: Config, user, cleartext_password):
with db.write_transaction() as conn:
userdata = conn.get_user(user)
if userdata:
# Update last login time.
conn.execute(
"UPDATE users SET last_login=? WHERE addr=?", (last_login, user)
"UPDATE users SET last_login=? WHERE addr=?", (int(time.time()), user)
)
userdata["home"] = str(config.get_user_maildir(user))
userdata["uid"] = "vmail"
userdata["gid"] = "vmail"
return userdata
@@ -124,34 +89,15 @@ def lookup_passdb(db, config: Config, user, cleartext_password, last_login=None)
encrypted_password = encrypt_password(cleartext_password)
q = """INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)"""
conn.execute(q, (user, encrypted_password, last_login))
print(f"Created address: {user}", file=sys.stderr)
conn.execute(q, (user, encrypted_password, int(time.time())))
return dict(
home=str(config.get_user_maildir(user)),
home=f"/home/vmail/mail/{config.mail_domain}/{user}",
uid="vmail",
gid="vmail",
password=encrypted_password,
)
def iter_userdb(db) -> list:
"""Get a list of all user addresses."""
with db.read_connection() as conn:
rows = conn.execute(
"SELECT addr from users",
).fetchall()
return [x[0] for x in rows]
def iter_userdb_lastlogin_before(db, cutoff_date):
"""Get a list of users where last login was before cutoff_date."""
with db.read_connection() as conn:
rows = conn.execute(
"SELECT addr FROM users WHERE last_login < ?", (cutoff_date,)
).fetchall()
return [x[0] for x in rows]
def split_and_unescape(s):
"""Split strings using double quote as a separator and backslash as escape character
into parts."""
@@ -179,12 +125,8 @@ def split_and_unescape(s):
def handle_dovecot_request(msg, db, config: Config):
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
short_command = msg[0]
if short_command == "H": # HELLO
# we don't do any checking on versions and just return
return
elif short_command == "L": # LOOKUP
if short_command == "L": # LOOKUP
parts = msg[1:].split("\t")
# Dovecot <2.3.17 has only one part,
@@ -200,7 +142,7 @@ def handle_dovecot_request(msg, db, config: Config):
if type == "userdb":
user = args[0]
if user.endswith(f"@{config.mail_domain}"):
res = lookup_userdb(db, config, user)
res = lookup_userdb(db, user)
if res:
reply_command = "O"
else:
@@ -215,29 +157,7 @@ def handle_dovecot_request(msg, db, config: Config):
reply_command = "N"
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
elif short_command == "I": # ITERATE
# example: I0\t0\tshared/userdb/
parts = msg[1:].split("\t")
if parts[2] == "shared/userdb/":
result = "".join(f"Oshared/userdb/{user}\t\n" for user in iter_userdb(db))
return f"{result}\n"
raise UnknownCommand(msg)
def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config):
while True:
msg = rfile.readline().strip().decode()
if not msg:
break
try:
res = handle_dovecot_request(msg, db, config)
except UnknownCommand:
logging.warning("unknown command: %r", msg)
else:
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
return None
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
@@ -245,14 +165,24 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
def main():
socket, cfgpath = sys.argv[1:]
config = read_config(cfgpath)
db = Database(config.passdb_path)
socket = sys.argv[1]
passwd_entry = pwd.getpwnam(sys.argv[2])
db = Database(sys.argv[3])
config = read_config(sys.argv[4])
class Handler(StreamRequestHandler):
def handle(self):
try:
handle_dovecot_protocol(self.rfile, self.wfile, db, config)
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, config)
if res:
self.wfile.write(res.encode("ascii"))
self.wfile.flush()
else:
logging.warn("request had no answer: %r", msg)
except Exception:
logging.exception("Exception in the handler")
raise
@@ -263,6 +193,7 @@ def main():
pass
with ThreadedUnixStreamServer(socket, Handler) as server:
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
try:
server.serve_forever()
except KeyboardInterrupt:

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,114 +1,20 @@
#!/usr/bin/env python3
import asyncio
import base64
import binascii
import logging
import sys
import time
from email import policy
import sys
from email.parser import BytesParser
from email import policy
from email.utils import parseaddr
from smtplib import SMTP as SMTPClient
from aiosmtpd.controller import Controller
from smtplib import SMTP as SMTPClient
from .config import read_config
def check_openpgp_payload(payload: bytes):
"""Checks the OpenPGP payload.
OpenPGP payload must consist only of PKESK and SKESK packets
terminated by a single SEIPD packet.
Returns True if OpenPGP payload is correct,
False otherwise.
May raise IndexError while trying to read OpenPGP packet header
if it is truncated.
"""
i = 0
while i < len(payload):
# Only OpenPGP format is allowed.
if payload[i] & 0xC0 != 0xC0:
return False
packet_type_id = payload[i] & 0x3F
i += 1
if payload[i] < 192:
# One-octet length.
body_len = payload[i]
i += 1
elif payload[i] < 224:
# Two-octet length.
body_len = ((payload[i] - 192) << 8) + payload[i + 1] + 192
i += 2
elif payload[i] == 255:
# Five-octet length.
body_len = (
(payload[i + 1] << 24)
| (payload[i + 2] << 16)
| (payload[i + 3] << 8)
| payload[i + 4]
)
i += 5
else:
# Partial body length is not allowed.
return False
i += body_len
if i == len(payload):
if packet_type_id == 18:
# Last packet should be
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
return True
elif packet_type_id not in [1, 3]:
# All packets except the last one must be either
# Public-Key Encrypted Session Key Packet (PKESK)
# or
# Symmetric-Key Encrypted Session Key Packet (SKESK)
return False
if i == 0:
return False
if i > len(payload):
# Payload is truncated.
return False
return True
def check_armored_payload(payload: str):
prefix = "-----BEGIN PGP MESSAGE-----\r\n\r\n"
if not payload.startswith(prefix):
return False
payload = payload.removeprefix(prefix)
suffix = "-----END PGP MESSAGE-----\r\n\r\n"
if not payload.endswith(suffix):
return False
payload = payload.removesuffix(suffix)
# Remove CRC24.
payload = payload.rpartition("=")[0]
try:
payload = base64.b64decode(payload)
except binascii.Error:
return False
try:
return check_openpgp_payload(payload)
except IndexError:
return False
def check_encrypted(message):
"""Check that the message is an OpenPGP-encrypted message.
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
"""
"""Check that the message is an OpenPGP-encrypted message."""
if not message.is_multipart():
return False
if message.get("subject") != "...":
@@ -117,44 +23,54 @@ 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
smtp_client = SMTPClient("localhost", config.postfix_reinject_port)
Controller(
BeforeQueueHandler(config, smtp_client=smtp_client),
hostname="127.0.0.1",
port=port,
).start()
Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
class BeforeQueueHandler:
def __init__(self, config, smtp_client):
def __init__(self, config):
self.config = config
self.smtp_client = smtp_client
self.send_rate_limiter = SendRateLimiter()
async def handle_MAIL(self, server, session, envelope, address, mail_options):
@@ -176,9 +92,8 @@ class BeforeQueueHandler:
if error:
return error
logging.info("re-injecting the mail that passed checks")
self.smtp_client.sendmail(
envelope.mail_from, envelope.rcpt_tos, envelope.content
)
client = SMTPClient("localhost", self.config.postfix_reinject_port)
client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
return "250 OK"
def check_DATA(self, envelope):
@@ -193,6 +108,9 @@ class BeforeQueueHandler:
if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if not mail_encrypted and check_mdn(message, envelope):
return
if envelope.mail_from in self.config.passthrough_senders:
return

View File

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

View File

@@ -8,20 +8,17 @@ mail_domain = {mail_domain}
#
#
# Restrictions on user addresses
# Account Restrictions
#
# how many mails a user can send out per minute
max_user_send_per_minute = 60
# maximum mailbox size of a chatmail address
# maximum mailbox size of a chatmail account
max_mailbox_size = 100M
# days after which mails are unconditionally deleted
delete_mails_after = 20
# days after which users without a login are deleted (database and mails)
delete_inactive_users_after = 100
delete_mails_after = 40
# minimum length a username must have
username_min_length = 9
@@ -32,22 +29,16 @@ username_max_length = 9
# minimum length a password must have
password_min_length = 9
# list of chatmail addresses which can send outbound un-encrypted mail
# list of chatmail accounts which can send outbound un-encrypted mail
passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails
passthrough_recipients = xstore@testrun.org groupsbot@hispanilandia.net
passthrough_recipients =
#
# Deployment Details
#
# Directory where user mailboxes are stored
mailboxes_dir = /home/vmail/mail/{mail_domain}
# user address sqlite database path
passdb_path = /home/vmail/passdb.sqlite
# where the filtermail SMTP service listens
filtermail_smtp_port = 10080
@@ -69,3 +60,4 @@ privacy_pdo =
# postal address of the privacy supervisor
privacy_supervisor =

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
import time
import sys
def main(vmail_dir=None):
@@ -15,15 +16,9 @@ def main(vmail_dir=None):
if path.name[:3] in ("ci-", "ac_"):
ci_accounts += 1
print("# HELP total number of accounts")
print("# TYPE accounts gauge")
print(f"accounts {accounts}")
print("# HELP number of CI accounts")
print("# TYPE ci_accounts gauge")
print(f"ci_accounts {ci_accounts}")
print("# HELP number of non-CI accounts")
print("# TYPE nonci_accounts gauge")
print(f"nonci_accounts {accounts - ci_accounts}")
timestamp = int(time.time() * 1000)
print(f"accounts {accounts} {timestamp}")
print(f"ci_accounts {ci_accounts} {timestamp}")
if __name__ == "__main__":

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,12 @@
import random
import importlib.resources
import itertools
import os
import random
from email import policy
from email.parser import BytesParser
from pathlib import Path
from email import policy
import pytest
from chatmaild.config import read_config, write_initial_config
from chatmaild.database import Database
from chatmaild.config import read_config, write_initial_config
@pytest.fixture
@@ -16,11 +14,7 @@ def make_config(tmp_path):
inipath = tmp_path.joinpath("chatmail.ini")
def make_conf(mail_domain):
basedir = tmp_path.joinpath(f"vmail/{mail_domain}")
basedir.mkdir(parents=True, exist_ok=True)
passdb = tmp_path.joinpath("vmail/passdb.sqlite")
overrides = dict(mailboxes_dir=str(basedir), passdb_path=str(passdb))
write_initial_config(inipath, mail_domain, overrides=overrides)
write_initial_config(inipath, mail_domain=mail_domain)
return read_config(inipath)
return make_conf
@@ -63,18 +57,11 @@ def db(tmpdir):
@pytest.fixture
def maildata(request):
try:
datadir = importlib.resources.files(__package__).joinpath("mail-data")
except TypeError:
# in python3.9 or lower, the above doesn't work, so we get datadir this way:
datadir = Path(os.getcwd()).joinpath("chatmaild/src/chatmaild/tests/mail-data")
datadir = importlib.resources.files(__package__).joinpath("mail-data")
assert datadir.exists(), datadir
def maildata(name, from_addr, to_addr):
# Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines.
data = datadir.joinpath(name).read_bytes().decode()
data = datadir.joinpath(name).read_text()
text = data.format(from_addr=from_addr, to_addr=to_addr)
return BytesParser(policy=policy.default).parsebytes(text.encode())

View File

@@ -1,4 +1,3 @@
import pytest
from chatmaild.config import read_config
@@ -25,37 +24,9 @@ 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 == "20"
assert config.delete_mails_after == "40"
assert config.username_min_length == 9
assert config.username_max_length == 9
assert config.password_min_length == 9
assert "privacy@testrun.org" in config.passthrough_recipients
assert config.passthrough_recipients == ["privacy@testrun.org"]
assert config.passthrough_senders == []
def test_config_userstate_paths(make_config, tmp_path):
config = make_config("something.testrun.org")
mailboxes_dir = config.mailboxes_dir
passdb_path = config.passdb_path
assert mailboxes_dir.name == "something.testrun.org"
assert passdb_path.name == "passdb.sqlite"
assert passdb_path.is_relative_to(tmp_path)
assert config.mail_domain == "something.testrun.org"
path = config.get_user_maildir("user1@something.testrun.org")
assert not path.exists()
assert path == mailboxes_dir.joinpath("user1@something.testrun.org")
with pytest.raises(ValueError):
config.get_user_maildir("")
with pytest.raises(ValueError):
config.get_user_maildir(None)
with pytest.raises(ValueError):
config.get_user_maildir("../some@something.testrun.org")
with pytest.raises(ValueError):
config.get_user_maildir("..")
with pytest.raises(ValueError):
config.get_user_maildir(".")

View File

@@ -1,51 +0,0 @@
import time
from chatmaild.delete_inactive_users import delete_inactive_users
from chatmaild.doveauth import lookup_passdb
def test_remove_stale_users(db, example_config):
new = time.time()
old = new - (example_config.delete_inactive_users_after * 86400) - 1
def create_user(addr, last_login):
lookup_passdb(db, example_config, addr, "q9mr3faue", last_login=last_login)
md = example_config.get_user_maildir(addr)
md.mkdir(parents=True)
md.joinpath("cur").mkdir()
md.joinpath("cur", "something").mkdir()
# create some stale and some new accounts
to_remove = []
for i in range(150):
addr = f"oldold{i:03}@chat.example.org"
create_user(addr, last_login=old)
with db.read_connection() as conn:
assert conn.get_user(addr)
to_remove.append(addr)
remain = []
for i in range(5):
addr = f"newnew{i:03}@chat.example.org"
create_user(addr, last_login=new)
remain.append(addr)
# check pre and post-conditions for delete_inactive_users()
for addr in to_remove:
assert example_config.get_user_maildir(addr).exists()
delete_inactive_users(db, example_config)
for p in example_config.mailboxes_dir.iterdir():
assert not p.name.startswith("old")
for addr in to_remove:
assert not example_config.get_user_maildir(addr).exists()
with db.read_connection() as conn:
assert not conn.get_user(addr)
for addr in remain:
assert example_config.get_user_maildir(addr).exists()
with db.read_connection() as conn:
assert conn.get_user(addr)

View File

@@ -1,27 +1,17 @@
import io
import json
import queue
import pytest
import threading
import queue
import traceback
import chatmaild.doveauth
import pytest
from chatmaild.doveauth import get_user_data, lookup_passdb, handle_dovecot_request
from chatmaild.database import DBError
from chatmaild.doveauth import (
get_user_data,
handle_dovecot_protocol,
handle_dovecot_request,
is_allowed_to_create,
iter_userdb,
iter_userdb_lastlogin_before,
lookup_passdb,
)
from chatmaild.newemail import create_newemail_dict
def test_basic(db, example_config):
lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue")
data = get_user_data(db, example_config, "asdf12345@chat.example.org")
data = get_user_data(db, "asdf12345@chat.example.org")
assert data
data2 = lookup_passdb(
db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue"
@@ -29,49 +19,6 @@ def test_basic(db, example_config):
assert data == data2
def test_iterate_addresses(db, example_config):
addresses = []
for i in range(10):
addresses.append(f"asdf1234{i}@chat.example.org")
lookup_passdb(db, example_config, addresses[-1], "q9mr3faue")
res = iter_userdb(db)
assert res == addresses
def test_iterate_addresses_lastlogin_before(db, example_config):
addresses = []
cutoff_date = 1000
for i in range(10):
addr = f"oldold{i:03}@chat.example.org"
lookup_passdb(
db, example_config, addr, "q9mr3faue", last_login=cutoff_date - 10
)
addresses.append(addr)
for i in range(5):
addr = f"newnew{i:03}@chat.example.org"
lookup_passdb(db, example_config, addr, "q9mr3faue", last_login=cutoff_date + i)
res = iter_userdb_lastlogin_before(db, cutoff_date)
assert sorted(res) == sorted(addresses)
def test_invalid_username_length(example_config):
config = example_config
config.username_min_length = 6
config.username_max_length = 10
password = create_newemail_dict(config)["password"]
assert not is_allowed_to_create(config, f"a1234@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"012345@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"0123456@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"0123456789@{config.mail_domain}", password)
assert not is_allowed_to_create(
config, f"0123456789x@{config.mail_domain}", password
)
def test_dont_overwrite_password_on_wrong_login(db, example_config):
"""Test that logging in with a different password doesn't create a new user"""
res = lookup_passdb(
@@ -90,7 +37,7 @@ def test_nocreate_file(db, monkeypatch, tmpdir, example_config):
lookup_passdb(
db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik"
)
assert not get_user_data(db, example_config, "newuser12@chat.example.org")
assert not get_user_data(db, "newuser12@chat.example.org")
def test_db_version(db):
@@ -114,40 +61,14 @@ def test_handle_dovecot_request(db, example_config):
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert userdata["home"].endswith("chat.example.org/some42123@chat.example.org")
assert (
userdata["home"]
== "/home/vmail/mail/chat.example.org/some42123@chat.example.org"
)
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(db, example_config, caplog):
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b""
assert not caplog.messages
def test_handle_dovecot_protocol(db, example_config):
rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b"N\n"
def test_handle_dovecot_protocol_iterate(db, gencreds, example_config):
lookup_passdb(db, example_config, "asdf00000@chat.example.org", "q9mr3faue")
lookup_passdb(db, example_config, "asdf11111@chat.example.org", "q9mr3faue")
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
lines = wfile.getvalue().decode("ascii").split("\n")
assert lines[0] == "Oshared/userdb/asdf00000@chat.example.org\t"
assert lines[1] == "Oshared/userdb/asdf11111@chat.example.org\t"
assert not lines[2]
def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
num_threads = 50
req_per_thread = 5

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,9 @@ def test_main(tmp_path, capsys):
out, _ = capsys.readouterr()
d = {}
for line in out.split("\n"):
if line.strip() and not line.startswith("#"):
name, num = line.split()
if line.strip():
name, num, _ = line.split()
d[name] = int(num)
assert d["accounts"] == 4
assert d["ci_accounts"] == 3
assert d["nonci_accounts"] == 1

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,22 +2,20 @@
Provides the `cmdeploy` entry point function,
along with command line option and subcommand parsing.
"""
import argparse
import shutil
import subprocess
import importlib.resources
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from chatmaild.config import read_config, write_initial_config
from termcolor import colored
from . import remote_funcs
from .dns import show_dns
from .sshexec import SSHExec
from termcolor import colored
from chatmaild.config import read_config, write_initial_config
from cmdeploy.dns import show_dns, check_necessary_dns
#
# cmdeploy sub commands and options
@@ -37,10 +35,13 @@ def init_cmd(args, out):
mail_domain = args.chatmail_domain
if args.inipath.exists():
print(f"Path exists, not modifying: {args.inipath}")
return 1
else:
write_initial_config(args.inipath, mail_domain, overrides={})
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):
@@ -54,7 +55,12 @@ def run_cmd_options(parser):
def run_cmd(args, out):
"""Deploy chatmail services on the remote server."""
retcode, remote_data = show_dns(args, out)
mail_domain = args.config.mail_domain
if not check_necessary_dns(
out,
mail_domain,
):
sys.exit(1)
env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath
@@ -63,15 +69,7 @@ def run_cmd(args, out):
cmd = f"{pyinf} --ssh-user root {args.config.mail_domain} {deploy_path}"
out.check_call(cmd, env=env)
if retcode == 0:
out.green("Deploy completed, call `cmdeploy test` next.")
elif not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy dns' or 'cmdeploy run' again")
retcode = 0
else:
out.red("Deploy failed")
return retcode
print("Deploy completed, call `cmdeploy dns` next.")
def dns_cmd_options(parser):
@@ -83,15 +81,15 @@ def dns_cmd_options(parser):
def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file."""
retcode, remote_data = show_dns(args, out)
return retcode
"""Generate dns zone file."""
exit_code = show_dns(args, out)
exit(exit_code)
def status_cmd(args, out):
"""Display status for online chatmail instance."""
sshexec = args.get_sshexec()
ssh = f"ssh root@{args.config.mail_domain}"
out.green(f"chatmail domain: {args.config.mail_domain}")
if args.config.privacy_mail:
@@ -99,8 +97,10 @@ def status_cmd(args, out):
else:
out.red("no privacy settings")
for line in sshexec(remote_funcs.get_systemd_running):
print(line)
s1 = "systemctl --type=service --state=running"
for line in out.shell_output(f"{ssh} -- {s1}").split("\n"):
if line.startswith(" "):
print(line)
def test_cmd_options(parser):
@@ -129,7 +129,7 @@ def test_cmd(args, out):
"-n4",
"-rs",
"-x",
"-v",
"-vrx",
"--durations=5",
]
if args.slow:
@@ -139,6 +139,14 @@ def test_cmd(args, out):
def fmt_cmd_options(parser):
parser.add_argument(
"--verbose",
"-v",
dest="verbose",
action="store_true",
help="provide information on invocations",
)
parser.add_argument(
"--check",
"-c",
@@ -148,26 +156,27 @@ def fmt_cmd_options(parser):
def fmt_cmd(args, out):
"""Run formattting fixes on all chatmail source code."""
"""Run formattting fixes (ruff and black) on all chatmail source code."""
sources = [str(importlib.resources.files(x)) for x in ("chatmaild", "cmdeploy")]
format_args = [shutil.which("ruff"), "format"]
check_args = [shutil.which("ruff"), "check"]
black_args = [shutil.which("black")]
ruff_args = [shutil.which("ruff")]
if args.check:
format_args.append("--diff")
black_args.append("--check")
else:
check_args.append("--fix")
ruff_args.append("--fix")
if not args.verbose:
check_args.append("--quiet")
format_args.append("--quiet")
black_args.append("-q")
ruff_args.append("-q")
format_args.extend(sources)
check_args.extend(sources)
black_args.extend(sources)
ruff_args.extend(sources)
out.check_call(" ".join(format_args), quiet=not args.verbose)
out.check_call(" ".join(check_args), quiet=not args.verbose)
out.check_call(" ".join(black_args), quiet=not args.verbose)
out.check_call(" ".join(ruff_args), quiet=not args.verbose)
return 0
def bench_cmd(args, out):
@@ -203,6 +212,16 @@ class Out:
color = "red" if red else ("green" if green else None)
print(colored(msg, color), file=file)
def shell_output(self, arg, no_print=False, timeout=10):
if not no_print:
self(f"[$ {arg}]", file=sys.stderr)
output = subprocess.STDOUT
else:
output = subprocess.DEVNULL
return subprocess.check_output(
arg, shell=True, timeout=timeout, stderr=output
).decode()
def check_call(self, arg, env=None, quiet=False):
if not quiet:
self(f"[$ {arg}]", file=sys.stderr)
@@ -212,7 +231,7 @@ class Out:
if not quiet:
cmdstring = " ".join(args)
self(f"[$ {cmdstring}]", file=sys.stderr)
proc = subprocess.run(args, env=env, check=False)
proc = subprocess.run(args, env=env)
return proc.returncode
@@ -225,14 +244,6 @@ def add_config_option(parser):
type=Path,
help="path to the chatmail.ini file",
)
parser.add_argument(
"--verbose",
"-v",
dest="verbose",
action="store_true",
default=False,
help="provide verbose logging",
)
def add_subcommand(subparsers, func):
@@ -272,18 +283,11 @@ def get_parser():
def main(args=None):
"""Provide main entry point for 'cmdeploy' CLI invocation."""
"""Provide main entry point for 'xdcget' CLI invocation."""
parser = get_parser()
args = parser.parse_args(args=args)
if not hasattr(args, "func"):
return parser.parse_args(["-h"])
def get_sshexec(log=None):
print(f"[ssh] login to {args.config.mail_domain}")
return SSHExec(args.config.mail_domain, remote_funcs, log=log)
args.get_sshexec = get_sshexec
out = Out()
kwargs = {}
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):

View File

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

View File

@@ -1,59 +1,214 @@
import datetime
import importlib
import sys
from . import remote_funcs
import requests
import importlib
import subprocess
import datetime
class DNS:
def __init__(self, out, mail_domain):
self.session = requests.Session()
self.out = out
self.ssh = f"ssh root@{mail_domain} -- "
try:
self.shell(f"unbound-control flush_zone {mail_domain}")
except subprocess.CalledProcessError:
pass
def shell(self, cmd):
try:
return self.out.shell_output(f"{self.ssh}{cmd}", no_print=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
if "exit status 255" in str(e) or "timed out" in str(e):
self.out.red(f"Error: can't reach the server with: {self.ssh[:-4]}")
sys.exit(1)
else:
raise
def get_ipv4(self):
cmd = "ip a | grep 'inet ' | grep 'scope global' | grep -oE '[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}' | head -1"
return self.shell(cmd).strip()
def get_ipv6(self):
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
return self.shell(cmd).strip()
def get(self, typ: str, domain: str) -> str | None:
"""Get a DNS entry"""
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
line = dig_result.partition("\n")[0]
if line:
return line
def check_ptr_record(self, ip: str, mail_domain) -> bool:
"""Check the PTR record for an IPv4 or IPv6 address."""
result = self.shell(f"dig -r -x {ip} +short").rstrip()
return result == f"{mail_domain}."
def show_dns(args, out) -> int:
"""Check existing DNS records, optionally write them to zone file
and return (exitcode, remote_data) tuple."""
"""Check existing DNS records, optionally write them to zone file, return exit code 0 or 1."""
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
mail_domain = args.config.mail_domain
ssh = f"ssh root@{mail_domain}"
dns = DNS(out, mail_domain)
def log_progress(data):
sys.stdout.write(".")
sys.stdout.flush()
def read_dkim_entries(entry):
lines = []
for line in entry.split("\n"):
if line.startswith(";") or not line.strip():
continue
line = line.replace("\t", " ")
lines.append(line)
lines[0] = f"dkim._domainkey.{mail_domain}. IN TXT " + lines[0].strip(
"dkim._domainkey IN TXT "
)
return "\n".join(lines)
sshexec = args.get_sshexec(log=print if args.verbose else log_progress)
print("Checking DNS entries ", end="\n" if args.verbose else "")
print("Checking your DKIM keys and DNS entries...")
try:
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
except subprocess.CalledProcessError:
print("Please run `cmdeploy run` first.")
return 1
dkim_entry = read_dkim_entries(
out.shell_output(f"{ssh} -- cat /var/lib/rspamd/dkim/{mail_domain}.dkim.zone")
)
remote_data = sshexec(remote_funcs.perform_initial_checks, mail_domain=mail_domain)
assert remote_data["ipv4"] or remote_data["ipv6"]
ipv6 = dns.get_ipv6()
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
ipv4 = dns.get_ipv4()
reverse_ipv4 = dns.check_ptr_record(ipv4, mail_domain)
to_print = []
with open(template, "r") as f:
zonefile = f.read().format(
acme_account_url=remote_data["acme_account_url"],
dkim_entry=remote_data["dkim_entry"],
ipv6=remote_data["ipv6"],
ipv4=remote_data["ipv4"],
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
zonefile = (
f.read()
.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
ipv6=ipv6,
ipv4=ipv4,
)
.strip()
)
try:
with open(args.zonefile, "w+") as zf:
zf.write(zonefile)
print(f"DNS records successfully written to: {args.zonefile}")
return 0
except TypeError:
pass
started_dkim_parsing = False
for line in zonefile.splitlines():
line = line.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
ipv6=ipv6,
).strip()
for typ in ["A", "AAAA", "CNAME", "CAA"]:
if f" {typ} " in line:
domain, value = line.split(f" {typ} ")
current = dns.get(typ, domain.strip()[:-1])
if current != value.strip():
to_print.append(line)
if " MX " in line:
domain, typ, prio, value = line.split()
current = dns.get(typ, domain[:-1])
if not current:
to_print.append(line)
elif current.split()[1] != value:
print(line.replace(prio, str(int(current[0]) + 1)))
if " SRV " in line:
domain, typ, prio, weight, port, value = line.split()
current = dns.get("SRV", domain[:-1])
if current != f"{prio} {weight} {port} {value}":
to_print.append(line)
if " TXT " in line:
domain, value = line.split(" TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if domain.startswith("_mta-sts."):
if current:
if current.split("id=")[0] == value.split("id=")[0]:
continue
if current != value:
to_print.append(line)
if " IN TXT ( " in line:
started_dkim_parsing = True
dkim_lines = [line]
if started_dkim_parsing and line.startswith('"'):
dkim_lines.append(" " + line)
domain, data = "\n".join(dkim_lines).split(" IN TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if current:
current = "( %s" % (current.replace('" "', '"\n "'))
if current != data:
to_print.append(dkim_entry)
else:
to_print.append(dkim_entry)
to_print = sshexec(remote_funcs.check_zonefile, zonefile=zonefile)
if not args.verbose:
print()
if getattr(args, "zonefile", None):
with open(args.zonefile, "w+") as zf:
zf.write(zonefile)
out.green(f"DNS records successfully written to: {args.zonefile}")
return 0, remote_data
exit_code = 0
if to_print:
to_print.insert(
0, "You should configure the following entries at your DNS provider:\n"
0, "You should configure the following DNS entries at your provider:\n"
)
to_print.append(
"\nIf you already configured the DNS entries, "
"wait a bit until the DNS entries propagate to the Internet."
"\nIf you already configured the DNS entries, wait a bit until the DNS entries propagate to the Internet."
)
out.red("\n".join(to_print))
print("\n".join(to_print))
exit_code = 1
else:
out.green("Great! All your DNS entries are verified and correct.")
exit_code = 0
out.green("Great! All your DNS entries are correct.")
return exit_code, remote_data
to_print = []
if not reverse_ipv4:
to_print.append(f"\tIPv4:\t{ipv4}\t{args.config.mail_domain}")
if not reverse_ipv6:
to_print.append(f"\tIPv6:\t{ipv6}\t{args.config.mail_domain}")
if len(to_print) > 0:
if len(to_print) == 1:
warning = "You should add the following PTR/reverse DNS entry:"
else:
warning = "You should add the following PTR/reverse DNS entries:"
out.red(warning)
for entry in to_print:
print(entry)
print(
"You can do so at your hosting provider (maybe this isn't your DNS provider)."
)
exit_code = 1
return exit_code
def check_necessary_dns(out, mail_domain):
"""Check whether $mail_domain and mta-sts.$mail_domain resolve."""
dns = DNS(out, mail_domain)
ipv4 = dns.get("A", mail_domain)
ipv6 = dns.get("AAAA", mail_domain)
mta_entry = dns.get("CNAME", "mta-sts." + mail_domain)
www_entry = dns.get("CNAME", "www." + mail_domain)
to_print = []
if not (ipv4 or ipv6):
to_print.append(f"\t{mail_domain}.\t\t\tA<your server's IPv4 address>")
if mta_entry != mail_domain + ".":
to_print.append(f"\tmta-sts.{mail_domain}.\tCNAME\t{mail_domain}.")
if www_entry != mail_domain + ".":
to_print.append(f"\twww.{mail_domain}.\tCNAME\t{mail_domain}.")
if to_print:
to_print.insert(
0,
"\nFor chatmail to work, you need to configure this at your DNS provider:\n",
)
for line in to_print:
print(line)
print()
else:
dns.out.green("\nAll necessary DNS entries seem to be set.")
return True

View File

@@ -1,7 +1,5 @@
uri = proxy:/run/doveauth/doveauth.socket:auth
iterate_disable = no
iterate_prefix = userdb/
uri = proxy:/run/dovecot/doveauth.socket:auth
iterate_disable = yes
default_pass_scheme = plain
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
# See <https://doc.dovecot.org/configuration_manual/config_file/config_variables/#modifiers>

View File

@@ -13,44 +13,15 @@ auth_cache_size = 100M
mail_debug = yes
{% endif %}
# Prevent warnings similar to:
# config: Warning: service auth { client_limit=1000 } is lower than required under max. load (10200). Counted for protocol services with service_count != 1: service lmtp { process_limit=100 } + service imap-urlauth-login { process_limit=100 } + service imap-login { process_limit=10000 }
# config: Warning: service anvil { client_limit=1000 } is lower than required under max. load (10103). Counted with: service imap-urlauth-login { process_limit=100 } + service imap-login { process_limit=10000 } + service auth { process_limit=1 }
# master: Warning: service(stats): client_limit (1000) reached, client connections are being dropped
default_client_limit = 20000
# Increase number of logged in IMAP connections.
# Each connection is handled by a separate `imap` process.
# `imap` process should have `client_limit=1` as described in
# <https://doc.dovecot.org/configuration_manual/service_configuration/#service-limits>
# so each logged in IMAP session will need its own `imap` process.
#
# If this limit is reached,
# users will fail to LOGIN as `imap-login` process
# will accept them logging in but fail to transfer logged in
# connection to `imap` process until someone logs out and
# the following warning will be logged:
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped
service imap {
process_limit = 50000
}
mail_server_admin = mailto:root@{{ config.mail_domain }}
mail_server_comment = Chatmail server
# `zlib` enables compressing messages stored in the maildir.
# See
# <https://doc.dovecot.org/configuration_manual/zlib_plugin/>
# for documentation.
#
# quota plugin documentation:
# <https://doc.dovecot.org/configuration_manual/quota_plugin/>
mail_plugins = zlib quota
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 XCHATMAIL
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA
# Authentication for system users.
@@ -67,7 +38,7 @@ userdb {
##
# Mailboxes are stored in the "mail" directory of the vmail user home.
mail_location = maildir:{{ config.mailboxes_dir }}/%u
mail_location = maildir:/home/vmail/mail/%d/%u
namespace inbox {
inbox = yes
@@ -100,10 +71,7 @@ mail_privileged_group = vmail
## Mail processes
##
# Pass all IMAP METADATA requests to the server implementing Dovecot's dict protocol.
mail_attribute_dict = proxy:/run/chatmail-metadata/metadata.socket:metadata
# `imap_zlib` enables IMAP COMPRESS (RFC 4978).
# Enable IMAP COMPRESS (RFC 4978).
# <https://datatracker.ietf.org/doc/html/rfc4978.html>
protocol imap {
mail_plugins = $mail_plugins imap_zlib imap_quota
@@ -111,19 +79,7 @@ protocol imap {
}
protocol lmtp {
# notify plugin is a dependency of push_notification plugin:
# <https://doc.dovecot.org/settings/plugin/notify-plugin/>
#
# push_notification plugin documentation:
# <https://doc.dovecot.org/configuration_manual/push_notification/>
#
# mail_lua and push_notification_lua are needed for Lua push notification handler.
# <https://doc.dovecot.org/configuration_manual/push_notification/#configuration>
mail_plugins = $mail_plugins mail_lua notify push_notification push_notification_lua
}
plugin {
zlib_save = gz
mail_plugins = $mail_plugins quota
}
plugin {
@@ -139,11 +95,7 @@ plugin {
# quota_over_flag_value = TRUE
}
# push_notification configuration
plugin {
# <https://doc.dovecot.org/configuration_manual/push_notification/#lua-lua>
push_notification_driver = lua:file=/etc/dovecot/push_notification.lua
}
service lmtp {
user=vmail

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
*/5 * * * * root {{ config.execpath }} {{ config.mailboxes_dir }} >/var/www/html/metrics
*/5 * * * * root {{ config.execpath }} /home/vmail/mail/{{ config.mail_domain }} >/var/www/html/metrics

View File

@@ -19,13 +19,6 @@
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<port>465</port>
@@ -40,12 +33,5 @@
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>

View File

@@ -1,30 +1,13 @@
load_module modules/ngx_stream_module.so;
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log syslog:server=unix:/dev/log,facility=local3;
error_log /var/log/nginx/error.log;
events {
worker_connections 768;
# multi_accept on;
}
stream {
map $ssl_preread_alpn_protocols $proxy {
default 127.0.0.1:8443;
~\bsmtp\b 127.0.0.1:submissions;
~\bimap\b 127.0.0.1:imaps;
}
server {
listen 443;
listen [::]:443;
proxy_pass $proxy;
ssl_preread on;
}
}
http {
sendfile on;
tcp_nopush on;
@@ -43,8 +26,8 @@ http {
gzip on;
server {
listen 8443 ssl default_server;
listen [::]:8443 ssl default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
root /var/www/html;
@@ -52,8 +35,6 @@ 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.
@@ -77,28 +58,16 @@ http {
}
# Old URL for compatibility with e.g. printed QR codes.
#
# Copy-paste instead of redirect to /new
# because Delta Chat core does not follow redirects.
#
# Redirects are only for browsers.
location /cgi-bin/newemail.py {
if ($request_method = GET) {
return 301 dcaccount:https://{{ config.domain_name }}/new;
}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
return 301 /new;
}
}
# Redirect www. to non-www
server {
listen 8443 ssl;
listen [::]:8443 ssl;
listen 443 ssl;
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;
}
}

View File

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

View File

@@ -1,28 +0,0 @@
if odkim.internal_ip(ctx) == 1 then
-- Outgoing message will be signed,
-- no need to look for signatures.
return nil
end
nsigs = odkim.get_sigcount(ctx)
if nsigs == nil then
return nil
end
for i = 1, nsigs do
sig = odkim.get_sighandle(ctx, i - 1)
sigres = odkim.sig_result(sig)
-- All signatures that do not correspond to From:
-- were ignored in screen.lua and return sigres -1.
--
-- Any valid signature that was not ignored like this
-- means the message is acceptable.
if sigres == 0 then
return nil
end
end
odkim.set_reply(ctx, "554", "5.7.1", "No valid DKIM signature found")
odkim.set_result(ctx, SMFIS_REJECT)
return nil

View File

@@ -8,12 +8,10 @@ SyslogSuccess yes
# oversigned, because it is often the identity key used by reputation systems
# and thus somewhat security sensitive.
Canonicalization relaxed/simple
#Mode sv
#SubDomains no
OversignHeaders From
On-BadSignature reject
On-KeyNotFound reject
On-NoSignature reject
# Signing domain, selector, and key (required). For example, perform signing
# for domain "example.com" with selector "2020" (2020._domainkey.example.com),
# using the private key stored in /etc/dkimkeys/example.private. More granular
@@ -24,32 +22,6 @@ KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
KeyTable /etc/dkimkeys/KeyTable
SigningTable refile:/etc/dkimkeys/SigningTable
# Sign Autocrypt header in addition to the default specified in RFC 6376.
#
# Default list is here:
# <https://github.com/trusteddomainproject/OpenDKIM/blob/5c539587561785a66c1f67f720f2fb741f320785/libopendkim/dkim.c#L221-L245>
SignHeaders *,+autocrypt,+content-type
# Prevent addition of second Content-Type header
# and other important headers that should not be added
# after signing the message.
# See
# <https://www.zone.eu/blog/2024/05/17/bimi-and-dmarc-cant-save-you/>
# and RFC 6376 (page 41) for reference.
#
# We don't use "l=" body length so the problem described in RFC 6376
# is not applicable, but adding e.g. a second "From" header
# or second "Autocrypt" header is better prevented in any case.
#
# Default is empty.
OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
# Script to ignore signatures that do not correspond to the From: domain.
ScreenPolicyScript /etc/opendkim/screen.lua
# Script to reject mails without a valid DKIM signature.
FinalPolicyScript /etc/opendkim/final.lua
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged
# user (for example, Postfix). You may need to add user "postfix" to group
@@ -57,10 +29,22 @@ FinalPolicyScript /etc/opendkim/final.lua
UserID opendkim
UMask 007
# Socket for the MTA connection (required). If the MTA is inside a chroot jail,
# it must be ensured that the socket is accessible. In Debian, Postfix runs in
# a chroot in /var/spool/postfix, therefore a Unix socket would have to be
# configured as shown on the last line below.
#Socket local:/run/opendkim/opendkim.sock
#Socket inet:8891@localhost
#Socket inet:8891
Socket local:/var/spool/postfix/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
# Hosts for which to sign rather than verify, default is 127.0.0.1. See the
# OPERATION section of opendkim(8) for more information.
#InternalHosts 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12
# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
# by the package dns-root-data.
TrustAnchorFile /usr/share/dns/root.key
#Nameservers 127.0.0.1

View File

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

View File

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

View File

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

View File

@@ -11,10 +11,13 @@
# ==========================================================================
{% if debug == true %}
smtp inet n - y - - smtpd -v
{%- else %}
{% else %}
smtp inet n - y - - smtpd
{%- endif %}
-o smtpd_milters=unix:opendkim/opendkim.sock
{% endif %}
#smtp inet n - y - 1 postscreen
#smtpd pass - - y - - smtpd
#dnsblog unix - - y - 0 dnsblog
#tlsproxy unix - - y - 0 tlsproxy
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
@@ -31,7 +34,6 @@ submission inet n - y - - smtpd
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
-o cleanup_service_name=authclean
smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
@@ -48,7 +50,6 @@ smtps inet n - y - - smtpd
-o smtpd_client_connection_count_limit=1000
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
-o cleanup_service_name=authclean
#628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
@@ -79,14 +80,3 @@ filter unix - n n - - lmtp
# Local SMTP server for reinjecting filered mail.
localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
-o syslog_name=postfix/reinject
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean
# Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP.
#
# We do not do this for received mails
# as this will break DKIM signatures
# if `Received` header is signed.
authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup

View File

@@ -1,105 +0,0 @@
"""
Functions to be executed on an ssh-connected host.
All functions of this module need to work with Python builtin types
and standard library dependencies only.
When a remote function executes remotely, it runs in a system python interpreter
without any installed dependencies.
"""
import re
import socket
from subprocess import CalledProcessError, check_output
def shell(command, fail_ok=False):
log(f"$ {command}")
try:
return check_output(command, shell=True).decode().rstrip()
except CalledProcessError:
if not fail_ok:
raise
return ""
def get_systemd_running():
lines = shell("systemctl --type=service --state=running").split("\n")
return [line for line in lines if line.startswith(" ")]
def perform_initial_checks(mail_domain):
res = {}
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
if not shell("dig", fail_ok=True):
shell("apt-get install -y dnsutils")
shell(f"unbound-control flush_zone {mail_domain}", fail_ok=True)
res["dkim_entry"] = get_dkim_entry(mail_domain, dkim_selector="opendkim")
res["ipv4"] = get_ip_address(socket.AF_INET)
res["ipv6"] = get_ip_address(socket.AF_INET6)
return res
def get_dkim_entry(mail_domain, dkim_selector):
dkim_pubkey = shell(
f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
)
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
return f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"'
def get_ip_address(typ):
sock = socket.socket(typ, socket.SOCK_DGRAM)
sock.settimeout(0)
sock.connect(("notifications.delta.chat", 1))
return sock.getsockname()[0]
def query_dns(typ, domain):
res = shell(f"dig -r -q {domain} -t {typ} +short")
return set(filter(None, res.split("\n")))
def check_zonefile(zonefile):
diff = []
for zf_line in zonefile.splitlines():
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip()
query_values = query_dns(zf_typ, zf_domain)
if zf_value in query_values:
continue
if zf_typ == "CAA" and zf_value.endswith('accounturi="'):
# this is an initial run where acmetool did not work yet
continue
if query_values and zf_typ == "TXT" and zf_domain.startswith("_mta-sts."):
(query_value,) = query_values
if query_value.split("id=")[0] == zf_value.split("id=")[0]:
continue
assert zf_typ in ("A", "AAAA", "CNAME", "CAA", "SRV", "MX", "TXT"), zf_line
diff.append(zf_line)
return diff
# check if this module is executed remotely
# and setup a simple serialized function-execution loop
if __name__ == "__channelexec__":
def log(item):
channel.send(("log", item)) # noqa
while 1:
func_name, kwargs = channel.receive() # noqa
res = globals()[func_name](**kwargs) # noqa
channel.send(("finish", res)) # noqa

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +0,0 @@
import execnet
class SSHExec:
RemoteError = execnet.RemoteError
def __init__(self, host, remote_funcs, log=None, python="python3", timeout=60):
self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}")
self._remote_cmdloop_channel = self.gateway.remote_exec(remote_funcs)
self.log = log
self.timeout = timeout
def __call__(self, func, **kwargs):
self._remote_cmdloop_channel.send((func.__name__, kwargs))
while 1:
code, data = self._remote_cmdloop_channel.receive(timeout=self.timeout)
if code == "log" and self.log:
self.log(data)
elif code == "finish":
return data

View File

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

View File

@@ -1,25 +1,6 @@
import smtplib
import pytest
from cmdeploy import remote_funcs
from cmdeploy.sshexec import SSHExec
class TestSSHExecutor:
@pytest.fixture
def sshexec(self, sshdomain):
return SSHExec(sshdomain, remote_funcs)
def test_ls(self, sshexec):
out = sshexec(remote_funcs.shell, command="ls")
out2 = sshexec(remote_funcs.shell, command="ls")
assert out == out2
def test_perform_initial(self, sshexec, maildomain):
res = sshexec(remote_funcs.perform_initial_checks, mail_domain=maildomain)
assert res["ipv4"] or res["ipv6"]
def test_remote(remote, imap_or_smtp):
lineproducer = remote.iter_output(imap_or_smtp.logcmd)
@@ -61,25 +42,13 @@ def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr):
assert "500" in str(e.value)
def test_authenticated_from(cmsetup, maildata):
"""Test that envelope FROM must be the same as login."""
user1, user2, user3 = cmsetup.gen_users(3)
msg = maildata("encrypted.eml", from_addr=user2.addr, to_addr=user3.addr)
with pytest.raises(smtplib.SMTPException) as e:
user1.smtp.sendmail(
from_addr=user2.addr, to_addrs=[user3.addr], msg=msg.as_string()
)
assert e.value.recipients[user3.addr][0] == 553
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr):
"""Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
recipient = cmsetup.gen_users(1)[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
with smtplib.SMTP(cmsetup.maildomain, 25) as s:
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
with pytest.raises(smtplib.SMTPDataError, match="missing DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
@@ -102,19 +71,3 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
assert b"4.7.1: Too much mail from" in outcome[1]
return
pytest.fail("Rate limit was not exceeded")
@pytest.mark.slow
def test_expunged(remote, chatmail_config):
outdated_days = int(chatmail_config.delete_mails_after) + 1
find_cmds = [
f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/.*/cur/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/new/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/.*/new/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/tmp/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
]
for cmd in find_cmds:
for line in remote.iter_output(cmd):
assert not line

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import os
import pytest
from cmdeploy.cmdeploy import get_parser, main
@@ -21,9 +20,8 @@ class TestCmdline:
run = parser.parse_args(["run"])
assert init and run
def test_init_not_overwrite(self, capsys):
assert main(["init", "chat.example.org"]) == 0
capsys.readouterr()
assert main(["init", "chat.example.org"]) == 1
out, err = capsys.readouterr()
assert "path exists" in out.lower()
@pytest.mark.xfail(reason="init doesn't exit anymore, check for CLI output instead")
def test_init_not_overwrite(self):
main(["init", "chat.example.org"])
with pytest.raises(SystemExit):
main(["init", "chat.example.org"])

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
#!/bin/sh
#!/bin/bash
set -e
python3 -m venv --upgrade-deps venv
python3 -m venv venv
venv/bin/pip install -e chatmaild
venv/bin/pip install -e cmdeploy

View File

@@ -1,17 +1,11 @@
<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 `@{{config.mail_domain}}` chat profile
👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>

View File

@@ -1,4 +1,6 @@
<img class="banner" src="collage-info.png"/>
## More information
{{ config.mail_domain }} provides a low-maintenance, resource efficient and
@@ -9,7 +11,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 `I already have a profile`
you may tap `LOG INTO YOUR E-MAIL ACCOUNT`
and fill the two fields like this:
- `Address`: invent a word with

View File

@@ -1,41 +1,21 @@
<img class="banner" src="collage-privacy.png"/>
# Privacy Policy for {{ config.mail_domain }}
{% 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.
We want to show you in a fair and transparent way
what personal data is processed by us.
We follow a strict privacy-by-design approach
and try to avoid processing your data in the first place,
but as you may know,
the internet,
and in particular sending e-mail messages,
does not work without data.
Still,
it's only fair that you know at all times
what personal data is processed
when you use our service.
If you have any remaining questions about data protection, please contact us.
## 1. Name and contact information
@@ -77,7 +57,7 @@ we process the following data and details:
- Users can retrieve or delete all stored messages
without intervention from the operators using standard IMAP client tools.
### 2.1 Account setup
### 3.1 Account setup
Creating an account happens in one of two ways on our mail servers:
@@ -98,7 +78,7 @@ Art. 6 (1) lit. b GDPR,
as you have a usage contract with us
by using our services.
### 2.2 Processing of E-Mail-Messages
## 3.2 Processing of E-Mail-Messages
In addition,
we will process data
@@ -124,7 +104,7 @@ Therefore, limits are enforced:
- message size limits
- any other limit necessary for the whole server to function in a healthy way
- any other limit neccessary for the whole server to function in a healthy way
and to prevent abuse.
The processing and use of the above permissions
@@ -198,9 +178,8 @@ for the purpose of drawing conclusions about your person.
## 4. Transfer of Data
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
Your personal data
will not be transferred to third parties
for purposes other than those listed below:
a) you have given your express consent