Compare commits

..

5 Commits

Author SHA1 Message Date
missytake
0bea4fad3f plan: persistence is achieved 2023-10-14 00:22:47 +02:00
missytake
446bb3483b dovecot: run auth-worker as vmail user 2023-10-14 00:11:03 +02:00
missytake
cb5c5de154 doveauth: adjust pytest for persistent database 2023-10-14 00:07:00 +02:00
missytake
6be51aa4df doveauth: integrate sqlite database 2023-10-14 00:04:57 +02:00
missytake
f76ddf0e22 doveauth: add sqlite database to persist accounts 2023-10-14 00:04:27 +02:00
80 changed files with 631 additions and 4781 deletions

View File

@@ -1,31 +0,0 @@
name: CI
on:
pull_request:
push:
jobs:
tox:
name: chatmail tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: run chatmaild tests
working-directory: chatmaild
run: pipx run tox
- name: run deploy-chatmail offline tests
working-directory: deploy-chatmail
run: pipx run tox
- name: run deploy-chatmail offline tests
working-directory: deploy-chatmail
run: pipx run tox
scripts:
name: chatmail script invocations
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: run init.sh
run: ./scripts/init.sh
- name: run test.sh
run: ./scripts/test.sh

8
.gitignore vendored
View File

@@ -3,11 +3,6 @@ __pycache__/
*.py[cod]
*$py.class
*.swp
www/privacy.html*
www/index.html*
www/info.html*
*qr-*.png
# C extensions
*.so
@@ -15,6 +10,7 @@ www/info.html*
# Distribution / packaging
.Python
build/
doveauth/dist/
develop-eggs/
dist/
downloads/
@@ -164,5 +160,3 @@ 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

152
README.md
View File

