Compare commits

..

1 Commits

Author SHA1 Message Date
Mark Felder 59dceb202d feat: metadata service: make turnserver socket path configurable 2026-05-12 11:34:41 -07:00
27 changed files with 269 additions and 349 deletions
+2 -2
View File
@@ -20,9 +20,9 @@ concurrency:
jobs: jobs:
no-dns: no-dns:
name: LXC deploy and test name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
with: with:
cmlxc_version: main cmlxc_version: v0.14.6
cmlxc_commands: | cmlxc_commands: |
cmlxc init cmlxc init
# single cmdeploy relay test # single cmdeploy relay test
+3 -3
View File
@@ -29,7 +29,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false persist-credentials: false
- name: download filtermail - name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.7.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.6/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox
@@ -57,9 +57,9 @@ jobs:
lxc-test: lxc-test:
name: LXC deploy and test name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
with: with:
cmlxc_version: main cmlxc_version: v0.14.6
cmlxc_commands: | cmlxc_commands: |
cmlxc init cmlxc init
# single cmdeploy relay test # single cmdeploy relay test
-1
View File
@@ -9,7 +9,6 @@ name: Trigger Docker build
on: on:
push: push:
branches: [main] branches: [main]
tags: ['[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch: workflow_dispatch:
permissions: {} permissions: {}
-42
View File
@@ -1,47 +1,5 @@
# Changelog for chatmail deployment # Changelog for chatmail deployment
## [1.11.0] - 2026-05-15
### Breaking Changes
- [**breaking**] Drop passthrough_sender and passthrough_recipients chatmail.ini options to eliminate one more source of unencrypted messages
### Features
- Use filtermail for delivery to remote MTAs
- Expose metadata "maxsmtprecipients" value
- Support setup without domain, with only an IPv4 address (#963)
- *(doc/docker)* Introduce docker images in documentation
- DKIM-sign bounce messages (mainly "user does not exist")
- *(config)* Load default values from Config(), not chatmail.ini.f (#853)
- Make turn_socket_path configurable, and cleanup tests and turnserver code.
- Warn about any unused chatmail.ini parameter at the end of "cmdeploy run"
### Bug Fixes
- Make www tests work with editable instead of just plain installs
- Use path with no leading slash for mxdeliv
- Increase filtermail-transport concurrency limit
- Fix #972 by increasing file descriptors for filtermail
- *(mtail)* Correct boot ordering and deploy restart logic
- *(cmdeploy)* Stop and disable unbound-resolvconf
- *(nginx)* Properly redirect www to mail_domain
- *(dns)* Query correct NS if MNAME server is hidden (#954)
- Legacy token metadata storage used list type, but if no new setmetadata happened, the user would not be notified at all.
- *(logging)* Log all http requests to syslog
### Documentation
- Document how to upgrade to new version (#965)
### Other
- *(deps)* Upgrade to filtermail v0.6.4
### Refactor
- Introduce automated change-tracking across deployers
## 1.10.0 2026-04-30 ## 1.10.0 2026-04-30
* start mtail after networking is fully up <https://github.com/chatmail/relay/pull/942> * start mtail after networking is fully up <https://github.com/chatmail/relay/pull/942>
+2
View File
@@ -10,6 +10,7 @@ dependencies = [
"filelock", "filelock",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'", "crypt-r >= 3.13.1 ; python_version >= '3.11'",
"domain-validator",
] ]
[tool.setuptools] [tool.setuptools]
@@ -25,6 +26,7 @@ chatmail-expire = "chatmaild.expire:daily_expire_main"
chatmail-quota-expire = "chatmaild.expire:quota_expire_main" chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main" lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"
[project.entry-points.pytest11] [project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin" "chatmaild.testplugin" = "chatmaild.tests.plugin"
+68 -42
View File
@@ -2,6 +2,7 @@ import ipaddress
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
from domain_validator import DomainValidator
from chatmaild.user import User from chatmaild.user import User
@@ -9,14 +10,18 @@ from chatmaild.user import User
def read_config(inipath): def read_config(inipath):
assert Path(inipath).exists(), inipath assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath) cfg = iniconfig.IniConfig(inipath)
return Config(inipath, params=cfg.sections["params"]) params = cfg.sections["params"]
default_config_content = get_default_config_content(params["mail_domain"])
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
class Config: class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
params = dict(params) raw_domain = params["mail_domain"]
raw_domain = params.pop("mail_domain")
self.mail_domain_bare = raw_domain self.mail_domain_bare = raw_domain
if is_valid_ipv4(raw_domain): if is_valid_ipv4(raw_domain):
@@ -24,63 +29,61 @@ class Config:
self.mail_domain = f"[{raw_domain}]" self.mail_domain = f"[{raw_domain}]"
self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer
else: else:
DomainValidator().validate_domain_re(raw_domain)
self.ipv4_relay = None self.ipv4_relay = None
self.mail_domain = raw_domain self.mail_domain = raw_domain
self.postfix_myhostname = raw_domain self.postfix_myhostname = raw_domain
self.max_user_send_per_minute = int(params.pop("max_user_send_per_minute", 60)) self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.max_user_send_burst_size = int(params.pop("max_user_send_burst_size", 10)) self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params.pop("max_mailbox_size", "500M") self.max_mailbox_size = params["max_mailbox_size"]
self.max_message_size = int(params.pop("max_message_size", 31457280)) self.max_message_size = int(params.get("max_message_size", "31457280"))
self.delete_mails_after = params.pop("delete_mails_after", "20") self.delete_mails_after = params["delete_mails_after"]
self.delete_large_after = params.pop("delete_large_after", "7") self.delete_large_after = params["delete_large_after"]
self.delete_inactive_users_after = int( self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
params.pop("delete_inactive_users_after", 90) self.username_min_length = int(params["username_min_length"])
) self.username_max_length = int(params["username_max_length"])
self.username_min_length = int(params.pop("username_min_length", 9)) self.password_min_length = int(params["password_min_length"])
self.username_max_length = int(params.pop("username_max_length", 9)) self.passthrough_senders = params["passthrough_senders"].split()
self.password_min_length = int(params.pop("password_min_length", 9)) self.passthrough_recipients = params["passthrough_recipients"].split()
self.www_folder = params.pop("www_folder", "") self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080")) self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
params.pop("filtermail_smtp_port_incoming", "10081") params.get("filtermail_smtp_port_incoming", "10081")
) )
self.filtermail_http_port_incoming = int( self.filtermail_http_port_incoming = int(
params.pop("filtermail_http_port_incoming", "10082") params.get("filtermail_http_port_incoming", "10082")
) )
self.filtermail_lmtp_port_transport = int( self.filtermail_lmtp_port_transport = int(
params.pop("filtermail_lmtp_port_transport", "10083") params.get("filtermail_lmtp_port_transport", "10083")
) )
self.postfix_reinject_port = int(params.pop("postfix_reinject_port", "10025")) self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.pop("postfix_reinject_port_incoming", "10026") params.get("postfix_reinject_port_incoming", "10026")
) )
self.mtail_address = params.pop("mtail_address", None) self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true" self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.acme_email = params.pop("acme_email", "") self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.pop("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.pop("imap_compress", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true"
self.turn_socket_path = params.pop( self.turn_socket_path = params.get("turn_socket_path", "/run/chatmail-turn/turn.socket")
"turn_socket_path", "/run/chatmail-turn/turn.socket" if "iroh_relay" not in params:
)
iroh_relay = params.pop("iroh_relay", None)
if iroh_relay is None:
self.iroh_relay = "https://" + raw_domain self.iroh_relay = "https://" + raw_domain
self.enable_iroh_relay = True self.enable_iroh_relay = True
else: else:
self.iroh_relay = iroh_relay.strip() self.iroh_relay = params["iroh_relay"].strip()
self.enable_iroh_relay = False self.enable_iroh_relay = False
self.privacy_postal = params.pop("privacy_postal", None) self.privacy_postal = params.get("privacy_postal")
self.privacy_mail = params.pop("privacy_mail", None) self.privacy_mail = params.get("privacy_mail")
self.privacy_pdo = params.pop("privacy_pdo", None) self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.pop("privacy_supervisor", None) self.privacy_supervisor = params.get("privacy_supervisor")
# TLS certificate management. # TLS certificate management.
# If tls_external_cert_and_key is set, use externally managed certs. # If tls_external_cert_and_key is set, use externally managed certs.
# Otherwise derived from the domain name: # Otherwise derived from the domain name:
# - Domains starting with "_" use self-signed certificates # - Domains starting with "_" use self-signed certificates
# - All other domains use ACME. # - All other domains use ACME.
external = params.pop("tls_external_cert_and_key", "").strip() external = params.get("tls_external_cert_and_key", "").strip()
if external: if external:
parts = external.split() parts = external.split()
@@ -101,12 +104,11 @@ class Config:
self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey" self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey"
# deprecated option # deprecated option
mbdir = params.pop("mailboxes_dir", f"/home/vmail/mail/{raw_domain}") mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{raw_domain}")
self.mailboxes_dir = Path(mbdir.strip()) self.mailboxes_dir = Path(mbdir.strip())
# old unused option (except for first migration from sqlite to maildir store) # old unused option (except for first migration from sqlite to maildir store)
self.passdb_path = Path(params.pop("passdb_path", "/home/vmail/passdb.sqlite")) self.passdb_path = Path(params.get("passdb_path", "/home/vmail/passdb.sqlite"))
self._unused_keys = list(params)
@property @property
def max_mailbox_size_mb(self): def max_mailbox_size_mb(self):
@@ -163,7 +165,31 @@ def get_default_config_content(mail_domain, **overrides):
for name, value in extra.items(): for name, value in extra.items():
new_line = f"{name} = {value}" new_line = f"{name} = {value}"
new_lines.append(new_line) new_lines.append(new_line)
return "\n".join(new_lines)
content = "\n".join(new_lines)
# apply testrun privacy overrides
if mail_domain.endswith(".testrun.org"):
override_inipath = inidir.joinpath("override-testrun.ini")
privacy = iniconfig.IniConfig(override_inipath)["privacy"]
lines = []
for line in content.split("\n"):
for key, value in privacy.items():
value_lines = value.format(mail_domain=mail_domain).strip().split("\n")
if not line.startswith(f"{key} =") or not value_lines:
continue
if len(value_lines) == 1:
lines.append(f"{key} = {value}")
else:
lines.append(f"{key} =")
for vl in value_lines:
lines.append(f" {vl}")
break
else:
lines.append(line)
content = "\n".join(lines)
return content
def is_valid_ipv4(address: str) -> bool: def is_valid_ipv4(address: str) -> bool:
-10
View File
@@ -168,16 +168,6 @@ class Expiry:
if mbox.last_login and mbox.last_login < cutoff_without_login: if mbox.last_login and mbox.last_login < cutoff_without_login:
self.remove_mailbox(mbox.basedir) self.remove_mailbox(mbox.basedir)
return return
elif mbox.last_login is None:
try:
if not self.dry:
os.rmdir(mbox.basedir)
self.del_mboxes += 1
except OSError:
print_info(
f"Skipped deleting {mbox.basedir}, doesn't have last_login but isn't empty"
)
return
mboxname = os.path.basename(mbox.basedir) mboxname = os.path.basename(mbox.basedir)
if self.verbose: if self.verbose:
+33 -15
View File
@@ -12,35 +12,42 @@ mail_domain = {mail_domain}
# #
# email sending rate per user and minute # email sending rate per user and minute
#max_user_send_per_minute = 60 max_user_send_per_minute = 60
# per-user max burst size for sending rate limiting (GCRA bucket capacity) # per-user max burst size for sending rate limiting (GCRA bucket capacity)
#max_user_send_burst_size = 10 max_user_send_burst_size = 10
# maximum mailbox size of a chatmail address # maximum mailbox size of a chatmail address
# (Oldest messages will be removed automatically, so mailboxes never run full) # Oldest messages will be removed automatically, so mailboxes never run full.
#max_mailbox_size = 500M max_mailbox_size = 500M
# maximum message size for an e-mail in bytes # maximum message size for an e-mail in bytes
#max_message_size = 31457280 max_message_size = 31457280
# days after which mails are unconditionally deleted # days after which mails are unconditionally deleted
#delete_mails_after = 20 delete_mails_after = 20
# days after which large messages (>200k) are unconditionally deleted # days after which large messages (>200k) are unconditionally deleted
#delete_large_after = 7 delete_large_after = 7
# days after which users without a successful login are deleted (database and mails) # days after which users without a successful login are deleted (database and mails)
#delete_inactive_users_after = 90 delete_inactive_users_after = 90
# minimum length a username must have # minimum length a username must have
#username_min_length = 9 username_min_length = 9
# maximum length a username can have # maximum length a username can have
#username_max_length = 9 username_max_length = 9
# minimum length a password must have # minimum length a password must have
#password_min_length = 9 password_min_length = 9
# list of chatmail addresses which can send outbound un-encrypted mail
passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails
# (space-separated, item may start with "@" to whitelist whole recipient domains)
passthrough_recipients =
# Use externally managed TLS certificates instead of built-in acmetool. # Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine). # Paths refer to files on the deployment server (not the build machine).
@@ -56,11 +63,22 @@ mail_domain = {mail_domain}
# Deployment Details # Deployment Details
# #
# Path to the TURN server Unix socket
turn_socket_path = /run/chatmail-turn/turn.socket
# SMTP outgoing filtermail and reinjection
filtermail_smtp_port = 10080
postfix_reinject_port = 10025
# SMTP incoming filtermail and reinjection
filtermail_smtp_port_incoming = 10081
postfix_reinject_port_incoming = 10026
# if set to "True" IPv6 is disabled # if set to "True" IPv6 is disabled
#disable_ipv6 = False disable_ipv6 = False
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates # Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
#acme_email = acme_email =
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail # Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
# service. # service.
@@ -93,13 +111,13 @@ mail_domain = {mail_domain}
# in per-maildir ".in/.out" files. # in per-maildir ".in/.out" files.
# Note that you need to manually cleanup these files # Note that you need to manually cleanup these files
# so use this option with caution on production servers. # so use this option with caution on production servers.
#imap_rawlog = false imap_rawlog = false
# set to true if you want to enable the IMAP COMPRESS Extension, # set to true if you want to enable the IMAP COMPRESS Extension,
# which allows IMAP connections to be efficiently compressed. # which allows IMAP connections to be efficiently compressed.
# WARNING: Enabling this makes it impossible to hibernate IMAP # WARNING: Enabling this makes it impossible to hibernate IMAP
# processes which will result in much higher memory/RAM usage. # processes which will result in much higher memory/RAM usage.
#imap_compress = false imap_compress = false
# #
@@ -0,0 +1,16 @@
[privacy]
passthrough_recipients = privacy@testrun.org echo@{mail_domain}
privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel,
Reichgrafen Str. 20, 79102 Freiburg, Germany
privacy_mail = privacy@testrun.org
privacy_pdo =
Prof. Dr. Fabian Schmieder, lexICT UG (limited), Ostfeldstr. 49, 30559 Hannover.
You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO)
privacy_supervisor =
State Commissioner for Data Protection and Freedom of Information of
Baden-Württemberg in 70173 Stuttgart, Germany.
+2 -17
View File
@@ -1,5 +1,4 @@
import logging import logging
import socket
import sys import sys
import time import time
from contextlib import contextmanager from contextlib import contextmanager
@@ -8,14 +7,7 @@ from .config import read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
from .filedict import FileDict from .filedict import FileDict
from .notifier import Notifier from .notifier import Notifier
from .turnserver import turn_credentials
def turn_credentials(turn_socket_path):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.settimeout(5)
client_socket.connect(turn_socket_path)
with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8").strip()
def _is_valid_token_timestamp(timestamp, now): def _is_valid_token_timestamp(timestamp, now):
@@ -87,14 +79,7 @@ class Metadata:
class MetadataDictProxy(DictProxy): class MetadataDictProxy(DictProxy):
def __init__( def __init__(self, notifier, metadata, iroh_relay=None, turn_hostname=None, turn_socket_path=None):
self,
notifier,
metadata,
iroh_relay=None,
turn_hostname=None,
turn_socket_path=None,
):
super().__init__() super().__init__()
self.notifier = notifier self.notifier = notifier
self.metadata = metadata self.metadata = metadata
+19 -19
View File
@@ -13,12 +13,7 @@ def test_read_config_basic(example_config):
assert not example_config.privacy_pdo and not example_config.privacy_postal assert not example_config.privacy_pdo and not example_config.privacy_postal
inipath = example_config._inipath inipath = example_config._inipath
inipath.write_text( inipath.write_text(inipath.read_text().replace("60", "37"))
inipath.read_text().replace(
"#max_user_send_per_minute = 60",
"max_user_send_per_minute = 37",
)
)
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 37 assert example_config.max_user_send_per_minute == 37
assert example_config.mail_domain == "chat.example.org" assert example_config.mail_domain == "chat.example.org"
@@ -36,21 +31,26 @@ def test_read_config_basic_using_defaults(tmp_path, maildomain):
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 60 assert example_config.max_user_send_per_minute == 60
assert example_config.filtermail_smtp_port_incoming == 10081 assert example_config.filtermail_smtp_port_incoming == 10081
assert example_config.filtermail_smtp_port == 10080
assert example_config.postfix_reinject_port == 10025
assert example_config.max_user_send_per_minute == 60
assert example_config.max_mailbox_size == "500M"
assert example_config.delete_mails_after == "20"
assert example_config.delete_large_after == "7"
assert example_config.username_min_length == 9
assert example_config.username_max_length == 9
assert example_config.password_min_length == 9
assert example_config._unused_keys == []
def test_config_unused_keys(make_config): def test_read_config_testrun(make_config):
config = make_config("chat.example.org", {"passthrough_senders": "x@y.org"}) config = make_config("something.testrun.org")
assert config._unused_keys == ["passthrough_senders"] assert config.mail_domain == "something.testrun.org"
assert len(config.privacy_postal.split("\n")) > 1
assert len(config.privacy_supervisor.split("\n")) > 1
assert len(config.privacy_pdo.split("\n")) > 1
assert config.privacy_mail == "privacy@testrun.org"
assert config.filtermail_smtp_port == 10080
assert config.postfix_reinject_port == 10025
assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "500M"
assert config.delete_mails_after == "20"
assert config.delete_large_after == "7"
assert config.username_min_length == 9
assert config.username_max_length == 9
assert config.password_min_length == 9
assert "privacy@testrun.org" in config.passthrough_recipients
assert config.passthrough_senders == []
def test_config_userstate_paths(make_config, tmp_path): def test_config_userstate_paths(make_config, tmp_path):
@@ -1,7 +1,6 @@
import itertools import itertools
import os import os
import random import random
import shutil
import time import time
from datetime import datetime from datetime import datetime
from fnmatch import fnmatch from fnmatch import fnmatch
@@ -10,7 +9,6 @@ from pathlib import Path
import pytest import pytest
from chatmaild.expire import ( from chatmaild.expire import (
Expiry,
FileEntry, FileEntry,
MailboxStat, MailboxStat,
expire_to_target, expire_to_target,
@@ -106,32 +104,6 @@ def test_stats_mailbox(mbox1):
assert mbox3.last_login is None assert mbox3.last_login is None
def test_mbox_without_password(mbox1, example_config, capsys):
password = Path(mbox1.basedir).joinpath("password")
os.remove(password)
mbox_rescan = MailboxStat(mbox1.basedir)
assert mbox_rescan.last_login is None
exp = Expiry(
example_config, dry=False, now=datetime.now().timestamp(), verbose=False
)
exp.process_mailbox_stat(mbox_rescan)
out, err = capsys.readouterr()
assert "doesn't have last_login but isn't empty" in err
assert os.path.isdir(mbox_rescan.basedir)
for entry in os.scandir(mbox_rescan.basedir):
if os.path.isdir(entry):
shutil.rmtree(entry)
else:
os.remove(entry)
exp.process_mailbox_stat(mbox_rescan)
out, err = capsys.readouterr()
assert "doesn't have last_login but isn't empty" not in err
assert not os.path.isdir(mbox_rescan.basedir)
def test_report_no_mailboxes(example_config): def test_report_no_mailboxes(example_config):
args = (str(example_config._inipath),) args = (str(example_config._inipath),)
report_main(args) report_main(args)
@@ -324,7 +324,7 @@ def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch):
turn_hostname="turn.example.org", turn_hostname="turn.example.org",
) )
def mock_turn_credentials(turn_socket_path): def mock_turn_credentials():
raise ConnectionRefusedError("socket not available") raise ConnectionRefusedError("socket not available")
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials) monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
@@ -348,9 +348,7 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
turn_hostname="turn.example.org", turn_hostname="turn.example.org",
) )
monkeypatch.setattr( monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass")
chatmaild.metadata, "turn_credentials", lambda path: "user:pass"
)
transactions = {} transactions = {}
res = dictproxy.handle_dovecot_request( res = dictproxy.handle_dovecot_request(
@@ -1,46 +0,0 @@
import socket
import threading
import pytest
from chatmaild.metadata import turn_credentials
@pytest.fixture
def turn_socket(tmp_path):
sock_path = str(tmp_path / "turn.socket")
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
yield sock_path, server
server.close()
def test_turn_credentials_timeout(turn_socket):
sock_path, server = turn_socket
with pytest.raises(socket.timeout):
# Inside turn_credentials the kernel listen backlog (1)
# completes connect() without accept()
# so the client blocks on readline() until the 5s timeout fires.
turn_credentials(sock_path)
def test_turn_credentials_connection_refused_on_not_existing_socket(tmp_path):
missing = str(tmp_path / "nonexistent.socket")
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
turn_credentials(missing)
def test_turn_credentials_socket_success(turn_socket):
sock_path, server = turn_socket
def respond():
conn, _ = server.accept()
conn.sendall(b"testuser:testpass\n")
conn.close()
t = threading.Thread(target=respond, daemon=True)
t.start()
result = turn_credentials(sock_path)
assert result == "testuser:testpass"
@@ -0,0 +1,73 @@
import socket
import threading
import time
from unittest.mock import patch
import pytest
from chatmaild.turnserver import turn_credentials
SOCKET_PATH = "/run/chatmail-turn/turn.socket"
@pytest.fixture
def turn_socket(tmp_path):
"""Create a real Unix socket server at a temp path."""
sock_path = str(tmp_path / "turn.socket")
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
yield sock_path, server
server.close()
def _call_turn_credentials(sock_path):
"""Call turn_credentials but connect to sock_path instead of hardcoded path."""
original_connect = socket.socket.connect
def patched_connect(self, address):
if address == SOCKET_PATH:
address = sock_path
return original_connect(self, address)
with patch.object(socket.socket, "connect", patched_connect):
return turn_credentials()
def test_turn_credentials_timeout(turn_socket):
"""Server accepts but never responds — must raise socket.timeout."""
sock_path, server = turn_socket
def accept_and_hang():
conn, _ = server.accept()
time.sleep(30)
conn.close()
t = threading.Thread(target=accept_and_hang, daemon=True)
t.start()
with pytest.raises(socket.timeout):
_call_turn_credentials(sock_path)
def test_turn_credentials_connection_refused(tmp_path):
"""Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError."""
missing = str(tmp_path / "nonexistent.socket")
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
_call_turn_credentials(missing)
def test_turn_credentials_success(turn_socket):
"""Server responds with credentials — must return stripped string."""
sock_path, server = turn_socket
def respond():
conn, _ = server.accept()
conn.sendall(b"testuser:testpass\n")
conn.close()
t = threading.Thread(target=respond, daemon=True)
t.start()
result = _call_turn_credentials(sock_path)
assert result == "testuser:testpass"
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
import socket
def turn_credentials(turn_socket_path) -> str:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.settimeout(5)
client_socket.connect(turn_socket_path)
with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8").strip()
@@ -1,2 +1,2 @@
"acme-enter-email": "{{ email }}" "acme-enter-email": "{{ email }}"
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.7-June-04-2026.pdf": true "acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.6-August-18-2025.pdf": true
-10
View File
@@ -84,15 +84,6 @@ def run_cmd_options(parser):
add_ssh_host_option(parser) add_ssh_host_option(parser)
def _warn_unused_settings(unused_keys, out):
if unused_keys:
names = ", ".join(unused_keys)
out.red(
f"WARNING: chatmail.ini contains settings that have no effect: {names}\n"
"Please remove them from chatmail.ini."
)
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
@@ -134,7 +125,6 @@ def run_cmd(args, out):
out.green("Deploy completed.") out.green("Deploy completed.")
else: else:
out.green("Deploy completed, call `cmdeploy dns` next.") out.green("Deploy completed, call `cmdeploy dns` next.")
_warn_unused_settings(args.config._unused_keys, out)
return 0 return 0
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
out.red("Deploy failed") out.red("Deploy failed")
+10 -10
View File
@@ -171,14 +171,16 @@ class UnboundDeployer(Deployer):
"unbound-anchor -a /var/lib/unbound/root.key || true", "unbound-anchor -a /var/lib/unbound/root.key || true",
], ],
) )
self.ensure_directory( if self.config.disable_ipv6:
path="/etc/unbound/unbound.conf.d", self.ensure_directory(
) path="/etc/unbound/unbound.conf.d",
self.put_template( )
"unbound/unbound.conf.j2", self.put_template(
"/etc/unbound/unbound.conf.d/chatmail.conf", "unbound/unbound.conf.j2",
disable_ipv6=self.config.disable_ipv6, "/etc/unbound/unbound.conf.d/chatmail.conf",
) )
else:
self.remove_file("/etc/unbound/unbound.conf.d/chatmail.conf")
def activate(self): def activate(self):
server.shell( server.shell(
@@ -512,8 +514,6 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
(["master", "smtpd"], config.postfix_reinject_port_incoming), (["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port), ("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming), ("filtermail", config.filtermail_smtp_port_incoming),
("filtermail", config.filtermail_http_port_incoming),
("filtermail", config.filtermail_lmtp_port_transport),
] ]
for service, port in port_services: for service, port in port_services:
print(f"Checking if port {port} is available for {service}...") print(f"Checking if port {port} is available for {service}...")
+3 -3
View File
@@ -20,10 +20,10 @@ class FiltermailDeployer(Deployer):
return return
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.0/filtermail-{arch}" url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.6/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "451f295a85b3b12dbb0f89e18ec319f742ee46dec218f20f7923bfb017a248bd", "x86_64": "05c7e7ac244606c2eeb275f2d282ffdbc2403e0169f1cdd3110ffcebdb994a92",
"aarch64": "6833061b2a2028264fdeb32f0a6123e1ff73de57dace125364016300b748452e", "aarch64": "8cf8bbda4d907beca547b365cc7e6753532a74b1712492d0d2f3d2d8a553fb3d",
}[arch] }[arch]
self.download_executable(url, self.bin_path, sha256sum) self.download_executable(url, self.bin_path, sha256sum)
@@ -6,7 +6,6 @@ ExecStart={{ bin_path }} {{ config_path }} transport
Restart=always Restart=always
RestartSec=30 RestartSec=30
User=vmail User=vmail
LimitNOFILE=524288
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+1 -17
View File
@@ -53,8 +53,7 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>. # See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
tls_preempt_cipherlist = yes tls_preempt_cipherlist = yes
# Reject by default, override per smtpd in master.cf smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
smtpd_relay_restrictions = reject
myhostname = {{ config.postfix_myhostname }} myhostname = {{ config.postfix_myhostname }}
alias_maps = hash:/etc/aliases alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases alias_database = hash:/etc/aliases
@@ -102,24 +101,9 @@ smtpd_peername_lookup = no
# so instead this is handled in filtermail. # so instead this is handled in filtermail.
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix. # We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }} default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
# All deliveries over lmtp-filtermail are treated
# as having the same destination [127.0.0.1],
# so it is not possible to limit per-destination concurrency here,
# it is a job for filtermail-transport.
# Total number of parallel deliveries is limited
# by "maxproc" column in /etc/postfix/master.cf for lmtp-filtermail.
# Settings below are to prevent Postfix queue manager
# from limiting the number of LMTP connections to filtermail-transport.
# Read <https://www.postfix.org/TUNING_README.html#rope> and
# <https://www.postfix.org/SCHEDULER_README.html> for the details
# of the Postfix algorithm that we effectively disable here.
lmtp-filtermail_initial_destination_concurrency=10000 lmtp-filtermail_initial_destination_concurrency=10000
lmtp-filtermail_destination_concurrency_limit=10000 lmtp-filtermail_destination_concurrency_limit=10000
# Do not try to deliver messages for more than 2 days.
maximal_queue_lifetime = 2d
{% if not config.ipv4_relay %} {% if not config.ipv4_relay %}
# DKIM-sign locally generated mail (bounces, DSNs). # DKIM-sign locally generated mail (bounces, DSNs).
# These bypass smtpd, so they need explicit milter configuration. # These bypass smtpd, so they need explicit milter configuration.
+1 -12
View File
@@ -17,7 +17,6 @@ smtp inet n - y - - smtpd
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.2 -o smtpd_tls_mandatory_protocols=>=TLSv1.2
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
-o smtpd_relay_restrictions=reject_unauth_destination
submission inet n - y - 5000 smtpd submission inet n - y - 5000 smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
@@ -82,14 +81,12 @@ filter unix - n n - - lmtp
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o cleanup_service_name=authclean -o cleanup_service_name=authclean
-o smtpd_relay_restrictions=permit_mynetworks,reject
{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock {% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock
{% endif %} {% endif %}
# Local SMTP server for reinjecting incoming filtered mail # Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject_incoming -o syslog_name=postfix/reinject_incoming
-o smtpd_relay_restrictions=reject_unauth_destination
# Cleanup `Received` headers for authenticated mail # Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP. # to avoid leaking client IP.
@@ -105,15 +102,7 @@ filter unix - n n - - lmtp
authclean unix n - - - 0 cleanup authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup -o header_checks=regexp:/etc/postfix/submission_header_cleanup
# Reducing `maxproc` here may result in a head of line blocking lmtp-filtermail unix - - y - 10000 lmtp
# when there are many messages sent to unreachable destinations
# at the same time.
# LMTP clients here talk to filtermail-transport.
# LMTP has no pipelining,
# so while filtermail-transport tries to deliver the message,
# possibly waiting for a long connection timeout
# or talking to a slow server, LMTP client cannot be reused.
lmtp-filtermail unix - - y - 500 lmtp
-o syslog_name=postfix/lmtp-filtermail -o syslog_name=postfix/lmtp-filtermail
-o lmtp_header_checks= -o lmtp_header_checks=
-o lmtp_tls_security_level=none -o lmtp_tls_security_level=none
+6 -4
View File
@@ -10,11 +10,13 @@ from pathlib import Path
import pytest import pytest
from chatmaild.config import is_valid_ipv4, read_config from chatmaild.config import is_valid_ipv4, read_config
from domain_validator import DomainValidator
def format_mail_domain(raw_domain: str) -> str: def format_mail_domain(raw_domain: str) -> str:
if is_valid_ipv4(raw_domain): if is_valid_ipv4(raw_domain):
return f"[{raw_domain}]" return f"[{raw_domain}]"
DomainValidator().validate_domain_re(raw_domain)
return raw_domain return raw_domain
@@ -347,9 +349,9 @@ class ChatmailACFactory:
qr = ( qr = (
f"dclogin:{addr}" f"dclogin:{addr}"
f"?p={password}&v=1" f"?p={password}&v=1"
f"&ih={domain}&ip=993&is=ssl" f"&ih={domain}&ip=993"
f"&sh={domain}&sp=465&ss=ssl" f"&sh={domain}&sp=465"
f"&ic=3" f"&ic=3&ss=default"
) )
future = account.add_transport_from_qr.future(qr) future = account.add_transport_from_qr.future(qr)
else: else:
@@ -360,7 +362,7 @@ class ChatmailACFactory:
# ensure messages stay in INBOX so that they can be # ensure messages stay in INBOX so that they can be
# concurrently fetched via extra IMAP connections during tests # concurrently fetched via extra IMAP connections during tests
account.set_config("bcc_self", "1") account.set_config("delete_server_after", "10")
accounts.append(account) accounts.append(account)
for future in futures: for future in futures:
@@ -1,7 +1,4 @@
# Managed by cmdeploy # Managed by cmdeploy: disable IPv6 in unbound.
server: server:
{% if disable_ipv6 %}
interface: 127.0.0.1 interface: 127.0.0.1
do-ip6: no do-ip6: no
{% endif %}
cache-max-negative-ttl: 0
-1
View File
@@ -60,7 +60,6 @@ and run the following commands:
:: ::
git pull origin main --rebase --autostash git pull origin main --rebase --autostash
scripts/initenv.sh
scripts/cmdeploy run scripts/cmdeploy run
If you don't want the latest development version, If you don't want the latest development version,
+15 -56
View File
@@ -156,7 +156,6 @@ Chatmail relay dependency diagram
postfix --- |10083|filtermail-transport; postfix --- |10083|filtermail-transport;
filtermail-outgoing --- |10025 reinject|postfix; filtermail-outgoing --- |10025 reinject|postfix;
filtermail-incoming --- |10026 reinject|postfix; filtermail-incoming --- |10026 reinject|postfix;
postfix --- |milter opendkim.sock|OpenDKIM
dovecot --- |doveauth.socket|doveauth; dovecot --- |doveauth.socket|doveauth;
dovecot --- |message delivery|maildir["maildir dovecot --- |message delivery|maildir["maildir
/home/vmail/.../user"]; /home/vmail/.../user"];
@@ -180,66 +179,26 @@ Chatmail relay dependency diagram
style nginx-right fill:#f66; style nginx-right fill:#f66;
style postfix fill:#f66; style postfix fill:#f66;
style dovecot fill:#f66; style dovecot fill:#f66;
style OpenDKIM fill:#f66;
style notification-proxy fill:#f66; style notification-proxy fill:#f66;
Accepting and delivering mail Message between users on the same relay
----------------------------- ---------------------------------------
.. mermaid:: .. mermaid::
:caption: This diagram shows all the paths a message can take. :caption: This diagram shows the path a non-federated message takes.
flowchart LR graph LR;
subgraph chatmail relay sender --> |465|smtps/smtpd;
subgraph postfix sender --> |587|submission/smtpd;
qmgr .-> lmtp-filtermail["lmtp/lmtp-filtermail (default_transport)"] smtps/smtpd --> |10080|filtermail;
qmgr .-> lmtp["lmtp (local_transport)"] submission/smtpd --> |10080|filtermail;
lmtp --> cleanup["cleanup (lmtp_header_cleanup)"] filtermail --> |10025|smtpd_reinject;
bounce smtpd_reinject --> cleanup;
smtpd-submission["smtpd/submission"] cleanup --> qmgr;
smtpd-smtps["smtpd/smtps"] qmgr --> smtpd_accepts_message;
smtpd-reinject-outgoing["smtpd/reinject-outgoing"] --> authclean["cleanup/authclean (submission_header_cleanup)"] qmgr --> |lmtp|dovecot;
authclean --> qmgr dovecot --> recipient;
smtpd-smtp["smtpd/smtp"] dovecot --> sender's_other_devices;
smtpd-reinject-incoming["smtpd/reinject-incoming"] --> qmgr
end
lmtp-filtermail --LMTP inet:10083--> filtermail-transport
cleanup --LMTP unix:private/dovecot-lmtp --> dovecot
dovecot --> maildir
smtpd-submission --SMTP inet:10080--> filtermail-outgoing
smtpd-smtps --SMTP inet:10080--> filtermail-outgoing
filtermail-outgoing --SMTP inet:10025--> smtpd-reinject-outgoing
open-dkim["OpenDKIM (signing only)"] <--milter unix:opendkim/opendkim.sock--> smtpd-reinject-outgoing
bounce <--milter unix:opendkim/opendkim.sock--> open-dkim
bounce --> qmgr
nginx
smtpd-smtp -.SMTP inet:10081.-> filtermail-incoming
nginx -.HTTP inet:10082.-> filtermail-incoming
filtermail-incoming --SMTP inet:10026--> smtpd-reinject-incoming
end
filtermail-transport -.SMTP inet:25.-> mta1[Remote relay]
filtermail-transport -.HTTPS /mxdeliv.-> mta1
client[Client] -.SMTP inet:587.-> smtpd-submission
client -.SMTP inet:465.-> smtpd-smtps
client -.SMTP inet:443.-> nginx
nginx -.SMTP inet:465.-> smtpd-smtps
mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp
mta2 -.HTTPS /mxdeliv.-> nginx
style postfix fill:#363
style qmgr fill:#252
style authclean fill:#252
style cleanup fill:#252
style lmtp-filtermail fill:#252
style lmtp fill:#252
style bounce fill:#252
style smtpd-submission fill:#252
style smtpd-smtps fill:#252
style smtpd-reinject-outgoing fill:#252
style smtpd-reinject-incoming fill:#252
style smtpd-smtp fill:#252
style filtermail-outgoing fill:#225
style filtermail-incoming fill:#225
style filtermail-transport fill:#225
Operational details of a chatmail relay Operational details of a chatmail relay
---------------------------------------- ----------------------------------------