Compare commits

..

1 Commits

Author SHA1 Message Date
missytake fe882e61df chore(release): prepare for 1.11.0 2026-05-15 16:48:28 +02:00
17 changed files with 84 additions and 227 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 -2
View File
@@ -8,8 +8,7 @@ name: Trigger Docker build
on: on:
push: push:
branches: [main, j4n/dovecot-multidist] branches: [main]
tags: ['[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch: workflow_dispatch:
permissions: {} permissions: {}
+1
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]
+2
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
@@ -24,6 +25,7 @@ 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
-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:
@@ -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)
+10 -16
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(
@@ -389,12 +391,6 @@ class ChatmailDeployer(Deployer):
src=BytesIO(b'APT::Install-Recommends "false";\n'), src=BytesIO(b'APT::Install-Recommends "false";\n'),
dest="/etc/apt/apt.conf.d/00InstallRecommends", 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.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True) apt.upgrade(name="upgrade apt packages", auto_remove=True)
@@ -518,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}...")
+29 -33
View File
@@ -1,3 +1,4 @@
import io
import urllib.request import urllib.request
from chatmaild.config import Config from chatmaild.config import Config
@@ -18,18 +19,12 @@ DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}" DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
DOVECOT_SHA256 = { DOVECOT_SHA256 = {
("amd64", "bookworm", "core"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d", ("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
("arm64", "bookworm", "core"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9", ("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
("amd64", "bookworm", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", ("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
("arm64", "bookworm", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", ("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("amd64", "bookworm", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", ("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("arm64", "bookworm", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", ("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
("amd64", "trixie", "core"): "406d3781ed81e0913c472077dcf62cb1106e3855983efa6e44ddf43b4b0c9be1",
("arm64", "trixie", "core"): "c75b0d9df11a77d07ebd8522920380c167fa47330ddefebe10575d99d0ecdf7f",
("amd64", "trixie", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
("arm64", "trixie", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("amd64", "trixie", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("arm64", "trixie", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
} }
@@ -43,32 +38,34 @@ class DovecotDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
codename = (host.get_fact(Command, "grep '^VERSION_CODENAME=' /etc/os-release | cut -d= -f2") or "").strip()
if codename not in {key[1] for key in DOVECOT_SHA256}:
raise ValueError(f"Unsupported Debian codename: {codename!r}")
with blocked_service_startup(): with blocked_service_startup():
debs = [] debs = []
for pkg in ("core", "imapd", "lmtpd"): for pkg in ("core", "imapd", "lmtpd"):
deb, changed = _download_dovecot_package(pkg, arch, codename) deb, changed = _download_dovecot_package(pkg, arch)
self.need_restart |= changed self.need_restart |= changed
if deb: if deb:
debs.append(deb) debs.append(deb)
if debs: if debs:
deb_list = " ".join(debs) deb_list = " ".join(debs)
# apt-get install with local .deb paths resolves depends # First dpkg may fail on missing dependencies (stderr suppressed);
# against the configured repos (e.g. pulls libwrap0), # apt-get --fix-broken pulls them in, then dpkg retries cleanly.
# The pin file written earlier by ChatmailDeployer prevents apt
# from installing a 'wrong' version
server.shell( server.shell(
name="Install dovecot packages", name="Install dovecot packages",
commands=[ commands=[
"DEBIAN_FRONTEND=noninteractive apt-get install -y " f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true",
'-o Dpkg::Options::="--force-confdef" ' "DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install",
'-o Dpkg::Options::="--force-confold" ' f"dpkg --force-confdef --force-confold -i {deb_list}",
f"--allow-downgrades {deb_list}",
], ],
) )
self.need_restart = True 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): def configure(self):
configure_remote_units(self, self.config.mail_domain_bare, self.units) configure_remote_units(self, self.config.mail_domain_bare, self.units)
@@ -81,7 +78,7 @@ class DovecotDeployer(Deployer):
if not self.disable_mail and not self.need_restart: if not self.disable_mail and not self.need_restart:
stale = host.get_fact( stale = host.get_fact(
Command, 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)"' ' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
" && echo STALE || true", " && echo STALE || true",
) )
@@ -105,13 +102,13 @@ def _pick_url(primary, fallback):
return fallback return fallback
def _download_dovecot_package(package: str, arch: str, codename: str) -> 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).""" """Download a dovecot .deb if needed, return (path, changed)."""
arch = "amd64" if arch == "x86_64" else arch arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch arch = "arm64" if arch == "aarch64" else arch
pkg_name = f"dovecot-{package}" pkg_name = f"dovecot-{package}"
sha256 = DOVECOT_SHA256.get((arch, codename, package)) sha256 = DOVECOT_SHA256.get((package, arch))
if sha256 is None: if sha256 is None:
op = apt.packages(packages=[pkg_name]) op = apt.packages(packages=[pkg_name])
return None, bool(getattr(op, "changed", False)) return None, bool(getattr(op, "changed", False))
@@ -122,10 +119,8 @@ def _download_dovecot_package(package: str, arch: str, codename: str) -> tuple[s
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
deb_base = f"{pkg_name}_{url_version}_{arch}.deb" deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{codename}/{url_version}/{deb_base}" primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
upstream_version = DOVECOT_ARCHIVE_VERSION.rsplit("-", 1)[0].replace("+", "%2B") fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
fallback_deb = f"{pkg_name}_{url_version}_{arch}_{codename}.deb"
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{upstream_version}/{fallback_deb}"
url = _pick_url(primary_url, fallback_url) url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{deb_base}" deb_filename = f"/root/{deb_base}"
@@ -139,7 +134,6 @@ def _download_dovecot_package(package: str, arch: str, codename: str) -> tuple[s
return deb_filename, True return deb_filename, True
def _configure_dovecot(deployer, config: Config, debug: bool = False): def _configure_dovecot(deployer, config: Config, debug: bool = False):
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
deployer.put_template( deployer.put_template(
@@ -150,7 +144,9 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf") deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf")
deployer.put_file("dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua") deployer.put_file(
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
)
# as per https://doc.dovecot.org/2.3/configuration_manual/os/ # as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits # it is recommended to set the following inotify limits
+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)
+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:
@@ -3,7 +3,6 @@ from types import SimpleNamespace
import pytest import pytest
from pyinfra.facts.deb import DebPackages from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Command
from cmdeploy.dovecot import deployer as dovecot_deployer from cmdeploy.dovecot import deployer as dovecot_deployer
@@ -20,7 +19,7 @@ def make_host(*fact_pairs):
""" """
facts = dict(fact_pairs) facts = dict(fact_pairs)
def get_fact(cls, *args): def get_fact(cls):
if cls not in facts: if cls not in facts:
registered = ", ".join(c.__name__ for c in facts) registered = ", ".join(c.__name__ for c in facts)
raise LookupError( raise LookupError(
@@ -83,9 +82,7 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
lambda **kwargs: downloads.append(kwargs), lambda **kwargs: downloads.append(kwargs),
) )
deb, changed = dovecot_deployer._download_dovecot_package( deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
"core", "amd64", codename="bookworm"
)
assert deb is None, f"expected no deb path when version matches, got {deb!r}" 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 changed is False, "should not flag changed when version already installed"
@@ -112,9 +109,7 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
lambda **kwargs: downloads.append(kwargs), lambda **kwargs: downloads.append(kwargs),
) )
deb, changed = dovecot_deployer._download_dovecot_package( deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
"core", "amd64", codename="bookworm"
)
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb" expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
@@ -144,7 +139,6 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
}, },
), ),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
(Command, "bookworm"),
), ),
) )
downloads = [] downloads = []
@@ -166,13 +160,11 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
def test_install_unsupported_arch_falls_back_to_apt( def test_install_unsupported_arch_falls_back_to_apt(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
): ):
# For unsupported architectures, all fact lookups return the arch string.
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
"host", "host",
make_host( SimpleNamespace(get_fact=lambda cls: "riscv64"),
(dovecot_deployer.Arch, "riscv64"),
(Command, "bookworm"),
),
) )
apt_calls = [] apt_calls = []
@@ -206,7 +198,6 @@ def test_install_runs_dpkg_when_packages_need_download(
make_host( make_host(
(dovecot_deployer.DebPackages, {}), (dovecot_deployer.DebPackages, {}),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
(Command, "bookworm"),
), ),
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -226,12 +217,10 @@ def test_install_runs_dpkg_when_packages_need_download(
f"expected one server.shell() call for dpkg install, got {len(track_shell)}" f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
) )
cmds = track_shell[0]["commands"] cmds = track_shell[0]["commands"]
assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}" assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}"
assert "apt-get install -y" in cmds[0] assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ")
assert '-o Dpkg::Options::="--force-confdef"' in cmds[0] assert "apt-get -y --fix-broken install" in cmds[1]
assert '-o Dpkg::Options::="--force-confold"' in cmds[0] assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ")
assert "--allow-downgrades" in cmds[0]
assert ".deb" in cmds[0]
assert deployer.need_restart is True, ( assert deployer.need_restart is True, (
"need_restart should be True after dpkg install" "need_restart should be True after dpkg install"
) )
@@ -246,19 +235,3 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
assert result == "http://fallback", ( assert result == "http://fallback", (
f"should fall back when primary fails, got {result!r}" 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, "sid"),
),
)
with pytest.raises(ValueError, match="Unsupported Debian codename"):
deployer.install()
@@ -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
---------------------------------------- ----------------------------------------