Compare commits

..

2 Commits

Author SHA1 Message Date
holger krekel 3f64f8caef try stripping subject from stored messages 2026-05-13 22:47:40 +02:00
Mark Felder aeb33fa3c8 Aggressive LMTP header cleanup
This will remove all headers possible during LMTP delivery.

From: required or clients do not process the message correctly

Message-Id: required for clients to know which messages have been
downloaded

Chat-Is-Post-Message: is required for our attachment previews

Content-Type: required (??)

Subject: required due to test assertions
2026-05-13 12:05:38 -07:00
23 changed files with 154 additions and 346 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>
+35 -37
View File
@@ -16,10 +16,8 @@ def read_config(inipath):
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
self.ssh_host = params.pop("ssh_host", raw_domain)
if is_valid_ipv4(raw_domain): if is_valid_ipv4(raw_domain):
self.ipv4_relay = raw_domain self.ipv4_relay = raw_domain
@@ -31,59 +29,60 @@ class Config:
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.get("max_mailbox_size", "500M")
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.get("delete_mails_after", "20")
self.delete_large_after = params.pop("delete_large_after", "7") self.delete_large_after = params.get("delete_large_after", "7")
self.delete_inactive_users_after = int( self.delete_inactive_users_after = int(
params.pop("delete_inactive_users_after", 90) params.get("delete_inactive_users_after", 90)
) )
self.username_min_length = int(params.pop("username_min_length", 9)) self.username_min_length = int(params.get("username_min_length", 9))
self.username_max_length = int(params.pop("username_max_length", 9)) self.username_max_length = int(params.get("username_max_length", 9))
self.password_min_length = int(params.pop("password_min_length", 9)) self.password_min_length = int(params.get("password_min_length", 9))
self.www_folder = params.pop("www_folder", "") self.passthrough_senders = params.get("passthrough_senders", "").split()
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080")) self.passthrough_recipients = params.get("passthrough_recipients", "").split()
self.www_folder = params.get("www_folder", "")
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"
) )
iroh_relay = params.pop("iroh_relay", None) if "iroh_relay" not in params:
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()
@@ -104,12 +103,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):
-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:
+7 -3
View File
@@ -3,9 +3,6 @@
# mail domain (MUST be set to fully qualified chat mail domain) # mail domain (MUST be set to fully qualified chat mail domain)
mail_domain = {mail_domain} mail_domain = {mail_domain}
# Where to deploy the relay - if unspecified, mail_domain will be used.
ssh_host = localhost
# #
# If you only do private test deploys, you don't need to modify any settings below # If you only do private test deploys, you don't need to modify any settings below
# #
@@ -45,6 +42,13 @@ ssh_host = localhost
# 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).
# Both files must already exist before running cmdeploy. # Both files must already exist before running cmdeploy.
+2 -6
View File
@@ -45,12 +45,8 @@ def test_read_config_basic_using_defaults(tmp_path, maildomain):
assert example_config.username_min_length == 9 assert example_config.username_min_length == 9
assert example_config.username_max_length == 9 assert example_config.username_max_length == 9
assert example_config.password_min_length == 9 assert example_config.password_min_length == 9
assert example_config._unused_keys == [] assert example_config.passthrough_recipients == []
assert example_config.passthrough_senders == []
def test_config_unused_keys(make_config):
config = make_config("chat.example.org", {"passthrough_senders": "x@y.org"})
assert config._unused_keys == ["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)
+4 -14
View File
@@ -84,19 +84,10 @@ 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."""
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare
sshexec = get_sshexec(ssh_host) sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme" strict_tls = args.config.tls_cert_mode == "acme"
@@ -116,7 +107,7 @@ def run_cmd(args, out):
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host in ["localhost", "@local"]: if ssh_host == "localhost":
cmd = f"{pyinf} @local {deploy_path} -y" cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"): if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -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")
@@ -158,7 +148,7 @@ def dns_cmd(args, out):
ipv4 = args.config.ipv4_relay ipv4 = args.config.ipv4_relay
print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.") print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.")
return 0 return 0
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
tls_cert_mode = args.config.tls_cert_mode tls_cert_mode = args.config.tls_cert_mode
strict_tls = tls_cert_mode == "acme" strict_tls = tls_cert_mode == "acme"
@@ -195,7 +185,7 @@ def status_cmd_options(parser):
def status_cmd(args, out): def status_cmd(args, out):
"""Display status for online chatmail instance.""" """Display status for online chatmail instance."""
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}") out.green(f"chatmail domain: {args.config.mail_domain}")
+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,23 +1,5 @@
# List of headers for incoming messages
# that must be retained for functionality and compatibility reasons
/^From:/ DUNNO /^From:/ DUNNO
/^Message-Id:/ DUNNO /^Message-Id:/ DUNNO
/^Chat-/ DUNNO /^Chat-Is-Post-Message:/ DUNNO
/^Content-Type:/ DUNNO /^Content-Type:/ DUNNO
# For receiving clear-text messages (still supported in May 2026)
/^Subject:/ DUNNO
/^Date:/ DUNNO
/^To:/ DUNNO
/^CC:/ DUNNO
/^References:/ DUNNO
/^In-Reply-To:/ DUNNO
# Senders might support Autocrypt 1 but not RFC9788 (Header Protection)
/^Autocrypt:/ DUNNO
# SecureJoin V2 protocol headers (for backward compatibility)
/^Secure-Join/ DUNNO
# Ignore all other headers
/.*/ IGNORE /.*/ IGNORE
+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
@@ -232,26 +232,6 @@ def try_n_times(n, f):
return f() return f()
def test_rewrite_subject(cmsetup, maildata):
"""Test that subject gets replaced with [...]."""
user1, user2 = cmsetup.gen_users(2)
sent_msg = maildata(
"encrypted.eml",
from_addr=user1.addr,
to_addr=user2.addr,
subject="Unencrypted subject",
).as_string()
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user2.addr], msg=sent_msg)
# The message may need some time to get delivered by postfix.
messages = try_n_times(5, user2.imap.fetch_all_messages)
assert len(messages) == 1
rcvd_msg = messages[0]
assert "Subject: [...]" not in sent_msg
assert "Subject: [...]" in rcvd_msg
assert "Subject: Unencrypted subject" in sent_msg
assert "Subject: Unencrypted subject" not in rcvd_msg
def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config): def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
+6 -6
View File
@@ -62,8 +62,8 @@ def maildomain(chatmail_config):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def sshdomain(chatmail_config): def sshdomain(maildomain):
return os.environ.get("CHATMAIL_SSH", chatmail_config.ssh_host) return os.environ.get("CHATMAIL_SSH", maildomain)
@pytest.fixture @pytest.fixture
@@ -349,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:
@@ -362,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,
+60 -34
View File
@@ -14,14 +14,21 @@ Minimal requirements and prerequisites
You will need the following: You will need the following:
- Control over a domain through a DNS provider of your choice. - A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
(there is experimental support for :ref:`IP-only relays <iponly>`).
- A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
chatmail addresses. chatmail addresses.
- A Linux or Unix **build machine** with key-based SSH access to the root
user of the deployment server.
You must add a passphrase-protected private key to your local ssh-agent because you
cant type in your passphrase during deployment.
(An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
- Control over a domain through a DNS provider of your choice
(there is experimental support for :ref:`IP-only relays <iponly>`).
.. _setup: .. _setup:
@@ -31,7 +38,7 @@ Setup with ``scripts/cmdeploy``
We use ``chat.example.org`` as the chatmail domain in the following We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain. steps. Please substitute it with your own domain.
1. Setup the initial DNS records for your relay. 1. Setup the initial DNS records for your deployment server.
The following is an example in the The following is an example in the
familiar BIND zone file format with a TTL of 1 hour (3600 seconds). familiar BIND zone file format with a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses. Please substitute your domain and IP addresses.
@@ -51,25 +58,22 @@ steps. Please substitute it with your own domain.
The ``mta-sts`` CNAME and ``_mta-sts`` TXT records The ``mta-sts`` CNAME and ``_mta-sts`` TXT records
are not needed for such domains. are not needed for such domains.
2. Login to the server with SSH, clone the repository and bootstrap the Python 2. On your local PC, clone the repository and bootstrap the Python
virtualenv. virtualenv.
:: ::
ssh root@chat.example.org
git clone https://github.com/chatmail/relay git clone https://github.com/chatmail/relay
cd relay cd relay
scripts/initenv.sh scripts/initenv.sh
3. Then, create a chatmail configuration file 3. On your local build machine (PC), create a chatmail configuration file
``chatmail.ini``: ``chatmail.ini``:
:: ::
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
.. note::
To use self-signed TLS certificates To use self-signed TLS certificates
instead of Let's Encrypt, instead of Let's Encrypt,
use a domain name starting with ``_`` use a domain name starting with ``_``
@@ -80,7 +84,13 @@ steps. Please substitute it with your own domain.
See the :doc:`overview` See the :doc:`overview`
for details on certificate provisioning. for details on certificate provisioning.
4. Now run the deployment script to install the relay to the server: 4. Verify that SSH root login to the deployment server server works:
::
ssh root@chat.example.org # <-- use your domain
5. From your local build machine, setup and configure the remote deployment server:
:: ::
@@ -92,6 +102,7 @@ steps. Please substitute it with your own domain.
public). public).
Docker installation Docker installation
------------------- -------------------
@@ -99,32 +110,26 @@ There is experimental support for running chatmail via Docker.
A monolithic image based on the above cmdeploy method is available `through a separate repository <https://github.com/chatmail/docker/pkgs/container/docker>`_. A monolithic image based on the above cmdeploy method is available `through a separate repository <https://github.com/chatmail/docker/pkgs/container/docker>`_.
See the `chatmail/docker README <https://github.com/chatmail/docker>`_ for full setup instructions. See the `chatmail/docker README <https://github.com/chatmail/docker>`_ for full setup instructions.
Other helpful commands
Next Steps
----------
Now you should display and check all recommended DNS records
to enable federation with other relays:
::
scripts/cmdeploy dns
You should also test whether your chatmail service is working correctly:
::
scripts/cmdeploy test
Other Helpful Commands
---------------------- ----------------------
To check the status of your chatmail relay: To check the status of your deployment server running the chatmail service:
:: ::
scripts/cmdeploy status scripts/cmdeploy status
To display and check all recommended DNS records:
::
scripts/cmdeploy dns
To test whether your chatmail service is working correctly:
::
scripts/cmdeploy test
To measure the performance of your chatmail service: To measure the performance of your chatmail service:
@@ -166,9 +171,8 @@ This starts a local live development cycle for chatmail web pages:
directory and generating HTML files and copying assets to the directory and generating HTML files and copying assets to the
``www/build`` directory. ``www/build`` directory.
- if you are running scripts/cmdeploy webdev on the relay itself, - Starts a browser window automatically where you can “refresh” as
you need to configure a route in /etc/nginx/nginx.conf needed.
to expose the build directory.
Custom web pages Custom web pages
---------------- ----------------
@@ -186,7 +190,7 @@ Disable automatic address creation
-------------------------------------------------------- --------------------------------------------------------
If you need to stop address creation, e.g. because some script is wildly If you need to stop address creation, e.g. because some script is wildly
creating addresses, login with ssh to the relay and run: creating addresses, login with ssh to the deployment machine and run:
:: ::
@@ -242,3 +246,25 @@ The deploy will verify that both files exist on the server.
If you use such a setup, you must trigger the reload explicitly after renewal:: If you use such a setup, you must trigger the reload explicitly after renewal::
systemctl start tls-cert-reload.service systemctl start tls-cert-reload.service
Migrating to a new build machine
----------------------------------
To move or add a build machine,
clone the relay repository on the new build machine, and copy the ``chatmail.ini`` file from the old build machine.
Make sure ``rsync`` is installed, then initialize the environment:
::
./scripts/initenv.sh
Run safety checks before a new deployment:
::
./scripts/cmdeploy dns
./scripts/cmdeploy status
If you keep multiple build machines (ie laptop and desktop), keep ``chatmail.ini`` in sync between
them.
+2 -16
View File
@@ -1,6 +1,6 @@
Migrating the relay to a new server Migrating to a new machine
=================================== ===========================
This migration tutorial provides a step-wise approach This migration tutorial provides a step-wise approach
to safely migrate a chatmail relay from one remote machine to another. to safely migrate a chatmail relay from one remote machine to another.
@@ -96,17 +96,3 @@ in this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
If you have lowered the Time-to-Live for DNS records in step 1, If you have lowered the Time-to-Live for DNS records in step 1,
better use a higher value again (between 14400 and 86400 seconds) once you are sure everything works. better use a higher value again (between 14400 and 86400 seconds) once you are sure everything works.
Migrating a local chatmail/relay repository to the server
=========================================================
To move the directory with your local chatmail/relay repository and ``chatmail.ini`` file,
clone the `relay repository <https://github.com/chatmail/relay/>` to the server where the relay is running,
and copy the ``chatmail.ini`` file from your local chatmail/relay repository there.
If you made local changes to the repository,
you can store them in a file with ``git diff origin/main > local-changes.patch``,
copy the file to the repository on the server,
and run ``git apply local-changes.patch``.
Then you can proceed with the `installation steps <setup>`.
+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
---------------------------------------- ----------------------------------------