@@ -1,131 +1,65 @@
# Chat Mail server configuration
<img width="800px" src="www/collage-top.png"/>
This package deploys Postfix and Dovecot servers, including OpenDKIM for DKIM signing.
# Chatmail instances optimized for Delta Chat apps
Postfix uses Dovecot for authentication as described in <https://www.postfix.org/SASL_README.html#server_dovecot>
This repository helps to setup a ready-to-use chatmail instance
comprised of a minimal setup of the battle-tested
[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).
prepare:
Chatmail accounts are automatically created by a first login,
after which the initially specified password is required for using them.
## Getting Started deploying your own chatmail instance
1. Prepare your local (presumably Linux) system:
scripts/init.sh
2. Setup a domain with `A` and `AAAA` records for your chatmail server.
3. Set environment variable to the chatmail domain you want to setup:
export CHATMAIL_DOMAIN=c1.testrun.org # replace with your host
4. Fill in privacy contact data into the `chatmail.ini` file
5. Deploy the chat mail instance to your chatmail server:
scripts/deploy.sh
This script remotely sets up packages and configures the chatmail provider.
6. Run `scripts/generate-dns-zone.sh` and
transfer the generated DNS records at your DNS provider
pip install -e chatmail-infra
### Home page and getting started for users
then run with pyinfra command line tool:
The `deploy.sh` script deploys
- 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 files are generated by the according markdown `.md` file in the `www` directory.
CHATMAIL_DOMAIN=c1.testrun.org pyinfra --ssh-user root c1.testrun.org deploy.py
### Refining the web pages
The `scripts/webdev.sh` script supports live development of the chatmail web presence:
```
scripts/init.sh # to locally initialize python virtual environments etc.
scripts/webdev.sh
## Structure (wip)
```
- uses the `www/src/page-layout.html` file for producing html documents
from `www/src/*.md` files.
# package doveauth tool and deploy chatmail server to a envvar-specified ssh-reachable host
deploy.py
- continously builds the web presence reading files from `www/src` directory
and generating html files and copying assets to the `www/build` directory.
# chatmail pyinfra deploy package
chatmail-pyinfra
pyproject.toml
chatmail/__init__ ...
- Starts a browser window automatically where you can "refresh" as needed.
# tests against the deployed system
tests/test_online_test.py
Note that this script is not needed for running `scripts/deploy.sh"
which deploys the whole chatmail setup remotely.
The code that generates the web pages is identical
which means that `webdev.sh` gives a pretty good preview.
# doveauth tool used by dovecot's auth mechanism on the host system
doveauth
README.md
pyproject.toml
doveauth.py
doveauth.lua
test_doveauth.py
# lmtp server to block (outgoing) unencrypted messages
filtermail
README.md
pyproject.toml
....
# scripts for setup/development/deployment
scripts/
init.sh # create venv/other perequires
deploy.sh # run pyinfra based deploy of everything
```
## Dovecot/Postfix configuration
### 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)
1. Set `CHATMAIL_SSH` so that `ssh root@$CHATMAIL_SSH` allows
to login to the chatmail instance server.
2. To run local and online tests:
scripts/test.sh
3. To run benchmarks against your chatmail instance:
scripts/bench.sh
## Development Background for chatmail instances
This repository drives the development of "chatmail instances",
comprised of minimal setups of
- [postfix smtp server](https://www.postfix.org)
- [dovecot imap server](https://www.dovecot.org)
as well as two custom services that are integrated with these two:
- `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.
## DNS
For DKIM you must add a DNS entry as in /etc/opendkim/selector.txt (where selector is the opendkim_selector configured in the chatmail inventory).

View File

@@ -3,13 +3,10 @@ requires = ["setuptools>=45"]
build-backend = "setuptools.build_meta"
[project]
name = "deploy-chatmail"
name = "chatmail"
version = "0.1"
dependencies = [
"pyinfra",
"pillow",
"qrcode",
"markdown",
]
[tool.pytest.ini_options]

View File

@@ -0,0 +1,213 @@
"""
Chat Mail pyinfra deploy.
"""
import importlib.resources
from pathlib import Path
from pyinfra import host, logger
from pyinfra.operations import apt, files, server, systemd, python
from pyinfra.facts.files import File
from .acmetool import deploy_acmetool
def _install_doveauth() -> None:
"""Setup chatctl."""
doveauth_filename = "doveauth-0.1.tar.gz"
doveauth_path = importlib.resources.files(__package__).joinpath(
f"../../../doveauth/dist/{doveauth_filename}"
)
remote_path = f"/tmp/{doveauth_filename}"
if Path(str(doveauth_path)).exists():
files.put(
name="upload local doveauth build",
src=doveauth_path.open("rb"),
dest=remote_path,
)
apt.packages(
name="apt install python3-pip",
packages="python3-pip",
)
# Maybe if we introduce dependencies to the doveauth package at some point, we should not install doveauth
# system-wide anymore. For now it's fine though.
server.shell(
name="install local doveauth build with pip",
commands=[f"pip install --break-system-packages {remote_path}"],
)
def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
"""Configures OpenDKIM"""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
files.directory(
name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
server.shell(
name="Generate OpenDKIM domain keys",
commands=[
f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
],
_sudo=True,
_sudo_user="opendkim",
)
need_restart |= main_config.changed
return need_restart
def _configure_postfix(domain: str) -> bool:
"""Configures Postfix SMTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/main.cf.j2"),
dest="/etc/postfix/main.cf",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= main_config.changed
master_config = files.put(
src=importlib.resources.files(__package__)
.joinpath("postfix/master.cf")
.open("rb"),
dest="/etc/postfix/master.cf",
user="root",
group="root",
mode="644",
)
need_restart |= master_config.changed
return need_restart
def _configure_dovecot(mail_server: str) -> bool:
"""Configures Dovecot IMAP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
user="root",
group="root",
mode="644",
config={"hostname": mail_server},
)
need_restart |= main_config.changed
# luarocks install http lpeg_patterns fifo
auth_script = files.put(
src=importlib.resources.files("doveauth").joinpath("doveauth.lua"),
dest="/etc/dovecot/doveauth.lua",
user="root",
group="root",
mode="644",
)
need_restart |= auth_script.changed
return need_restart
def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> None:
"""Deploy a chat-mail instance.
:param mail_domain: domain part of your future email addresses
:param mail_server: the DNS name under which your mail server is reachable
:param dkim_selector:
"""
apt.update(name="apt update")
server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
# Deploy acmetool to have TLS certificates.
deploy_acmetool(domains=[mail_server])
apt.packages(
name="Install Postfix",
packages="postfix",
)
apt.packages(
name="Install Dovecot",
packages=[
"dovecot-imapd",
"dovecot-lmtpd",
"dovecot-auth-lua",
],
)
apt.packages(
name="Install OpenDKIM",
packages=[
"opendkim",
"opendkim-tools",
],
)
_install_doveauth()
dovecot_need_restart = _configure_dovecot(mail_server)
postfix_need_restart = _configure_postfix(mail_domain)
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
systemd.service(
name="Start and enable OpenDKIM",
service="opendkim.service",
running=True,
enabled=True,
restarted=opendkim_need_restart,
)
systemd.service(
name="Start and enable Postfix",
service="postfix.service",
running=True,
enabled=True,
restarted=postfix_need_restart,
)
systemd.service(
name="Start and enable Dovecot",
service="dovecot.service",
running=True,
enabled=True,
restarted=dovecot_need_restart,
)
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)

View File

@@ -1,6 +1,6 @@
import importlib.resources
from pyinfra.operations import apt, files, server
from pyinfra.operations import apt, files, systemd, server
def deploy_acmetool(nginx_hook=False, email="", domains=[]):
@@ -38,15 +38,25 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
email=email,
)
files.template(
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
dest="/var/lib/acme/conf/target",
service_file = files.put(
src=importlib.resources.files(__package__)
.joinpath("acmetool-redirector.service")
.open("rb"),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
group="root",
mode="644",
)
server.shell(
name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want { ' '.join(domains)}"],
systemd.service(
name="Setup acmetool-redirector service",
service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=service_file.changed,
)
for domain in domains:
server.shell(
name=f"Request certificate for {domain}",
commands=[f"acmetool want {domain}"],
)

View File

@@ -0,0 +1,11 @@
[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

View File

@@ -4,33 +4,21 @@ protocols = imap lmtp
auth_mechanisms = plain
{% if debug == true %}
auth_verbose = yes
auth_debug = yes
auth_debug_passwords = yes
auth_verbose_passwords = plain
auth_cache_size = 100M
mail_debug = yes
{% endif %}
mail_plugins = quota
# these are the capabilities Delta Chat cares about actually
# so let's keep the network overhead per login small
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY
# Authentication for system users.
passdb {
driver = dict
args = /etc/dovecot/auth.conf
driver = lua
args = file=/etc/dovecot/doveauth.lua
}
userdb {
driver = dict
args = /etc/dovecot/auth.conf
driver = lua
args = file=/etc/dovecot/doveauth.lua
}
##
## Mailbox locations and namespaces
##
@@ -72,28 +60,13 @@ mail_privileged_group = vmail
# Enable IMAP COMPRESS (RFC 4978).
# <https://datatracker.ietf.org/doc/html/rfc4978.html>
protocol imap {
mail_plugins = $mail_plugins imap_zlib imap_quota
}
protocol lmtp {
mail_plugins = $mail_plugins quota
mail_plugins = $mail_plugins imap_zlib
}
plugin {
imap_compress_deflate_level = 6
}
plugin {
# for now we define static quota-rules for all users
quota = maildir:User quota
quota_rule = *:storage=100M
quota_max_mail_size=30M
quota_grace = 0
# quota_over_flag_value = TRUE
}
service lmtp {
user=vmail
@@ -118,24 +91,6 @@ 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

View File

@@ -1,4 +1,7 @@
# OpenDKIM configuration.
# 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.
Syslog yes
SyslogSuccess yes
@@ -18,9 +21,7 @@ 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
KeyTable /etc/dkimkeys/KeyTable
SigningTable refile:/etc/dkimkeys/SigningTable
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
# 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

View File

@@ -23,7 +23,6 @@ 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 }}
@@ -38,8 +37,6 @@ mydestination =
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
# maximum 30MB sized messages
message_size_limit = 31457280
recipient_delimiter = +
inet_interfaces = all
inet_protocols = all

View File

@@ -9,11 +9,7 @@
# service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (no) (never) (100)
# ==========================================================================
{% if debug == true %}
smtp inet n - y - - smtpd -v
{% else %}
smtp inet n - y - - smtpd
{% endif %}
smtp inet n - y - - smtpd
#smtp inet n - y - 1 postscreen
#smtpd pass - - y - - smtpd
#dnsblog unix - - y - 0 dnsblog
@@ -32,8 +28,6 @@ submission inet n - y - - smtpd
-o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:10080
smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
@@ -47,9 +41,7 @@ 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 smtpd_proxy_filter=127.0.0.1:10080
#628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
@@ -72,11 +64,9 @@ showq unix n - y - - showq
error unix - - y - - error
retry unix - - y - - error
discard unix - - y - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil
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:10025 inet n - n - 10 smtpd
-o syslog_name=postfix/reinject

View File

@@ -0,0 +1,28 @@
import pytest
import imaplib
@pytest.fixture
def conn():
return connect("c1.testrun.org")
def login(conn, user, password):
print("trying to login", user, password)
conn.login(user, password)
def connect(host):
print(f"connecting to {host}")
conn = imaplib.IMAP4_SSL(host)
return conn
def test_login_ok(conn):
login(conn, "link2xt@c1.testrun.org", "Ahyei6ie")
def test_login_fail(conn):
with pytest.raises(imaplib.IMAP4.error) as excinfo:
login(conn, "link2xt@c1.testrun.org", "qweqwe")
assert "AUTHENTICATIONFAILED" in str(excinfo)

View File

@@ -1,15 +0,0 @@
[config]
privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel,
Reichgrafen Str. 20, 79102 Freiburg, Germany
privacy_mail = delta-privacy@merlinux.eu
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.

View File

@@ -1,5 +0,0 @@
# chatmaild
chatmaild provides dovecot autentication
to create dovecot users on login
and mail filtering.

View File

@@ -1,156 +0,0 @@
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

View File

@@ -1,10 +0,0 @@
[Unit]
Description=Dict authentication proxy for dovecot
[Service]
ExecStart=/usr/local/bin/doveauth /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -1,157 +0,0 @@
#!/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.smtp import SMTP
from aiosmtpd.controller import Controller
from smtplib import SMTP as SMTPClient
def check_encrypted(message):
"""Check that the message is an OpenPGP-encrypted message."""
if not message.is_multipart():
return False
if message.get("subject") != "...":
return False
if message.get_content_type() != "multipart/encrypted":
return False
parts_count = 0
for part in message.iter_parts():
if parts_count == 0:
if part.get_content_type() != "application/pgp-encrypted":
return False
elif parts_count == 1:
if part.get_content_type() != "application/octet-stream":
return False
else:
return False
parts_count += 1
return True
def check_mdn(message, envelope):
if len(envelope.rcpt_tos) != 1:
return False
for name in ["auto-submitted", "chat-version"]:
if not message.get(name):
return False
if message.get_content_type() != "multipart/report":
return False
body = message.get_body()
if body.get_content_type() != "text/plain":
return False
if list(body.iter_attachments()) or list(body.iter_parts()):
return False
# even with all mime-structural checks an attacker
# could try to abuse the subject or body to contain links or other
# annoyance -- we skip on checking subject/body for now as Delta Chat
# should evolve to create E2E-encrypted read receipts anyway.
# and then MDNs are just encrypted mail and can pass the border
# to other instances.
return True
class SMTPController(Controller):
def factory(self):
return SMTP(self.handler, **self.SMTP_kwargs)
class BeforeQueueHandler:
def __init__(self):
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
if not self.send_rate_limiter.is_sending_allowed(address):
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):
logging.info("handle_DATA before-queue")
error = check_DATA(envelope)
if error:
return error
logging.info("re-injecting the mail that passed checks")
client = SMTPClient("localhost", "10025")
client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
return "250 OK"
async def asyncmain_beforequeue(port):
Controller(BeforeQueueHandler(), hostname="127.0.0.1", port=port).start()
def check_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)
_, from_addr = parseaddr(message.get("from").strip())
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if not mail_encrypted and check_mdn(message, envelope):
return
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
res = recipient.split("@")
if len(res) != 2:
return f"500 Invalid address <{recipient}>"
_recipient_addr, recipient_domain = res
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}>"
class SendRateLimiter:
MAX_USER_SEND_PER_MINUTE = 80
def __init__(self):
self.addr2timestamps = {}
def is_sending_allowed(self, mail_from):
last = self.addr2timestamps.setdefault(mail_from, [])
now = time.time()
last[:] = [ts for ts in last if ts >= (now - 60)]
if len(last) <= self.MAX_USER_SEND_PER_MINUTE:
last.append(now)
return True
return False
def main():
args = sys.argv[1:]
assert len(args) == 1
logging.basicConfig(level=logging.WARN)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = asyncmain_beforequeue(port=int(args[0]))
loop.create_task(task)
loop.run_forever()

View File

@@ -1,10 +0,0 @@
[Unit]
Description=Chatmail Postfix BeforeQeue filter
[Service]
ExecStart=/usr/local/bin/filtermail 10080
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -1,28 +0,0 @@
#!/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()

View File

@@ -1,454 +0,0 @@
"""
Chat Mail pyinfra deploy.
"""
import importlib.resources
import configparser
from pathlib import Path
from pyinfra import host
from pyinfra.operations import apt, files, server, systemd
from pyinfra.facts.files import File
from pyinfra.facts.systemd import SystemdEnabled
from .acmetool import deploy_acmetool
def _install_chatmaild() -> None:
chatmaild_filename = "chatmaild-0.1.tar.gz"
chatmaild_path = importlib.resources.files(__package__).joinpath(
f"../../../dist/{chatmaild_filename}"
)
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,
)
apt.packages(
name="apt install python3-aiosmtpd python3-pip python3-venv",
packages=["python3-aiosmtpd", "python3-pip", "python3-venv"],
)
# --no-deps because aiosmtplib is installed with `apt`.
server.shell(
name="install chatmaild with pip",
commands=[f"pip install --break-system-packages {remote_path}"],
)
# disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service(
name="Disable legacy doveauth-dictproxy.service",
service="doveauth-dictproxy.service",
running=False,
enabled=False,
)
# install systemd units
for fn in (
"doveauth",
"filtermail",
):
files.put(
name=f"Upload {fn}.service",
src=importlib.resources.files("chatmaild")
.joinpath(f"{fn}.service")
.open("rb"),
dest=f"/etc/systemd/system/{fn}.service",
user="root",
group="root",
mode="644",
)
systemd.service(
name=f"Setup {fn} service",
service=f"{fn}.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
)
def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
"""Configures OpenDKIM"""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= main_config.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
keytable = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/KeyTable"),
dest="/etc/dkimkeys/KeyTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= keytable.changed
signing_table = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/SigningTable"),
dest="/etc/dkimkeys/SigningTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= signing_table.changed
files.directory(
name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
server.shell(
name="Generate OpenDKIM domain keys",
commands=[
f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
],
_sudo=True,
_sudo_user="opendkim",
)
return need_restart
def _install_mta_sts_daemon() -> bool:
need_restart = False
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:
"""Configures Postfix SMTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/main.cf.j2"),
dest="/etc/postfix/main.cf",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= main_config.changed
master_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/master.cf.j2"),
dest="/etc/postfix/master.cf",
user="root",
group="root",
mode="644",
debug=debug,
)
need_restart |= master_config.changed
return need_restart
def _configure_dovecot(mail_server: str, debug: bool = False) -> bool:
"""Configures Dovecot IMAP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
user="root",
group="root",
mode="644",
config={"hostname": mail_server},
debug=debug,
)
need_restart |= main_config.changed
auth_config = files.put(
src=importlib.resources.files(__package__).joinpath("dovecot/auth.conf"),
dest="/etc/dovecot/auth.conf",
user="root",
group="root",
mode="644",
)
need_restart |= auth_config.changed
files.put(
src=importlib.resources.files(__package__)
.joinpath("dovecot/expunge.cron")
.open("rb"),
dest="/etc/cron.d/expunge",
user="root",
group="root",
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 get_ini_settings(mail_domain, inipath):
parser = configparser.ConfigParser()
parser.read(inipath)
settings = {key: value.strip() for (key, value) in parser["config"].items()}
if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"):
for value in settings.values():
value = value.lower()
if "merlinux" in value or "schmieder" in value or "@testrun.org" in value:
raise ValueError(
f"please set your own privacy contacts/addresses in {inipath}"
)
settings["mail_domain"] = mail_domain
return settings
def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> None:
"""Deploy a chat-mail instance.
:param mail_domain: domain part of your future email addresses
: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)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
# Deploy acmetool to have TLS certificates.
deploy_acmetool(nginx_hook=True, domains=[mail_server, f"mta-sts.{mail_server}"])
apt.packages(
name="Install Postfix",
packages="postfix",
)
apt.packages(
name="Install Dovecot",
packages=["dovecot-imapd", "dovecot-lmtpd"],
)
apt.packages(
name="Install OpenDKIM",
packages=[
"opendkim",
"opendkim-tools",
],
)
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 = get_ini_settings(mail_domain, chatmail_ini)
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_chatmaild()
debug = False
dovecot_need_restart = _configure_dovecot(mail_server, debug=debug)
postfix_need_restart = _configure_postfix(mail_domain, 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",
service="opendkim.service",
running=True,
enabled=True,
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",
running=True,
enabled=True,
restarted=postfix_need_restart,
)
systemd.service(
name="Start and enable Dovecot",
service="dovecot.service",
running=True,
enabled=True,
restarted=dovecot_need_restart,
)
systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=nginx_need_restart,
)
# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName
server.shell(
name="Setup /etc/mailname",
commands=[f"echo {mail_domain} >/etc/mailname; chmod 644 /etc/mailname"],
)
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,
)

View File

@@ -1,7 +0,0 @@
request:
provider: https://acme-v02.api.letsencrypt.org/directory
key:
type: rsa
challenge:
webroot-paths:
- /var/www/html/.well-known/acme-challenge

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,5 +0,0 @@
uri = proxy:/run/dovecot/doveauth.socket:auth
iterate_disable = yes
default_pass_scheme = plain
password_key = passdb/%w/%u
user_key = userdb/%u

View File

@@ -1,4 +0,0 @@
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE 40d INBOX
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE 40d Deltachat
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE 40d Trash
2 30 * * * dovecot doveadm purge -A

View File

@@ -1,87 +0,0 @@
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
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

View File

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

View File

@@ -1,37 +0,0 @@
<?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>

View File

@@ -1,4 +0,0 @@
version: STSv1
mode: enforce
mx: {{ config.domain_name }}
max_age: 2419200

View File

@@ -1,55 +0,0 @@
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;
}
}

View File

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

View File

@@ -1 +0,0 @@
*@{{ config.domain_name }} {{ config.opendkim_selector }}._domainkey.{{ config.domain_name }}

View File

@@ -1,10 +0,0 @@
[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

View File

@@ -1,13 +0,0 @@
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

View File

@@ -1,110 +0,0 @@
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 deploy_chatmail import get_ini_settings
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["mail_domain"]
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():
chatmail_domain = "example.testrun.org"
path = importlib.resources.files(__package__)
reporoot = path.joinpath("../../../").resolve()
inipath = reporoot.joinpath("chatmail.ini")
config = get_ini_settings(chatmail_domain, inipath)
config["webdev"] = True
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()

View File

@@ -1,12 +1,12 @@
import os
import pyinfra
from deploy_chatmail import deploy_chatmail
from 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", "dkim")
dkim_selector = os.getenv("CHATMAIL_DKIM_SELECTOR", "2023")
assert mail_domain
assert mail_server

7
doveauth/README.md Normal file
View File

@@ -0,0 +1,7 @@
# doveauth
doveauth is a python tool
to create dovecot users on login.
It is called by the
[dovecot lua authentication module](https://doc.dovecot.org/configuration_manual/authentication/lua_based_authentication/)

View File

@@ -3,27 +3,20 @@ requires = ["setuptools>=45"]
build-backend = "setuptools.build_meta"
[project]
name = "chatmaild"
name = "doveauth"
version = "0.1"
dependencies = [
"aiosmtpd",
]
[project.scripts]
doveauth = "chatmaild.doveauth:main"
filtermail = "chatmaild.filtermail:main"
doveauth = "doveauth.doveauth:main"
[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,py
envlist = lint
[testenv:lint]
skipdist = True
@@ -34,10 +27,4 @@ deps =
commands =
black --quiet --check --diff src/
ruff src/
[testenv]
passenv = CHATMAIL_DOMAIN
deps = pytest
pdbpp
commands = pytest -v -rsXx {posargs: ../tests/chatmaild}
"""

View File

@@ -33,6 +33,13 @@ 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 = ?"
@@ -46,15 +53,30 @@ class Connection:
)
return result
def set_config(self, name: str, value: str) -> str:
ok = [
"dbversion",
]
assert name in ok, name
q = "INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"
self.cursor().execute(q, (name, value)).fetchone()
return value
def get_config(self, key: str) -> str:
q = "SELECT key, value from config WHERE name = ?"
c = self._sqlconn.cursor()
try:
return c.execute(q, key).fetchone()
except sqlite3.OperationalError:
return None
class Database:
def __init__(self, path: str):
self.path = Path(path)
self.ensure_tables()
def _get_connection(
self, write=False, transaction=False, closing=False
) -> Connection:
def _get_connection(self, write=False, transaction=False, closing=False) -> Connection:
# we let the database serialize all writers at connection time
# to play it very safe (we don't have massive amounts of writes).
mode = "ro"
@@ -107,20 +129,10 @@ class Database:
def read_connection(self, closing=True) -> Connection:
return self._get_connection(closing=closing, write=False)
def get_schema_version(self) -> int:
with self.read_connection() as conn:
dbversion = conn.execute("PRAGMA user_version").fetchone()[0]
return dbversion
CURRENT_DBVERSION = 1
def ensure_tables(self):
with self.write_transaction() as conn:
if self.get_schema_version() > 1:
raise DBError(
"version is %s; downgrading schema is not supported"
% (self.get_schema_version(),)
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS users (
@@ -130,4 +142,12 @@ class Database:
)
""",
)
conn.execute("PRAGMA user_version=%s" % (self.CURRENT_DBVERSION,))
conn.execute(
"""
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT
)
""",
)
conn.set_config("dbversion", self.CURRENT_DBVERSION)

View File

@@ -0,0 +1,59 @@
-- Escape shell argument by hex encoding it and wrapping in quotes.
function escape(data)
b16 = data:gsub(".", function(char) return string.format("%2X", char:byte()) end)
return ("'"..b16.."'")
end
-- call out to python program to actually manage authentication for dovecot
function chatctl_verify(user, password)
local cmd = "doveauth hexauth "..escape(user).." "..escape(password)
print("executing: "..cmd)
local handle = io.popen(cmd)
local result = handle:read("*a")
handle:close()
return split_chatctl(result)
end
function chatctl_lookup(user)
local cmd = "doveauth hexlookup "..escape(user)
assert(user)
print("executing: "..cmd)
local handle = io.popen(cmd)
local result = handle:read("*a")
handle:close()
return split_chatctl(result)
end
function get_extra_dovecot_output(res)
return {home=res.home, uid=res.uid, gid=res.gid}
end
function auth_password_verify(request, password)
local res = chatctl_verify(request.user, password)
-- request:log_error("auth_password_verify "..request.user.." "..password)
if res.status == "ok" then
local extra = get_extra_dovecot_output(res)
return dovecot.auth.PASSDB_RESULT_OK, get_extra_dovecot_output(res)
end
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, ""
end
function auth_userdb_lookup(request)
local res = chatctl_lookup(request.user)
if res.status == "ok" then
return dovecot.auth.USERDB_RESULT_OK, get_extra_dovecot_output(res)
end
return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, "no such user"
end
function split_chatctl(output)
local ret = {}
for key, value in output:gmatch "(%w+)%s*=%s*(%w+)" do
ret[key] = value
end
return ret
end

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import base64
import sys
from .database import Database
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 create_user(db, user, password):
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 verify_user(db, user, password):
userdata = get_user_data(db, user)
if userdata:
if userdata.get("password") == password:
userdata["status"] = "ok"
else:
userdata["status"] = "fail"
else:
userdata = create_user(db, user, password)
userdata["status"] = "ok"
return userdata
def lookup_user(db, user):
userdata = get_user_data(db, user)
if userdata:
userdata["status"] = "ok"
else:
userdata["status"] = "fail"
return userdata
def dump_result(res):
for key, value in res.items():
print(f"{key}={value}")
def main():
db = Database("/home/vmail/passdb.sqlite")
if sys.argv[1] == "hexauth":
login = base64.b16decode(sys.argv[2]).decode()
password = base64.b16decode(sys.argv[3]).decode()
res = verify_user(db, login, password)
dump_result(res)
elif sys.argv[1] == "hexlookup":
login = base64.b16decode(sys.argv[2]).decode()
res = lookup_user(db, login)
dump_result(res)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,78 @@
require "doveauth"
-- simulate dovecot defined result codes
dovecot = {
auth = {
PASSDB_RESULT_OK="PASSWORD-OK",
PASSDB_RESULT_PASSWORD_MISMATCH="PASSWORD-MISMATCH",
USERDB_RESULT_OK="USERDB-OK",
USERDB_RESULT_USER_UNKNOWN="USERDB-UNKNOWN"
}
}
-- Tests for testing the lua<->python interaction
function test_password_verify_ok(user, password)
local res, extra = auth_password_verify({user=user}, password)
assert(res==dovecot.auth.PASSDB_RESULT_OK)
assert(extra.uid == "vmail")
assert(extra.gid == "vmail")
-- assert(extra.homedir == "/home/vmail/link2xt")
print("OK test_password_verify_ok "..user.." "..password)
end
function test_password_verify_mismatch(user, password)
local res = auth_password_verify({user=user}, password)
assert(res == dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH)
print("OK test_password_verify_mismatch "..user.." "..password)
end
function test_userdb_lookup_ok(user)
local res, extra = auth_userdb_lookup({user=user})
assert(extra.uid == "vmail")
assert(extra.gid == "vmail")
assert(res == dovecot.auth.USERDB_RESULT_OK)
print("OK test_userdb_lookup_ok "..user)
end
function test_userdb_lookup_mismatch(user)
local res, extra = auth_userdb_lookup({user=user})
assert(res == dovecot.auth.USERDB_RESULT_USER_UNKNOWN)
print("OK test_userdb_lookup_mismatch "..user)
end
function test_passdb_lookup_ok(user)
local res, extra = auth_passdb_lookup({user=user})
assert(extra.uid == "vmail")
assert(extra.gid == "vmail")
assert(res == dovecot.auth.PASSDB_RESULT_OK)
print("OK test_passdb_lookup_ok "..user)
end
function test_passdb_lookup_mismatch(user)
local res, extra = auth_passdb_lookup({user=user})
assert(res == dovecot.auth.PASSDB_RESULT_USER_UNKNOWN)
print("OK test_passdb_lookup_mismatch "..user)
end
function test_split_chatctl()
local res = split_chatctl("a=3 b=4\nc=5")
assert(res["a"] == "3")
assert(res["b"] == "4")
assert(res["c"] == "5")
print("OK test_split_chatctl")
end
test_split_chatctl()
test_password_verify_ok("link2xt@c1.testrun.org", "Ahyei6ie")
test_password_verify_mismatch("link2xt@c1.testrun.org", "Aqwlek")
test_userdb_lookup_ok("link2xt@c1.testrun.org")
test_userdb_lookup_mismatch("wlekqjlew@xyz.org")
-- probably not needed by dovecot?
-- test_passdb_lookup_ok("link2xt@c1.testrun.org")
-- test_passdb_lookup_mismatch("llqkwjelqwe@xyz.org")

View File

@@ -0,0 +1,25 @@
import subprocess
import pytest
from doveauth.doveauth import get_user_data, verify_user, Database
def test_basic(tmpdir):
db = Database(tmpdir / "passdb.sqlite")
verify_user(db, "link2xt@c1.testrun.org", "asdf")
data = get_user_data(db, "link2xt@c1.testrun.org")
assert data
def test_verify_or_create(tmpdir):
db = Database(tmpdir / "passdb.sqlite")
res = verify_user(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
assert res["status"] == "ok"
res = verify_user(db, "newuser1@something.org", "kajdlqweqwe")
assert res["status"] == "fail"
def test_lua_integration(request):
p = request.fspath.dirpath("test_doveauth.lua")
proc = subprocess.run(["lua", str(p)])
assert proc.returncode == 0

View File

@@ -2,58 +2,30 @@
## Dovecot goals/steps
- automatic expiry of messages older than M days
- also expunge unread messages
1. create-user-on-login ("doveauth")
- limit: configure max-connections per account
2. per-user quota (adaptive)
3. automatic expiry of messages older than M days
4. automatic expiry of users that haven't logged in for N days
## nami: send out rate limit / rspamd
## Postfix goals/steps
- 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)
1. block all outgoing mails with our own LMTP program
2. only allow (outgoing) mails if secure-join or autocrypt-pgp-encrypted format
(probably via an lmtp service)
## 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 ...)
3. basic outgoing send rate/limits (depending on "account-rating")
## 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)
- write tests for postfix logins
- write A<>B send/receive tests
## Delta Chat

View File

@@ -1,4 +0,0 @@
#!/bin/bash
set -e
venv/bin/pytest tests/online/benchmark.py -vrx

View File

@@ -1,15 +1,7 @@
#!/usr/bin/env bash
echo -----------------------------------------
echo deploying to $CHATMAIL_DOMAIN
echo -----------------------------------------
echo WARNING: in five seconds deploy to $CHATMAIL_DOMAIN starts
sleep 5
venv/bin/python3 -m build -n --sdist chatmaild --outdir dist
venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" \
deploy-chatmail/src/deploy_chatmail/deploy.py
: ${CHATMAIL_DOMAIN:=c1.testrun.org}
export CHATMAIL_DOMAIN
cd doveauth
venv/bin/python3 -m build
../chatmail-pyinfra/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" ../deploy.py
rm -r dist/

View File

@@ -1,23 +0,0 @@
#!/bin/sh
: ${CHATMAIL_DOMAIN:=c1.testrun.org}
: ${CHATMAIL_SSH:=$CHATMAIL_DOMAIN}
set -e
SSH="ssh root@$CHATMAIL_SSH"
EMAIL="root@$CHATMAIL_DOMAIN"
ACME_ACCOUNT_URL="$($SSH -- acmetool account-url)"
cat <<EOF
$CHATMAIL_DOMAIN. MX 10 $CHATMAIL_DOMAIN.
$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"
_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"
_mta-sts.$CHATMAIL_DOMAIN. IN TXT "v=STSv1; id=$(date -u '+%Y%m%d%H%M')"
mta-sts.$CHATMAIL_DOMAIN. IN CNAME $CHATMAIL_DOMAIN.
_smtp._tls.$CHATMAIL_DOMAIN. IN TXT "v=TLSRPTv1;rua=mailto:$EMAIL"
EOF
$SSH opendkim-genzone -F | sed 's/^;.*$//;/^$/d'

View File

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

View File

@@ -1,8 +1,8 @@
#!/bin/sh
set -e
python3 -m venv venv
pip=venv/bin/pip
$pip install pyinfra pytest build 'setuptools>=68' tox
$pip install -e deploy-chatmail
$pip install -e chatmaild
python3 -m venv chatmail-pyinfra/venv
chatmail-pyinfra/venv/bin/pip install pyinfra pytest
chatmail-pyinfra/venv/bin/pip install -e chatmail-pyinfra
chatmail-pyinfra/venv/bin/pip install -e doveauth
python3 -m venv doveauth/venv
doveauth/venv/bin/pip install pytest build
doveauth/venv/bin/pip install -e doveauth

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
#!/bin/bash
venv/bin/tox -c chatmaild
venv/bin/tox -c deploy-chatmail
venv/bin/pytest tests/online -rs -vrx --durations=5 $@
#!/bin/sh
chatmail-pyinfra/venv/bin/pytest chatmail-pyinfra/tests
cd doveauth/src/doveauth
../../venv/bin/pytest

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env bash
echo -----------------------------------------
echo starting local webdev
echo -----------------------------------------
venv/bin/python3 -m deploy_chatmail.www

View File

@@ -1,90 +0,0 @@
import json
import sys
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 Database, 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_100_concurrent_lookups_different_accounts(db, gencreds):
num_threads = 100
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}")

View File

@@ -1,82 +0,0 @@
from chatmaild.filtermail import check_encrypted, check_DATA, 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"
def test_reject_forged_from(maildata, gencreds):
class env:
mail_from = gencreds()[0]
rcpt_tos = [gencreds()[0]]
# test that the filter lets good mail through
env.content = maildata("plain.eml", from_addr=env.mail_from).as_bytes()
assert not check_DATA(envelope=env)
# test that the filter rejects forged mail
env.content = maildata("plain.eml", from_addr="forged@c3.testrun.org").as_bytes()
error = check_DATA(envelope=env)
assert "500" in error
def test_filtermail_no_encryption_detection(maildata):
msg = maildata("plain.eml")
assert not check_encrypted(msg)
# https://xkcd.com/1181/
msg = maildata("fake-encrypted.eml")
assert not check_encrypted(msg)
def test_filtermail_encryption_detection(maildata):
msg = maildata("encrypted.eml")
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):
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 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"):
if i <= SendRateLimiter.MAX_USER_SEND_PER_MINUTE:
continue
pytest.fail("limiter didn't work")
else:
assert i == SendRateLimiter.MAX_USER_SEND_PER_MINUTE + 1
break

View File

@@ -1,29 +0,0 @@
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

View File

@@ -1,402 +0,0 @@
import os
import io
import time
import random
import subprocess
import imaplib
import smtplib
import itertools
from email.parser import BytesParser
from email import policy
from pathlib import Path
import pytest
from chatmaild.database import Database
conftestdir = Path(__file__).parent
def pytest_addoption(parser):
parser.addoption(
"--slow", action="store_true", default=False, help="also run slow tests"
)
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:
if not item.config.getoption("--slow"):
pytest.skip("skipping slow test, use --slow to run")
@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
@pytest.fixture
def sshdomain(maildomain):
return os.environ.get("CHATMAIL_SSH", maildomain)
@pytest.fixture
def maildomain2():
domain = os.environ.get("CHATMAIL_DOMAIN2")
if not domain:
pytest.skip("set CHATMAIL_DOMAIN2 to a ssh-reachable chatmail instance")
return domain
@pytest.fixture
def sshdomain2(maildomain2):
return os.environ.get("CHATMAIL_SSH2", maildomain2)
def pytest_report_header():
domain = os.environ.get("CHATMAIL_DOMAIN")
if domain:
text = f"chatmail test instance: {domain}"
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"
name = "dovecot"
def __init__(self, host):
self.host = host
def connect(self):
print(f"imap-connect {self.host}")
self.conn = imaplib.IMAP4_SSL(self.host)
def login(self, user, password):
print(f"imap-login {user!r} {password!r}")
self.conn.login(user, password)
def fetch_all(self):
print("imap-fetch all")
status, res = self.conn.select()
if int(res[0]) == 0:
raise ValueError("no messages in imap folder")
status, results = self.conn.fetch("1:*", "(RFC822)")
assert status == "OK"
return results
def fetch_all_messages(self):
print("imap-fetch all messages")
results = self.fetch_all()
messages = []
for item in results:
if len(item) == 2:
messages.append(item[1].decode())
return messages
@pytest.fixture
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"
name = "postfix"
def __init__(self, host):
self.host = host
def connect(self):
print(f"smtp-connect {self.host}")
self.conn = smtplib.SMTP_SSL(self.host)
def login(self, user, password):
print(f"smtp-login {user!r} {password!r}")
self.conn.login(user, password)
def sendmail(self, from_addr, to_addrs, msg):
print(f"smtp-sendmail from={from_addr!r} to_addrs={to_addrs!r}")
print(f"smtp-sendmail message size: {len(msg)}")
return self.conn.sendmail(from_addr=from_addr, to_addrs=to_addrs, msg=msg)
@pytest.fixture(params=["imap", "smtp"])
def imap_or_smtp(request):
return request.getfixturevalue(request.param)
@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)
#
# Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts
#
class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
def __init__(self, pytestconfig, maildomain, gencreds):
self.pytestconfig = pytestconfig
self.maildomain = maildomain
assert "." in self.maildomain, maildomain
self.gencreds = gencreds
self._addr2files = {}
def get_liveconfig_producer(self):
while 1:
user, password = self.gencreds(self.maildomain)
config = {
"addr": user,
"mail_pw": password,
}
# speed up account configuration
config["mail_server"] = self.maildomain
config["send_server"] = self.maildomain
yield config
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
pass
def cache_maybe_store_configured_db_files(self, acc):
pass
@pytest.fixture
def cmfactory(request, gencreds, tmpdir, maildomain):
# cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory
testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=None)
# nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2):
am.testprocess.maildomain = maildomain2
am.switch_maildomain = switch_maildomain
yield am
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
if testproc.pytestconfig.getoption("--extra-info"):
logfile = io.StringIO()
am.dump_imap_summary(logfile=logfile)
print(logfile.getvalue())
# request.node.add_report_section("call", "imap-server-state", s)
@pytest.fixture
def remote(sshdomain):
return Remote(sshdomain)
class Remote:
def __init__(self, sshdomain):
self.sshdomain = sshdomain
def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd
self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal],
stdout=subprocess.PIPE,
)
while 1:
line = self.popen.stdout.readline()
res = line.decode().strip().lower()
if res:
yield res
else:
break
@pytest.fixture
def lp(request):
class LP:
def sec(self, msg):
print(f"---- {msg} ----")
def indent(self, msg):
print(f" {msg}")
return LP()
@pytest.fixture
def maildata(request, gencreds):
datadir = conftestdir.joinpath("mail-data")
def maildata(name, from_addr=None, to_addr=None):
if from_addr is None:
from_addr = gencreds()[0]
if to_addr is None:
to_addr = gencreds()[0]
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
@pytest.fixture
def cmsetup(maildomain, gencreds):
return CMSetup(maildomain, gencreds)
class CMSetup:
def __init__(self, maildomain, gencreds):
self.maildomain = maildomain
self.gencreds = gencreds
def gen_users(self, num):
print(f"Creating {num} online users")
users = []
for i in range(num):
addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password)
assert user.smtp
users.append(user)
return users
class CMUser:
def __init__(self, maildomain, addr, password):
self.maildomain = maildomain
self.addr = addr
self.password = password
self._smtp = None
self._imap = None
@property
def smtp(self):
if not self._smtp:
handle = SmtpConn(self.maildomain)
handle.connect()
handle.login(self.addr, self.password)
self._smtp = handle
return self._smtp
@property
def imap(self):
if not self._imap:
imap = ImapConn(self.maildomain)
imap.connect()
imap.login(self.addr, self.password)
self._imap = imap
return self._imap

View File

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

View File

@@ -1,25 +0,0 @@
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-----

View File

@@ -1,33 +0,0 @@
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--

View File

@@ -1,23 +0,0 @@
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!

View File

@@ -1,60 +0,0 @@
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)

View File

@@ -1,70 +0,0 @@
import pytest
import threading
import queue
def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
"""Test a) that an initial login creates a user automatically
and b) verify we can also login a second time with the same password
and c) that using a different password fails the login."""
user, password = gencreds()
lp.sec(f"login first time with {user} {password}")
imap_or_smtp.connect()
imap_or_smtp.login(user, password)
lp.indent("success")
lp.sec(f"reconnect and login second time {user} {password}")
imap_or_smtp.connect()
imap_or_smtp.login(user, password)
imap_or_smtp.connect()
lp.sec("success")
lp.sec(f"reconnect and verify wrong password fails {user} ")
imap_or_smtp.connect()
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
to ensure that authentication process does not confuse the users
by using only the password hash as a key.
"""
user1, password1 = gencreds()
user2, _ = gencreds()
imap_or_smtp.connect()
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()

View File

@@ -1,6 +0,0 @@
from deploy_chatmail.genqr import gen_qr_png_data
def test_gen_qr_png_data(maildomain):
data = gen_qr_png_data(maildomain)
assert data

View File

@@ -1,63 +0,0 @@
import smtplib
import pytest
def test_remote(remote, imap_or_smtp):
lineproducer = remote.iter_output(imap_or_smtp.logcmd)
imap_or_smtp.connect()
assert imap_or_smtp.name in next(lineproducer)
def test_use_two_chatmailservers(cmfactory, 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()
cmfactory.get_accepted_chat(ac1, ac2)
domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1]
assert domain1 != domain2
@pytest.mark.parametrize("forgeaddr", ["internal", "someone@example.org"])
def test_reject_forged_from(cmsetup, maildata, lp, forgeaddr):
user1, user3 = cmsetup.gen_users(2)
lp.sec("send encrypted message with forged from")
print("envelope_from", user1.addr)
if forgeaddr == "internal":
addr_to_forge = cmsetup.gen_users(1)[0].addr
else:
addr_to_forge = "someone@example.org"
print("message to inject:")
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")
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)
@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 < 80:
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("Rate limit was not exceeded")

View File

@@ -1,110 +0,0 @@
import time
import random
import pytest
class TestEndToEndDeltaChat:
"Tests that use Delta Chat accounts on the chat mail instance."
def test_one_on_one(self, cmfactory, lp):
"""Test that a DC account can send a message to a second DC account
on the same chat-mail instance."""
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: prepare and send text message to ac2")
chat.send_text("message0")
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0"
@pytest.mark.slow
def test_exceed_quota(self, cmfactory, lp, tmpdir, remote):
"""This is a very slow test as it needs to upload >100MB of mail data
before quota is exceeded, and thus depends on the speed of the upload.
"""
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
quota = 1024 * 1024 * 100
attachsize = 1 * 1024 * 1024
num_to_send = quota // attachsize + 2
lp.sec(f"ac1: send {num_to_send} large files to ac2")
lp.indent(f"per-user quota is assumed to be: {quota/(1024*1024)}MB")
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
msgs = []
for i in range(num_to_send):
attachment = tmpdir / f"attachment{i}"
data = "".join(random.choice(alphanumeric) for i in range(1024))
with open(attachment, "w+") as f:
for j in range(attachsize // len(data)):
f.write(data)
msg = chat.send_file(str(attachment))
msgs.append(msg)
lp.indent(f"Sent out msg {i}, size {attachsize/(1024*1024)}MB")
lp.sec("ac2: check messages are arriving until quota is reached")
addr = ac2.get_config("addr").lower()
saved_ok = 0
for line in remote.iter_output("journalctl -f -u dovecot"):
if addr not in line:
# print(line)
continue
if "quota" in line:
if "quota exceeded" in line:
if saved_ok < num_to_send // 2:
pytest.fail(
f"quota exceeded too early: after {saved_ok} messages already"
)
lp.indent("good, message sending failed because quota was exceeded")
return
if "saved mail to inbox" in line:
saved_ok += 1
print(f"{saved_ok}: {line}")
if saved_ok >= num_to_send:
break
pytest.fail("sending succeeded although messages should exceed quota")
def test_securejoin(self, cmfactory, lp, maildomain2):
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("ac1: create QR code and let ac2 scan it, starting the securejoin")
qr = ac1.get_setup_contact_qr()
lp.sec("ac2: start QR-code based setup contact protocol")
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)

View File

@@ -1,2 +0,0 @@
[pytest]
addopts = -vrsx --strict-markers

View File

@@ -1,47 +0,0 @@
import textwrap
import importlib.resources
from deploy_chatmail.www import build_webpages
from deploy_chatmail import get_ini_settings
def create_ini(inipath):
inipath.write_text(
textwrap.dedent(
"""\
[config]
privacy_postal =
address-line1
address-line2
privacy_mail = privacy@example.org
privacy_pdo =
address-line3
"""
)
)
def test_build_webpages(tmp_path):
pkgroot = importlib.resources.files("deploy_chatmail")
src_dir = pkgroot.joinpath("../../../www/src").resolve()
assert src_dir.exists(), src_dir
inipath = tmp_path.joinpath("chatmail.ini")
create_ini(inipath)
config = get_ini_settings("example.org", inipath)
build_dir = tmp_path.joinpath("build")
build_webpages(src_dir, build_dir, config)
def test_get_settings(tmp_path):
inipath = tmp_path.joinpath("chatmail.ini")
create_ini(inipath)
d = get_ini_settings("x.testrun.org", inipath)
assert d["privacy_postal"] == "address-line1\naddress-line2"
assert d["privacy_mail"] == "privacy@example.org"
assert d["privacy_pdo"] == "address-line3"
assert d["mail_domain"] == "x.testrun.org"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -1,19 +0,0 @@
<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 ⚡

View File

@@ -1,39 +0,0 @@
<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.

View File

@@ -1,20 +0,0 @@
<!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>

View File

@@ -1,321 +0,0 @@
<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.

File diff suppressed because it is too large Load Diff