mirror of
https://github.com/chatmail/relay.git
synced 2026-05-10 16:04:37 +00:00
Compare commits
150 Commits
forged_fro
...
draft-init
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
805d743e9e | ||
|
|
b0d5ee084e | ||
|
|
56c7853e5b | ||
|
|
071d708a89 | ||
|
|
c7c7ed8ff2 | ||
|
|
0aa0ef8a74 | ||
|
|
0662e3a8b1 | ||
|
|
6c453f93a1 | ||
|
|
54f29f6bae | ||
|
|
ad8fee76cd | ||
|
|
528cd3da25 | ||
|
|
5734e00625 | ||
|
|
947e1d6f89 | ||
|
|
33423459fe | ||
|
|
c70b72a21a | ||
|
|
33352f4694 | ||
|
|
59083ad16a | ||
|
|
11518c2ef4 | ||
|
|
a0cdfe6126 | ||
|
|
c25eefccc4 | ||
|
|
8b878e38cf | ||
|
|
9e1e6d3c69 | ||
|
|
a1c817d758 | ||
|
|
ccc552f852 | ||
|
|
73768256f6 | ||
|
|
34f7b3c0d3 | ||
|
|
e2828f4103 | ||
|
|
df515bea41 | ||
|
|
5614f03611 | ||
|
|
70443545d7 | ||
|
|
10ef842061 | ||
|
|
98f92cd9b6 | ||
|
|
fe99b97386 | ||
|
|
542decf798 | ||
|
|
2e7f8483b3 | ||
|
|
5c58e625f0 | ||
|
|
009f549619 | ||
|
|
99d36235fe | ||
|
|
b52a8c969f | ||
|
|
8520a9d8f2 | ||
|
|
652b9688d3 | ||
|
|
59c3730d84 | ||
|
|
84db074686 | ||
|
|
7eec0ab301 | ||
|
|
7cb8f90340 | ||
|
|
32360061b4 | ||
|
|
2055e9f5b8 | ||
|
|
8cb77d3b98 | ||
|
|
c67fb69af2 | ||
|
|
960bc1599b | ||
|
|
0f05216ea0 | ||
|
|
75551224b3 | ||
|
|
0b8de41da2 | ||
|
|
f9b5783296 | ||
|
|
d3281cc746 | ||
|
|
4c7e39c10c | ||
|
|
7b3c1d5ab9 | ||
|
|
2d5eb86776 | ||
|
|
5c9d9a98b3 | ||
|
|
5eb5c09052 | ||
|
|
a86e135967 | ||
|
|
776bd87888 | ||
|
|
d7683ed3f7 | ||
|
|
0cc9f18468 | ||
|
|
889e18f803 | ||
|
|
773b8d1e00 | ||
|
|
dca6d35a6f | ||
|
|
d29d2d147b | ||
|
|
347dae1f84 | ||
|
|
63cbb83344 | ||
|
|
27d135fee7 | ||
|
|
ccd7c789f0 | ||
|
|
c7625fad81 | ||
|
|
5305dfab12 | ||
|
|
4478270fc9 | ||
|
|
e7c9992fdc | ||
|
|
a9d43c42f4 | ||
|
|
bbf2f0dd36 | ||
|
|
43c02377ef | ||
|
|
70f330b0e4 | ||
|
|
02eaa55441 | ||
|
|
6c3ec903c2 | ||
|
|
7d9b81863f | ||
|
|
af90d0a7de | ||
|
|
322bc9a3aa | ||
|
|
e4009854dc | ||
|
|
9e14a741c3 | ||
|
|
01fcb9ae0e | ||
|
|
064f6d36ad | ||
|
|
6b3590e7c8 | ||
|
|
251aac18fb | ||
|
|
f46bf2f670 | ||
|
|
40a88c7fc6 | ||
|
|
8791e7735d | ||
|
|
248f67dcf6 | ||
|
|
a24df735d4 | ||
|
|
7d0797c510 | ||
|
|
3a9db729f8 | ||
|
|
7eb86cba34 | ||
|
|
5633c0612e | ||
|
|
d5912b909c | ||
|
|
f75eb0658c | ||
|
|
7c5ec1e0df | ||
|
|
11ebc4623c | ||
|
|
cf29053389 | ||
|
|
1e7d0d10f5 | ||
|
|
3dd94cbe69 | ||
|
|
ed1b2f9da1 | ||
|
|
7ee84b44df | ||
|
|
02205246dd | ||
|
|
fcd3194eb1 | ||
|
|
bdef189ce1 | ||
|
|
3058ddc542 | ||
|
|
bada933fef | ||
|
|
1d74b94162 | ||
|
|
eee6d0c871 | ||
|
|
ed5e37f1fa | ||
|
|
364300274e | ||
|
|
848b25c790 | ||
|
|
107d10ace4 | ||
|
|
83e6a42252 | ||
|
|
eb69dd58f7 | ||
|
|
31c45f951d | ||
|
|
3012bfb79d | ||
|
|
03442bc115 | ||
|
|
1ae6291d06 | ||
|
|
1b347f97a0 | ||
|
|
902f98c9ba | ||
|
|
89311063f8 | ||
|
|
1cdc5d1351 | ||
|
|
30680cb170 | ||
|
|
c514fb00a3 | ||
|
|
c7995356b9 | ||
|
|
fb2ea27477 | ||
|
|
7cf6cc2c91 | ||
|
|
4358d5fe61 | ||
|
|
10cb099c0e | ||
|
|
329b845c79 | ||
|
|
bbd2773506 | ||
|
|
410bc50a8b | ||
|
|
015269fa7b | ||
|
|
b8673d8625 | ||
|
|
31c71fa6e9 | ||
|
|
8fcd423015 | ||
|
|
df39d05263 | ||
|
|
05ce4f769b | ||
|
|
8dc05ba7ec | ||
|
|
6701c9749c | ||
|
|
c6d8f7e759 | ||
|
|
76765164dc |
40
.github/workflows/ci.yaml
vendored
Normal file
40
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
tox:
|
||||
name: isolated chatmaild tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: run chatmaild tests
|
||||
working-directory: chatmaild
|
||||
run: pipx run tox
|
||||
|
||||
scripts:
|
||||
name: deploy-chatmail tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: initenv
|
||||
run: scripts/initenv.sh
|
||||
|
||||
- name: append venv/bin to PATH
|
||||
run: echo venv/bin >>$GITHUB_PATH
|
||||
|
||||
- name: run formatting checks
|
||||
run: cmdeploy fmt -v
|
||||
|
||||
- name: run deploy-chatmail offline tests
|
||||
run: pytest tests
|
||||
|
||||
- name: initialize with chatmail domain
|
||||
run: cmdeploy init chat.example.org
|
||||
|
||||
# all other cmdeploy commands require a staging server
|
||||
# see https://github.com/deltachat/chatmail/issues/100
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -3,6 +3,9 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.swp
|
||||
*qr-*.png
|
||||
chatmail.ini
|
||||
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
@@ -159,3 +162,5 @@ cython_debug/
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
chatmail.zone
|
||||
|
||||
155
README.md
155
README.md
@@ -1,22 +1,124 @@
|
||||
# Chat Mail server configuration
|
||||
|
||||
This repository setups a ready-to-go chatmail instance
|
||||
<img width="800px" src="www/src/collage-top.png"/>
|
||||
|
||||
# Chatmail services optimized for Delta Chat apps
|
||||
|
||||
This repository helps to setup a ready-to-use chatmail server
|
||||
comprised of a minimal setup of the battle-tested
|
||||
[postfix smtp server](https://www.postfix.org) and [dovecot imap server](https://www.dovecot.org).
|
||||
[postfix smtp](https://www.postfix.org) and [dovecot imap](https://www.dovecot.org) services.
|
||||
|
||||
## Getting started
|
||||
The setup is designed and optimized for providing chatmail accounts
|
||||
for use by [Delta Chat apps](https://delta.chat).
|
||||
|
||||
1. prepare your local system:
|
||||
Chatmail accounts are automatically created by a first login,
|
||||
after which the initially specified password is required for using them.
|
||||
|
||||
scripts/init.sh
|
||||
## Deploying your own chatmail server
|
||||
|
||||
2. set environment variable to the chatmail domain you want to setup:
|
||||
We subsequently use `CHATMAIL_DOMAIN` as a placeholder for your fully qualified
|
||||
DNS domain name (FQDN), for example `chat.example.org`.
|
||||
|
||||
export CHATMAIL_DOMAIN=c1.testrun.org # replace with your host
|
||||
1. Setup DNS `A` and `AAAA` records for your `CHATMAIL_DOMAIN`.
|
||||
Verify that DNS is set and SSH root login works:
|
||||
|
||||
3. run the deploy of the chat mail instance:
|
||||
```
|
||||
ssh root@CHATMAIL_DOMAIN
|
||||
```
|
||||
|
||||
scripts/deploy.sh
|
||||
2. Install the `cmdeploy` command in a virtualenv
|
||||
|
||||
```
|
||||
source scripts/initenv.sh
|
||||
```
|
||||
|
||||
3. Create chatmail configuration file `chatmail.ini`:
|
||||
|
||||
```
|
||||
cmdeploy init CHATMAIL_DOMAIN
|
||||
```
|
||||
|
||||
4. Deploy to the remote chatmail server:
|
||||
|
||||
```
|
||||
cmdeploy run
|
||||
```
|
||||
|
||||
5. To output a DNS zone file from which you can transfer DNS records
|
||||
to your DNS provider:
|
||||
|
||||
```
|
||||
cmdeploy dns
|
||||
```
|
||||
|
||||
6. To check status of your remotely running chatmail service:
|
||||
|
||||
```
|
||||
cmdeploy status
|
||||
```
|
||||
|
||||
7. To test your chatmail service:
|
||||
|
||||
```
|
||||
cmdeploy test
|
||||
```
|
||||
|
||||
8. To benchmark your chatmail service:
|
||||
|
||||
```
|
||||
cmdeploy bench
|
||||
```
|
||||
|
||||
### Refining the web pages
|
||||
|
||||
|
||||
```
|
||||
cmdeploy webdev
|
||||
```
|
||||
|
||||
This starts a local live development cycle for chatmail Web pages:
|
||||
|
||||
- uses the `www/src/page-layout.html` file for producing static
|
||||
HTML pages from `www/src/*.md` files
|
||||
|
||||
- continously builds the web presence reading files from `www/src` directory
|
||||
and generating html files and copying assets to the `www/build` directory.
|
||||
|
||||
- Starts a browser window automatically where you can "refresh" as needed.
|
||||
|
||||
|
||||
### Home page and getting started for users
|
||||
|
||||
`cmdeploy run` sets up mail services,
|
||||
and also creates default static Web pages and deploys them:
|
||||
|
||||
- a default `index.html` along with a QR code that users can click to
|
||||
create accounts on your chatmail provider,
|
||||
|
||||
- a default `info.html` that is linked from the home page,
|
||||
|
||||
- a default `policy.html` that is linked from the home page.
|
||||
|
||||
All `.html` files are generated
|
||||
by the according markdown `.md` file in the `www/src` directory.
|
||||
|
||||
|
||||
### Ports
|
||||
|
||||
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
|
||||
Dovecot listens on ports 143(imap) and 993 (imaps).
|
||||
|
||||
Delta Chat will, however, discover all ports and configurations
|
||||
automatically by reading the `autoconfig.xml` file from the chatmail instance.
|
||||
|
||||
|
||||
## Emergency Commands to disable automatic account creation
|
||||
|
||||
If you need to stop account creation,
|
||||
e.g. because some script is wildly creating accounts, run:
|
||||
|
||||
touch /etc/chatmail-nocreate
|
||||
|
||||
While this file is present, account creation will be blocked.
|
||||
|
||||
|
||||
## Running tests and benchmarks (offline and online)
|
||||
@@ -32,28 +134,27 @@ comprised of a minimal setup of the battle-tested
|
||||
|
||||
scripts/bench.sh
|
||||
|
||||
## Running tests (offline and online)
|
||||
|
||||
```
|
||||
## Dovecot/Postfix configuration
|
||||
## Development Background for chatmail instances
|
||||
|
||||
### Ports
|
||||
This repository drives the development of "chatmail instances",
|
||||
comprised of minimal setups of
|
||||
|
||||
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
|
||||
Dovecot listens on ports 143(imap) and 993 (imaps).
|
||||
- [postfix smtp server](https://www.postfix.org)
|
||||
- [dovecot imap server](https://www.dovecot.org)
|
||||
|
||||
## DNS
|
||||
as well as two custom services that are integrated with these two:
|
||||
|
||||
For DKIM you must add a DNS entry as found in /etc/opendkim/selector.txt on your chatmail instance.
|
||||
The above `scripts/deploy.sh` prints out the DKIM selector and DNS entry you
|
||||
need to setup with your DNS provider.
|
||||
- `chatmaild/src/chatmaild/doveauth.py` implements
|
||||
create-on-login account creation semantics and is used
|
||||
by Dovecot during login authentication and by Postfix
|
||||
which in turn uses [Dovecot SASL](https://doc.dovecot.org/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket)
|
||||
to authenticate users
|
||||
to send mails for them.
|
||||
|
||||
- `chatmaild/src/chatmaild/filtermail.py` prevents
|
||||
unencrypted e-mail from leaving the chatmail instance
|
||||
and is integrated into postfix's outbound mail pipelines.
|
||||
|
||||
## Emergency Commands
|
||||
|
||||
If you need to stop account creation,
|
||||
e.g. because some script is wildly creating accounts,
|
||||
just run `touch /tmp/nocreate`.
|
||||
You can remove the file
|
||||
as soon as the attacker was banned
|
||||
by different means.
|
||||
|
||||
|
||||
4
chatmaild/MANIFEST.in
Normal file
4
chatmaild/MANIFEST.in
Normal file
@@ -0,0 +1,4 @@
|
||||
include src/chatmaild/*.f
|
||||
include src/chatmaild/ini/*.ini.f
|
||||
include src/chatmaild/ini/*.ini
|
||||
include src/chatmaild/tests/mail-data/*
|
||||
@@ -1,26 +1,39 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45"]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "chatmaild"
|
||||
version = "0.1"
|
||||
version = "0.2"
|
||||
dependencies = [
|
||||
"aiosmtpd"
|
||||
"aiosmtpd",
|
||||
"iniconfig",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ['src']
|
||||
|
||||
[project.scripts]
|
||||
doveauth-dictproxy = "chatmaild.dictproxy:main"
|
||||
doveauth = "chatmaild.doveauth:main"
|
||||
filtermail = "chatmaild.filtermail:main"
|
||||
|
||||
[project.entry-points.pytest11]
|
||||
"chatmaild.testplugin" = "chatmaild.tests.plugin"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-v -ra --strict-markers"
|
||||
log_format = "%(asctime)s %(levelname)s %(message)s"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
log_level = "INFO"
|
||||
|
||||
[tool.tox]
|
||||
legacy_tox_ini = """
|
||||
[tox]
|
||||
isolated_build = true
|
||||
envlist = lint
|
||||
envlist = lint,py
|
||||
|
||||
[testenv:lint]
|
||||
skipdist = True
|
||||
@@ -31,4 +44,9 @@ deps =
|
||||
commands =
|
||||
black --quiet --check --diff src/
|
||||
ruff src/
|
||||
|
||||
[testenv]
|
||||
deps = pytest
|
||||
pdbpp
|
||||
commands = pytest -v -rsXx {posargs}
|
||||
"""
|
||||
|
||||
51
chatmaild/src/chatmaild/config.py
Normal file
51
chatmaild/src/chatmaild/config.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import iniconfig
|
||||
|
||||
|
||||
def read_config(inipath):
|
||||
cfg = iniconfig.IniConfig(inipath)
|
||||
return Config(inipath, params=cfg.sections["params"])
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, inipath, params):
|
||||
self._inipath = inipath
|
||||
self.mailname = self.mail_domain = params["mailname"]
|
||||
self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
|
||||
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
|
||||
self.postfix_reinject_port = int(params["postfix_reinject_port"])
|
||||
self.passthrough_recipients = params["passthrough_recipients"].split()
|
||||
self.privacy_postal = params.get("privacy_postal")
|
||||
self.privacy_mail = params.get("privacy_mail")
|
||||
self.privacy_pdo = params.get("privacy_pdo")
|
||||
self.privacy_supervisor = params.get("privacy_supervisor")
|
||||
|
||||
def _getbytefile(self):
|
||||
return open(self._inipath, "rb")
|
||||
|
||||
|
||||
def write_initial_config(inipath, mailname):
|
||||
from importlib.resources import files
|
||||
|
||||
inidir = files(__package__).joinpath("ini")
|
||||
content = inidir.joinpath("chatmail.ini.f").read_text().format(mailname=mailname)
|
||||
if mailname.endswith(".testrun.org"):
|
||||
override_inipath = inidir.joinpath("override-testrun.ini")
|
||||
privacy = iniconfig.IniConfig(override_inipath)["privacy"]
|
||||
lines = []
|
||||
for line in content.split("\n"):
|
||||
for key, value in privacy.items():
|
||||
value_lines = value.strip().split("\n")
|
||||
if not line.startswith(f"{key} =") or not value_lines:
|
||||
continue
|
||||
if len(value_lines) == 1:
|
||||
lines.append(f"{key} = {value}")
|
||||
else:
|
||||
lines.append(f"{key} =")
|
||||
for vl in value_lines:
|
||||
lines.append(f" {vl}")
|
||||
break
|
||||
else:
|
||||
lines.append(line)
|
||||
content = "\n".join(lines)
|
||||
|
||||
inipath.write_text(content)
|
||||
@@ -33,13 +33,6 @@ class Connection:
|
||||
def cursor(self):
|
||||
return self._sqlconn.cursor()
|
||||
|
||||
def create_user(self, addr: str, password: str):
|
||||
"""Create a row in the users table."""
|
||||
self.execute("PRAGMA foreign_keys=on")
|
||||
q = """INSERT INTO users (addr, password, last_login)
|
||||
VALUES (?, ?, ?)"""
|
||||
self.execute(q, (addr, password, int(time.time())))
|
||||
|
||||
def get_user(self, addr: str) -> {}:
|
||||
"""Get a row from the users table."""
|
||||
q = "SELECT addr, password, last_login from users WHERE addr = ?"
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from socketserver import (
|
||||
UnixStreamServer,
|
||||
StreamRequestHandler,
|
||||
ThreadingMixIn,
|
||||
)
|
||||
import pwd
|
||||
import subprocess
|
||||
|
||||
from .database import Database
|
||||
|
||||
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
||||
|
||||
|
||||
def encrypt_password(password: str):
|
||||
password = password.encode("ascii")
|
||||
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
|
||||
process = subprocess.Popen(
|
||||
["doveadm", "pw", "-s", "SHA512-CRYPT"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
stdout_data, _stderr_data = process.communicate(
|
||||
input=password + b"\n" + password + b"\n"
|
||||
)
|
||||
return stdout_data.decode("ascii").strip()
|
||||
|
||||
|
||||
def create_user(db, user, password):
|
||||
if os.path.exists(NOCREATE_FILE):
|
||||
logging.warning(
|
||||
f"Didn't create account: {NOCREATE_FILE} exists. Delete the file to enable account creation."
|
||||
)
|
||||
return
|
||||
with db.write_transaction() as conn:
|
||||
conn.create_user(user, password)
|
||||
return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password)
|
||||
|
||||
|
||||
def get_user_data(db, user):
|
||||
with db.read_connection() as conn:
|
||||
result = conn.get_user(user)
|
||||
if result:
|
||||
result["uid"] = "vmail"
|
||||
result["gid"] = "vmail"
|
||||
return result
|
||||
|
||||
|
||||
def lookup_userdb(db, user):
|
||||
return get_user_data(db, user)
|
||||
|
||||
|
||||
def lookup_passdb(db, user, password):
|
||||
userdata = get_user_data(db, user)
|
||||
if not userdata:
|
||||
return create_user(db, user, encrypt_password(password))
|
||||
userdata["password"] = userdata["password"].strip()
|
||||
return userdata
|
||||
|
||||
|
||||
def handle_dovecot_request(msg, db, mail_domain):
|
||||
print(f"received msg: {msg!r}", file=sys.stderr)
|
||||
short_command = msg[0]
|
||||
if short_command == "L": # LOOKUP
|
||||
parts = msg[1:].split("\t")
|
||||
keyname, user = parts[:2]
|
||||
namespace, type, *args = keyname.split("/")
|
||||
reply_command = "F"
|
||||
res = ""
|
||||
if namespace == "shared":
|
||||
if type == "userdb":
|
||||
if user.endswith(f"@{mail_domain}"):
|
||||
res = lookup_userdb(db, user)
|
||||
if res:
|
||||
reply_command = "O"
|
||||
else:
|
||||
reply_command = "N"
|
||||
elif type == "passdb":
|
||||
if user.endswith(f"@{mail_domain}"):
|
||||
res = lookup_passdb(db, user, password=args[0])
|
||||
if res:
|
||||
reply_command = "O"
|
||||
else:
|
||||
reply_command = "N"
|
||||
print(f"res: {res!r}", file=sys.stderr)
|
||||
json_res = json.dumps(res) if res else ""
|
||||
return f"{reply_command}{json_res}\n"
|
||||
return None
|
||||
|
||||
|
||||
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
socket = sys.argv[1]
|
||||
passwd_entry = pwd.getpwnam(sys.argv[2])
|
||||
db = Database(sys.argv[3])
|
||||
with open("/etc/mailname", "r") as fp:
|
||||
mail_domain = fp.read().strip()
|
||||
|
||||
class Handler(StreamRequestHandler):
|
||||
def handle(self):
|
||||
while True:
|
||||
msg = self.rfile.readline().strip().decode()
|
||||
if not msg:
|
||||
break
|
||||
res = handle_dovecot_request(msg, db, mail_domain)
|
||||
if res:
|
||||
print(f"sending result: {res!r}", file=sys.stderr)
|
||||
self.wfile.write(res.encode("ascii"))
|
||||
self.wfile.flush()
|
||||
|
||||
try:
|
||||
os.unlink(socket)
|
||||
except FileNotFoundError:
|
||||
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:
|
||||
pass
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Dict authentication proxy for dovecot
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/doveauth-dictproxy /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
156
chatmaild/src/chatmaild/doveauth.py
Normal file
156
chatmaild/src/chatmaild/doveauth.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
import json
|
||||
import crypt
|
||||
from socketserver import (
|
||||
UnixStreamServer,
|
||||
StreamRequestHandler,
|
||||
ThreadingMixIn,
|
||||
)
|
||||
import pwd
|
||||
|
||||
from .database import Database
|
||||
|
||||
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
||||
|
||||
|
||||
def encrypt_password(password: str):
|
||||
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
|
||||
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
|
||||
return "{SHA512-CRYPT}" + passhash
|
||||
|
||||
|
||||
def is_allowed_to_create(user, cleartext_password) -> bool:
|
||||
"""Return True if user and password are admissable."""
|
||||
if os.path.exists(NOCREATE_FILE):
|
||||
logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.")
|
||||
return False
|
||||
|
||||
if len(cleartext_password) < 9:
|
||||
logging.warning("Password needs to be at least 9 characters long")
|
||||
return False
|
||||
|
||||
parts = user.split("@")
|
||||
if len(parts) != 2:
|
||||
logging.warning(f"user {user!r} is not a proper e-mail address")
|
||||
return False
|
||||
localpart, domain = parts
|
||||
|
||||
if domain == "nine.testrun.org":
|
||||
# nine.testrun.org policy, username has to be exactly nine chars
|
||||
if len(localpart) != 9:
|
||||
logging.warning(f"localpart {localpart!r} has not exactly nine chars")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_user_data(db, user):
|
||||
with db.read_connection() as conn:
|
||||
result = conn.get_user(user)
|
||||
if result:
|
||||
result["uid"] = "vmail"
|
||||
result["gid"] = "vmail"
|
||||
return result
|
||||
|
||||
|
||||
def lookup_userdb(db, user):
|
||||
return get_user_data(db, user)
|
||||
|
||||
|
||||
def lookup_passdb(db, 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=?", (int(time.time()), user)
|
||||
)
|
||||
|
||||
userdata["uid"] = "vmail"
|
||||
userdata["gid"] = "vmail"
|
||||
return userdata
|
||||
if not is_allowed_to_create(user, cleartext_password):
|
||||
return
|
||||
|
||||
encrypted_password = encrypt_password(cleartext_password)
|
||||
q = """INSERT INTO users (addr, password, last_login)
|
||||
VALUES (?, ?, ?)"""
|
||||
conn.execute(q, (user, encrypted_password, int(time.time())))
|
||||
return dict(
|
||||
home=f"/home/vmail/{user}",
|
||||
uid="vmail",
|
||||
gid="vmail",
|
||||
password=encrypted_password,
|
||||
)
|
||||
|
||||
|
||||
def handle_dovecot_request(msg, db, mail_domain):
|
||||
short_command = msg[0]
|
||||
if short_command == "L": # LOOKUP
|
||||
parts = msg[1:].split("\t")
|
||||
keyname, user = parts[:2]
|
||||
namespace, type, *args = keyname.split("/")
|
||||
reply_command = "F"
|
||||
res = ""
|
||||
if namespace == "shared":
|
||||
if type == "userdb":
|
||||
if user.endswith(f"@{mail_domain}"):
|
||||
res = lookup_userdb(db, user)
|
||||
if res:
|
||||
reply_command = "O"
|
||||
else:
|
||||
reply_command = "N"
|
||||
elif type == "passdb":
|
||||
if user.endswith(f"@{mail_domain}"):
|
||||
res = lookup_passdb(db, user, cleartext_password=args[0])
|
||||
if res:
|
||||
reply_command = "O"
|
||||
else:
|
||||
reply_command = "N"
|
||||
json_res = json.dumps(res) if res else ""
|
||||
return f"{reply_command}{json_res}\n"
|
||||
return None
|
||||
|
||||
|
||||
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
||||
request_queue_size = 100
|
||||
|
||||
|
||||
def main():
|
||||
socket = sys.argv[1]
|
||||
passwd_entry = pwd.getpwnam(sys.argv[2])
|
||||
db = Database(sys.argv[3])
|
||||
with open("/etc/mailname", "r") as fp:
|
||||
mail_domain = fp.read().strip()
|
||||
|
||||
class Handler(StreamRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
while True:
|
||||
msg = self.rfile.readline().strip().decode()
|
||||
if not msg:
|
||||
break
|
||||
res = handle_dovecot_request(msg, db, mail_domain)
|
||||
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
|
||||
|
||||
try:
|
||||
os.unlink(socket)
|
||||
except FileNotFoundError:
|
||||
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:
|
||||
pass
|
||||
10
chatmaild/src/chatmaild/doveauth.service.f
Normal file
10
chatmaild/src/chatmaild/doveauth.service.f
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Chatmail dict authentication proxy for dovecot
|
||||
|
||||
[Service]
|
||||
ExecStart={execpath} /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,14 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import sys
|
||||
from email.parser import BytesParser
|
||||
from email import policy
|
||||
from email.utils import parseaddr
|
||||
|
||||
from aiosmtpd.lmtp import LMTP
|
||||
from aiosmtpd.controller import UnixSocketController
|
||||
from aiosmtpd.controller import Controller
|
||||
from smtplib import SMTP as SMTPClient
|
||||
|
||||
from .config import read_config
|
||||
|
||||
|
||||
def check_encrypted(message):
|
||||
"""Check that the message is an OpenPGP-encrypted message."""
|
||||
@@ -32,94 +35,126 @@ def check_encrypted(message):
|
||||
return True
|
||||
|
||||
|
||||
class ExampleController(UnixSocketController):
|
||||
def factory(self):
|
||||
return LMTP(self.handler, **self.SMTP_kwargs)
|
||||
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
|
||||
|
||||
|
||||
class ExampleHandler:
|
||||
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
||||
envelope.rcpt_tos.append(address)
|
||||
async def asyncmain_beforequeue(config):
|
||||
port = config.filtermail_smtp_port
|
||||
Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
|
||||
|
||||
|
||||
class BeforeQueueHandler:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.send_rate_limiter = SendRateLimiter()
|
||||
|
||||
async def handle_MAIL(self, server, session, envelope, address, mail_options):
|
||||
logging.info(f"handle_MAIL from {address}")
|
||||
envelope.mail_from = address
|
||||
max_sent = self.config.max_user_send_per_minute
|
||||
if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
|
||||
return f"450 4.7.1: Too much mail from {address}"
|
||||
|
||||
parts = envelope.mail_from.split("@")
|
||||
if len(parts) != 2:
|
||||
return f"500 Invalid from address <{envelope.mail_from!r}>"
|
||||
|
||||
return "250 OK"
|
||||
|
||||
async def handle_DATA(self, server, session, envelope):
|
||||
valid_recipients, res = lmtp_handle_DATA(envelope)
|
||||
# Reinject the mail back into Postfix.
|
||||
if valid_recipients:
|
||||
logging.info("Reinjecting the mail")
|
||||
client = SMTPClient("localhost", "10026")
|
||||
client.sendmail(envelope.mail_from, valid_recipients, envelope.content)
|
||||
else:
|
||||
logging.info("no valid recipients, ignoring mail")
|
||||
logging.info("handle_DATA before-queue")
|
||||
error = self.check_DATA(envelope)
|
||||
if error:
|
||||
return error
|
||||
logging.info("re-injecting the mail that passed checks")
|
||||
client = SMTPClient("localhost", self.config.postfix_reinject_port)
|
||||
client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
|
||||
return "250 OK"
|
||||
|
||||
return "\r\n".join(res)
|
||||
def check_DATA(self, envelope):
|
||||
"""the central filtering function for e-mails."""
|
||||
logging.info(f"Processing DATA message from {envelope.mail_from}")
|
||||
|
||||
|
||||
async def asyncmain(loop):
|
||||
controller = ExampleController(
|
||||
ExampleHandler(), unix_socket="/var/spool/postfix/private/filtermail"
|
||||
)
|
||||
controller.start()
|
||||
|
||||
|
||||
def lmtp_handle_DATA(envelope):
|
||||
"""the central filtering function for e-mails."""
|
||||
logging.info(f"Processing DATA message from {envelope.mail_from}")
|
||||
|
||||
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
|
||||
mail_encrypted = check_encrypted(message)
|
||||
|
||||
valid_recipients = []
|
||||
res = []
|
||||
for recipient in envelope.rcpt_tos:
|
||||
my_local_domain = envelope.mail_from.split("@")
|
||||
if len(my_local_domain) != 2:
|
||||
res += [f"500 Invalid from address <{envelope.mail_from}>"]
|
||||
continue
|
||||
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
|
||||
mail_encrypted = check_encrypted(message)
|
||||
|
||||
_, from_addr = parseaddr(message.get("from").strip())
|
||||
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from}")
|
||||
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
|
||||
if envelope.mail_from.lower() != from_addr.lower():
|
||||
res += [f"500 Invalid FROM <{from_addr}> for <{envelope.mail_from}>"]
|
||||
continue
|
||||
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
|
||||
|
||||
if envelope.mail_from == recipient:
|
||||
# Always allow sending emails to self.
|
||||
valid_recipients += [recipient]
|
||||
res += ["250 OK"]
|
||||
continue
|
||||
if not mail_encrypted and check_mdn(message, envelope):
|
||||
return
|
||||
|
||||
recipient_local_domain = recipient.split("@")
|
||||
if len(recipient_local_domain) != 2:
|
||||
res += [f"500 Invalid address <{recipient}>"]
|
||||
continue
|
||||
passthrough_recipients = self.config.passthrough_recipients
|
||||
envelope_from_domain = from_addr.split("@").pop()
|
||||
for recipient in envelope.rcpt_tos:
|
||||
if envelope.mail_from == recipient:
|
||||
# Always allow sending emails to self.
|
||||
continue
|
||||
if recipient in passthrough_recipients:
|
||||
continue
|
||||
res = recipient.split("@")
|
||||
if len(res) != 2:
|
||||
return f"500 Invalid address <{recipient}>"
|
||||
_recipient_addr, recipient_domain = res
|
||||
|
||||
is_outgoing = recipient_local_domain[1] != my_local_domain[1]
|
||||
is_outgoing = recipient_domain != envelope_from_domain
|
||||
if is_outgoing and not mail_encrypted:
|
||||
is_securejoin = message.get("secure-join") in [
|
||||
"vc-request",
|
||||
"vg-request",
|
||||
]
|
||||
if not is_securejoin:
|
||||
return f"500 Invalid unencrypted mail to <{recipient}>"
|
||||
|
||||
if (
|
||||
is_outgoing
|
||||
and not mail_encrypted
|
||||
and message.get("secure-join") != "vc-request"
|
||||
and message.get("secure-join") != "vg-request"
|
||||
):
|
||||
res += ["500 Outgoing mail must be encrypted"]
|
||||
continue
|
||||
|
||||
valid_recipients += [recipient]
|
||||
res += ["250 OK"]
|
||||
class SendRateLimiter:
|
||||
def __init__(self):
|
||||
self.addr2timestamps = {}
|
||||
|
||||
assert len(envelope.rcpt_tos) == len(res)
|
||||
assert len(valid_recipients) <= len(res)
|
||||
return valid_recipients, res
|
||||
def is_sending_allowed(self, mail_from, max_send_per_minute):
|
||||
last = self.addr2timestamps.setdefault(mail_from, [])
|
||||
now = time.time()
|
||||
last[:] = [ts for ts in last if ts >= (now - 60)]
|
||||
if len(last) <= max_send_per_minute:
|
||||
last.append(now)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
args = sys.argv[1:]
|
||||
assert len(args) == 1
|
||||
config = read_config(args[0])
|
||||
logging.basicConfig(level=logging.WARN)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.create_task(asyncmain(loop=loop))
|
||||
task = asyncmain_beforequeue(config)
|
||||
loop.create_task(task)
|
||||
loop.run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[Unit]
|
||||
Description=Email filter for chatmail servers
|
||||
Description=Chatmail Postfix BeforeQeue filter
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/filtermail
|
||||
ExecStart={execpath} {config_path}
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
33
chatmaild/src/chatmaild/ini/chatmail.ini.f
Normal file
33
chatmaild/src/chatmaild/ini/chatmail.ini.f
Normal file
@@ -0,0 +1,33 @@
|
||||
[params]
|
||||
|
||||
# mail domain (MUST be set to fully qualified chat mail domain)
|
||||
mailname = {mailname}
|
||||
|
||||
#
|
||||
# If you only do private test deploys, you don't need to modify any settings below
|
||||
#
|
||||
|
||||
# how many mails a user can send out per minute
|
||||
max_user_send_per_minute = 60
|
||||
|
||||
# list of e-mail recipients for which to accept outbound un-encrypted mails
|
||||
passthrough_recipients =
|
||||
|
||||
# where the filtermail SMTP service listens
|
||||
filtermail_smtp_port = 10080
|
||||
|
||||
# postfix accepts on the localhost reinject SMTP port
|
||||
postfix_reinject_port = 10025
|
||||
|
||||
# postal address of privacy contact
|
||||
privacy_postal =
|
||||
|
||||
# email address of privacy contact
|
||||
privacy_mail =
|
||||
|
||||
# postal address of the privacy data officer
|
||||
privacy_pdo =
|
||||
|
||||
# postal address of the privacy supervisor
|
||||
privacy_supervisor =
|
||||
|
||||
16
chatmaild/src/chatmaild/ini/override-testrun.ini
Normal file
16
chatmaild/src/chatmaild/ini/override-testrun.ini
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
[privacy]
|
||||
|
||||
passthrough_recipients = privacy@testrun.org
|
||||
|
||||
privacy_postal =
|
||||
Merlinux GmbH, Represented by the managing director H. Krekel,
|
||||
Reichgrafen Str. 20, 79102 Freiburg, Germany
|
||||
|
||||
privacy_mail = privacy@testrun.org
|
||||
privacy_pdo =
|
||||
Prof. Dr. Fabian Schmieder, lexICT UG (limited), Ostfeldstr. 49, 30559 Hannover.
|
||||
You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO)
|
||||
privacy_supervisor =
|
||||
State Commissioner for Data Protection and Freedom of Information of
|
||||
Baden-Württemberg in 70173 Stuttgart, Germany.
|
||||
28
chatmaild/src/chatmaild/newemail.py
Normal file
28
chatmaild/src/chatmaild/newemail.py
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
""" CGI script for creating new accounts. """
|
||||
|
||||
import json
|
||||
import random
|
||||
|
||||
mailname_path = "/etc/mailname"
|
||||
|
||||
|
||||
def create_newemail_dict(domain):
|
||||
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
user = "".join(random.choices(alphanumeric, k=9))
|
||||
password = "".join(random.choices(alphanumeric, k=12))
|
||||
return dict(email=f"{user}@{domain}", password=f"{password}")
|
||||
|
||||
|
||||
def print_new_account():
|
||||
domain = open(mailname_path).read().strip()
|
||||
creds = create_newemail_dict(domain=domain)
|
||||
|
||||
print("Content-Type: application/json")
|
||||
print("")
|
||||
print(json.dumps(creds))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print_new_account()
|
||||
@@ -1,53 +0,0 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
import chatmaild.dictproxy
|
||||
from .dictproxy import get_user_data, lookup_passdb
|
||||
from .database import Database, DBError
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmpdir):
|
||||
db_path = tmpdir / "passdb.sqlite"
|
||||
print("database path:", db_path)
|
||||
return Database(db_path)
|
||||
|
||||
|
||||
def test_basic(db):
|
||||
chatmaild.dictproxy.NOCREATE_FILE = "/tmp/nocreate"
|
||||
if os.path.exists(chatmaild.dictproxy.NOCREATE_FILE):
|
||||
os.remove(chatmaild.dictproxy.NOCREATE_FILE)
|
||||
lookup_passdb(db, "link2xt@c1.testrun.org", "asdf")
|
||||
data = get_user_data(db, "link2xt@c1.testrun.org")
|
||||
assert data
|
||||
|
||||
|
||||
def test_dont_overwrite_password_on_wrong_login(db):
|
||||
"""Test that logging in with a different password doesn't create a new user"""
|
||||
res = lookup_passdb(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
|
||||
assert res["password"]
|
||||
res2 = lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
|
||||
# this function always returns a password hash, which is actually compared by dovecot.
|
||||
assert res["password"] == res2["password"]
|
||||
|
||||
|
||||
def test_nocreate_file(db):
|
||||
chatmaild.dictproxy.NOCREATE_FILE = "/tmp/nocreate"
|
||||
with open(chatmaild.dictproxy.NOCREATE_FILE, "w+") as f:
|
||||
f.write("")
|
||||
assert os.path.exists(chatmaild.dictproxy.NOCREATE_FILE)
|
||||
lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
|
||||
assert not get_user_data(db, "newuser1@something.org")
|
||||
os.remove(chatmaild.dictproxy.NOCREATE_FILE)
|
||||
|
||||
|
||||
def test_db_version(db):
|
||||
assert db.get_schema_version() == 1
|
||||
|
||||
|
||||
def test_too_high_db_version(db):
|
||||
with db.write_transaction() as conn:
|
||||
conn.execute("PRAGMA user_version=%s;" % (999,))
|
||||
with pytest.raises(DBError):
|
||||
db.ensure_tables()
|
||||
@@ -1,328 +0,0 @@
|
||||
from .filtermail import check_encrypted, lmtp_handle_DATA
|
||||
from email.parser import BytesParser
|
||||
from email import policy
|
||||
|
||||
|
||||
def test_reject_forged_from():
|
||||
def makemail(from_addr):
|
||||
return BytesParser(policy=policy.default).parsebytes(
|
||||
"\r\n".join(
|
||||
[
|
||||
f"From: <{from_addr}",
|
||||
"To: <barbaz@c3.testrun.org>",
|
||||
"Date: Sun, 15 Oct 2023 16:41:44 +0000",
|
||||
"Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
|
||||
"References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
|
||||
"Chat-Version: 1.0",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
|
||||
"",
|
||||
"Hi!",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
).encode()
|
||||
)
|
||||
|
||||
class envelope:
|
||||
mail_from = "bob@c3.testrun.org"
|
||||
rcpt_tos = ["somebody@c3.testrun.org"]
|
||||
|
||||
# test that the filter lets good mail through
|
||||
envelope.content = makemail(envelope.mail_from).as_bytes()
|
||||
valid_recipients, res = lmtp_handle_DATA(envelope=envelope)
|
||||
assert valid_recipients == envelope.rcpt_tos
|
||||
assert len(res) == 1 and "250" in res[0]
|
||||
|
||||
# test that the filter rejects forged mail
|
||||
envelope.content = makemail("forged@c3.testrun.org").as_bytes()
|
||||
valid_recipients, res = lmtp_handle_DATA(envelope=envelope)
|
||||
assert not valid_recipients
|
||||
assert len(res) == 1 and "500" in res[0]
|
||||
|
||||
|
||||
def test_filtermail():
|
||||
def check_encrypted_bstr(content):
|
||||
message = BytesParser(policy=policy.default).parsebytes(content)
|
||||
return check_encrypted(message)
|
||||
|
||||
assert not check_encrypted_bstr(b"foo")
|
||||
|
||||
assert not check_encrypted_bstr(
|
||||
"\r\n".join(
|
||||
[
|
||||
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
|
||||
"Chat-Disposition-Notification-To: foobar@c2.testrun.org",
|
||||
"Chat-User-Avatar: 0",
|
||||
"From: <foobar@c2.testrun.org>",
|
||||
"To: <barbaz@c2.testrun.org>",
|
||||
"Date: Sun, 15 Oct 2023 16:41:44 +0000",
|
||||
"Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
|
||||
"References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
|
||||
"Chat-Version: 1.0",
|
||||
"Autocrypt: addr=foobar@c2.testrun.org; prefer-encrypt=mutual;",
|
||||
"\tkeydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m",
|
||||
"\tnNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID",
|
||||
"\tFgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I",
|
||||
"\tHjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK",
|
||||
"\tKwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ",
|
||||
"\tJlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI",
|
||||
"\tvYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
|
||||
"",
|
||||
"Hi!",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
).encode()
|
||||
)
|
||||
|
||||
assert not check_encrypted_bstr(
|
||||
"\r\n".join(
|
||||
[
|
||||
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
|
||||
"Chat-Disposition-Notification-To: foobar@c2.testrun.org",
|
||||
"Chat-User-Avatar: 0",
|
||||
"From: <foobar@c2.testrun.org>",
|
||||
"To: <barbaz@c2.testrun.org>",
|
||||
"Date: Sun, 15 Oct 2023 16:41:44 +0000",
|
||||
"Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
|
||||
"References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
|
||||
"Chat-Version: 1.0",
|
||||
"Autocrypt: addr=foobar@c2.testrun.org; prefer-encrypt=mutual;",
|
||||
"\tkeydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m",
|
||||
"\tnNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID",
|
||||
"\tFgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I",
|
||||
"\tHjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK",
|
||||
"\tKwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ",
|
||||
"\tJlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI",
|
||||
"\tvYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
|
||||
"",
|
||||
"Hi!",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
).encode()
|
||||
)
|
||||
|
||||
# https://xkcd.com/1181/
|
||||
assert not check_encrypted_bstr(
|
||||
"\r\n".join(
|
||||
[
|
||||
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
|
||||
"Chat-Disposition-Notification-To: foobar@c2.testrun.org",
|
||||
"Chat-User-Avatar: 0",
|
||||
"From: <foobar@c2.testrun.org>",
|
||||
"To: <barbaz@c2.testrun.org>",
|
||||
"Date: Sun, 15 Oct 2023 16:41:44 +0000",
|
||||
"Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
|
||||
"References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
|
||||
"Chat-Version: 1.0",
|
||||
"Autocrypt: addr=foobar@c2.testrun.org; prefer-encrypt=mutual;",
|
||||
"\tkeydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m",
|
||||
"\tnNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID",
|
||||
"\tFgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I",
|
||||
"\tHjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK",
|
||||
"\tKwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ",
|
||||
"\tJlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI",
|
||||
"\tvYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
|
||||
"",
|
||||
"-----BEGIN PGP MESSAGE-----",
|
||||
"Hi!",
|
||||
"-----END PGP MESSAGE-----",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
).encode()
|
||||
)
|
||||
|
||||
assert check_encrypted_bstr(
|
||||
"\r\n".join(
|
||||
[
|
||||
"Subject: ...",
|
||||
"From: <barbaz@c2.testrun.org>",
|
||||
"To: <foobar@c2.testrun.org>",
|
||||
"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>",
|
||||
"\t<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>",
|
||||
"Chat-Version: 1.0",
|
||||
"Autocrypt: addr=barbaz@c2.testrun.org; prefer-encrypt=mutual;",
|
||||
"\tkeydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH",
|
||||
"\trNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID",
|
||||
"\tFgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW",
|
||||
"\tXQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK",
|
||||
"\tKwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ",
|
||||
"\tJlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP",
|
||||
"\tIXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO",
|
||||
"MIME-Version: 1.0",
|
||||
'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";',
|
||||
'\tboundary="YFrteb74qSXmggbOxZL9dRnhymywAi"',
|
||||
"",
|
||||
"",
|
||||
"--YFrteb74qSXmggbOxZL9dRnhymywAi",
|
||||
"Content-Description: PGP/MIME version identification",
|
||||
"Content-Type: application/pgp-encrypted",
|
||||
"",
|
||||
"Version: 1",
|
||||
"",
|
||||
"",
|
||||
"--YFrteb74qSXmggbOxZL9dRnhymywAi",
|
||||
"Content-Description: OpenPGP encrypted message",
|
||||
'Content-Disposition: inline; filename="encrypted.asc";',
|
||||
'Content-Type: application/octet-stream; name="encrypted.asc"',
|
||||
"",
|
||||
"-----BEGIN PGP MESSAGE-----",
|
||||
"",
|
||||
"wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg",
|
||||
"O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae",
|
||||
"8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI",
|
||||
"JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no",
|
||||
"lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz",
|
||||
"ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM",
|
||||
"YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA",
|
||||
"kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI",
|
||||
"+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg",
|
||||
"RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo",
|
||||
"tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7",
|
||||
"rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp",
|
||||
"H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI",
|
||||
"fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9",
|
||||
"61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN",
|
||||
"XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3",
|
||||
"w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb",
|
||||
"NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs",
|
||||
"baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW",
|
||||
"A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8",
|
||||
"uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI",
|
||||
"E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn",
|
||||
"lkOWnEbCD+XTnbDd",
|
||||
"=agR5",
|
||||
"-----END PGP MESSAGE-----",
|
||||
"",
|
||||
"",
|
||||
"--YFrteb74qSXmggbOxZL9dRnhymywAi--",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
).encode()
|
||||
)
|
||||
|
||||
assert not check_encrypted_bstr(
|
||||
"\r\n".join(
|
||||
[
|
||||
"Subject: Buy Penis Enlargement at www.malicious-domain.com",
|
||||
"From: <barbaz@c2.testrun.org>",
|
||||
"To: <foobar@c2.testrun.org>",
|
||||
"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>",
|
||||
"\t<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>",
|
||||
"Chat-Version: 1.0",
|
||||
"Autocrypt: addr=barbaz@c2.testrun.org; prefer-encrypt=mutual;",
|
||||
"\tkeydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH",
|
||||
"\trNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID",
|
||||
"\tFgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW",
|
||||
"\tXQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK",
|
||||
"\tKwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ",
|
||||
"\tJlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP",
|
||||
"\tIXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO",
|
||||
"MIME-Version: 1.0",
|
||||
'Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";',
|
||||
'\tboundary="YFrteb74qSXmggbOxZL9dRnhymywAi"',
|
||||
"",
|
||||
"",
|
||||
"--YFrteb74qSXmggbOxZL9dRnhymywAi",
|
||||
"Content-Description: PGP/MIME version identification",
|
||||
"Content-Type: application/pgp-encrypted",
|
||||
"",
|
||||
"Version: 1",
|
||||
"",
|
||||
"",
|
||||
"--YFrteb74qSXmggbOxZL9dRnhymywAi",
|
||||
"Content-Description: OpenPGP encrypted message",
|
||||
'Content-Disposition: inline; filename="encrypted.asc";',
|
||||
'Content-Type: application/octet-stream; name="encrypted.asc"',
|
||||
"",
|
||||
"-----BEGIN PGP MESSAGE-----",
|
||||
"",
|
||||
"wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg",
|
||||
"O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae",
|
||||
"8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI",
|
||||
"JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no",
|
||||
"lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz",
|
||||
"ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM",
|
||||
"YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA",
|
||||
"kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI",
|
||||
"+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg",
|
||||
"RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo",
|
||||
"tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7",
|
||||
"rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp",
|
||||
"H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI",
|
||||
"fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9",
|
||||
"61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN",
|
||||
"XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3",
|
||||
"w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb",
|
||||
"NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs",
|
||||
"baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW",
|
||||
"A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8",
|
||||
"uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI",
|
||||
"E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn",
|
||||
"lkOWnEbCD+XTnbDd",
|
||||
"=agR5",
|
||||
"-----END PGP MESSAGE-----",
|
||||
"",
|
||||
"",
|
||||
"--YFrteb74qSXmggbOxZL9dRnhymywAi--",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
).encode()
|
||||
)
|
||||
|
||||
assert not check_encrypted_bstr(
|
||||
"\r\n".join(
|
||||
[
|
||||
"Subject: Message opened",
|
||||
"From: <barbaz@c2.testrun.org>",
|
||||
"To: <foobar@c2.testrun.org>",
|
||||
"Date: Sun, 15 Oct 2023 16:43:25 +0000",
|
||||
"Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>",
|
||||
"Auto-Submitted: auto-replied",
|
||||
"Chat-Version: 1.0",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/report; report-type=disposition-notification;",
|
||||
'\tboundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"',
|
||||
"",
|
||||
"",
|
||||
"--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi",
|
||||
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
|
||||
"",
|
||||
'The "Hi!" message you sent was displayed on the screen of the recipient.',
|
||||
"",
|
||||
"This is no guarantee the content was read.",
|
||||
"",
|
||||
"",
|
||||
"--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi",
|
||||
"Content-Type: message/disposition-notification",
|
||||
"",
|
||||
"Reporting-UA: Delta Chat 1.124.1",
|
||||
"Original-Recipient: rfc822;barbaz@c2.testrun.org",
|
||||
"Final-Recipient: rfc822;barbaz@c2.testrun.org",
|
||||
"Original-Message-ID: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>",
|
||||
"Disposition: manual-action/MDN-sent-automatically; displayed",
|
||||
"",
|
||||
"",
|
||||
"--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
).encode()
|
||||
)
|
||||
25
chatmaild/src/chatmaild/tests/mail-data/fake-encrypted.eml
Normal file
25
chatmaild/src/chatmaild/tests/mail-data/fake-encrypted.eml
Normal file
@@ -0,0 +1,25 @@
|
||||
Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=
|
||||
Chat-Disposition-Notification-To: foobar@c2.testrun.org
|
||||
Chat-User-Avatar: 0
|
||||
From: <{from_addr}>
|
||||
To: <{to_addr}>
|
||||
Date: Sun, 15 Oct 2023 16:41:44 +0000
|
||||
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||
Chat-Version: 1.0
|
||||
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
|
||||
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
|
||||
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
|
||||
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
|
||||
HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK
|
||||
KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ
|
||||
JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI
|
||||
vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
||||
|
||||
-----BEGIN PGP MESSAGE-----
|
||||
Hi!
|
||||
-----END PGP MESSAGE-----
|
||||
|
||||
|
||||
33
chatmaild/src/chatmaild/tests/mail-data/mdn.eml
Normal file
33
chatmaild/src/chatmaild/tests/mail-data/mdn.eml
Normal file
@@ -0,0 +1,33 @@
|
||||
Subject: Message opened
|
||||
From: <{from_addr}>
|
||||
To: <{to_addr}>
|
||||
Date: Sun, 15 Oct 2023 16:43:25 +0000
|
||||
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
|
||||
Auto-Submitted: auto-replied
|
||||
Chat-Version: 1.0
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/report; report-type=disposition-notification;
|
||||
boundary="Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi"
|
||||
|
||||
|
||||
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
|
||||
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
||||
|
||||
The "Hi!" message you sent was displayed on the screen of the recipient.
|
||||
|
||||
This is no guarantee the content was read.
|
||||
|
||||
|
||||
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi
|
||||
Content-Type: message/disposition-notification
|
||||
|
||||
Reporting-UA: Delta Chat 1.124.1
|
||||
Original-Recipient: rfc822;barbaz@c2.testrun.org
|
||||
Final-Recipient: rfc822;barbaz@c2.testrun.org
|
||||
Original-Message-ID: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||
Disposition: manual-action/MDN-sent-automatically; displayed
|
||||
|
||||
|
||||
--Gl92xgZjOShJ5PGHntqYkoo2OK2Dvi--
|
||||
|
||||
|
||||
23
chatmaild/src/chatmaild/tests/mail-data/plain.eml
Normal file
23
chatmaild/src/chatmaild/tests/mail-data/plain.eml
Normal file
@@ -0,0 +1,23 @@
|
||||
Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=
|
||||
Chat-Disposition-Notification-To: foobar@c2.testrun.org
|
||||
Chat-User-Avatar: 0
|
||||
From: <{from_addr}>
|
||||
To: <{to_addr}>
|
||||
Date: Sun, 15 Oct 2023 16:41:44 +0000
|
||||
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||
Chat-Version: 1.0
|
||||
Autocrypt: addr=foobar@c2.testrun.org; prefer-encrypt=mutual;
|
||||
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
|
||||
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
|
||||
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
|
||||
HjNLht7Qf6++mAhlJmtDcA/0C8VYJhsUpmiDjuZaMDWNv4FO2BJG6LH7gSm6n7ClMJzjgEZSrw3hIK
|
||||
KwYBBAGXVQEFAQEHQAxGG/QW0owCfMp1A+vXEMwgzWcBpNFr58kX2eXuPpM6AwEIB8J4BBgWCAAgBQ
|
||||
JlKvDeAhsMFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJDg1gEAwLf8KDoAAKyYgjyI
|
||||
vYvO9VEgBni1C4Xx1VjcaEmlDK8BALoFuUCK+enw76TtDcAUKhlhUiM6SDRExkS4Nskp/BcK
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
||||
|
||||
Hi!
|
||||
|
||||
|
||||
68
chatmaild/src/chatmaild/tests/plugin.py
Normal file
68
chatmaild/src/chatmaild/tests/plugin.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import random
|
||||
import importlib.resources
|
||||
import itertools
|
||||
from email.parser import BytesParser
|
||||
from email import policy
|
||||
import pytest
|
||||
|
||||
from chatmaild.database import Database
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_config(tmp_path):
|
||||
inipath = tmp_path.joinpath("chatmail.ini")
|
||||
|
||||
def make_conf(mailname):
|
||||
write_initial_config(inipath, mailname=mailname)
|
||||
return read_config(inipath)
|
||||
|
||||
return make_conf
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def example_config(make_config):
|
||||
return make_config("chat.example.org")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maildomain(example_config):
|
||||
return example_config.mailname
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gencreds(maildomain):
|
||||
count = itertools.count()
|
||||
next(count)
|
||||
|
||||
def gen(domain=None):
|
||||
domain = domain if domain else maildomain
|
||||
while 1:
|
||||
num = next(count)
|
||||
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
user = "".join(random.choices(alphanumeric, k=10))
|
||||
user = f"ac{num}_{user}"[:9]
|
||||
password = "".join(random.choices(alphanumeric, k=12))
|
||||
yield f"{user}@{domain}", f"{password}"
|
||||
|
||||
return lambda domain=None: next(gen(domain))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmpdir):
|
||||
db_path = tmpdir / "passdb.sqlite"
|
||||
print("database path:", db_path)
|
||||
return Database(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maildata(request):
|
||||
datadir = importlib.resources.files(__package__).joinpath("mail-data")
|
||||
assert datadir.exists(), datadir
|
||||
|
||||
def maildata(name, from_addr, to_addr):
|
||||
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())
|
||||
|
||||
return maildata
|
||||
27
chatmaild/src/chatmaild/tests/test_config.py
Normal file
27
chatmaild/src/chatmaild/tests/test_config.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
def test_read_config_basic(make_config):
|
||||
config = make_config("chat.example.org")
|
||||
assert config.mailname == "chat.example.org"
|
||||
assert not config.privacy_supervisor and not config.privacy_mail
|
||||
assert not config.privacy_pdo and not config.privacy_postal
|
||||
|
||||
inipath = config._inipath
|
||||
inipath.write_text(inipath.read_text().replace("60", "37"))
|
||||
config = read_config(inipath)
|
||||
assert config.max_user_send_per_minute == 37
|
||||
assert config.mailname == "chat.example.org"
|
||||
|
||||
|
||||
def test_read_config_testrun(make_config):
|
||||
config = make_config("something.testrun.org")
|
||||
assert config.mailname == "something.testrun.org"
|
||||
assert len(config.privacy_postal.split("\n")) > 1
|
||||
assert len(config.privacy_supervisor.split("\n")) > 1
|
||||
assert len(config.privacy_pdo.split("\n")) > 1
|
||||
assert config.privacy_mail == "privacy@testrun.org"
|
||||
assert config.filtermail_smtp_port == 10080
|
||||
assert config.postfix_reinject_port == 10025
|
||||
assert config.max_user_send_per_minute == 60
|
||||
assert config.passthrough_recipients
|
||||
89
chatmaild/src/chatmaild/tests/test_doveauth.py
Normal file
89
chatmaild/src/chatmaild/tests/test_doveauth.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
import pytest
|
||||
import threading
|
||||
import queue
|
||||
import traceback
|
||||
|
||||
import chatmaild.doveauth
|
||||
from chatmaild.doveauth import get_user_data, lookup_passdb, handle_dovecot_request
|
||||
from chatmaild.database import DBError
|
||||
|
||||
|
||||
def test_basic(db):
|
||||
lookup_passdb(db, "link2xt@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
|
||||
data = get_user_data(db, "link2xt@c1.testrun.org")
|
||||
assert data
|
||||
data2 = lookup_passdb(db, "link2xt@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
|
||||
assert data == data2
|
||||
|
||||
|
||||
def test_dont_overwrite_password_on_wrong_login(db):
|
||||
"""Test that logging in with a different password doesn't create a new user"""
|
||||
res = lookup_passdb(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
|
||||
assert res["password"]
|
||||
res2 = lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
|
||||
# this function always returns a password hash, which is actually compared by dovecot.
|
||||
assert res["password"] == res2["password"]
|
||||
|
||||
|
||||
def test_nocreate_file(db, monkeypatch, tmpdir):
|
||||
p = tmpdir.join("nocreate")
|
||||
p.write("")
|
||||
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
|
||||
lookup_passdb(db, "newuser1@something.org", "zequ0Aimuchoodaechik")
|
||||
assert not get_user_data(db, "newuser1@something.org")
|
||||
|
||||
|
||||
def test_db_version(db):
|
||||
assert db.get_schema_version() == 1
|
||||
|
||||
|
||||
def test_too_high_db_version(db):
|
||||
with db.write_transaction() as conn:
|
||||
conn.execute("PRAGMA user_version=%s;" % (999,))
|
||||
with pytest.raises(DBError):
|
||||
db.ensure_tables()
|
||||
|
||||
|
||||
def test_handle_dovecot_request(db):
|
||||
msg = (
|
||||
"Lshared/passdb/laksjdlaksjdlaksjdlk12j3l1k2j3123/"
|
||||
"some42@c3.testrun.org\tsome42@c3.testrun.org"
|
||||
)
|
||||
res = handle_dovecot_request(msg, db, "c3.testrun.org")
|
||||
assert res
|
||||
assert res[0] == "O" and res.endswith("\n")
|
||||
userdata = json.loads(res[1:].strip())
|
||||
assert userdata["home"] == "/home/vmail/some42@c3.testrun.org"
|
||||
assert userdata["uid"] == userdata["gid"] == "vmail"
|
||||
assert userdata["password"].startswith("{SHA512-CRYPT}")
|
||||
|
||||
|
||||
def test_50_concurrent_lookups_different_accounts(db, gencreds):
|
||||
num_threads = 50
|
||||
req_per_thread = 5
|
||||
results = queue.Queue()
|
||||
|
||||
def lookup(db):
|
||||
for i in range(req_per_thread):
|
||||
addr, password = gencreds()
|
||||
try:
|
||||
lookup_passdb(db, addr, password)
|
||||
except Exception:
|
||||
results.put(traceback.format_exc())
|
||||
else:
|
||||
results.put(None)
|
||||
|
||||
threads = []
|
||||
for i in range(num_threads):
|
||||
thread = threading.Thread(target=lookup, args=(db,), daemon=True)
|
||||
threads.append(thread)
|
||||
|
||||
print(f"created {num_threads} threads, starting them and waiting for results")
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
for i in range(num_threads * req_per_thread):
|
||||
res = results.get()
|
||||
if res is not None:
|
||||
pytest.fail(f"concurrent lookup failed\n{res}")
|
||||
129
chatmaild/src/chatmaild/tests/test_filtermail.py
Normal file
129
chatmaild/src/chatmaild/tests/test_filtermail.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from chatmaild.filtermail import (
|
||||
check_encrypted,
|
||||
BeforeQueueHandler,
|
||||
SendRateLimiter,
|
||||
check_mdn,
|
||||
)
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maildomain():
|
||||
# let's not depend on a real chatmail instance for the offline tests below
|
||||
return "chatmail.example.org"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(make_config, maildomain):
|
||||
config = make_config(maildomain)
|
||||
return BeforeQueueHandler(config)
|
||||
|
||||
|
||||
def test_reject_forged_from(maildata, gencreds, handler):
|
||||
class env:
|
||||
mail_from = gencreds()[0]
|
||||
rcpt_tos = [gencreds()[0]]
|
||||
|
||||
# test that the filter lets good mail through
|
||||
to_addr = gencreds()[0]
|
||||
env.content = maildata(
|
||||
"plain.eml", from_addr=env.mail_from, to_addr=to_addr
|
||||
).as_bytes()
|
||||
|
||||
assert not handler.check_DATA(envelope=env)
|
||||
|
||||
# test that the filter rejects forged mail
|
||||
env.content = maildata(
|
||||
"plain.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr
|
||||
).as_bytes()
|
||||
error = handler.check_DATA(envelope=env)
|
||||
assert "500" in error
|
||||
|
||||
|
||||
def test_filtermail_no_encryption_detection(maildata):
|
||||
msg = maildata(
|
||||
"plain.eml", from_addr="some@example.org", to_addr="other@example.org"
|
||||
)
|
||||
assert not check_encrypted(msg)
|
||||
|
||||
# https://xkcd.com/1181/
|
||||
msg = maildata(
|
||||
"fake-encrypted.eml", from_addr="some@example.org", to_addr="other@example.org"
|
||||
)
|
||||
assert not check_encrypted(msg)
|
||||
|
||||
|
||||
def test_filtermail_encryption_detection(maildata):
|
||||
msg = maildata("encrypted.eml", from_addr="1@example.org", to_addr="2@example.org")
|
||||
assert check_encrypted(msg)
|
||||
|
||||
# if the subject is not "..." it is not considered ac-encrypted
|
||||
msg.replace_header("Subject", "Click this link")
|
||||
assert not check_encrypted(msg)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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():
|
||||
limiter = SendRateLimiter()
|
||||
for i in range(100):
|
||||
if limiter.is_sending_allowed("some@example.org", 10):
|
||||
if i <= 10:
|
||||
continue
|
||||
pytest.fail("limiter didn't work")
|
||||
else:
|
||||
assert i == 11
|
||||
break
|
||||
|
||||
|
||||
def test_excempt_privacy(maildata, gencreds, handler):
|
||||
from_addr = gencreds()[0]
|
||||
to_addr = "privacy@testrun.org"
|
||||
handler.config.passthrough_recipients = [to_addr]
|
||||
false_to = "privacy@something.org"
|
||||
|
||||
msg = maildata("plain.eml", from_addr, to_addr)
|
||||
|
||||
class env:
|
||||
mail_from = from_addr
|
||||
rcpt_tos = [to_addr]
|
||||
content = msg.as_bytes()
|
||||
|
||||
# assert that None/no error is returned
|
||||
assert not handler.check_DATA(envelope=env)
|
||||
|
||||
class env2:
|
||||
mail_from = from_addr
|
||||
rcpt_tos = [to_addr, false_to]
|
||||
content = msg.as_bytes()
|
||||
|
||||
assert "500" in handler.check_DATA(envelope=env2)
|
||||
29
chatmaild/src/chatmaild/tests/test_newmail.py
Normal file
29
chatmaild/src/chatmaild/tests/test_newmail.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import json
|
||||
|
||||
import chatmaild
|
||||
from chatmaild.newemail import create_newemail_dict, print_new_account
|
||||
|
||||
|
||||
def test_create_newemail_dict():
|
||||
ac1 = create_newemail_dict(domain="example.org")
|
||||
assert "@" in ac1["email"]
|
||||
assert len(ac1["password"]) >= 10
|
||||
|
||||
ac2 = create_newemail_dict(domain="example.org")
|
||||
|
||||
assert ac1["email"] != ac2["email"]
|
||||
assert ac1["password"] != ac2["password"]
|
||||
|
||||
|
||||
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir):
|
||||
p = tmpdir.join("mailname")
|
||||
p.write(maildomain)
|
||||
monkeypatch.setattr(chatmaild.newemail, "mailname_path", str(p))
|
||||
print_new_account()
|
||||
out, err = capsys.readouterr()
|
||||
lines = out.split("\n")
|
||||
assert lines[0] == "Content-Type: application/json"
|
||||
assert not lines[1]
|
||||
dic = json.loads(lines[2])
|
||||
assert dic["email"].endswith(f"@{maildomain}")
|
||||
assert len(dic["password"]) >= 10
|
||||
@@ -1,5 +1,5 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=45"]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
@@ -7,24 +7,25 @@ name = "deploy-chatmail"
|
||||
version = "0.1"
|
||||
dependencies = [
|
||||
"pyinfra",
|
||||
"pillow",
|
||||
"qrcode",
|
||||
"markdown",
|
||||
"pytest",
|
||||
"setuptools>=68",
|
||||
"termcolor",
|
||||
"build",
|
||||
"tox",
|
||||
"ruff",
|
||||
"black",
|
||||
"pytest",
|
||||
"pytest-xdist",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
cmdeploy = "deploy_chatmail.cmdeploy:main"
|
||||
|
||||
[project.entry-points.pytest11]
|
||||
"chatmaild.testplugin" = "chatmaild.tests.plugin"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-v -ra --strict-markers"
|
||||
|
||||
[tool.tox]
|
||||
legacy_tox_ini = """
|
||||
[tox]
|
||||
isolated_build = true
|
||||
envlist = lint
|
||||
|
||||
[testenv:lint]
|
||||
skipdist = True
|
||||
skip_install = True
|
||||
deps =
|
||||
ruff
|
||||
black
|
||||
commands =
|
||||
black --quiet --check --diff src/
|
||||
ruff src/
|
||||
"""
|
||||
|
||||
@@ -1,71 +1,110 @@
|
||||
"""
|
||||
Chat Mail pyinfra deploy.
|
||||
"""
|
||||
import sys
|
||||
import importlib.resources
|
||||
import subprocess
|
||||
import shutil
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from pyinfra import host, logger
|
||||
from pyinfra.operations import apt, files, server, systemd, python
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import apt, files, server, systemd, pip
|
||||
from pyinfra.facts.files import File
|
||||
from pyinfra.facts.systemd import SystemdEnabled
|
||||
from .acmetool import deploy_acmetool
|
||||
|
||||
from chatmaild.config import read_config, Config
|
||||
|
||||
def _install_chatmaild() -> None:
|
||||
chatmaild_filename = "chatmaild-0.1.tar.gz"
|
||||
chatmaild_path = importlib.resources.files(__package__).joinpath(
|
||||
f"../../../dist/{chatmaild_filename}"
|
||||
|
||||
def _build_chatmaild(dist_dir) -> None:
|
||||
dist_dir = Path(dist_dir).resolve()
|
||||
if dist_dir.exists():
|
||||
shutil.rmtree(dist_dir)
|
||||
dist_dir.mkdir()
|
||||
subprocess.check_output(
|
||||
[sys.executable, "-m", "build", "-n"]
|
||||
+ ["--sdist", "chatmaild", "--outdir", str(dist_dir)]
|
||||
)
|
||||
remote_path = f"/tmp/{chatmaild_filename}"
|
||||
if Path(str(chatmaild_path)).exists():
|
||||
files.put(
|
||||
name="Upload chatmaild source package",
|
||||
src=chatmaild_path.open("rb"),
|
||||
dest=remote_path,
|
||||
)
|
||||
entries = list(dist_dir.iterdir())
|
||||
assert len(entries) == 1
|
||||
return entries[0]
|
||||
|
||||
apt.packages(
|
||||
name="apt install python3-aiosmtpd",
|
||||
packages=["python3-aiosmtpd", "python3-pip"],
|
||||
)
|
||||
|
||||
# --no-deps because aiosmtplib is installed with `apt`.
|
||||
server.shell(
|
||||
name="install chatmaild with pip",
|
||||
commands=[f"pip install --break-system-packages {remote_path}"],
|
||||
)
|
||||
|
||||
files.put(
|
||||
name="upload doveauth-dictproxy.service",
|
||||
src=importlib.resources.files("chatmaild")
|
||||
.joinpath("doveauth-dictproxy.service")
|
||||
.open("rb"),
|
||||
dest="/etc/systemd/system/doveauth-dictproxy.service",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
def remove_legacy_artifacts():
|
||||
# disable legacy doveauth-dictproxy.service
|
||||
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
|
||||
systemd.service(
|
||||
name="Setup doveauth-dictproxy service",
|
||||
name="Disable legacy doveauth-dictproxy.service",
|
||||
service="doveauth-dictproxy.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=True,
|
||||
daemon_reload=True,
|
||||
running=False,
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def _install_remote_venv_with_chatmaild(config) -> None:
|
||||
remove_legacy_artifacts()
|
||||
dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist"))
|
||||
remote_base_dir = "/usr/local/lib/chatmaild"
|
||||
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
|
||||
remote_venv_dir = f"{remote_base_dir}/venv"
|
||||
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
|
||||
root_owned = dict(user="root", group="root", mode="644")
|
||||
|
||||
apt.packages(
|
||||
name="apt install python3-virtualenv",
|
||||
packages=["python3-virtualenv"],
|
||||
)
|
||||
|
||||
files.put(
|
||||
name="Upload chatmaild source package",
|
||||
src=dist_file.open("rb"),
|
||||
dest=remote_dist_file,
|
||||
create_remote_dir=True,
|
||||
**root_owned,
|
||||
)
|
||||
|
||||
files.put(
|
||||
name=f"Upload {remote_chatmail_inipath}",
|
||||
src=config._getbytefile(),
|
||||
dest=remote_chatmail_inipath,
|
||||
**root_owned,
|
||||
)
|
||||
|
||||
pip.virtualenv(
|
||||
name=f"chatmaild virtualenv {remote_venv_dir}",
|
||||
path=remote_venv_dir,
|
||||
always_copy=True,
|
||||
)
|
||||
|
||||
server.shell(
|
||||
name=f"forced pip-install {dist_file.name}",
|
||||
commands=[
|
||||
f"{remote_venv_dir}/bin/pip install --force-reinstall {remote_dist_file}"
|
||||
],
|
||||
)
|
||||
|
||||
# install systemd units
|
||||
for fn in (
|
||||
"doveauth",
|
||||
"filtermail",
|
||||
):
|
||||
params = dict(
|
||||
execpath=f"{remote_venv_dir}/bin/{fn}",
|
||||
config_path=remote_chatmail_inipath,
|
||||
)
|
||||
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
|
||||
content = source_path.read_text().format(**params).encode()
|
||||
|
||||
files.put(
|
||||
name="upload filtermail.service",
|
||||
src=importlib.resources.files("chatmaild")
|
||||
.joinpath("filtermail.service")
|
||||
.open("rb"),
|
||||
dest="/etc/systemd/system/filtermail.service",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
name=f"Upload {fn}.service",
|
||||
src=io.BytesIO(content),
|
||||
dest=f"/etc/systemd/system/{fn}.service",
|
||||
**root_owned,
|
||||
)
|
||||
systemd.service(
|
||||
name="Setup filtermail service",
|
||||
service="filtermail.service",
|
||||
name=f"Setup {fn} service",
|
||||
service=f"{fn}.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=True,
|
||||
@@ -85,6 +124,36 @@ def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
|
||||
mode="644",
|
||||
config={"domain_name": domain, "opendkim_selector": dkim_selector},
|
||||
)
|
||||
need_restart |= main_config.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",
|
||||
@@ -105,12 +174,48 @@ def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
|
||||
_sudo_user="opendkim",
|
||||
)
|
||||
|
||||
need_restart |= main_config.changed
|
||||
return need_restart
|
||||
|
||||
|
||||
def _install_mta_sts_daemon() -> bool:
|
||||
need_restart = False
|
||||
|
||||
config = files.put(
|
||||
name="upload postfix-mta-sts-resolver config",
|
||||
src=importlib.resources.files(__package__).joinpath(
|
||||
"postfix/mta-sts-daemon.yml"
|
||||
),
|
||||
dest="/etc/mta-sts-daemon.yml",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
need_restart |= config.changed
|
||||
|
||||
server.shell(
|
||||
name="install postfix-mta-sts-resolver with pip",
|
||||
commands=[
|
||||
"python3 -m venv /usr/local/lib/postfix-mta-sts-resolver",
|
||||
"/usr/local/lib/postfix-mta-sts-resolver/bin/pip install postfix-mta-sts-resolver",
|
||||
],
|
||||
)
|
||||
|
||||
systemd_unit = files.put(
|
||||
name="upload mta-sts-daemon systemd unit",
|
||||
src=importlib.resources.files(__package__).joinpath(
|
||||
"postfix/mta-sts-daemon.service"
|
||||
),
|
||||
dest="/etc/systemd/system/mta-sts-daemon.service",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
need_restart |= systemd_unit.changed
|
||||
|
||||
return need_restart
|
||||
|
||||
|
||||
def _configure_postfix(domain: str, debug: bool = False) -> bool:
|
||||
def _configure_postfix(config: Config, debug: bool = False) -> bool:
|
||||
"""Configures Postfix SMTP server."""
|
||||
need_restart = False
|
||||
|
||||
@@ -120,7 +225,7 @@ def _configure_postfix(domain: str, debug: bool = False) -> bool:
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={"domain_name": domain},
|
||||
config=config,
|
||||
)
|
||||
need_restart |= main_config.changed
|
||||
|
||||
@@ -131,6 +236,7 @@ def _configure_postfix(domain: str, debug: bool = False) -> bool:
|
||||
group="root",
|
||||
mode="644",
|
||||
debug=debug,
|
||||
config=config,
|
||||
)
|
||||
need_restart |= master_config.changed
|
||||
|
||||
@@ -170,9 +276,88 @@ def _configure_dovecot(mail_server: str, debug: bool = False) -> bool:
|
||||
mode="644",
|
||||
)
|
||||
|
||||
# as per https://doc.dovecot.org/configuration_manual/os/
|
||||
# it is recommended to set the following inotify limits
|
||||
for name in ("max_user_instances", "max_user_watches"):
|
||||
key = f"fs.inotify.{name}"
|
||||
server.sysctl(
|
||||
name=f"Change {key}",
|
||||
key=key,
|
||||
value=65535,
|
||||
persist=True,
|
||||
)
|
||||
|
||||
return need_restart
|
||||
|
||||
|
||||
def _configure_nginx(domain: str, debug: bool = False) -> bool:
|
||||
"""Configures nginx HTTP server."""
|
||||
need_restart = False
|
||||
|
||||
main_config = files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("nginx/nginx.conf.j2"),
|
||||
dest="/etc/nginx/nginx.conf",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={"domain_name": domain},
|
||||
)
|
||||
need_restart |= main_config.changed
|
||||
|
||||
autoconfig = files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("nginx/autoconfig.xml.j2"),
|
||||
dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={"domain_name": domain},
|
||||
)
|
||||
need_restart |= autoconfig.changed
|
||||
|
||||
mta_sts_config = files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("nginx/mta-sts.txt.j2"),
|
||||
dest="/var/www/html/.well-known/mta-sts.txt",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={"domain_name": domain},
|
||||
)
|
||||
need_restart |= mta_sts_config.changed
|
||||
|
||||
# install CGI newemail script
|
||||
#
|
||||
cgi_dir = "/usr/lib/cgi-bin"
|
||||
files.directory(
|
||||
name=f"Ensure {cgi_dir} exists",
|
||||
path=cgi_dir,
|
||||
user="root",
|
||||
group="root",
|
||||
)
|
||||
|
||||
files.put(
|
||||
name="Upload cgi newemail.py script",
|
||||
src=importlib.resources.files("chatmaild").joinpath("newemail.py").open("rb"),
|
||||
dest=f"{cgi_dir}/newemail.py",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="755",
|
||||
)
|
||||
|
||||
return need_restart
|
||||
|
||||
|
||||
def check_config(config):
|
||||
mailname = config.mailname
|
||||
if mailname != "testrun.org" and not mailname.endswith(".testrun.org"):
|
||||
blocked_words = "merlinux schmieder testrun.org".split()
|
||||
for value in config.__dict__.values():
|
||||
if any(x in value for x in blocked_words):
|
||||
raise ValueError(
|
||||
f"please set your own privacy contacts/addresses in {config._inipath}"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> None:
|
||||
"""Deploy a chat-mail instance.
|
||||
|
||||
@@ -180,6 +365,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
|
||||
:param mail_server: the DNS name under which your mail server is reachable
|
||||
:param dkim_selector:
|
||||
"""
|
||||
from .www import build_webpages
|
||||
|
||||
apt.update(name="apt update", cache_time=24 * 3600)
|
||||
server.group(name="Create vmail group", group="vmail", system=True)
|
||||
@@ -194,7 +380,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
|
||||
)
|
||||
|
||||
# Deploy acmetool to have TLS certificates.
|
||||
deploy_acmetool(domains=[mail_server])
|
||||
deploy_acmetool(nginx_hook=True, domains=[mail_server, f"mta-sts.{mail_server}"])
|
||||
|
||||
apt.packages(
|
||||
name="Install Postfix",
|
||||
@@ -214,11 +400,34 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
|
||||
],
|
||||
)
|
||||
|
||||
_install_chatmaild()
|
||||
apt.packages(
|
||||
name="Install nginx",
|
||||
packages=["nginx"],
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name="Install fcgiwrap",
|
||||
packages=["fcgiwrap"],
|
||||
)
|
||||
|
||||
pkg_root = importlib.resources.files(__package__)
|
||||
chatmail_ini = pkg_root.joinpath("../../../chatmail.ini").resolve()
|
||||
config = read_config(chatmail_ini)
|
||||
check_config(config)
|
||||
www_path = pkg_root.joinpath("../../../www").resolve()
|
||||
|
||||
build_dir = www_path.joinpath("build")
|
||||
src_dir = www_path.joinpath("src")
|
||||
build_webpages(src_dir, build_dir, config)
|
||||
files.rsync(f"{build_dir}/", "/var/www/html", flags=["-avz"])
|
||||
|
||||
_install_remote_venv_with_chatmaild(config)
|
||||
debug = False
|
||||
dovecot_need_restart = _configure_dovecot(mail_server, debug=debug)
|
||||
postfix_need_restart = _configure_postfix(mail_domain, debug=debug)
|
||||
postfix_need_restart = _configure_postfix(config, debug=debug)
|
||||
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
|
||||
mta_sts_need_restart = _install_mta_sts_daemon()
|
||||
nginx_need_restart = _configure_nginx(mail_domain)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable OpenDKIM",
|
||||
@@ -228,6 +437,15 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
|
||||
restarted=opendkim_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable MTA-STS daemon",
|
||||
service="mta-sts-daemon.service",
|
||||
daemon_reload=True,
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=mta_sts_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable Postfix",
|
||||
service="postfix.service",
|
||||
@@ -244,6 +462,14 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
|
||||
restarted=dovecot_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable nginx",
|
||||
service="nginx.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=nginx_need_restart,
|
||||
)
|
||||
|
||||
# This file is used by auth proxy.
|
||||
# https://wiki.debian.org/EtcMailName
|
||||
server.shell(
|
||||
@@ -251,13 +477,18 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
|
||||
commands=[f"echo {mail_domain} >/etc/mailname; chmod 644 /etc/mailname"],
|
||||
)
|
||||
|
||||
def callback():
|
||||
result = server.shell(
|
||||
commands=[
|
||||
f"""sed 's/\tIN/ 600 IN/;s/\t(//;s/\"$//;s/^\t \"//g; s/ ).*//' """
|
||||
f"""/etc/dkimkeys/{dkim_selector}.txt | tr --delete '\n'"""
|
||||
]
|
||||
)
|
||||
logger.info(f"Add this TXT entry into DNS zone: {result.stdout}")
|
||||
|
||||
python.call(name="Print TXT entry for DKIM", function=callback)
|
||||
journald_conf = files.put(
|
||||
name="Configure journald",
|
||||
src=importlib.resources.files(__package__).joinpath("journald.conf"),
|
||||
dest="/etc/systemd/journald.conf",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
systemd.service(
|
||||
name="Start and enable journald",
|
||||
service="systemd-journald.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=journald_conf,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import importlib.resources
|
||||
|
||||
from pyinfra.operations import apt, files, systemd, server
|
||||
from pyinfra.operations import apt, files, server
|
||||
|
||||
|
||||
def deploy_acmetool(nginx_hook=False, email="", domains=[]):
|
||||
@@ -38,25 +38,15 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
|
||||
email=email,
|
||||
)
|
||||
|
||||
service_file = files.put(
|
||||
src=importlib.resources.files(__package__)
|
||||
.joinpath("acmetool-redirector.service")
|
||||
.open("rb"),
|
||||
dest="/etc/systemd/system/acmetool-redirector.service",
|
||||
files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
|
||||
dest="/var/lib/acme/conf/target",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
systemd.service(
|
||||
name="Setup acmetool-redirector service",
|
||||
service="acmetool-redirector.service",
|
||||
running=False,
|
||||
enabled=False,
|
||||
restarted=service_file.changed,
|
||||
)
|
||||
|
||||
for domain in domains:
|
||||
server.shell(
|
||||
name=f"Request certificate for {domain}",
|
||||
commands=[f"acmetool want {domain}"],
|
||||
)
|
||||
server.shell(
|
||||
name=f"Request certificate for: { ', '.join(domains) }",
|
||||
commands=[f"acmetool want { ' '.join(domains)}"],
|
||||
)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=acmetool HTTP redirector
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,7 @@
|
||||
request:
|
||||
provider: https://acme-v02.api.letsencrypt.org/directory
|
||||
key:
|
||||
type: rsa
|
||||
challenge:
|
||||
webroot-paths:
|
||||
- /var/www/html/.well-known/acme-challenge
|
||||
12
deploy-chatmail/src/deploy_chatmail/chatmail.zone.f
Normal file
12
deploy-chatmail/src/deploy_chatmail/chatmail.zone.f
Normal file
@@ -0,0 +1,12 @@
|
||||
{chatmail_domain}. MX 10 {chatmail_domain}.
|
||||
_submission._tcp.{chatmail_domain}. SRV 0 1 587 {chatmail_domain}.
|
||||
_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}. IN CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}"
|
||||
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all"
|
||||
_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;rua=mailto:{email};ruf=mailto:{email};fo=1;adkim=r;aspf=r"
|
||||
_mta-sts.{chatmail_domain}. IN TXT "v=STSv1; id={sts_id}"
|
||||
mta-sts.{chatmail_domain}. IN CNAME {chatmail_domain}.
|
||||
_smtp._tls.{chatmail_domain}. IN TXT "v=TLSRPTv1;rua=mailto:{email}"
|
||||
{dkim_entry}
|
||||
301
deploy-chatmail/src/deploy_chatmail/cmdeploy.py
Normal file
301
deploy-chatmail/src/deploy_chatmail/cmdeploy.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
Provides the `cmdeploy` entry point function,
|
||||
along with command line option and subcommand parsing.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import shutil
|
||||
import subprocess
|
||||
import importlib.resources
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from termcolor import colored
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
|
||||
|
||||
#
|
||||
# cmdeploy sub commands and options
|
||||
#
|
||||
|
||||
|
||||
def init_cmd_options(parser):
|
||||
parser.add_argument(
|
||||
"chatmail_domain",
|
||||
action="store",
|
||||
help="fully qualified DNS domain name for your chatmail instance",
|
||||
)
|
||||
|
||||
|
||||
def init_cmd(args, out):
|
||||
"""Initialize chatmail config file."""
|
||||
if args.inipath.exists():
|
||||
out.red(f"Path exists, not modifying: {args.inipath}")
|
||||
raise SystemExit(1)
|
||||
write_initial_config(args.inipath, args.chatmail_domain)
|
||||
out.green(f"created config file for {args.chatmail_domain} in {args.inipath}")
|
||||
|
||||
|
||||
def run_cmd_options(parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
dest="dry_run",
|
||||
action="store_true",
|
||||
help="don't actually modify the server",
|
||||
)
|
||||
|
||||
|
||||
def run_cmd(args, out):
|
||||
"""Deploy chatmail services on the remote server."""
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CHATMAIL_DOMAIN"] = args.config.mailname
|
||||
deploypy = "deploy-chatmail/src/deploy_chatmail/deploy.py"
|
||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||
cmd = f"{pyinf} --ssh-user root {args.config.mailname} {deploypy}"
|
||||
out.check_call(cmd, env=env)
|
||||
|
||||
|
||||
def dns_cmd(args, out):
|
||||
"""Generate dns zone file."""
|
||||
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
|
||||
ssh = f"ssh root@{args.config.mailname}"
|
||||
|
||||
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)
|
||||
return "\n".join(lines)
|
||||
|
||||
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
|
||||
dkim_entry = read_dkim_entries(out.shell_output(f"{ssh} -- opendkim-genzone -F"))
|
||||
|
||||
out(
|
||||
f"[writing {args.config.mailname} zone data (using space as separator) to stdout output]",
|
||||
green=True,
|
||||
)
|
||||
print(
|
||||
template.read_text()
|
||||
.format(
|
||||
acme_account_url=acme_account_url,
|
||||
email=f"root@{args.config.mailname}",
|
||||
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
|
||||
chatmail_domain=args.config.mailname,
|
||||
dkim_entry=dkim_entry,
|
||||
)
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def status_cmd(args, out):
|
||||
"""Display status for online chatmail instance."""
|
||||
|
||||
ssh = f"ssh root@{args.config.mailname}"
|
||||
|
||||
out.green(f"chatmail domain: {args.config.mailname}")
|
||||
if args.config.privacy_mail:
|
||||
out.green("privacy settings: present")
|
||||
else:
|
||||
out.red("no privacy settings")
|
||||
|
||||
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(args, out):
|
||||
"""Run local and online tests for chatmail deployment.
|
||||
|
||||
This will automatically pip-install 'deltachat' if it's not available.
|
||||
"""
|
||||
|
||||
x = importlib.util.find_spec("deltachat")
|
||||
if x is None:
|
||||
out.check_call(f"{sys.executable} -m pip install deltachat")
|
||||
|
||||
pytest_path = shutil.which("pytest")
|
||||
ret = out.run_ret(
|
||||
[pytest_path, "tests/", "-n4", "-rs", "-x", "-vrx", "--durations=5"]
|
||||
)
|
||||
return ret
|
||||
|
||||
|
||||
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",
|
||||
action="store_true",
|
||||
help="only check but don't fix problems",
|
||||
)
|
||||
|
||||
|
||||
def fmt_cmd(args, out):
|
||||
"""Run formattting fixes (fuff and black) on all chatmail source code."""
|
||||
|
||||
chatmaild = importlib.resources.files("chatmaild")
|
||||
deploy_chatmail = importlib.resources.files("deploy_chatmail")
|
||||
tests = deploy_chatmail.joinpath("../../../tests")
|
||||
sources = list(str(x) for x in [chatmaild, deploy_chatmail, tests])
|
||||
|
||||
black_args = [shutil.which("black")]
|
||||
ruff_args = [shutil.which("ruff")]
|
||||
|
||||
if args.check:
|
||||
black_args.append("--check")
|
||||
else:
|
||||
ruff_args.append("--fix")
|
||||
|
||||
if not args.verbose:
|
||||
black_args.append("-q")
|
||||
ruff_args.append("-q")
|
||||
|
||||
black_args.extend(sources)
|
||||
ruff_args.extend(sources)
|
||||
|
||||
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):
|
||||
"""Run benchmarks against an online chatmail instance."""
|
||||
pytest_path = shutil.which("pytest")
|
||||
benchmark = "tests/online/benchmark.py"
|
||||
subprocess.check_call([pytest_path, benchmark, "-vrx"])
|
||||
|
||||
|
||||
def webdev_cmd(args, out):
|
||||
"""Run local web development loop for static web pages."""
|
||||
from .www import main
|
||||
|
||||
main()
|
||||
|
||||
|
||||
#
|
||||
# Parsing command line options and starting commands
|
||||
#
|
||||
|
||||
|
||||
class Out:
|
||||
"""Convenience output printer providing coloring."""
|
||||
|
||||
def red(self, msg, file=sys.stderr):
|
||||
print(colored(msg, "red"), file=file)
|
||||
|
||||
def green(self, msg, file=sys.stderr):
|
||||
print(colored(msg, "green"), file=file)
|
||||
|
||||
def __call__(self, msg, red=False, green=False, file=sys.stdout):
|
||||
color = "red" if red else ("green" if green else None)
|
||||
print(colored(msg, color), file=file)
|
||||
|
||||
def shell_output(self, arg):
|
||||
self(f"[$ {arg}]", file=sys.stderr)
|
||||
return subprocess.check_output(arg, shell=True).decode()
|
||||
|
||||
def check_call(self, arg, env=None, quiet=False):
|
||||
if not quiet:
|
||||
self(f"[$ {arg}]", file=sys.stderr)
|
||||
return subprocess.check_call(arg, shell=True, env=env)
|
||||
|
||||
def run_ret(self, args, env=None, quiet=False):
|
||||
if not quiet:
|
||||
cmdstring = " ".join(args)
|
||||
self(f"[$ {cmdstring}]", file=sys.stderr)
|
||||
proc = subprocess.run(args, env=env)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def add_config_option(parser):
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
dest="inipath",
|
||||
action="store",
|
||||
default=Path("chatmail.ini"),
|
||||
type=Path,
|
||||
help="path to the chatmail.ini file",
|
||||
)
|
||||
|
||||
|
||||
def add_subcommand(subparsers, func):
|
||||
name = func.__name__
|
||||
assert name.endswith("_cmd")
|
||||
name = name[:-4]
|
||||
doc = func.__doc__.strip()
|
||||
help = doc.split("\n")[0].strip(".")
|
||||
p = subparsers.add_parser(name, description=doc, help=help)
|
||||
p.set_defaults(func=func)
|
||||
add_config_option(p)
|
||||
return p
|
||||
|
||||
|
||||
description = """
|
||||
Setup your chatmail server configuration and
|
||||
deploy it via SSH to your remote location.
|
||||
"""
|
||||
|
||||
|
||||
def get_parser():
|
||||
"""Return an ArgumentParser for the 'cmdeploy' CLI"""
|
||||
|
||||
parser = argparse.ArgumentParser(description=description.strip())
|
||||
subparsers = parser.add_subparsers(title="subcommands")
|
||||
|
||||
# find all subcommands in the module namespace
|
||||
glob = globals()
|
||||
for name, func in glob.items():
|
||||
if name.endswith("_cmd"):
|
||||
subparser = add_subcommand(subparsers, func)
|
||||
addopts = glob.get(name + "_options")
|
||||
if addopts is not None:
|
||||
addopts(subparser)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(args=None):
|
||||
"""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"])
|
||||
out = Out()
|
||||
kwargs = {}
|
||||
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
|
||||
if not args.inipath.exists():
|
||||
out.red(f"expecting {args.inipath} to exist, run init first?")
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
args.config = read_config(args.inipath)
|
||||
except Exception as ex:
|
||||
out.red(ex)
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
res = args.func(args, out, **kwargs)
|
||||
if res is None:
|
||||
res = 0
|
||||
return res
|
||||
|
||||
except KeyboardInterrupt:
|
||||
out.red("KeyboardInterrupt")
|
||||
sys.exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
deploy-chatmail/src/deploy_chatmail/data/delta-chat-bw.png
Normal file
BIN
deploy-chatmail/src/deploy_chatmail/data/delta-chat-bw.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
deploy-chatmail/src/deploy_chatmail/data/opensans-regular.ttf
Normal file
BIN
deploy-chatmail/src/deploy_chatmail/data/opensans-regular.ttf
Normal file
Binary file not shown.
@@ -6,7 +6,7 @@ from deploy_chatmail import deploy_chatmail
|
||||
def main():
|
||||
mail_domain = os.getenv("CHATMAIL_DOMAIN")
|
||||
mail_server = os.getenv("CHATMAIL_SERVER", mail_domain)
|
||||
dkim_selector = os.getenv("CHATMAIL_DKIM_SELECTOR", "2023")
|
||||
dkim_selector = os.getenv("CHATMAIL_DKIM_SELECTOR", "dkim")
|
||||
|
||||
assert mail_domain
|
||||
assert mail_server
|
||||
|
||||
@@ -19,7 +19,7 @@ mail_plugins = quota
|
||||
# these are the capabilities Delta Chat cares about actually
|
||||
# so let's keep the network overhead per login small
|
||||
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
|
||||
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE
|
||||
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY
|
||||
|
||||
|
||||
# Authentication for system users.
|
||||
@@ -118,6 +118,24 @@ service auth-worker {
|
||||
user = vmail
|
||||
}
|
||||
|
||||
service imap-login {
|
||||
# High-security mode.
|
||||
# Each process serves a single connection and exits afterwards.
|
||||
# This is the default, but we set it explicitly to be sure.
|
||||
# See <https://doc.dovecot.org/admin_manual/login_processes/#high-security-mode> for details.
|
||||
service_count = 1
|
||||
|
||||
# Inrease the number of simultaneous connections.
|
||||
#
|
||||
# As of Dovecot 2.3.19.1 the default is 100 processes.
|
||||
# Combined with `service_count = 1` it means only 100 connections
|
||||
# can be handled simultaneously.
|
||||
process_limit = 10000
|
||||
|
||||
# Avoid startup latency for new connections.
|
||||
process_min_avail = 10
|
||||
}
|
||||
|
||||
ssl = required
|
||||
ssl_cert = </var/lib/acme/live/{{ config.hostname }}/fullchain
|
||||
ssl_key = </var/lib/acme/live/{{ config.hostname }}/privkey
|
||||
|
||||
87
deploy-chatmail/src/deploy_chatmail/genqr.py
Normal file
87
deploy-chatmail/src/deploy_chatmail/genqr.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import importlib
|
||||
import qrcode
|
||||
import os
|
||||
from PIL import ImageFont, ImageDraw, Image
|
||||
import io
|
||||
|
||||
|
||||
def gen_qr_png_data(maildomain):
|
||||
url = f"DCACCOUNT:https://{maildomain}/cgi-bin/newemail.py"
|
||||
image = gen_qr(maildomain, url)
|
||||
temp = io.BytesIO()
|
||||
image.save(temp, format="png")
|
||||
temp.seek(0)
|
||||
return temp
|
||||
|
||||
|
||||
def gen_qr(maildomain, url):
|
||||
# taken and modified from
|
||||
# https://github.com/deltachat/mailadm/blob/master/src/mailadm/gen_qr.py
|
||||
|
||||
# info = f"{maildomain} invite code"
|
||||
info = ""
|
||||
|
||||
# load QR code
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_H,
|
||||
box_size=1,
|
||||
border=1,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
qr_img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# paint all elements
|
||||
ttf_path = str(
|
||||
importlib.resources.files(__package__).joinpath("data/opensans-regular.ttf")
|
||||
)
|
||||
logo_red_path = str(
|
||||
importlib.resources.files(__package__).joinpath("data/delta-chat-bw.png")
|
||||
)
|
||||
|
||||
assert os.path.exists(ttf_path), ttf_path
|
||||
font_size = 16
|
||||
font = ImageFont.truetype(font=ttf_path, size=font_size)
|
||||
|
||||
num_lines = ((info).count("\n") + 1) if info else 0
|
||||
|
||||
size = width = 384
|
||||
qr_padding = 6
|
||||
text_height = font_size * num_lines
|
||||
height = size + text_height
|
||||
|
||||
image = Image.new("RGBA", (width, height), "white")
|
||||
qr_final_size = width - (qr_padding * 2)
|
||||
|
||||
if num_lines:
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# draw text
|
||||
if hasattr(font, "getsize"):
|
||||
info_pos = (width - font.getsize(info.strip())[0]) // 2
|
||||
else:
|
||||
info_pos = (width - font.getbbox(info.strip())[3]) // 2
|
||||
|
||||
draw.multiline_text(
|
||||
(info_pos, size - qr_padding // 2),
|
||||
info,
|
||||
font=font,
|
||||
fill="black",
|
||||
align="right",
|
||||
)
|
||||
|
||||
# paste QR code
|
||||
image.paste(
|
||||
qr_img.resize((qr_final_size, qr_final_size), resample=Image.NEAREST),
|
||||
(qr_padding, qr_padding),
|
||||
)
|
||||
|
||||
# background delta logo
|
||||
logo2_img = Image.open(logo_red_path)
|
||||
logo2_width = int(size / 6)
|
||||
logo2 = logo2_img.resize((logo2_width, logo2_width), resample=Image.NEAREST)
|
||||
pos = int((size / 2) - (logo2_width / 2))
|
||||
image.paste(logo2, (pos, pos), mask=logo2)
|
||||
|
||||
return image
|
||||
2
deploy-chatmail/src/deploy_chatmail/journald.conf
Normal file
2
deploy-chatmail/src/deploy_chatmail/journald.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
[Journal]
|
||||
MaxRetentionSec=3d
|
||||
37
deploy-chatmail/src/deploy_chatmail/nginx/autoconfig.xml.j2
Normal file
37
deploy-chatmail/src/deploy_chatmail/nginx/autoconfig.xml.j2
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="{{ config.domain_name }}">
|
||||
<domain>{{ config.domain_name }}</domain>
|
||||
<displayName>{{ config.domain_name }} chatmail</displayName>
|
||||
<displayShortName>{{ config.domain_name }}</displayShortName>
|
||||
<incomingServer type="imap">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<incomingServer type="imap">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<port>143</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
4
deploy-chatmail/src/deploy_chatmail/nginx/mta-sts.txt.j2
Normal file
4
deploy-chatmail/src/deploy_chatmail/nginx/mta-sts.txt.j2
Normal file
@@ -0,0 +1,4 @@
|
||||
version: STSv1
|
||||
mode: enforce
|
||||
mx: {{ config.domain_name }}
|
||||
max_age: 2419200
|
||||
55
deploy-chatmail/src/deploy_chatmail/nginx/nginx.conf.j2
Normal file
55
deploy-chatmail/src/deploy_chatmail/nginx/nginx.conf.j2
Normal file
@@ -0,0 +1,55 @@
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
# multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
|
||||
# Do not emit nginx version on error pages.
|
||||
server_tokens off;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain;
|
||||
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey;
|
||||
|
||||
gzip on;
|
||||
|
||||
server {
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server;
|
||||
|
||||
root /var/www/html;
|
||||
|
||||
index index.html index.htm;
|
||||
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
# First attempt to serve request as file, then
|
||||
# as directory, then fall back to displaying a 404.
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# add cgi-bin support
|
||||
include /usr/share/doc/fcgiwrap/examples/nginx.conf;
|
||||
}
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
1
deploy-chatmail/src/deploy_chatmail/opendkim/KeyTable
Normal file
1
deploy-chatmail/src/deploy_chatmail/opendkim/KeyTable
Normal file
@@ -0,0 +1 @@
|
||||
dkim._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/dkim.private
|
||||
@@ -0,0 +1 @@
|
||||
*@{{ config.domain_name }} {{ config.opendkim_selector }}._domainkey.{{ config.domain_name }}
|
||||
@@ -1,7 +1,4 @@
|
||||
# This is a basic configuration for signing and verifying. It can easily be
|
||||
# adapted to suit a basic installation. See opendkim.conf(5) and
|
||||
# /usr/share/doc/opendkim/examples/opendkim.conf.sample for complete
|
||||
# documentation of available configuration parameters.
|
||||
# OpenDKIM configuration.
|
||||
|
||||
Syslog yes
|
||||
SyslogSuccess yes
|
||||
@@ -21,7 +18,9 @@ OversignHeaders From
|
||||
# setup options can be found in /usr/share/doc/opendkim/README.opendkim.
|
||||
Domain {{ config.domain_name }}
|
||||
Selector {{ config.opendkim_selector }}
|
||||
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
|
||||
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
|
||||
KeyTable /etc/dkimkeys/KeyTable
|
||||
SigningTable refile:/etc/dkimkeys/SigningTable
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
myorigin = {{ config.domain_name }}
|
||||
myorigin = {{ config.mailname }}
|
||||
|
||||
smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU)
|
||||
biff = no
|
||||
@@ -16,16 +16,17 @@ readme_directory = no
|
||||
compatibility_level = 2
|
||||
|
||||
# TLS parameters
|
||||
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.domain_name }}/fullchain
|
||||
smtpd_tls_key_file=/var/lib/acme/live/{{ config.domain_name }}/privkey
|
||||
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mailname }}/fullchain
|
||||
smtpd_tls_key_file=/var/lib/acme/live/{{ config.mailname }}/privkey
|
||||
smtpd_tls_security_level=may
|
||||
|
||||
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_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
|
||||
myhostname = {{ config.domain_name }}
|
||||
myhostname = {{ config.mailname }}
|
||||
alias_maps = hash:/etc/aliases
|
||||
alias_database = hash:/etc/aliases
|
||||
|
||||
@@ -44,7 +45,7 @@ inet_interfaces = all
|
||||
inet_protocols = all
|
||||
|
||||
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
||||
virtual_mailbox_domains = {{ config.domain_name }}
|
||||
virtual_mailbox_domains = {{ config.mailname }}
|
||||
|
||||
smtpd_milters = unix:opendkim/opendkim.sock
|
||||
non_smtpd_milters = $smtpd_milters
|
||||
|
||||
@@ -32,7 +32,8 @@ submission inet n - y - - smtpd
|
||||
-o smtpd_recipient_restrictions=
|
||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||
-o milter_macro_daemon_name=ORIGINATING
|
||||
-o content_filter=filter:unix:private/filtermail
|
||||
-o smtpd_client_connection_count_limit=1000
|
||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
|
||||
smtps inet n - y - - smtpd
|
||||
-o syslog_name=postfix/smtps
|
||||
-o smtpd_tls_wrappermode=yes
|
||||
@@ -46,8 +47,9 @@ smtps inet n - y - - smtpd
|
||||
-o smtpd_sender_restrictions=$mua_sender_restrictions
|
||||
-o smtpd_recipient_restrictions=
|
||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||
-o smtpd_client_connection_count_limit=1000
|
||||
-o milter_macro_daemon_name=ORIGINATING
|
||||
-o content_filter=filter:unix:private/filtermail
|
||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
|
||||
#628 inet n - y - - qmqpd
|
||||
pickup unix n - y 60 1 pickup
|
||||
cleanup unix n - y - 0 cleanup
|
||||
@@ -76,5 +78,5 @@ scache unix - - y - 1 scache
|
||||
postlog unix-dgram n - n - 1 postlogd
|
||||
filter unix - n n - - lmtp
|
||||
# Local SMTP server for reinjecting filered mail.
|
||||
localhost:10026 inet n - n - 10 smtpd
|
||||
-o content_filter=
|
||||
localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
|
||||
-o syslog_name=postfix/reinject
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Postfix MTA-STS resolver daemon
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/lib/postfix-mta-sts-resolver/bin/mta-sts-daemon
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,13 @@
|
||||
host: 127.0.0.1
|
||||
port: 8461
|
||||
reuse_port: true
|
||||
shutdown_timeout: 20
|
||||
cache:
|
||||
type: internal
|
||||
options:
|
||||
cache_size: 10000
|
||||
proactive_policy_fetching:
|
||||
enabled: true
|
||||
default_zone:
|
||||
strict_testing: false
|
||||
timeout: 4
|
||||
110
deploy-chatmail/src/deploy_chatmail/www.py
Normal file
110
deploy-chatmail/src/deploy_chatmail/www.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import importlib.resources
|
||||
import webbrowser
|
||||
import hashlib
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import markdown
|
||||
from jinja2 import Template
|
||||
from .genqr import gen_qr_png_data
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
def snapshot_dir_stats(somedir):
|
||||
d = {}
|
||||
for path in somedir.iterdir():
|
||||
if path.is_file() and path.name[0] != "." and path.suffix != ".swp":
|
||||
mtime = path.stat().st_mtime
|
||||
hash = hashlib.md5(path.read_bytes()).hexdigest()
|
||||
d[path] = (mtime, hash)
|
||||
return d
|
||||
|
||||
|
||||
def prepare_template(source):
|
||||
assert source.exists(), source
|
||||
render_vars = {}
|
||||
render_vars["pagename"] = "home" if source.stem == "index" else source.stem
|
||||
render_vars["markdown_html"] = markdown.markdown(source.read_text())
|
||||
page_layout = source.with_name("page-layout.html").read_text()
|
||||
return render_vars, page_layout
|
||||
|
||||
|
||||
def build_webpages(src_dir, build_dir, config):
|
||||
try:
|
||||
_build_webpages(src_dir, build_dir, config)
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
|
||||
|
||||
def _build_webpages(src_dir, build_dir, config):
|
||||
mail_domain = config.mailname
|
||||
assert src_dir.exists(), src_dir
|
||||
if not build_dir.exists():
|
||||
build_dir.mkdir()
|
||||
|
||||
qr_path = build_dir.joinpath(f"qr-chatmail-invite-{mail_domain}.png")
|
||||
qr_path.write_bytes(gen_qr_png_data(mail_domain).read())
|
||||
|
||||
for path in src_dir.iterdir():
|
||||
if path.suffix == ".md":
|
||||
render_vars, content = prepare_template(path)
|
||||
target = build_dir.joinpath(path.stem + ".html")
|
||||
|
||||
# recursive jinja2 rendering
|
||||
while 1:
|
||||
new = Template(content).render(config=config, **render_vars)
|
||||
if new == content:
|
||||
break
|
||||
content = new
|
||||
|
||||
with target.open("w") as f:
|
||||
f.write(content)
|
||||
elif path.name != "page-layout.html":
|
||||
target = build_dir.joinpath(path.name)
|
||||
target.write_bytes(path.read_bytes())
|
||||
return build_dir
|
||||
|
||||
|
||||
def main():
|
||||
path = importlib.resources.files(__package__)
|
||||
reporoot = path.joinpath("../../../").resolve()
|
||||
inipath = reporoot.joinpath("chatmail.ini")
|
||||
config = read_config(inipath)
|
||||
config.webdev = True
|
||||
assert config.mailname
|
||||
www_path = reporoot.joinpath("www")
|
||||
src_path = www_path.joinpath("src")
|
||||
stats = None
|
||||
build_dir = www_path.joinpath("build")
|
||||
src_dir = www_path.joinpath("src")
|
||||
index_path = build_dir.joinpath("index.html")
|
||||
|
||||
# start web page generation, open a browser and wait for changes
|
||||
build_webpages(src_dir, build_dir, config)
|
||||
webbrowser.open(str(index_path))
|
||||
stats = snapshot_dir_stats(src_path)
|
||||
print(f"\nOpened URL: file://{index_path.resolve()}\n")
|
||||
print(f"watching {src_path} directory for changes")
|
||||
|
||||
changenum = 0
|
||||
for count in range(0, 1000000):
|
||||
newstats = snapshot_dir_stats(src_path)
|
||||
if newstats == stats and count % 60 != 0:
|
||||
count += 1
|
||||
time.sleep(1.0)
|
||||
continue
|
||||
|
||||
for key in newstats:
|
||||
if stats[key] != newstats[key]:
|
||||
print(f"*** CHANGED: {key}")
|
||||
changenum += 1
|
||||
|
||||
stats = newstats
|
||||
build_webpages(src_dir, build_dir, config)
|
||||
print(f"[{changenum}] regenerated web pages at: {index_path}")
|
||||
print(f"URL: file://{index_path.resolve()}\n\n")
|
||||
count = 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,34 +0,0 @@
|
||||
def test_tls_serialized_connect(benchmark, imap_or_smtp):
|
||||
def connect():
|
||||
imap_or_smtp.connect()
|
||||
|
||||
benchmark(connect)
|
||||
|
||||
|
||||
def test_login(benchmark, imap_or_smtp, gencreds):
|
||||
cls = imap_or_smtp.__class__
|
||||
conns = []
|
||||
for i in range(20):
|
||||
conn = cls(imap_or_smtp.host)
|
||||
conn.connect()
|
||||
conns.append(conn)
|
||||
|
||||
def login():
|
||||
conn = conns.pop()
|
||||
conn.login(*gencreds())
|
||||
|
||||
benchmark(login)
|
||||
|
||||
|
||||
def test_send_and_receive_10(benchmark, cmfactory, lp):
|
||||
"""send many messages between two accounts"""
|
||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||
|
||||
def send_10_receive_all():
|
||||
for i in range(10):
|
||||
chat.send_text(f"hello {i}")
|
||||
for i in range(10):
|
||||
ac2.wait_next_incoming_message()
|
||||
|
||||
benchmark(send_10_receive_all)
|
||||
@@ -1,3 +0,0 @@
|
||||
[pytest]
|
||||
addopts = -vrsx --strict-markers
|
||||
markers = slow: mark test as slow (requires --slow option to run)
|
||||
65
plan.txt
65
plan.txt
@@ -1,65 +0,0 @@
|
||||
# Chat-mail server development (up until Oct 18th)
|
||||
|
||||
## Dovecot goals/steps
|
||||
|
||||
- automatic expiry of messages older than M days
|
||||
- also expunge unread messages
|
||||
|
||||
- limit: configure max-connections per account
|
||||
|
||||
|
||||
## nami: send out rate limit / rspamd
|
||||
|
||||
- basic outgoing send rate/limits (depending on "account-rating")
|
||||
use rspamd in a minimal way, check support dkim-signing
|
||||
(including an online test exceeding rate limit)
|
||||
|
||||
|
||||
## doveauth questions/futures
|
||||
|
||||
- bcrypt-password scheme is slow: require long passwords, use faster hashing
|
||||
|
||||
- define user-name and password policies, and implement them
|
||||
(be very restrictive at the beginning, we can relax later)
|
||||
|
||||
- password is part of the dictproxy-lookup key, is it safe to use auth-caching?
|
||||
|
||||
|
||||
## How to limit creation of accounts?
|
||||
|
||||
attack: a 3-line bash script to fill the chatmail db with millions of unused accouts
|
||||
|
||||
- make it computationally expensive (somehow try to except our tests from it)
|
||||
1st pass instant onboarding: create userid + cheap password -- if it fails then
|
||||
2nd pass instant onboarding: create userdid + comput. expensive password
|
||||
|
||||
- probably also do firewall: limit number of new tcp-connections per IP address per duration
|
||||
|
||||
|
||||
## Open/deferred questions
|
||||
|
||||
- automatic expiry of users that haven't logged in for N days
|
||||
Is it neccessary? If all messages are gone, does the existence of
|
||||
an e-mail address bother anybody?
|
||||
|
||||
|
||||
## web page for chat-mail servers?
|
||||
|
||||
- documentation for users, privacy policy etc.
|
||||
(probably also with provider-messages ...)
|
||||
|
||||
|
||||
## online tests (first with plain python/pytest)
|
||||
|
||||
- write tests for dovecot login (exists)
|
||||
- write tests for postfix logins (exists)
|
||||
- write A<>B send/receive tests (exists)
|
||||
|
||||
|
||||
## Delta Chat
|
||||
|
||||
1. qr code that defines access to a chatmail instance (like mailadm but without http etc.)
|
||||
|
||||
2. support for creating username/password and verifying login works
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
online-tests/venv/bin/pytest online-tests/benchmark.py -vrx
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
: ${CHATMAIL_DOMAIN:=c1.testrun.org}
|
||||
export CHATMAIL_DOMAIN
|
||||
|
||||
chatmaild/venv/bin/python3 -m build -n --sdist chatmaild --outdir dist
|
||||
|
||||
deploy-chatmail/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" \
|
||||
deploy-chatmail/src/deploy_chatmail/deploy.py
|
||||
|
||||
rm -r dist/
|
||||
@@ -1,14 +0,0 @@
|
||||
import os
|
||||
import time
|
||||
import imaplib
|
||||
|
||||
domain = os.environ.get("CHATMAIL_DOMAIN", "c3.testrun.org")
|
||||
|
||||
print("connecting")
|
||||
conn = imaplib.IMAP4_SSL(domain)
|
||||
print("logging in")
|
||||
conn.login(f"imapcapa", "pass")
|
||||
status, res = conn.capability()
|
||||
for capa in sorted(res[0].decode().split()):
|
||||
print(capa)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
python3 -m venv deploy-chatmail/venv
|
||||
deploy-chatmail/venv/bin/pip install pyinfra pytest
|
||||
deploy-chatmail/venv/bin/pip install -e deploy-chatmail
|
||||
deploy-chatmail/venv/bin/pip install -e chatmaild
|
||||
|
||||
python3 -m venv chatmaild/venv
|
||||
sudo apt install -y dovecot-core && sudo systemctl disable --now dovecot
|
||||
chatmaild/venv/bin/pip install --upgrade pytest build 'setuptools>=68'
|
||||
chatmaild/venv/bin/pip install -e chatmaild
|
||||
|
||||
python3 -m venv online-tests/venv
|
||||
online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat pytest-benchmark
|
||||
9
scripts/initenv.sh
Executable file
9
scripts/initenv.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python3 -m venv venv
|
||||
|
||||
venv/bin/pip install -e deploy-chatmail
|
||||
venv/bin/pip install -e chatmaild
|
||||
|
||||
source venv/bin/activate
|
||||
echo activated 'venv' python virtualenv environment containing "cmdeploy" tool
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import time
|
||||
import imaplib
|
||||
|
||||
domain = os.environ.get("CHATMAIL_DOMAIN", "c3.testrun.org")
|
||||
|
||||
NUM_CONNECTIONS=10
|
||||
|
||||
conns = []
|
||||
|
||||
start = time.time()
|
||||
for i in range(NUM_CONNECTIONS):
|
||||
print(f"opening connection {i} to {domain}")
|
||||
conn = imaplib.IMAP4_SSL(domain)
|
||||
conns.append(conn)
|
||||
|
||||
tlsdone = time.time()
|
||||
duration = tlsdone-start
|
||||
print(f"{duration}: TLS connections opening TLS connections")
|
||||
|
||||
for i, conn in enumerate(conns):
|
||||
print(f"logging into connection {i}")
|
||||
conn.login(f"measure{i}", "pass")
|
||||
|
||||
logindone = time.time()
|
||||
duration = logindone - tlsdone
|
||||
print(f"{duration}: LOGINS done")
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
: ${CHATMAIL_DOMAIN:=c1.testrun.org}
|
||||
: ${CHATMAIL_SSH:=$CHATMAIL_DOMAIN}
|
||||
|
||||
rsync -avz . "root@$CHATMAIL_SSH:/root/chatmail" --exclude='/.git' --filter="dir-merge,- .gitignore"
|
||||
ssh "root@$CHATMAIL_SSH" "cd /root/chatmail; apt install -y python3-venv; python3 -m venv venv; venv/bin/pip install pyinfra build; venv/bin/python3 -m build -n --sdist chatmaild --outdir dist; venv/bin/pip install -e ./deploy-chatmail -e ./chatmaild; export CHATMAIL_DOMAIN=$CHATMAIL_DOMAIN; venv/bin/pyinfra @local deploy.py"
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
chatmaild/venv/bin/pytest chatmaild/ $@
|
||||
online-tests/venv/bin/pytest online-tests/ -vrx --durations=5 $@
|
||||
@@ -1,12 +1,20 @@
|
||||
import os
|
||||
import io
|
||||
import time
|
||||
import random
|
||||
import subprocess
|
||||
import imaplib
|
||||
import smtplib
|
||||
import itertools
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from chatmaild.database import Database
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
conftestdir = Path(__file__).parent
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
@@ -14,6 +22,13 @@ def pytest_addoption(parser):
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config._benchresults = {}
|
||||
config.addinivalue_line(
|
||||
"markers", "slow: mark test to require --slow option to run"
|
||||
)
|
||||
|
||||
|
||||
def pytest_runtest_setup(item):
|
||||
markers = list(item.iter_markers(name="slow"))
|
||||
if markers:
|
||||
@@ -22,11 +37,22 @@ def pytest_runtest_setup(item):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maildomain():
|
||||
domain = os.environ.get("CHATMAIL_DOMAIN")
|
||||
if not domain:
|
||||
pytest.skip("set CHATMAIL_DOMAIN to a ssh-reachable chatmail instance")
|
||||
return domain
|
||||
def chatmail_config(pytestconfig):
|
||||
current = basedir = Path()
|
||||
while 1:
|
||||
path = current.joinpath("chatmail.ini").resolve()
|
||||
if path.exists():
|
||||
return read_config(path)
|
||||
if current == current.parent:
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maildomain(chatmail_config):
|
||||
return chatmail_config.mailname
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -54,11 +80,76 @@ def pytest_report_header():
|
||||
return ["-" * len(text), text, "-" * len(text)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def benchmark(request):
|
||||
def bench(func, num, name=None, reportfunc=None):
|
||||
if name is None:
|
||||
name = func.__name__
|
||||
durations = []
|
||||
for i in range(num):
|
||||
now = time.time()
|
||||
func()
|
||||
durations.append(time.time() - now)
|
||||
durations.sort()
|
||||
request.config._benchresults[name] = (reportfunc, durations)
|
||||
|
||||
return bench
|
||||
|
||||
|
||||
def pytest_terminal_summary(terminalreporter):
|
||||
tr = terminalreporter
|
||||
results = tr.config._benchresults
|
||||
if not results:
|
||||
return
|
||||
|
||||
tr.section("benchmark results")
|
||||
float_names = "median min max".split()
|
||||
width = max(map(len, float_names))
|
||||
|
||||
def fcol(parts):
|
||||
return " ".join(part.rjust(width) for part in parts)
|
||||
|
||||
headers = f"{'benchmark name': <30} " + fcol(float_names)
|
||||
tr.write_line(headers)
|
||||
tr.write_line("-" * len(headers))
|
||||
summary_lines = []
|
||||
|
||||
for name, (reportfunc, durations) in results.items():
|
||||
measures = [
|
||||
sorted(durations)[len(durations) // 2],
|
||||
min(durations),
|
||||
max(durations),
|
||||
]
|
||||
line = f"{name: <30} "
|
||||
line += fcol(f"{float: 2.2f}" for float in measures)
|
||||
tr.write_line(line)
|
||||
vmedian, vmin, vmax = measures
|
||||
if reportfunc:
|
||||
for line in reportfunc(vmin=vmin, vmedian=vmedian, vmax=vmax):
|
||||
summary_lines.append(line)
|
||||
|
||||
if summary_lines:
|
||||
tr.write_line("")
|
||||
tr.section("benchmark summary measures")
|
||||
for line in summary_lines:
|
||||
tr.write_line(line)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imap(maildomain):
|
||||
return ImapConn(maildomain)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_imap_connection(maildomain):
|
||||
def make_imap_connection():
|
||||
conn = ImapConn(maildomain)
|
||||
conn.connect()
|
||||
return conn
|
||||
|
||||
return make_imap_connection
|
||||
|
||||
|
||||
class ImapConn:
|
||||
AuthError = imaplib.IMAP4.error
|
||||
logcmd = "journalctl -f -u dovecot"
|
||||
@@ -99,6 +190,16 @@ def smtp(maildomain):
|
||||
return SmtpConn(maildomain)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_smtp_connection(maildomain):
|
||||
def make_smtp_connection():
|
||||
conn = SmtpConn(maildomain)
|
||||
conn.connect()
|
||||
return conn
|
||||
|
||||
return make_smtp_connection
|
||||
|
||||
|
||||
class SmtpConn:
|
||||
AuthError = smtplib.SMTPAuthenticationError
|
||||
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
|
||||
@@ -137,13 +238,20 @@ def gencreds(maildomain):
|
||||
num = next(count)
|
||||
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
user = "".join(random.choices(alphanumeric, k=10))
|
||||
user = f"ac{num}_{user}"
|
||||
password = "".join(random.choices(alphanumeric, k=10))
|
||||
user = f"ac{num}_{user}"[:9]
|
||||
password = "".join(random.choices(alphanumeric, k=12))
|
||||
yield f"{user}@{domain}", f"{password}"
|
||||
|
||||
return lambda domain=None: next(gen(domain))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmpdir):
|
||||
db_path = tmpdir / "passdb.sqlite"
|
||||
print("database path:", db_path)
|
||||
return Database(db_path)
|
||||
|
||||
|
||||
#
|
||||
# Delta Chat testplugin re-use
|
||||
# use the cmfactory fixture to get chatmail instance accounts
|
||||
@@ -180,11 +288,13 @@ class ChatmailTestProcess:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmfactory(request, gencreds, tmpdir, data, maildomain):
|
||||
def cmfactory(request, gencreds, tmpdir, maildomain):
|
||||
# cloned from deltachat.testplugin.amfactory
|
||||
pytest.importorskip("deltachat")
|
||||
from deltachat.testplugin import ACFactory
|
||||
|
||||
data = request.getfixturevalue("data")
|
||||
|
||||
testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
|
||||
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=data)
|
||||
|
||||
@@ -214,7 +324,7 @@ class Remote:
|
||||
self.sshdomain = sshdomain
|
||||
|
||||
def iter_output(self, logcmd=""):
|
||||
getjournal = f"journalctl -f" if not logcmd else logcmd
|
||||
getjournal = "journalctl -f" if not logcmd else logcmd
|
||||
self.popen = subprocess.Popen(
|
||||
["ssh", f"root@{self.sshdomain}", getjournal],
|
||||
stdout=subprocess.PIPE,
|
||||
@@ -229,13 +339,15 @@ class Remote:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mailgen(request):
|
||||
class Mailgen:
|
||||
def get_encrypted(self, from_addr, to_addr):
|
||||
data = request.fspath.dirpath("mailgen/encrypted.eml").read()
|
||||
return data.format(from_addr=from_addr, to_addr=to_addr)
|
||||
def lp(request):
|
||||
class LP:
|
||||
def sec(self, msg):
|
||||
print(f"---- {msg} ----")
|
||||
|
||||
return Mailgen()
|
||||
def indent(self, msg):
|
||||
print(f" {msg}")
|
||||
|
||||
return LP()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
60
tests/online/benchmark.py
Normal file
60
tests/online/benchmark.py
Normal file
@@ -0,0 +1,60 @@
|
||||
def test_tls_imap(benchmark, imap):
|
||||
def imap_connect():
|
||||
imap.connect()
|
||||
|
||||
benchmark(imap_connect, 10)
|
||||
|
||||
|
||||
def test_login_imap(benchmark, imap, gencreds):
|
||||
def imap_connect_and_login():
|
||||
imap.connect()
|
||||
imap.login(*gencreds())
|
||||
|
||||
benchmark(imap_connect_and_login, 10)
|
||||
|
||||
|
||||
def test_tls_smtp(benchmark, smtp):
|
||||
def smtp_connect():
|
||||
smtp.connect()
|
||||
|
||||
benchmark(smtp_connect, 10)
|
||||
|
||||
|
||||
def test_login_smtp(benchmark, smtp, gencreds):
|
||||
def smtp_connect_and_login():
|
||||
smtp.connect()
|
||||
smtp.login(*gencreds())
|
||||
|
||||
benchmark(smtp_connect_and_login, 10)
|
||||
|
||||
|
||||
class TestDC:
|
||||
def test_autoconfigure(self, benchmark, cmfactory):
|
||||
def autoconfig_and_idle_ready():
|
||||
cmfactory.get_online_accounts(1)
|
||||
|
||||
benchmark(autoconfig_and_idle_ready, 5)
|
||||
|
||||
def test_ping_pong(self, benchmark, cmfactory):
|
||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||
|
||||
def ping_pong():
|
||||
chat.send_text("ping")
|
||||
msg = ac2.wait_next_incoming_message()
|
||||
msg.chat.send_text("pong")
|
||||
ac1.wait_next_incoming_message()
|
||||
|
||||
benchmark(ping_pong, 5)
|
||||
|
||||
def test_send_10_receive_10(self, benchmark, cmfactory, lp):
|
||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||
|
||||
def send_10_receive_10():
|
||||
for i in range(10):
|
||||
chat.send_text(f"hello {i}")
|
||||
for i in range(10):
|
||||
ac2.wait_next_incoming_message()
|
||||
|
||||
benchmark(send_10_receive_10, 5)
|
||||
@@ -1,4 +1,6 @@
|
||||
import pytest
|
||||
import threading
|
||||
import queue
|
||||
|
||||
|
||||
def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
|
||||
@@ -22,6 +24,11 @@ def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
|
||||
with pytest.raises(imap_or_smtp.AuthError):
|
||||
imap_or_smtp.login(user, password + "wrong")
|
||||
|
||||
lp.sec("creating users with a short password is not allowed")
|
||||
user, _password = gencreds()
|
||||
with pytest.raises(imap_or_smtp.AuthError):
|
||||
imap_or_smtp.login(user, "admin")
|
||||
|
||||
|
||||
def test_login_same_password(imap_or_smtp, gencreds):
|
||||
"""Test two different users logging in with the same password
|
||||
@@ -34,3 +41,30 @@ def test_login_same_password(imap_or_smtp, gencreds):
|
||||
imap_or_smtp.login(user1, password1)
|
||||
imap_or_smtp.connect()
|
||||
imap_or_smtp.login(user2, password1)
|
||||
|
||||
|
||||
def test_concurrent_logins_same_account(
|
||||
make_imap_connection, make_smtp_connection, gencreds
|
||||
):
|
||||
"""Test concurrent smtp and imap logins
|
||||
and check remote server succeeds on each connection.
|
||||
"""
|
||||
user1, password1 = gencreds()
|
||||
login_results = queue.Queue()
|
||||
|
||||
def login_smtp_imap(smtp, imap):
|
||||
try:
|
||||
imap.login(user1, password1)
|
||||
except Exception:
|
||||
login_results.put(False)
|
||||
else:
|
||||
login_results.put(True)
|
||||
|
||||
conns = [(make_smtp_connection(), make_imap_connection()) for i in range(10)]
|
||||
|
||||
for args in conns:
|
||||
thread = threading.Thread(target=login_smtp_imap, args=args, daemon=True)
|
||||
thread.start()
|
||||
|
||||
for _ in conns:
|
||||
assert login_results.get()
|
||||
6
tests/online/test_0_qr.py
Normal file
6
tests/online/test_0_qr.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from deploy_chatmail.genqr import gen_qr_png_data
|
||||
|
||||
|
||||
def test_gen_qr_png_data(maildomain):
|
||||
data = gen_qr_png_data(maildomain)
|
||||
assert data
|
||||
@@ -20,7 +20,7 @@ def test_use_two_chatmailservers(cmfactory, maildomain2):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("forgeaddr", ["internal", "someone@example.org"])
|
||||
def test_reject_forged_from(cmsetup, mailgen, lp, remote, forgeaddr):
|
||||
def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr):
|
||||
user1, user3 = cmsetup.gen_users(2)
|
||||
|
||||
lp.sec("send encrypted message with forged from")
|
||||
@@ -31,22 +31,33 @@ def test_reject_forged_from(cmsetup, mailgen, lp, remote, forgeaddr):
|
||||
addr_to_forge = "someone@example.org"
|
||||
|
||||
print("message to inject:")
|
||||
msg = mailgen.get_encrypted(from_addr=addr_to_forge, to_addr=user3.addr)
|
||||
msg = maildata("encrypted.eml", from_addr=addr_to_forge, to_addr=user3.addr)
|
||||
msg = msg.as_string()
|
||||
for line in msg.split("\n")[:4]:
|
||||
print(f" {line}")
|
||||
|
||||
lp.sec("Send forged mail and check remote postfix lmtp processing result")
|
||||
remote_log = remote.iter_output("journalctl -t postfix/lmtp")
|
||||
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user3.addr], msg=msg)
|
||||
for line in remote_log:
|
||||
# print(line)
|
||||
if "500 invalid from" in line and user3.addr in line:
|
||||
break
|
||||
else:
|
||||
pytest.fail("remote postfix/filtermail failed to reject message")
|
||||
with pytest.raises(smtplib.SMTPException) as e:
|
||||
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user3.addr], msg=msg)
|
||||
assert "500" in str(e.value)
|
||||
|
||||
# check that the logged in user (who sent the forged msg) got a non-delivery notice
|
||||
for message in user1.imap.fetch_all_messages():
|
||||
if "Invalid FROM" in message and addr_to_forge in message:
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_exceed_rate_limit(cmsetup, gencreds, maildata):
|
||||
"""Test that the per-account send-mail limit is exceeded."""
|
||||
user1, user2 = cmsetup.gen_users(2)
|
||||
mail = maildata(
|
||||
"encrypted.eml", from_addr=user1.addr, to_addr=user2.addr
|
||||
).as_string()
|
||||
for i in range(100):
|
||||
print("Sending mail", str(i))
|
||||
try:
|
||||
user1.smtp.sendmail(user1.addr, [user2.addr], mail)
|
||||
except smtplib.SMTPException as e:
|
||||
if i < 60:
|
||||
pytest.fail(f"rate limit was exceeded too early with msg {i}")
|
||||
outcome = e.recipients[user2.addr]
|
||||
assert outcome[0] == 450
|
||||
assert b"4.7.1: Too much mail from" in outcome[1]
|
||||
return
|
||||
pytest.fail(f"forged From={addr_to_forge} did not cause non-delivery notice")
|
||||
pytest.fail("Rate limit was not exceeded")
|
||||
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
import random
|
||||
import pytest
|
||||
|
||||
@@ -81,3 +82,29 @@ class TestEndToEndDeltaChat:
|
||||
ch = ac2.qr_setup_contact(qr)
|
||||
assert ch.id >= 10
|
||||
ac1._evtracker.wait_securejoin_inviter_progress(1000)
|
||||
|
||||
def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2):
|
||||
ac1 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.switch_maildomain(maildomain2)
|
||||
ac2 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.bring_accounts_online()
|
||||
|
||||
lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
|
||||
qr = ac1.get_setup_contact_qr()
|
||||
ac2.qr_setup_contact(qr)
|
||||
msg = ac2.wait_next_incoming_message()
|
||||
assert "verified" in msg.text
|
||||
|
||||
lp.sec("ac1 sends a message and ac2 marks it as seen")
|
||||
chat = ac1.create_chat(ac2)
|
||||
msg = chat.send_text("hi")
|
||||
m = ac2.wait_next_incoming_message()
|
||||
m.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")
|
||||
deadline = time.time() + 3.1
|
||||
while time.time() < deadline:
|
||||
msgs = m.chat.get_messages()
|
||||
for msg in msgs:
|
||||
assert "error" not in m.get_message_info()
|
||||
time.sleep(1)
|
||||
2
tests/pytest.ini
Normal file
2
tests/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
addopts = -vrsx --strict-markers
|
||||
33
tests/test_cmdeploy.py
Normal file
33
tests/test_cmdeploy.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from deploy_chatmail.cmdeploy import get_parser, main
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _chdir(tmp_path):
|
||||
old = os.getcwd()
|
||||
os.chdir(tmp_path)
|
||||
yield
|
||||
os.chdir(old)
|
||||
|
||||
|
||||
class TestCmdline:
|
||||
def test_parser(self, capsys):
|
||||
parser = get_parser()
|
||||
parser.parse_args([])
|
||||
init = parser.parse_args(["init", "chat.example.org"])
|
||||
run = parser.parse_args(["run"])
|
||||
assert init and run
|
||||
|
||||
def test_init(self, tmp_path):
|
||||
main(["init", "chat.example.org"])
|
||||
inipath = tmp_path.joinpath("chatmail.ini")
|
||||
config = read_config(inipath)
|
||||
assert config.mailname == "chat.example.org"
|
||||
|
||||
def test_init_not_overwrite(self):
|
||||
main(["init", "chat.example.org"])
|
||||
with pytest.raises(SystemExit):
|
||||
main(["init", "chat.example.org"])
|
||||
13
tests/test_helpers.py
Normal file
13
tests/test_helpers.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import importlib.resources
|
||||
|
||||
from deploy_chatmail.www import build_webpages
|
||||
|
||||
|
||||
def test_build_webpages(tmp_path, make_config):
|
||||
pkgroot = importlib.resources.files("deploy_chatmail")
|
||||
src_dir = pkgroot.joinpath("../../../www/src").resolve()
|
||||
assert src_dir.exists(), src_dir
|
||||
config = make_config("chat.example.org")
|
||||
build_dir = tmp_path.joinpath("build")
|
||||
build_webpages(src_dir, build_dir, config)
|
||||
assert len([x for x in build_dir.iterdir() if x.suffix == ".html"]) >= 3
|
||||
BIN
www/src/collage-info.png
Normal file
BIN
www/src/collage-info.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
www/src/collage-privacy.png
Normal file
BIN
www/src/collage-privacy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
www/src/collage-top.png
Normal file
BIN
www/src/collage-top.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
19
www/src/index.md
Normal file
19
www/src/index.md
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
<img width="800px" src="collage-top.png"/>
|
||||
|
||||
## Dear [Delta Chat](https://get.delta.chat) users and newcomers,
|
||||
|
||||
Welcome to instant, interoperable and [privacy-preserving](privacy.html) messaging :)
|
||||
|
||||
👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address
|
||||
|
||||
<a href="DCACCOUNT:https://{{ config.mail_domain }}/cgi-bin/newemail.py">
|
||||
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
|
||||
|
||||
🐣 **Choose** your Avatar and Name
|
||||
|
||||
💬 **Start** chatting with any Delta Chat contacts using [QR invite codes](https://delta.chat/en/help#howtoe2ee)
|
||||
|
||||
|
||||
## ⚡ Note: this is an experimental service ⚡
|
||||
|
||||
39
www/src/info.md
Normal file
39
www/src/info.md
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
<img width="800px" src="collage-info.png"/>
|
||||
|
||||
## More information
|
||||
|
||||
### Choosing a chatmail address instead of using a random one
|
||||
|
||||
In the Delta Chat account setup
|
||||
you may tap `LOG INTO YOUR E-MAIL ACCOUNT`
|
||||
and fill the two fields like this:
|
||||
|
||||
- `Address`: invent a word with *exactly* nine characters
|
||||
and append `@{{config.mail_domain}}` to it.
|
||||
|
||||
- `Password`: invent at least 9 characters.
|
||||
|
||||
If the e-mail address is not yet taken, you'll get that account.
|
||||
The first login sets your password.
|
||||
|
||||
|
||||
### Rate and storage limits
|
||||
|
||||
- Un-encrypted messages are blocked to recipients outside
|
||||
{{config.mail_domain}} but setting up contact via [QR invite codes](https://delta.chat/en/help#howtoe2ee)
|
||||
allows your messages to pass freely to any outside recipients.
|
||||
|
||||
- You may send up to 60 messages per minute
|
||||
|
||||
- Messages are unconditionally removed 40 days after arriving on the server
|
||||
|
||||
- You can store up to [100MB messages on the server](https://delta.chat/en/help#what-happens-if-i-turn-on-delete-old-messages-from-server)
|
||||
|
||||
|
||||
### Who are the operators? Which software is running?
|
||||
|
||||
This chatmail provider is run by a small voluntary group of devs and sysadmins,
|
||||
who [publically develop chatmail provider setups](https://github.com/deltachat/chatmail).
|
||||
Chatmail setups aim to be very low-maintenance, resource efficient and
|
||||
interoperable with any other standards-compliant e-mail service.
|
||||
20
www/src/page-layout.html
Normal file
20
www/src/page-layout.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
{% if config.webdev %}
|
||||
<meta http-equiv="refresh" content="3">
|
||||
{% endif %}
|
||||
<title>{{ config.mail_domain }} {{ pagename }}</title>
|
||||
<link rel="stylesheet" href="./water.css">
|
||||
</head>
|
||||
<body>
|
||||
{{ markdown_html }}
|
||||
<footer>
|
||||
<a href="index.html">home</a> |
|
||||
<a href="info.html">more info</a> |
|
||||
<a href="privacy.html">privacy</a> |
|
||||
<a href="https://github.com/deltachat/chatmail">-> public development </a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
321
www/src/privacy.md
Normal file
321
www/src/privacy.md
Normal file
@@ -0,0 +1,321 @@
|
||||
<img width="800px" src="collage-privacy.png"/>
|
||||
|
||||
# Privacy Policy for {{ config.mail_domain }}
|
||||
|
||||
We want to show you in a fair and transparent way
|
||||
what personal data is processed by us.
|
||||
We follow a strict privacy-by-design approach
|
||||
and try to avoid processing your data in the first place,
|
||||
but as you may know,
|
||||
the internet,
|
||||
and in particular sending e-mail messages,
|
||||
does not work without data.
|
||||
Still,
|
||||
it's only fair that you know at all times
|
||||
what personal data is processed
|
||||
when you use our service.
|
||||
|
||||
If you have any remaining questions about data protection, please contact us.
|
||||
|
||||
## 1. Name and contact information
|
||||
|
||||
Responsible for the processing of your personal data is:
|
||||
```
|
||||
{{ config.privacy_postal }}
|
||||
```
|
||||
|
||||
E-mail: {{ config.privacy_mail }}
|
||||
|
||||
We have appointed a data protection officer:
|
||||
|
||||
```
|
||||
{{ config.privacy_pdo }}
|
||||
```
|
||||
|
||||
## 2. Processing when using chat e-mail services
|
||||
|
||||
We provide e-mail services optimized for the use from [Delta Chat](https://delta.chat) apps
|
||||
and process only the data necessary
|
||||
for the setup and technical execution of the e-mail dispatch.
|
||||
The purpose of the processing is to
|
||||
read, write, manage, delete, send, and receive emails.
|
||||
For this purpose,
|
||||
we operate server-side software
|
||||
that enables us to send and receive e-mail messages.
|
||||
Allowing the use of the e-mail service,
|
||||
we process the following data and details:
|
||||
|
||||
- Outgoing and incoming messages (SMTP) are stored for transit
|
||||
on behalf of their users until the message can be delivered.
|
||||
|
||||
- E-Mail-Messages are stored for the recipient and made accessible via IMAP protocols,
|
||||
until explicitly deleted by the user or until a fixed time period is exceeded,
|
||||
(*usually 4-8 weeks*).
|
||||
|
||||
- IMAP and SMTP protocols are password protected with unique credentials for each account.
|
||||
|
||||
- Users can retrieve or delete all stored messages
|
||||
without intervention from the operators using standard IMAP client tools.
|
||||
|
||||
### 3.1 Account setup
|
||||
|
||||
Creating an account happens in one of two ways on our mail servers:
|
||||
|
||||
- with a QR invitation token
|
||||
which is scanned using the DeltaChat app
|
||||
and then the account is created.
|
||||
|
||||
- by letting Delta Chat otherwise create an account
|
||||
and register it with a {{ config.mail_domain }} mail server.
|
||||
|
||||
In either case, we process the newly created email address.
|
||||
No phone numbers,
|
||||
other email addresses,
|
||||
or other identifiable data
|
||||
is currently required.
|
||||
The legal basis for the processing is
|
||||
Art. 6 (1) lit. b GDPR,
|
||||
as you have a usage contract with us
|
||||
by using our services.
|
||||
|
||||
## 3.2 Processing of E-Mail-Messages
|
||||
|
||||
In addition,
|
||||
we will process data
|
||||
to keep the server infrastructure operational
|
||||
for purposes of e-mail dispatch
|
||||
and abuse prevention.
|
||||
|
||||
- Therefore,
|
||||
it is necessary to process the content and/or metadata
|
||||
(e.g., headers of the email as well as smtp chatter)
|
||||
of E-Mail-Messages in transit.
|
||||
|
||||
- We will keep logs of messages in transit for a limited time.
|
||||
These logs are used to debug delivery problems and software bugs.
|
||||
|
||||
In addition,
|
||||
we process data to protect the systems from excessive use.
|
||||
Therefore, limits are enforced:
|
||||
|
||||
- rate limits
|
||||
|
||||
- storage limits
|
||||
|
||||
- message size limits
|
||||
|
||||
- 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
|
||||
are performed to provide the service.
|
||||
The data processing is necessary for the use of our services,
|
||||
therefore the legal basis of the processing is
|
||||
Art. 6 (1) lit. b GDPR,
|
||||
as you have a usage contract with us
|
||||
by using our services.
|
||||
The legal basis for the data processing
|
||||
for the purposes of security and abuse prevention is
|
||||
Art. 6 (1) lit. f GDPR.
|
||||
Our legitimate interest results
|
||||
from the aforementioned purposes.
|
||||
We will not use the collected data
|
||||
for the purpose of drawing conclusions
|
||||
about your person.
|
||||
|
||||
|
||||
## 3. Processing when using our Website
|
||||
|
||||
When you visit our website,
|
||||
the browser used on your end device
|
||||
automatically sends information to the server of our website.
|
||||
This information is temporarily stored in a so-called log file.
|
||||
The following information is collected and stored
|
||||
until it is automatically deleted
|
||||
(*usually 7 days*):
|
||||
|
||||
- used type of browser,
|
||||
|
||||
- used operating system,
|
||||
|
||||
- access date and time as well as
|
||||
|
||||
- country of origin and IP address,
|
||||
|
||||
- the requested file name or HTTP resource,
|
||||
|
||||
- the amount of data transferred,
|
||||
|
||||
- the access status (file transferred, file not found, etc.) and
|
||||
|
||||
- the page from which the file was requested.
|
||||
|
||||
This website is hosted by an external service provider (hoster).
|
||||
The personal data collected on this website is stored
|
||||
on the hoster's servers.
|
||||
Our hoster will process your data
|
||||
only to the extent necessary to fulfill its obligations
|
||||
to perform under our instructions.
|
||||
In order to ensure data protection-compliant processing,
|
||||
we have concluded a data processing agreement with our hoster.
|
||||
|
||||
The aforementioned data is processed by us for the following purposes:
|
||||
|
||||
- Ensuring a reliable connection setup of the website,
|
||||
|
||||
- ensuring a convenient use of our website,
|
||||
|
||||
- checking and ensuring system security and stability, and
|
||||
|
||||
- for other administrative purposes.
|
||||
|
||||
The legal basis for the data processing is
|
||||
Art. 6 (1) lit. f GDPR.
|
||||
Our legitimate interest results
|
||||
from the aforementioned purposes of data collection.
|
||||
We will not use the collected data
|
||||
for the purpose of drawing conclusions about your person.
|
||||
|
||||
## 4. Transfer of Data
|
||||
|
||||
Your personal data
|
||||
will not be transferred to third parties
|
||||
for purposes other than those listed below:
|
||||
|
||||
a) you have given your express consent
|
||||
in accordance with Art. 6 para. 1 sentence 1 lit. a GDPR,
|
||||
|
||||
b) the disclosure is necessary for the assertion, exercise or defence of legal claims
|
||||
pursuant to Art. 6 (1) sentence 1 lit. f GDPR
|
||||
and there is no reason to assume that you have
|
||||
an overriding interest worthy of protection
|
||||
in the non-disclosure of your data,
|
||||
|
||||
c) in the event that there is a legal obligation to disclose your data
|
||||
pursuant to Art. 6 para. 1 sentence 1 lit. c GDPR,
|
||||
as well as
|
||||
|
||||
d) this is legally permissible and necessary
|
||||
in accordance with Art. 6 Para. 1 S. 1 lit. b GDPR
|
||||
for the processing of contractual relationships with you,
|
||||
|
||||
e) this is carried out by a service provider
|
||||
acting on our behalf and on our exclusive instructions,
|
||||
whom we have carefully selected (Art. 28 (1) GDPR)
|
||||
and with whom we have concluded a corresponding contract on commissioned processing (Art. 28 (3) GDPR),
|
||||
which obliges our contractor,
|
||||
among other things,
|
||||
to implement appropriate security measures
|
||||
and grants us comprehensive control powers.
|
||||
|
||||
## 5. Rights of the data subject
|
||||
|
||||
The rights arise from Articles 12 to 23 GDPR.
|
||||
Since no personal data is stored on our servers,
|
||||
even in encrypted form,
|
||||
there is no need to provide information
|
||||
on these or possible objections.
|
||||
A deletion can be made
|
||||
directly in the Delta Chat email messenger.
|
||||
|
||||
a) request information about your personal data processed by us
|
||||
in accordance with Art. 15 GDPR.
|
||||
In particular,
|
||||
you can request information about the processing purposes,
|
||||
the category of personal data,
|
||||
the categories of recipients to whom your data have been or will be disclosed,
|
||||
the planned storage period,
|
||||
the existence of a right to rectification, erasure, restriction of processing or objection,
|
||||
the existence of a right of complaint,
|
||||
the origin of your data if it has not been collected by us,
|
||||
as well as the existence of automated decision-making including profiling
|
||||
and, if applicable,
|
||||
meaningful information about its details;
|
||||
|
||||
b) in accordance with Art. 16 of the GDPR,
|
||||
immediately request the correction
|
||||
of inaccurate or incomplete personal data stored by us;
|
||||
|
||||
c) pursuant to Article 17 of the GDPR,
|
||||
to request the erasure of your personal data stored by us,
|
||||
unless the processing is necessary
|
||||
for the exercise of the right to freedom of expression and information,
|
||||
for compliance with a legal obligation,
|
||||
for reasons of public interest,
|
||||
or for the establishment, exercise or defence of legal claims;
|
||||
|
||||
d) pursuant to Art. 18 GDPR,
|
||||
to request the restriction of the processing of your personal data,
|
||||
insofar as the accuracy of the data is disputed by you,
|
||||
the processing is unlawful,
|
||||
but you object to its erasure
|
||||
and we no longer require the data,
|
||||
but you need it for the assertion, exercise or defence of legal claims
|
||||
or you have objected to the processing pursuant to Art. 21 GDPR;
|
||||
|
||||
e) pursuant to Art. 20 GDPR,
|
||||
to receive your personal data that you have provided to us
|
||||
in a structured, common and machine-readable format
|
||||
or to request that it be transferred to another controller;
|
||||
|
||||
f) in accordance with Art. 7 (3) of the GDPR,
|
||||
to revoke your consent given to us at any time.
|
||||
This has the consequence that we may no longer continue the data processing
|
||||
based on this consent in the future; and
|
||||
|
||||
g) complain to a supervisory authority
|
||||
in accordance with Article 77 of the GDPR.
|
||||
As a rule,
|
||||
you can contact the supervisory authority of your usual place of residence
|
||||
or workplace
|
||||
or our registered office for this purpose.
|
||||
The supervisory authority responsible for our place of business
|
||||
is the `{{ config.privacy_supervisor }}`.
|
||||
|
||||
If you have any questions or complaints, please feel free to contact us by email:
|
||||
{{ config.privacy_mail }}
|
||||
|
||||
|
||||
### 5.1 Right to object
|
||||
|
||||
If your personal data is processed on the basis of our legitimate interests
|
||||
in accordance with Art. 6 (1) lit. f GDPR,
|
||||
you have the right to object to the processing of your personal data
|
||||
in accordance with Art. 21 GDPR,
|
||||
provided that there are grounds for this based on your particular situation
|
||||
or the objection is directed against direct advertising.
|
||||
In the latter case,
|
||||
you have a general right of objection,
|
||||
which will be implemented by us
|
||||
without specifying a particular situation.
|
||||
|
||||
If you wish to exercise your right of objection,
|
||||
simply send an e-mail to: {{ config.privacy_mail }}
|
||||
|
||||
### 5.2 Right to withdraw
|
||||
|
||||
If your personal data is processed on the basis of your consent
|
||||
in accordance with Art. 6 (1) lit. a GDPR
|
||||
(e.g. via the mailing list),
|
||||
you can withdraw your consent at any time
|
||||
and without any disadvantages.
|
||||
As a result,
|
||||
we may no longer continue the data processing
|
||||
that was based on this consent for the future.
|
||||
However,
|
||||
the withdrawal of your consent
|
||||
does not affect the lawfulness of the processing
|
||||
carried out on the basis of the consent until the withdrawal.
|
||||
|
||||
If you wish to make use of your right of withdrawal,
|
||||
simply send an e-mail to: {{ config.privacy_mail }}
|
||||
|
||||
## 6. Validity of this privacy policy
|
||||
|
||||
This data protection declaration is valid
|
||||
as of *December 2023*.
|
||||
Due to the further development of our service and offers
|
||||
or due to changed legal or official requirements,
|
||||
it may become necessary to revise this data protection declaration from time to time.
|
||||
|
||||
|
||||
1690
www/src/water.css
Normal file
1690
www/src/water.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user