From d9e61cb8fd0aa0466ffd03f23957e346be5ae3cf Mon Sep 17 00:00:00 2001 From: holger krekel Date: Fri, 8 May 2026 00:08:57 +0200 Subject: [PATCH] retain "config.mail_domain" as the domain part of @ email addresses, so for ipv4 relays "[1.2.3.4]" and introduce config.ipv4_relay and config.mail_domain_bare helpers. --- chatmaild/src/chatmaild/config.py | 39 ++++++++++++------- chatmaild/src/chatmaild/doveauth.py | 4 +- chatmaild/src/chatmaild/newemail.py | 10 ++--- chatmaild/src/chatmaild/tests/test_config.py | 14 +++---- .../src/chatmaild/tests/test_doveauth.py | 20 +++------- .../tests/test_filtermail_blackbox.py | 2 +- chatmaild/src/chatmaild/tests/test_newmail.py | 2 +- cmdeploy/src/cmdeploy/cmdeploy.py | 15 +++---- cmdeploy/src/cmdeploy/deployers.py | 15 ++++--- cmdeploy/src/cmdeploy/dovecot/deployer.py | 2 +- cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 | 2 +- cmdeploy/src/cmdeploy/opendkim/deployer.py | 6 +-- cmdeploy/src/cmdeploy/postfix/main.cf.j2 | 6 +-- cmdeploy/src/cmdeploy/postfix/master.cf.j2 | 2 +- .../src/cmdeploy/tests/online/test_0_login.py | 6 +-- cmdeploy/src/cmdeploy/tests/plugin.py | 12 +++--- 16 files changed, 78 insertions(+), 79 deletions(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index fb040683..2767be75 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -21,9 +21,19 @@ def read_config(inipath): class Config: def __init__(self, inipath, params): self._inipath = inipath - self.mail_domain = params["mail_domain"] - self.mail_domain_hostname = format_arpa_address(params["mail_domain"]) - self.mail_domain_deliverable = format_deliverable_domain(params["mail_domain"]) + raw_domain = params["mail_domain"] + self.mail_domain_bare = raw_domain + + if is_valid_ipv4(raw_domain): + self.ipv4_relay = raw_domain + self.mail_domain = f"[{raw_domain}]" + self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer + else: + DomainValidator().validate_domain_re(raw_domain) + self.ipv4_relay = None + self.mail_domain = raw_domain + self.postfix_myhostname = raw_domain + self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60)) self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10)) self.max_mailbox_size = params["max_mailbox_size"] @@ -57,7 +67,7 @@ class Config: self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true" if "iroh_relay" not in params: - self.iroh_relay = "https://" + params["mail_domain"] + self.iroh_relay = "https://" + raw_domain self.enable_iroh_relay = True else: self.iroh_relay = params["iroh_relay"].strip() @@ -83,19 +93,17 @@ class Config: ) self.tls_cert_mode = "external" self.tls_cert_path, self.tls_key_path = parts - elif self.mail_domain.startswith("_") or is_valid_ipv4(params["mail_domain"]): + elif raw_domain.startswith("_") or self.ipv4_relay: self.tls_cert_mode = "self" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_key_path = "/etc/ssl/private/mailserver.key" else: self.tls_cert_mode = "acme" - self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain" - self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey" + self.tls_cert_path = f"/var/lib/acme/live/{raw_domain}/fullchain" + self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey" # deprecated option - mbdir = params.get( - "mailboxes_dir", f"/home/vmail/mail/{self.mail_domain_deliverable}" - ) + mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{raw_domain}") self.mailboxes_dir = Path(mbdir.strip()) # old unused option (except for first migration from sqlite to maildir store) @@ -192,6 +200,7 @@ def is_valid_ipv4(address: str) -> bool: return False + def format_arpa_address(address: str) -> str: if is_valid_ipv4(address): return ipaddress.IPv4Address(address).reverse_pointer @@ -199,8 +208,8 @@ def format_arpa_address(address: str) -> str: return address -def format_deliverable_domain(mail_domain: str) -> str: - if is_valid_ipv4(mail_domain): - return f"[{mail_domain}]" - DomainValidator().validate_domain_re(mail_domain) - return mail_domain +def format_mail_domain(raw_domain: str) -> str: + if is_valid_ipv4(raw_domain): + return f"[{raw_domain}]" + DomainValidator().validate_domain_re(raw_domain) + return raw_domain diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index b0410400..314e7348 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -108,7 +108,7 @@ class AuthDictProxy(DictProxy): if namespace == "shared": if type == "userdb": user = args[0] - if user.endswith(f"@{config.mail_domain_deliverable}"): + if user.endswith(f"@{config.mail_domain}"): res = self.lookup_userdb(user) if res: reply_command = "O" @@ -116,7 +116,7 @@ class AuthDictProxy(DictProxy): reply_command = "N" elif type == "passdb": user = args[1] - if user.endswith(f"@{config.mail_domain_deliverable}"): + if user.endswith(f"@{config.mail_domain}"): res = self.lookup_passdb(user, cleartext_password=args[0]) if res: reply_command = "O" diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index 7d2f42c1..80888220 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -22,9 +22,7 @@ def create_newemail_dict(config: Config): secrets.choice(ALPHANUMERIC_PUNCT) for _ in range(config.password_min_length + 3) ) - return dict( - email=f"{user}@{config.mail_domain_deliverable}", password=f"{password}" - ) + return dict(email=f"{user}@{config.mail_domain}", password=f"{password}") def create_dclogin_url(config, email, password): @@ -33,9 +31,9 @@ def create_dclogin_url(config, email, password): Uses ic=3 (AcceptInvalidCertificates) so chatmail clients can connect to servers with self-signed TLS certificates. """ - if config.mail_domain != config.mail_domain_deliverable: - imap_host = "&ih=" + config.mail_domain - smtp_host = "&sh=" + config.mail_domain + if config.ipv4_relay: + imap_host = "&ih=" + config.ipv4_relay + smtp_host = "&sh=" + config.ipv4_relay else: imap_host = "" smtp_host = "" diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index ca609000..cd3f1efb 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -4,7 +4,7 @@ import pytest from chatmaild.config import ( format_arpa_address, - format_deliverable_domain, + format_mail_domain, is_valid_ipv4, parse_size_mb, read_config, @@ -21,12 +21,12 @@ def test_read_config_basic(example_config): example_config = read_config(inipath) assert example_config.max_user_send_per_minute == 37 assert example_config.mail_domain == "chat.example.org" - assert example_config.mail_domain_deliverable == "chat.example.org" + assert example_config.ipv4_relay is None -def test_read_config_deliverable(ipv4_config): - assert ipv4_config.mail_domain == "1.3.3.7" - assert ipv4_config.mail_domain_deliverable == "[1.3.3.7]" +def test_read_config_ipv4(ipv4_config): + assert ipv4_config.ipv4_relay == "1.3.3.7" + assert ipv4_config.mail_domain == "[1.3.3.7]" def test_read_config_basic_using_defaults(tmp_path, maildomain): @@ -188,6 +188,6 @@ def test_format_arpa_address(input, result, exception): ("12394142", None, pytest.raises(ValueError)), ], ) -def test_format_deliverable_domain(input, result, exception): +def test_format_mail_domain(input, result, exception): with exception: - assert result == format_deliverable_domain(input) + assert result == format_mail_domain(input) diff --git a/chatmaild/src/chatmaild/tests/test_doveauth.py b/chatmaild/src/chatmaild/tests/test_doveauth.py index eb86b307..3b18d97d 100644 --- a/chatmaild/src/chatmaild/tests/test_doveauth.py +++ b/chatmaild/src/chatmaild/tests/test_doveauth.py @@ -44,20 +44,12 @@ def test_invalid_username_length(example_config): config.username_min_length = 6 config.username_max_length = 10 password = create_newemail_dict(config)["password"] + assert not is_allowed_to_create(config, f"a1234@{config.mail_domain}", password) + assert is_allowed_to_create(config, f"012345@{config.mail_domain}", password) + assert is_allowed_to_create(config, f"0123456@{config.mail_domain}", password) + assert is_allowed_to_create(config, f"0123456789@{config.mail_domain}", password) assert not is_allowed_to_create( - config, f"a1234@{config.mail_domain_deliverable}", password - ) - assert is_allowed_to_create( - config, f"012345@{config.mail_domain_deliverable}", password - ) - assert is_allowed_to_create( - config, f"0123456@{config.mail_domain_deliverable}", password - ) - assert is_allowed_to_create( - config, f"0123456789@{config.mail_domain_deliverable}", password - ) - assert not is_allowed_to_create( - config, f"0123456789x@{config.mail_domain_deliverable}", password + config, f"0123456789x@{config.mail_domain}", password ) @@ -132,7 +124,7 @@ def test_invalid_localpart_characters(make_config): """Test that is_allowed_to_create rejects localparts with invalid characters.""" config = make_config("chat.example.org", {"username_min_length": "3"}) password = "zequ0Aimuchoodaechik" - domain = config.mail_domain_deliverable + domain = config.mail_domain # valid localparts assert is_allowed_to_create(config, f"abc123@{domain}", password) diff --git a/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py b/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py index 6d9d56cd..c8766656 100644 --- a/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py +++ b/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py @@ -74,7 +74,7 @@ def test_one_mail( print(line.decode("ascii"), file=sys.stderr) pytest.fail("starting filtermail failed") - addr = f"user1@{config.mail_domain_deliverable}" + addr = f"user1@{config.mail_domain}" config.get_user(addr).set_password("l1k2j3l1k2j3l") # send encrypted mail diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index 6e509712..f7046ca3 100644 --- a/chatmaild/src/chatmaild/tests/test_newmail.py +++ b/chatmaild/src/chatmaild/tests/test_newmail.py @@ -56,7 +56,7 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf assert lines[0] == "Content-Type: application/json" assert not lines[1] dic = json.loads(lines[2]) - assert dic["email"].endswith(f"@{example_config.mail_domain_deliverable}") + assert dic["email"].endswith(f"@{example_config.mail_domain}") assert len(dic["password"]) >= 10 # default tls_cert=acme should not include dclogin_url assert "dclogin_url" not in dic diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 711e4024..c4ea4815 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -13,7 +13,7 @@ import sys from pathlib import Path import pyinfra -from chatmaild.config import read_config, write_initial_config, is_valid_ipv4 +from chatmaild.config import read_config, write_initial_config from packaging import version from termcolor import colored @@ -87,11 +87,11 @@ def run_cmd_options(parser): def run_cmd(args, out): """Deploy chatmail services on the remote server.""" - ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain + ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare sshexec = get_sshexec(ssh_host) require_iroh = args.config.enable_iroh_relay strict_tls = args.config.tls_cert_mode == "acme" - if is_valid_ipv4(args.config.mail_domain): + if args.config.ipv4_relay: args.dns_check_disabled = True if not args.dns_check_disabled: remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) @@ -121,7 +121,7 @@ def run_cmd(args, out): elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: out.red("Deploy completed but letsencrypt not configured") out.red("Run 'cmdeploy run' again") - elif is_valid_ipv4(args.config.mail_domain): + elif args.config.ipv4_relay: out.green("Deploy completed.") else: out.green("Deploy completed, call `cmdeploy dns` next.") @@ -144,8 +144,9 @@ def dns_cmd_options(parser): def dns_cmd(args, out): """Check DNS entries and optionally generate dns zone file.""" - if is_valid_ipv4(args.config.mail_domain): - print(f"[WARNING] {args.config.mail_domain} is not a domain, skipping DNS checks.") + if args.config.ipv4_relay: + ipv4 = args.config.ipv4_relay + print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.") return 0 ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain sshexec = get_sshexec(ssh_host, verbose=args.verbose) @@ -184,7 +185,7 @@ def status_cmd_options(parser): def status_cmd(args, out): """Display status for online chatmail instance.""" - ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain + ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare sshexec = get_sshexec(ssh_host, verbose=args.verbose) out.green(f"chatmail domain: {args.config.mail_domain}") diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index e9dd6aee..c9b06a46 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -468,7 +468,7 @@ class ChatmailVenvDeployer(Deployer): def configure(self): _configure_remote_venv_with_chatmaild(self.config) - configure_remote_units(self.config.mail_domain, self.units) + configure_remote_units(self.config.mail_domain_bare, self.units) def activate(self): activate_remote_units(self.units) @@ -482,7 +482,7 @@ class ChatmailDeployer(Deployer): def __init__(self, config): self.config = config - self.mail_domain_deliverable = config.mail_domain_deliverable + self.mail_domain = config.mail_domain def install(self): files.put( @@ -522,7 +522,7 @@ class ChatmailDeployer(Deployer): server.shell( name="Setup /etc/mailname", commands=[ - f"echo {self.mail_domain_deliverable} >/etc/mailname; chmod 644 /etc/mailname" + f"echo {self.mail_domain} >/etc/mailname; chmod 644 /etc/mailname" ], ) @@ -584,8 +584,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - """ config = read_config(config_path) check_config(config) - mail_domain = config.mail_domain - mail_domain_deliverable = config.mail_domain_deliverable + bare_host = config.mail_domain_bare if website_only: Deployment().perform_stages([WebsiteDeployer(config)]) @@ -636,7 +635,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - ) exit(1) - tls_deployer = get_tls_deployer(config, mail_domain) + tls_deployer = get_tls_deployer(config, bare_host) all_deployers = [ ChatmailDeployer(config), @@ -644,13 +643,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - FiltermailDeployer(), JournaldDeployer(), UnboundDeployer(config), - TurnDeployer(mail_domain), + TurnDeployer(bare_host), IrohDeployer(config.enable_iroh_relay), tls_deployer, WebsiteDeployer(config), ChatmailVenvDeployer(config), MtastsDeployer(), - OpendkimDeployer(mail_domain_deliverable), + OpendkimDeployer(config.mail_domain), # Dovecot should be started before Postfix # because it creates authentication socket # required by Postfix. diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index 37215d35..a1c057ef 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -73,7 +73,7 @@ class DovecotDeployer(Deployer): ) def configure(self): - configure_remote_units(self.config.mail_domain, self.units) + configure_remote_units(self.config.mail_domain_bare, self.units) config_restart, self.daemon_reload = _configure_dovecot(self.config) self.need_restart |= config_restart diff --git a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 index ddfa65a2..a7ce7b62 100644 --- a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 +++ b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 @@ -40,7 +40,7 @@ service imap { process_limit = 50000 } -mail_server_admin = mailto:root@{{ config.mail_domain_deliverable }} +mail_server_admin = mailto:root@{{ config.mail_domain }} mail_server_comment = Chatmail server # `zlib` enables compressing messages stored in the maildir. diff --git a/cmdeploy/src/cmdeploy/opendkim/deployer.py b/cmdeploy/src/cmdeploy/opendkim/deployer.py index 98ef4a74..44d4a3ed 100644 --- a/cmdeploy/src/cmdeploy/opendkim/deployer.py +++ b/cmdeploy/src/cmdeploy/opendkim/deployer.py @@ -12,8 +12,8 @@ from cmdeploy.basedeploy import Deployer, get_resource class OpendkimDeployer(Deployer): required_users = [("opendkim", None, ["opendkim"])] - def __init__(self, mail_domain_deliverable): - self.mail_domain_deliverable = mail_domain_deliverable + def __init__(self, mail_domain): + self.mail_domain = mail_domain def install(self): apt.packages( @@ -22,7 +22,7 @@ class OpendkimDeployer(Deployer): ) def configure(self): - domain = self.mail_domain_deliverable + domain = self.mail_domain dkim_selector = "opendkim" """Configures OpenDKIM""" need_restart = False diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index 77cbf131..bb354c14 100644 --- a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 @@ -1,4 +1,4 @@ -myorigin = {{ config.mail_domain_deliverable }} +myorigin = {{ config.mail_domain }} smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU) biff = no @@ -54,13 +54,13 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES tls_preempt_cipherlist = yes smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination -myhostname = {{ config.mail_domain_hostname }} +myhostname = {{ config.postfix_myhostname }} alias_maps = hash:/etc/aliases alias_database = hash:/etc/aliases # When postfix receives mail for $mydestination, # it hands it over to dovecot via $local_transport. -mydestination = {{ config.mail_domain_deliverable }} +mydestination = {{ config.mail_domain }} local_transport = lmtp:unix:private/dovecot-lmtp # postfix doesn't check whether local users exist or not: local_recipient_maps = diff --git a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 index 51e42b56..bf108fec 100644 --- a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 @@ -81,7 +81,7 @@ filter unix - n n - - lmtp -o syslog_name=postfix/reinject -o milter_macro_daemon_name=ORIGINATING -o cleanup_service_name=authclean -{% if config.mail_domain == config.mail_domain_deliverable %} -o smtpd_milters=unix:opendkim/opendkim.sock +{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock {% endif %} # Local SMTP server for reinjecting incoming filtered mail diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_login.py b/cmdeploy/src/cmdeploy/tests/online/test_0_login.py index 516aab08..d746aba2 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_login.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_login.py @@ -89,14 +89,14 @@ def test_concurrent_logins_same_account( assert login_results.get() -def test_no_vrfy(cmfactory, chatmail_config): +def test_no_vrfy(cmfactory, chatmail_config, maildomain): ac = cmfactory.get_online_account() addr = ac.get_config("addr") - s = smtplib.SMTP(chatmail_config.mail_domain) + s = smtplib.SMTP(maildomain) s.starttls() - s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain_deliverable}") + s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") result = s.getreply() print(result) s.putcmd("vrfy", addr) diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index 7deef20a..841ed65c 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -10,7 +10,7 @@ import time from pathlib import Path import pytest -from chatmaild.config import read_config, format_deliverable_domain, is_valid_ipv4 +from chatmaild.config import read_config, format_mail_domain, is_valid_ipv4 conftestdir = Path(__file__).parent @@ -59,12 +59,12 @@ def chatmail_config(pytestconfig): @pytest.fixture(scope="session") def maildomain(chatmail_config): - return chatmail_config.mail_domain + return chatmail_config.mail_domain_bare @pytest.fixture(scope="session") def maildomain_deliverable(maildomain): - return format_deliverable_domain(maildomain) + return format_mail_domain(maildomain) @pytest.fixture(scope="session") @@ -283,7 +283,7 @@ def gencreds(chatmail_config): next(count) def gen(domain=None): - domain = domain if domain else chatmail_config.mail_domain_deliverable + domain = domain if domain else chatmail_config.mail_domain while 1: num = next(count) alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" @@ -322,7 +322,7 @@ class ChatmailACFactory: def _make_transport(self, domain): """Build a transport config dict for the given domain.""" - domain_deliverable = format_deliverable_domain(domain) + domain_deliverable = format_mail_domain(domain) addr, password = self.gencreds(domain_deliverable) transport = { "addr": addr, @@ -347,7 +347,7 @@ class ChatmailACFactory: accounts = [] for _ in range(num): account = self.dc.add_account() - domain_deliverable = format_deliverable_domain(domain) + domain_deliverable = format_mail_domain(domain) addr, password = self.gencreds(domain_deliverable) if _is_ip(domain): # Use DCLOGIN scheme with explicit server hosts,