Compare commits

..
Author SHA1 Message Date
holger krekel 33f925caa7 refactor: un-hardcode filesystem paths in configuration templates
Drive all file system paths from templating variables,
useful e.g. for FreeBSD which keeps debian's `/etc` hiearchy rather in `/usr/local/etc`.
Also support static nginx builds which have the stream module compiled in
and thus don't need dynamic linking to a stream module.
2026-09-26 23:27:55 +02:00
maren-bunkandmissytake 4435864779 Update faq.rst to clarify how to deploy tagged releases 2026-09-24 12:18:00 +02:00
missytake 4ffb501c5e chore(release): prepare for 1.13.0 2026-09-22 15:18:38 +02:00
Jagoda Ślązakandmissytake b5f0d5f268 chore(deps): Upgrade filtermail to v0.7.7
## 0.7.7 - 2026-09-11

### Performance

- Don't convert Bytes to Vec

## 0.7.6 - 2026-09-11

### Miscellaneous Tasks

- Always use --locked flag in CI

### Performance

- Don't keep multiple copies of the same mail data in memory

## 0.7.5 - 2026-09-10

### Performance

- Pass HttpsClient by reference instead of cloning it
- Remove connection pool

### Refactor

- Do not evaluate smtp_write! argument twice
- Do not clone the Config unnecessarily

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-09-22 14:04:26 +02:00
18 changed files with 189 additions and 231 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.7.4/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.7.7/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests
working-directory: chatmaild
run: pipx run tox
+1 -1
View File
@@ -8,7 +8,7 @@ name: Trigger Docker build
on:
push:
branches: [main, j4n/dovecot-multidist]
branches: [main]
tags: ['[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
+33
View File
@@ -1,5 +1,38 @@
# Changelog for chatmail deployment
## [1.13.0] - 2026-09-22
### Bug Fixes
- Actually use UTC time instead of just seemingly using it
### Documentation
- Describe Madmail v2 as a Rust chatmail relay
### Features
- Serve an APPVERSIONS.json index file to clients via IMAP metadata
- *(mtail)* Deploy filtermail.mtail and gate mtail rule copy on mtail_address
- *(mtail)* Validate programs during deploy
- Move doveauth from dictproxy to lua/http
- Distinguish AUTHENTICATION_FAILED/UNAVAILABLE login failures
### Miscellaneous Tasks
- Try to fix lack of RFC822 item support in madmail and make CI pass
- Un-hardcode executable paths in some systemd service files
- Follow the new mtail release source, upgrade 3.0.8 to 3.4.9
- *(cmdeploy)* Refactor all pins into pins.py
- *(doc)* Use sphinx roles for referencing repository files and dirs
- update deltachat-android link to 2.59.1
### Testing
- Integrate lua testing into regular pytest run for push notifications
- Cleanup and allow a repo-root level "pytest -n6" to succeed.
- [**breaking**] Remove global registration of pytest plugins
## [1.12.0] - 2026-07-31
### Breaking Changes
+3 -3
View File
@@ -1,12 +1,12 @@
# Releasing a new version of chatmail relay
For example, to release version 1.13.0 of chatmail relay, do the following steps.
For example, to release version 1.14.0 of chatmail relay, do the following steps.
1. Update the changelog: `git cliff --unreleased --tag 1.13.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.13.0 -p CHANGELOG.md`.
1. Update the changelog: `git cliff --unreleased --tag 1.14.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.14.0 -p CHANGELOG.md`.
2. Open the changelog in the editor, edit it if required.
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`.
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.14.0`.
4. Open a PR with the new commit, merge it to main after review.
-23
View File
@@ -99,23 +99,6 @@ def _install_remote_venv_with_chatmaild(deployer) -> None:
dest=remote_dist_file,
)
# Remove venv if its Python major.minor doesn't match the system Python
server.shell(
name="remove stale chatmaild venv if python version changed",
commands=[
"\n".join(
[
r"re='[0-9]+\.[0-9]+'", # major.minor out of 'Python X.Y.Z'
'sys_version=$(python3 --version 2>/dev/null | grep -oE "$re")',
f'venv_version=$({remote_venv_dir}/bin/python --version 2>/dev/null | grep -oE "$re")',
# an empty sys_version means we could not tell: keep the venv
f'[ -z "$sys_version" ] || [ "$sys_version" = "$venv_version" ] '
f"|| rm -rf {remote_venv_dir}",
]
)
],
)
pip.virtualenv(
name=f"chatmaild virtualenv {remote_venv_dir}",
path=remote_venv_dir,
@@ -417,12 +400,6 @@ class ChatmailDeployer(Deployer):
src=BytesIO(b'APT::Install-Recommends "false";\n'),
dest="/etc/apt/apt.conf.d/00InstallRecommends",
)
# Pin dovecot-* to priority -1 before any apt operation, apt should
# never manage dovecot as our version might be lower than the distro's.
self.put_file(
src=StringIO("Package: dovecot-*\nPin: version *\nPin-Priority: -1\n"),
dest="/etc/apt/preferences.d/pin-dovecot",
)
apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True)
+32 -49
View File
@@ -1,10 +1,11 @@
import io
import urllib.request
from chatmaild.config import Config
from pyinfra import host
from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Arch, Command, Sysctl
from pyinfra.operations import files, server
from pyinfra.operations import apt, files, server
from cmdeploy.basedeploy import (
Deployer,
@@ -15,13 +16,7 @@ from cmdeploy.basedeploy import (
)
from cmdeploy.pins import DOVECOT_SHA256, DOVECOT_VERSION
VERSION_ID_CMD = "grep '^VERSION_ID=' /etc/os-release"
def _stamped_version(deb_release: int) -> str:
"""Version as built, including the per-distro suffix stamped by
chatmail/dovecot CI into package version and filename."""
return f"{DOVECOT_VERSION}+deb{deb_release}u1"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_VERSION}"
class DovecotDeployer(Deployer):
@@ -34,30 +29,34 @@ class DovecotDeployer(Deployer):
def install(self):
arch = host.get_fact(Arch)
deb_release = _parse_version_id(host.get_fact(Command, VERSION_ID_CMD))
with blocked_service_startup():
debs = []
for pkg in ("core", "imapd", "lmtpd", "auth-lua"):
deb, changed = _download_dovecot_package(pkg, arch, deb_release)
deb, changed = _download_dovecot_package(pkg, arch)
self.need_restart |= changed
if deb:
debs.append(deb)
if debs:
deb_list = " ".join(debs)
# apt-get install with local .deb paths resolves depends
# against the configured repos (e.g. pulls libwrap0),
# The pin file written earlier by ChatmailDeployer prevents apt
# from installing a 'wrong' version
# First dpkg may fail on missing dependencies (stderr suppressed);
# apt-get --fix-broken pulls them in, then dpkg retries cleanly.
server.shell(
name="Install dovecot packages",
commands=[
"DEBIAN_FRONTEND=noninteractive apt-get install -y "
'-o Dpkg::Options::="--force-confdef" '
'-o Dpkg::Options::="--force-confold" '
f"--allow-downgrades {deb_list}",
f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true",
"DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install",
f"dpkg --force-confdef --force-confold -i {deb_list}",
],
)
self.need_restart = True
self.put_file(
src=io.StringIO(
"Package: dovecot-*\n"
"Pin: version *\n"
"Pin-Priority: -1\n"
),
dest="/etc/apt/preferences.d/pin-dovecot",
)
def configure(self):
configure_remote_units(self, self.config.mail_domain_bare, self.units)
@@ -70,7 +69,7 @@ class DovecotDeployer(Deployer):
if not self.disable_mail and not self.need_restart:
stale = host.get_fact(
Command,
"pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);"
'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);'
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
" && echo STALE || true",
)
@@ -85,15 +84,6 @@ class DovecotDeployer(Deployer):
)
def _parse_version_id(version_line: str) -> int:
"""Debian major release from an /etc/os-release VERSION_ID line."""
_, _, raw = (version_line or "").strip().partition("=")
try:
return int(raw.strip('"'))
except ValueError:
raise ValueError(f"cannot determine Debian release from {version_line!r}")
def _pick_url(primary, fallback):
try:
req = urllib.request.Request(primary, method="HEAD")
@@ -103,36 +93,27 @@ def _pick_url(primary, fallback):
return fallback
def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tuple[str | None, bool]:
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]:
"""Download a dovecot .deb if needed, return (path, changed)."""
arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch
pkg_name = f"dovecot-{package}"
try:
# never fall back to the distro package: it is pinned to -1 and would
# in any case be a version we did not build and do not support
sha256 = DOVECOT_SHA256[(arch, deb_release, package)]
except KeyError:
raise ValueError(f"no dovecot build for {pkg_name} on deb{deb_release}/{arch}")
sha256 = DOVECOT_SHA256.get((package, arch))
if sha256 is None:
op = apt.packages(packages=[pkg_name])
return None, bool(getattr(op, "changed", False))
stamped_version = _stamped_version(deb_release)
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if f"1:{stamped_version}" in installed_versions:
if DOVECOT_PACKAGE_VERSION in installed_versions:
return None, False
# Primary URL: flat structure with distro suffix in filename
primary_deb = f"{pkg_name}_{stamped_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{primary_deb}"
# GitHub release files: escaped + in filename; the release tag stays
# distro-neutral, both distros ship in one combined release
tag_version = DOVECOT_VERSION.replace("+", "%2B")
fallback_deb = f"{pkg_name}_{stamped_version.replace('+', '%2B')}_{arch}.deb"
fallback_url = (
f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{tag_version}/{fallback_deb}"
)
url_version = DOVECOT_VERSION.replace("+", "%2B")
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{primary_deb}"
deb_filename = f"/root/{deb_base}"
files.download(
name=f"Download {pkg_name}",
@@ -144,7 +125,6 @@ def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tupl
return deb_filename, True
def _configure_dovecot(deployer, config: Config, debug: bool = False):
"""Configures Dovecot IMAP server."""
deployer.put_template(
@@ -153,6 +133,9 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
config=config,
debug=debug,
disable_ipv6=config.disable_ipv6,
config_dir="/etc/dovecot",
dh_path="/usr/share/dovecot/dh.pem",
quota_expire_bin="/usr/local/lib/chatmaild/venv/bin/chatmail-quota-expire",
)
deployer.put_template("dovecot/auth.lua.j2", "/etc/dovecot/auth.lua", config=config)
deployer.remove_file("/etc/dovecot/auth.conf")
@@ -62,11 +62,11 @@ imap_capability = +XDELTAPUSH XCHATMAIL
# Authentication for system users.
passdb {
driver = lua
args = file=/etc/dovecot/auth.lua blocking=yes
args = file={{ config_dir }}/auth.lua blocking=yes
}
userdb {
driver = lua
args = file=/etc/dovecot/auth.lua blocking=yes
args = file={{ config_dir }}/auth.lua blocking=yes
}
##
## Mailbox locations and namespaces
@@ -168,7 +168,7 @@ plugin {
}
service quota-warning {
executable = script /usr/local/lib/chatmaild/venv/bin/chatmail-quota-expire
executable = script {{ quota_expire_bin }}
user = vmail
unix_listener quota-warning {
user = vmail
@@ -179,7 +179,7 @@ service quota-warning {
# push_notification configuration
plugin {
# <https://doc.dovecot.org/2.3/configuration_manual/push_notification/#lua-lua>
push_notification_driver = lua:file=/etc/dovecot/push_notification.lua
push_notification_driver = lua:file={{ config_dir }}/push_notification.lua
}
service lmtp {
@@ -254,7 +254,7 @@ service anvil {
ssl = required
ssl_cert = <{{ config.tls_cert_path }}
ssl_key = <{{ config.tls_key_path }}
ssl_dh = </usr/share/dovecot/dh.pem
ssl_dh = <{{ dh_path }}
ssl_min_protocol = TLSv1.3
ssl_prefer_server_ciphers = yes
+4
View File
@@ -55,6 +55,10 @@ def _configure_nginx(deployer, config: Config, debug: bool = False):
"/etc/nginx/nginx.conf",
config=config,
disable_ipv6=config.disable_ipv6,
config_dir="/etc/nginx",
stream_module="modules/ngx_stream_module.so",
www_root="/var/www/html",
cgi_dir="/usr/lib/cgi-bin",
)
deployer.put_template(
+7 -7
View File
@@ -1,4 +1,4 @@
load_module modules/ngx_stream_module.so;
{% if stream_module %}load_module {{ stream_module }};{% endif %}
user www-data;
worker_processes auto;
@@ -54,7 +54,7 @@ http {
# Do not emit nginx version on error pages.
server_tokens off;
include /etc/nginx/mime.types;
include {{ config_dir }}/mime.types;
default_type application/octet-stream;
ssl_protocols TLSv1.2 TLSv1.3;
@@ -68,7 +68,7 @@ http {
listen 127.0.0.1:8443 ssl default_server;
root /var/www/html;
root {{ www_root }};
index index.html index.htm;
@@ -96,8 +96,8 @@ http {
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
include {{ config_dir }}/fastcgi_params;
fastcgi_param SCRIPT_FILENAME {{ cgi_dir }}/newemail.py;
}
# Old URL for compatibility with e.g. printed QR codes.
@@ -114,8 +114,8 @@ http {
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
include {{ config_dir }}/fastcgi_params;
fastcgi_param SCRIPT_FILENAME {{ cgi_dir }}/newemail.py;
}
# Proxy to iroh-relay service.
+1 -1
View File
@@ -1 +1 @@
{{ config.opendkim_selector }}._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/{{ config.opendkim_selector }}.private
{{ config.opendkim_selector }}._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:{{ keys_dir }}/{{ config.opendkim_selector }}.private
@@ -30,6 +30,8 @@ class OpendkimDeployer(Deployer):
"opendkim/opendkim.conf",
"/etc/opendkim.conf",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
keys_dir="/etc/dkimkeys",
trust_anchor="/usr/share/dns/root.key",
)
self.remove_file("/etc/opendkim/screen.lua")
@@ -46,6 +48,7 @@ class OpendkimDeployer(Deployer):
"/etc/dkimkeys/KeyTable",
owner="opendkim",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
keys_dir="/etc/dkimkeys",
)
self.put_template(
+4 -4
View File
@@ -21,9 +21,9 @@ DNSTimeout 60
# 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 {{ keys_dir }}/{{ config.opendkim_selector }}.private
KeyTable {{ keys_dir }}/KeyTable
SigningTable refile:{{ keys_dir }}/SigningTable
# Sign Autocrypt header in addition to the default specified in RFC 6376.
#
@@ -58,7 +58,7 @@ PidFile /run/opendkim/opendkim.pid
# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
# by the package dns-root-data.
TrustAnchorFile /usr/share/dns/root.key
TrustAnchorFile {{ trust_anchor }}
# Sign messages when `-o milter_macro_daemon_name=ORIGINATING` is set.
MTA ORIGINATING
+12 -21
View File
@@ -1,14 +1,14 @@
"""Versions, hashes, and download URLs for pre-built artifacts fetched during deploy."""
FILTERMAIL_VERSION = "v0.7.4"
FILTERMAIL_VERSION = "v0.7.7"
FILTERMAIL_ARTIFACTS = {
"x86_64": (
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-x86_64",
"484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
"0691debf501f854f4e6a9dd6516f3a0ef03d95721a9b540309014bfe1a1f52b1",
),
"aarch64": (
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-aarch64",
"66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
"964f85df8b65b812666113f968cbfdd8da88ce16d218284deb359c1e2400d2da",
),
"mtail": (
f"https://raw.githubusercontent.com/chatmail/filtermail/{FILTERMAIL_VERSION}/contrib/filtermail.mtail",
@@ -28,25 +28,16 @@ MTAIL_ARTIFACTS = {
),
}
# distro-neutral base version, as committed in chatmail/dovecot debian/changelog
DOVECOT_VERSION = "2.3.21+dfsg1-3+chatmail2"
DOVECOT_VERSION = "2.3.21+dfsg1-3"
DOVECOT_SHA256 = {
("amd64", 12, "auth-lua"): "ef1b8e1db45147a74b48d63125bd61b2cc2f250e1006656ba1c58b9c12f5cde6",
("arm64", 12, "auth-lua"): "c1a06ee9374439893e397ba3b0cacf532a733290c184f4f30ea39df8699be329",
("amd64", 13, "auth-lua"): "6c0946d2516efcbcaa09a27df9b8ea701861cce270d71941056b3c69831a5ea2",
("arm64", 13, "auth-lua"): "5e6c9cfe47f7f3b8aa0d68a3e607161b6abaee1ad696cb1419e997b3099b2985",
("amd64", 12, "core"): "ac3977264d9b9a6fcec53fd3f5cdd2a79ca8aa0324de530c07e535008540826e",
("arm64", 12, "core"): "21626c9c9b52cbdcf1a17b5c09e3c4043e69aa371bf83cc2fcb3b7ddaecdc109",
("amd64", 13, "core"): "47c242ef23c17e700ac19d52d82c9fdb2ebd757d8beb3a7f6781d2de59f87bd0",
("arm64", 13, "core"): "c14c53f112c875f698c4cb6e5870c605cd0a9dd98d35a66e94ceb1827f8020a3",
("amd64", 12, "imapd"): "92a7ab5fc7dc32886a0c34404f919f1335d397b48c467e0c1ef77e56978f60ea",
("arm64", 12, "imapd"): "9369fd566fec4df109ef23debf34ea0417ae85beb29cbe7de619d4d1f31b120c",
("amd64", 13, "imapd"): "e38cc1266455f937ed62f971ea859c47e1a99247841ed0ad946963b524cfdbc5",
("arm64", 13, "imapd"): "11d97dabf23171b37f8b1335dfdb81d408f8b95391aea6d4066aecc9fde01dfe",
("amd64", 12, "lmtpd"): "dc3de473789969f7dd3504ac8783da5e42a446d2d7a305a4e9d7081a6dfe71ab",
("arm64", 12, "lmtpd"): "ae2cbd6c5c43f6d8e2172997b055448f4c79238e2f99cd9ab9200a7d9f548908",
("amd64", 13, "lmtpd"): "833b243e28c7baff141ecf37456e310f5d836e7944a3b9f2fe5074adf0d6a418",
("arm64", 13, "lmtpd"): "55af47a121ba7e23966b20ddaab2dff7feba4b34677864e045e31a702afa180d",
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
("auth-lua", "amd64"): "d724f37712faba52e177153114af1831e54da555c57c6474c05f96f176176ce4",
("auth-lua", "arm64"): "7272768e20de148c35891d99cd60205acbe7e732b056caf0a83e8194d49136e6",
}
TURN_VERSION = "v0.4"
TURN_ARTIFACTS = {
@@ -24,6 +24,8 @@ class PostfixDeployer(Deployer):
"/etc/postfix/main.cf",
config=config,
disable_ipv6=config.disable_ipv6,
config_dir="/etc/postfix",
ca_path="/etc/ssl/certs",
)
self.put_template(
@@ -31,6 +33,7 @@ class PostfixDeployer(Deployer):
"/etc/postfix/master.cf",
debug=False,
config=config,
config_dir="/etc/postfix",
)
self.put_file(
+4 -4
View File
@@ -19,13 +19,13 @@ smtpd_tls_cert_file={{ config.tls_cert_path }}
smtpd_tls_key_file={{ config.tls_key_path }}
smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs
smtp_tls_CApath={{ ca_path }}
smtp_tls_security_level=verify
# Send SNI extension when connecting to other servers.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = regexp:/etc/postfix/smtp_tls_policy_map
smtp_tls_policy_maps = regexp:{{ config_dir }}/smtp_tls_policy_map
smtp_tls_protocols = >=TLSv1.2
smtp_tls_mandatory_protocols = >=TLSv1.2
@@ -83,7 +83,7 @@ inet_protocols = ipv4
inet_protocols = all
{% endif %}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
lmtp_header_checks = regexp:{{ config_dir }}/lmtp_header_cleanup
# Do not apply header checks to MIME headers
# and other headers that are actually part of the message body.
@@ -98,7 +98,7 @@ mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticate
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
# 1:1 map MAIL FROM to SASL login name.
smtpd_sender_login_maps = regexp:/etc/postfix/login_map
smtpd_sender_login_maps = regexp:{{ config_dir }}/login_map
# Do not lookup SMTP client hostnames to reduce delays
# and avoid unnecessary DNS requests.
+1 -1
View File
@@ -102,7 +102,7 @@ postlog unix-dgram n - n - 1 postlogd
# to make sure the users
# cannot send unprotected Subject.
authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup
-o header_checks=regexp:{{ config_dir }}/submission_header_cleanup
# Reducing `maxproc` here may result in a head of line blocking
# when there are many messages sent to unreachable destinations
@@ -3,40 +3,29 @@ from types import SimpleNamespace
import pytest
from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Command
from cmdeploy.dovecot import deployer as dovecot_deployer
def _fact_name(key):
if isinstance(key, tuple):
return f"{key[0].__name__}{key[1:]!r}"
return key.__name__
def make_host(*fact_pairs):
"""Build a mock host; get_fact() dispatches to the provided facts mapping.
"""Build a mock host; get_fact(cls) dispatches to the provided facts mapping.
Args:
*fact_pairs: (fact_class, value) to match any call of that fact, or
((fact_class, *args), value) to match one specific call. Needed
for Command, which install() and check_restart() invoke with
different scripts; a bare Command entry would serve both.
*fact_pairs: tuples of (fact_class, fact_value) to register
Returns:
SimpleNamespace with get_fact that raises a clear error if an
unregistered fact is requested.
unexpected fact type is requested.
"""
facts = dict(fact_pairs)
def get_fact(cls, *args):
for key in ((cls, *args), cls):
if key in facts:
return facts[key]
registered = ", ".join(_fact_name(k) for k in facts)
raise LookupError(
f"unexpected get_fact({_fact_name((cls, *args))}); only registered: {registered}"
)
def get_fact(cls):
if cls not in facts:
registered = ", ".join(c.__name__ for c in facts)
raise LookupError(
f"unexpected get_fact({cls.__name__}); only registered: {registered}"
)
return facts[cls]
return SimpleNamespace(get_fact=get_fact)
@@ -75,9 +64,7 @@ def track_shell(monkeypatch):
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
# what dpkg reports after installing our deb: epoch + the +debNu1 suffix
# that chatmail/dovecot CI stamps via dch before building
epoch_version = f"1:{dovecot_deployer._stamped_version(12)}"
epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION
downloads = []
monkeypatch.setattr(
dovecot_deployer,
@@ -95,17 +82,15 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
lambda **kwargs: downloads.append(kwargs),
)
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
assert changed is False, "should not flag changed when version already installed"
assert downloads == [], "should not download when version already installed"
@pytest.mark.parametrize("deb_release", [12, 13])
@pytest.mark.parametrize("arch", ["amd64", "arm64"])
def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
monkeypatch, deb_release, arch
monkeypatch,
):
downloads = []
monkeypatch.setattr(
@@ -124,26 +109,18 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
lambda **kwargs: downloads.append(kwargs),
)
deb, changed = dovecot_deployer._download_dovecot_package(
"core", arch, deb_release=deb_release
)
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
stamped = dovecot_deployer._stamped_version(deb_release)
expected_deb = f"/root/dovecot-core_{stamped}_{arch}.deb"
archive_version = dovecot_deployer.DOVECOT_VERSION.replace("+", "%2B")
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
# path uses the stamped version, and deb filenames never carry the epoch
# Verify the returned path uses archive version, not package version (with epoch)
assert changed is True, "should flag changed when package not yet installed"
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
assert "1:" not in deb, f"deb filename must not contain the epoch, got {deb!r}"
assert len(downloads) == 1, "files.download should be called exactly once"
# the checksum is the security boundary: verify the right table row is used
assert (
downloads[0]["sha256sum"]
== dovecot_deployer.DOVECOT_SHA256[(arch, deb_release, "core")]
), "must pass the sha256 matching (arch, release, package)"
assert f"deb{deb_release}u1" in downloads[0]["src"], (
f"download URL should carry the deb{deb_release} suffix, got {downloads[0]['src']!r}"
assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, (
f"deb path should use archive version (no epoch), got {deb!r}"
)
assert len(downloads) == 1, "files.download should be called exactly once"
def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
@@ -156,14 +133,13 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
(
dovecot_deployer.DebPackages,
{
"dovecot-core": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-imapd": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-lmtpd": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-auth-lua": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-auth-lua": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
},
),
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
)
downloads = []
@@ -177,26 +153,44 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
assert downloads == [], "should not download when all packages epoch-matched"
assert track_shell == [], "should not run dpkg when all packages epoch-matched"
assert deployer.need_restart is False, "need_restart should be False when nothing changed"
assert deployer.need_restart is False, (
"need_restart should be False when nothing changed"
)
def test_install_unsupported_arch_raises(
def test_install_unsupported_arch_falls_back_to_apt(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
# For unsupported architectures, all fact lookups return the arch string.
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "riscv64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
SimpleNamespace(get_fact=lambda cls: "riscv64"),
)
apt_calls = []
# we never fall back to the pinned distro package
with pytest.raises(ValueError, match="no dovecot build for dovecot-core"):
deployer.install()
# Mirrors apt.packages() return value: OperationMeta with .changed property.
# Only lmtpd triggers a change to verify |= accumulation of changed flags.
def fake_apt(**kwargs):
apt_calls.append(kwargs)
changed = "lmtpd" in kwargs["packages"][0]
return SimpleNamespace(changed=changed)
assert track_shell == [], "should not run apt-get for unsupported arch"
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt)
deployer.install()
actual_pkgs = [c["packages"] for c in apt_calls]
assert actual_pkgs == [
["dovecot-core"],
["dovecot-imapd"],
["dovecot-lmtpd"],
["dovecot-auth-lua"],
], f"expected apt install of core/imapd/lmtpd/auth-lua, got {actual_pkgs}"
assert track_shell == [], "should not run dpkg for unsupported arch"
assert deployer.need_restart is True, (
"need_restart should be True when apt installed a package"
)
def test_install_runs_dpkg_when_packages_need_download(
@@ -208,7 +202,6 @@ def test_install_runs_dpkg_when_packages_need_download(
make_host(
(dovecot_deployer.DebPackages, {}),
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
)
monkeypatch.setattr(
@@ -224,15 +217,17 @@ def test_install_runs_dpkg_when_packages_need_download(
deployer.install()
assert len(track_shell) == 1, f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
assert len(track_shell) == 1, (
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
)
cmds = track_shell[0]["commands"]
assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
assert "apt-get install -y" in cmds[0]
assert '-o Dpkg::Options::="--force-confdef"' in cmds[0]
assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
assert "--allow-downgrades" in cmds[0]
assert ".deb" in cmds[0]
assert deployer.need_restart is True, "need_restart should be True after dpkg install"
assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}"
assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ")
assert "apt-get -y --fix-broken install" in cmds[1]
assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ")
assert deployer.need_restart is True, (
"need_restart should be True after dpkg install"
)
def test_pick_url_falls_back_on_primary_error(monkeypatch):
@@ -241,45 +236,6 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
result = dovecot_deployer._pick_url("http://primary", "http://fallback")
assert result == "http://fallback", f"should fall back when primary fails, got {result!r}"
def test_install_fails_on_unsupported_debian_version(deployer, patch_blocked, monkeypatch):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="99"'),
),
assert result == "http://fallback", (
f"should fall back when primary fails, got {result!r}"
)
with pytest.raises(ValueError, match="no dovecot build for dovecot-core on deb99"):
deployer.install()
@pytest.mark.parametrize(
"version_line", ["", None, "ID=debian"], ids=["empty", "none", "no-version-id"]
)
def test_parse_version_id_raises_without_version_id(version_line):
with pytest.raises(ValueError, match="cannot determine Debian release"):
dovecot_deployer._parse_version_id(version_line)
@pytest.mark.parametrize("deb_release", [12, 13])
def test_parse_version_id(deb_release):
parsed = dovecot_deployer._parse_version_id(f'VERSION_ID="{deb_release}"\n')
assert parsed == deb_release
def test_dovecot_sha256_covers_all_packages_per_release():
"""Every release in the table needs all four packages on both arches."""
table = dovecot_deployer.DOVECOT_SHA256
expected = {
(arch, pkg)
for arch in ("amd64", "arm64")
for pkg in ("core", "imapd", "lmtpd", "auth-lua")
}
for release in {r for _, r, _ in table}:
got = {(arch, pkg) for arch, r, pkg in table if r == release}
assert got == expected, f"deb{release} incomplete: {sorted(expected - got)}"
+11 -3
View File
@@ -63,9 +63,17 @@ and run the following commands:
scripts/initenv.sh
scripts/cmdeploy run
If you don't want the latest development version,
but a specific tagged release like `1.10.0 <https://github.com/chatmail/relay/releases/tag/1.10.0>`_,
run ``git pull origin 1.10.0`` instead.
To upgrade to the latest tag,
``cd`` into your local checkout of https://github.com/chatmail/relay/
and run the following commands:
::
git fetch --tags
latestTag=$(git describe --tags "$(git rev-list --tags --max-count=1)")
git checkout $latestTag
scripts/initenv.sh
scripts/cmdeploy run
If you made local changes for your setup,
they will be reapplied as long as they don't conflict with the upgrade.