From cc2a962e94fbc801cbf0d1873955890488368f0a Mon Sep 17 00:00:00 2001 From: missytake Date: Thu, 12 Mar 2026 17:28:11 +0100 Subject: [PATCH 01/16] dovecot: enable login names with square brackets --- cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 | 1 + 1 file changed, 1 insertion(+) diff --git a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 index 036bd79f0..a7ce7b62c 100644 --- a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 +++ b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 @@ -7,6 +7,7 @@ listen = 0.0.0.0 protocols = imap lmtp auth_mechanisms = plain +auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[] {% if debug == true %} auth_verbose = yes From 21a7b5654f6c2485547555e28848c791248eec43 Mon Sep 17 00:00:00 2001 From: missytake Date: Tue, 14 Apr 2026 11:11:19 +0200 Subject: [PATCH 02/16] config: make IPv4-only relays use self-signed TLS certs --- chatmaild/src/chatmaild/config.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 6f9acd04d..aad44694e 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -1,3 +1,4 @@ +import ipaddress from pathlib import Path import iniconfig @@ -79,7 +80,7 @@ def __init__(self, inipath, params): ) self.tls_cert_mode = "external" self.tls_cert_path, self.tls_key_path = parts - elif self.mail_domain.startswith("_"): + elif self.mail_domain.startswith("_") or is_valid_ipv4(params["mail_domain"]): self.tls_cert_mode = "self" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_key_path = "/etc/ssl/private/mailserver.key" @@ -175,3 +176,12 @@ def get_default_config_content(mail_domain, **overrides): lines.append(line) content = "\n".join(lines) return content + + +def is_valid_ipv4(address: str) -> bool: + """Check if a mail_domain is an IPv4 address.""" + try: + ipaddress.IPv4Address(address) + return True + except ValueError: + return False From ebbbbfc044d6c46abf92d9d50907708aa03cbaa8 Mon Sep 17 00:00:00 2001 From: missytake Date: Thu, 12 Mar 2026 17:40:30 +0100 Subject: [PATCH 03/16] postfix: make delivery for IP-only relays work --- chatmaild/src/chatmaild/config.py | 11 +++++++++++ cmdeploy/src/cmdeploy/postfix/main.cf.j2 | 16 ++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index aad44694e..c467e3f2c 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -21,6 +21,11 @@ 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"]) + if is_valid_ipv4(params["mail_domain"]): + self.mail_domain_deliverable = f"[{params['mail_domain']}]" + else: + self.mail_domain_deliverable = params["mail_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"] @@ -185,3 +190,9 @@ def is_valid_ipv4(address: str) -> bool: return True except ValueError: return False + + +def format_arpa_address(address: str) -> str: + if is_valid_ipv4(address): + return ipaddress.IPv4Address(address).reverse_pointer + return address diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index 856996bb3..77cbf1315 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 }} +myorigin = {{ config.mail_domain_deliverable }} smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU) biff = no @@ -54,14 +54,16 @@ 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 }} +myhostname = {{ config.mail_domain_hostname }} alias_maps = hash:/etc/aliases alias_database = hash:/etc/aliases -# Postfix does not deliver mail for any domain by itself. -# Primary domain is listed in `virtual_mailbox_domains` instead -# and handed over to Dovecot. -mydestination = +# When postfix receives mail for $mydestination, +# it hands it over to dovecot via $local_transport. +mydestination = {{ config.mail_domain_deliverable }} +local_transport = lmtp:unix:private/dovecot-lmtp +# postfix doesn't check whether local users exist or not: +local_recipient_maps = relayhost = {% if disable_ipv6 %} @@ -79,8 +81,6 @@ inet_protocols = ipv4 inet_protocols = all {% endif %} -virtual_transport = lmtp:unix:private/dovecot-lmtp -virtual_mailbox_domains = {{ config.mail_domain }} lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup mua_client_restrictions = permit_sasl_authenticated, reject From c56c519f06ccf049c2667765af69174e7d3c4335 Mon Sep 17 00:00:00 2001 From: missytake Date: Tue, 14 Apr 2026 11:26:15 +0200 Subject: [PATCH 04/16] cmdeploy: skip DNS checks for IPv4 only relays --- cmdeploy/src/cmdeploy/cmdeploy.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 9fbc221de..3b1195b89 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -13,7 +13,7 @@ from pathlib import Path import pyinfra -from chatmaild.config import read_config, write_initial_config +from chatmaild.config import read_config, write_initial_config, is_valid_ipv4 from packaging import version from termcolor import colored @@ -91,6 +91,8 @@ def run_cmd(args, out): 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): + args.dns_check_disabled = True if not args.dns_check_disabled: remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): @@ -119,6 +121,8 @@ 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): + out.green("Deploy completed.") else: out.green("Deploy completed, call `cmdeploy dns` next.") return 0 From ff67b69e15e31f2a0de8f32655432f1fbefba2da Mon Sep 17 00:00:00 2001 From: missytake Date: Thu, 12 Mar 2026 20:28:01 +0100 Subject: [PATCH 05/16] www: generate dclogin codes for IPv4-only relays --- chatmaild/src/chatmaild/newemail.py | 29 +++++++++---------- chatmaild/src/chatmaild/tests/plugin.py | 5 ++++ chatmaild/src/chatmaild/tests/test_newmail.py | 25 +++++++++++----- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index 4aa54aee7..7d2f42c1b 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -2,7 +2,6 @@ """CGI script for creating new accounts.""" -import ipaddress import json import secrets import string @@ -15,16 +14,6 @@ ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation -def wrap_ip(host): - if host.startswith("[") and host.endswith("]"): - return host - try: - ipaddress.ip_address(host) - return f"[{host}]" - except ValueError: - return host - - def create_newemail_dict(config: Config): user = "".join( secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length) @@ -33,16 +22,24 @@ def create_newemail_dict(config: Config): secrets.choice(ALPHANUMERIC_PUNCT) for _ in range(config.password_min_length + 3) ) - return dict(email=f"{user}@{wrap_ip(config.mail_domain)}", password=f"{password}") + return dict( + email=f"{user}@{config.mail_domain_deliverable}", password=f"{password}" + ) -def create_dclogin_url(email, password): +def create_dclogin_url(config, email, password): """Build a dclogin: URL with credentials and self-signed cert acceptance. Uses ic=3 (AcceptInvalidCertificates) so chatmail clients can connect to servers with self-signed TLS certificates. """ - return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3" + if config.mail_domain != config.mail_domain_deliverable: + imap_host = "&ih=" + config.mail_domain + smtp_host = "&sh=" + config.mail_domain + else: + imap_host = "" + smtp_host = "" + return f"dclogin:{quote(email, safe='@[]')}?p={quote(password, safe='')}&v=1{imap_host}{smtp_host}&ic=3" def print_new_account(): @@ -51,7 +48,9 @@ def print_new_account(): result = dict(email=creds["email"], password=creds["password"]) if config.tls_cert_mode == "self": - result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"]) + result["dclogin_url"] = create_dclogin_url( + config, creds["email"], creds["password"] + ) print("Content-Type: application/json") print("") diff --git a/chatmaild/src/chatmaild/tests/plugin.py b/chatmaild/src/chatmaild/tests/plugin.py index b57418a3a..27de56af7 100644 --- a/chatmaild/src/chatmaild/tests/plugin.py +++ b/chatmaild/src/chatmaild/tests/plugin.py @@ -31,6 +31,11 @@ def example_config(make_config): return make_config("chat.example.org") +@pytest.fixture +def ipv4_config(make_config): + return make_config("1.3.3.7") + + @pytest.fixture def maildomain(example_config): return example_config.mail_domain diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index b266d11e9..f7046ca3e 100644 --- a/chatmaild/src/chatmaild/tests/test_newmail.py +++ b/chatmaild/src/chatmaild/tests/test_newmail.py @@ -19,24 +19,35 @@ def test_create_newemail_dict(example_config): assert ac1["password"] != ac2["password"] -def test_create_newemail_dict_ip(make_config): - config = make_config("1.2.3.4") - ac = create_newemail_dict(config) - assert ac["email"].endswith("@[1.2.3.4]") +def test_create_newemail_dict_ip(ipv4_config): + ac = create_newemail_dict(ipv4_config) + assert ac["email"].endswith("@[1.3.3.7]") -def test_create_dclogin_url(): - url = create_dclogin_url("user@example.org", "p@ss w+rd") +def test_create_dclogin_url(example_config): + addr = "user@example.org" + password = "p@ss w+rd" + url = create_dclogin_url(example_config, addr, password) assert url.startswith("dclogin:") assert "v=1" in url assert "ic=3" in url - assert "user@example.org" in url + assert addr in url # password special chars must be encoded assert "p%40ss" in url assert "w%2Brd" in url +def test_create_dclogin_url_ipv4(ipv4_config): + addr = "user@[1.3.3.7]" + password = "p@ss w+rd" + url = create_dclogin_url(ipv4_config, addr, password) + assert url.startswith("dclogin:") + assert "v=1" in url + assert "ic=3" in url + assert addr in url + + def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config): monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) print_new_account() From b5633f288dfb5e40970ece4e58eac7c306e8d262 Mon Sep 17 00:00:00 2001 From: missytake Date: Thu, 12 Mar 2026 21:06:18 +0100 Subject: [PATCH 06/16] opendkim: disable DKIM signing on ipv4-only relays --- cmdeploy/src/cmdeploy/postfix/master.cf.j2 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 index 0d6e3f797..c21e2469e 100644 --- a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 @@ -80,7 +80,9 @@ filter unix - n n - - lmtp 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd -o syslog_name=postfix/reinject -o milter_macro_daemon_name=ORIGINATING +{% if config.mail_domain == config.mail_domain_deliverable %} -o smtpd_milters=unix:opendkim/opendkim.sock +{% endif %} -o cleanup_service_name=authclean # Local SMTP server for reinjecting incoming filtered mail From 2c5c8d77424c418e7acbc50b3e473dba357b30db Mon Sep 17 00:00:00 2001 From: missytake Date: Wed, 15 Apr 2026 12:39:28 +0200 Subject: [PATCH 07/16] get delivery working --- chatmaild/src/chatmaild/config.py | 4 +++- chatmaild/src/chatmaild/doveauth.py | 4 ++-- chatmaild/src/chatmaild/tests/test_config.py | 1 + .../src/chatmaild/tests/test_doveauth.py | 20 +++++++++++++------ chatmaild/src/chatmaild/tests/test_newmail.py | 2 +- cmdeploy/src/cmdeploy/deployers.py | 7 ++++--- cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 | 2 +- cmdeploy/src/cmdeploy/opendkim/deployer.py | 6 +++--- 8 files changed, 29 insertions(+), 17 deletions(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index c467e3f2c..76e736eb6 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -95,7 +95,9 @@ def __init__(self, inipath, params): self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey" # deprecated option - mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") + mbdir = params.get( + "mailboxes_dir", f"/home/vmail/mail/{self.mail_domain_deliverable}" + ) self.mailboxes_dir = Path(mbdir.strip()) # old unused option (except for first migration from sqlite to maildir store) diff --git a/chatmaild/src/chatmaild/doveauth.py b/chatmaild/src/chatmaild/doveauth.py index 314e7348d..b04104008 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -108,7 +108,7 @@ def handle_lookup(self, parts): if namespace == "shared": if type == "userdb": user = args[0] - if user.endswith(f"@{config.mail_domain}"): + if user.endswith(f"@{config.mail_domain_deliverable}"): res = self.lookup_userdb(user) if res: reply_command = "O" @@ -116,7 +116,7 @@ def handle_lookup(self, parts): reply_command = "N" elif type == "passdb": user = args[1] - if user.endswith(f"@{config.mail_domain}"): + if user.endswith(f"@{config.mail_domain_deliverable}"): res = self.lookup_passdb(user, cleartext_password=args[0]) if res: reply_command = "O" diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index bccea3185..f23928e30 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -13,6 +13,7 @@ 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" def test_read_config_basic_using_defaults(tmp_path, maildomain): diff --git a/chatmaild/src/chatmaild/tests/test_doveauth.py b/chatmaild/src/chatmaild/tests/test_doveauth.py index 3b18d97de..eb86b3075 100644 --- a/chatmaild/src/chatmaild/tests/test_doveauth.py +++ b/chatmaild/src/chatmaild/tests/test_doveauth.py @@ -44,12 +44,20 @@ 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"0123456789x@{config.mail_domain}", password + 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 ) @@ -124,7 +132,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 + domain = config.mail_domain_deliverable # valid localparts assert is_allowed_to_create(config, f"abc123@{domain}", password) diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index f7046ca3e..6e5097123 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}") + assert dic["email"].endswith(f"@{example_config.mail_domain_deliverable}") 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/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 292d64bce..056838f82 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -384,7 +384,7 @@ class ChatmailDeployer(Deployer): def __init__(self, config): self.config = config - self.mail_domain = config.mail_domain + self.mail_domain_deliverable = config.mail_domain_deliverable def install(self): self.put_file( @@ -417,7 +417,7 @@ def configure(self): server.shell( name="Setup /etc/mailname", commands=[ - f"echo {self.mail_domain} >/etc/mailname; chmod 644 /etc/mailname" + f"echo {self.mail_domain_deliverable} >/etc/mailname; chmod 644 /etc/mailname" ], ) @@ -470,6 +470,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 if website_only: Deployment().perform_stages([WebsiteDeployer(config)]) @@ -540,7 +541,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - WebsiteDeployer(config), ChatmailVenvDeployer(config), MtastsDeployer(), - OpendkimDeployer(mail_domain), + OpendkimDeployer(mail_domain_deliverable), # Dovecot should be started before Postfix # because it creates authentication socket # required by Postfix. diff --git a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 index a7ce7b62c..ddfa65a28 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 }} +mail_server_admin = mailto:root@{{ config.mail_domain_deliverable }} 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 27b3876a2..2417df112 100644 --- a/cmdeploy/src/cmdeploy/opendkim/deployer.py +++ b/cmdeploy/src/cmdeploy/opendkim/deployer.py @@ -12,8 +12,8 @@ class OpendkimDeployer(Deployer): required_users = [("opendkim", None, ["opendkim"])] - def __init__(self, mail_domain): - self.mail_domain = mail_domain + def __init__(self, mail_domain_deliverable): + self.mail_domain_deliverable = mail_domain_deliverable def install(self): apt.packages( @@ -22,7 +22,7 @@ def install(self): ) def configure(self): - domain = self.mail_domain + domain = self.mail_domain_deliverable dkim_selector = "opendkim" """Configures OpenDKIM""" From db00af28dc9bfa7d46b0ede1df714f3b87ad753e Mon Sep 17 00:00:00 2001 From: missytake Date: Wed, 15 Apr 2026 14:57:23 +0200 Subject: [PATCH 08/16] get tests working on IPv4 only machine --- chatmaild/src/chatmaild/config.py | 11 +++++++---- .../tests/test_filtermail_blackbox.py | 2 +- cmdeploy/src/cmdeploy/postfix/master.cf.j2 | 5 ++--- .../src/cmdeploy/tests/online/test_0_login.py | 5 ++--- .../src/cmdeploy/tests/online/test_1_basic.py | 3 +++ .../cmdeploy/tests/online/test_2_deltachat.py | 4 ++-- cmdeploy/src/cmdeploy/tests/plugin.py | 19 +++++++++++++------ 7 files changed, 30 insertions(+), 19 deletions(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 76e736eb6..35733b576 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -22,10 +22,7 @@ def __init__(self, inipath, params): self._inipath = inipath self.mail_domain = params["mail_domain"] self.mail_domain_hostname = format_arpa_address(params["mail_domain"]) - if is_valid_ipv4(params["mail_domain"]): - self.mail_domain_deliverable = f"[{params['mail_domain']}]" - else: - self.mail_domain_deliverable = params["mail_domain"] + self.mail_domain_deliverable = format_deliverable_domain(params["mail_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"] @@ -198,3 +195,9 @@ def format_arpa_address(address: str) -> str: if is_valid_ipv4(address): return ipaddress.IPv4Address(address).reverse_pointer return address + + +def format_deliverable_domain(mail_domain: str) -> str: + if is_valid_ipv4(mail_domain): + return f"[{mail_domain}]" + return mail_domain diff --git a/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py b/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py index c87666564..6d9d56cde 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}" + addr = f"user1@{config.mail_domain_deliverable}" config.get_user(addr).set_password("l1k2j3l1k2j3l") # send encrypted mail diff --git a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 index c21e2469e..51e42b56b 100644 --- a/cmdeploy/src/cmdeploy/postfix/master.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/master.cf.j2 @@ -80,10 +80,9 @@ filter unix - n n - - lmtp 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd -o syslog_name=postfix/reinject -o milter_macro_daemon_name=ORIGINATING -{% if config.mail_domain == config.mail_domain_deliverable %} - -o smtpd_milters=unix:opendkim/opendkim.sock -{% endif %} -o cleanup_service_name=authclean +{% if config.mail_domain == config.mail_domain_deliverable %} -o smtpd_milters=unix:opendkim/opendkim.sock +{% endif %} # Local SMTP server for reinjecting incoming filtered mail 127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_login.py b/cmdeploy/src/cmdeploy/tests/online/test_0_login.py index eab39a5c7..516aab08a 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_login.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_login.py @@ -92,12 +92,11 @@ def login_smtp_imap(smtp, imap): def test_no_vrfy(cmfactory, chatmail_config): ac = cmfactory.get_online_account() addr = ac.get_config("addr") - domain = chatmail_config.mail_domain - s = smtplib.SMTP(domain) + s = smtplib.SMTP(chatmail_config.mail_domain) s.starttls() - s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") + s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain_deliverable}") result = s.getreply() print(result) s.putcmd("vrfy", addr) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py index 52bbe02fa..753cd4860 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py @@ -8,6 +8,7 @@ from cmdeploy import remote from cmdeploy.cmdeploy import get_sshexec +from chatmaild.config import is_valid_ipv4 class TestSSHExecutor: @@ -21,6 +22,8 @@ def test_ls(self, sshexec): assert out == out2 def test_perform_initial(self, sshexec, maildomain): + if is_valid_ipv4(maildomain): + pytest.skip(f"{maildomain} is not a domain") res = sshexec( remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) ) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index 947c34f91..9cf79ffce 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -15,7 +15,7 @@ def imap_mailbox(cmfactory, ssl_context): (ac1,) = cmfactory.get_online_accounts(1) user = ac1.get_config("addr") password = ac1.get_config("mail_pw") - host = user.split("@")[1] + host = user.split("@")[1].strip("[").strip("]") mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox.login(user, password) mailbox.dc_ac = ac1 @@ -178,7 +178,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context): chat.send_text("testing submission header cleanup") user2.wait_for_incoming_msg() addr = user2.get_config("addr") - host = addr.split("@")[1] + host = addr.split("@")[1].strip("[").strip("]") pw = user2.get_config("mail_pw") mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox.login(addr, pw) diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index fc5e2f269..f0d86d786 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -10,7 +10,8 @@ from pathlib import Path import pytest -from chatmaild.config import read_config +from chatmaild.config import read_config, format_deliverable_domain, is_valid_ipv4 + conftestdir = Path(__file__).parent @@ -61,6 +62,11 @@ def maildomain(chatmail_config): return chatmail_config.mail_domain +@pytest.fixture(scope="session") +def maildomain_deliverable(maildomain): + return format_deliverable_domain(maildomain) + + @pytest.fixture(scope="session") def sshdomain(maildomain): return os.environ.get("CHATMAIL_SSH", maildomain) @@ -277,8 +283,7 @@ def gencreds(chatmail_config): next(count) def gen(domain=None): - domain = domain if domain else chatmail_config.mail_domain - addr_domain = f"[{domain}]" if _is_ip(domain) else domain + domain = domain if domain else chatmail_config.mail_domain_deliverable while 1: num = next(count) alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" @@ -317,7 +322,8 @@ def __init__(self, rpc, maildomain, gencreds, chatmail_config): def _make_transport(self, domain): """Build a transport config dict for the given domain.""" - addr, password = self.gencreds(domain) + domain_deliverable = format_deliverable_domain(domain) + addr, password = self.gencreds(domain_deliverable) transport = { "addr": addr, "password": password, @@ -326,7 +332,7 @@ def _make_transport(self, domain): "imapServer": domain, "smtpServer": domain, } - if self.chatmail_config.tls_cert_mode == "self": + if domain.startswith("_") or is_valid_ipv4(domain): transport["certificateChecks"] = "acceptInvalidCertificates" return transport @@ -341,7 +347,8 @@ def get_online_accounts(self, num, domain=None): accounts = [] for _ in range(num): account = self.dc.add_account() - addr, password = self.gencreds(domain) + domain_deliverable = format_deliverable_domain(domain) + addr, password = self.gencreds(domain_deliverable) if _is_ip(domain): # Use DCLOGIN scheme with explicit server hosts, # matching how madmail presents its addresses to users. From b72345014d58612a664c468ae6418e6f9654cd4b Mon Sep 17 00:00:00 2001 From: missytake Date: Thu, 16 Apr 2026 11:59:23 +0200 Subject: [PATCH 09/16] doc: document IPv4-only relays --- doc/source/getting_started.rst | 7 +++++-- doc/source/index.rst | 1 + doc/source/iponly.rst | 29 +++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 doc/source/iponly.rst diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 28781f28e..76d7bacf3 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -14,8 +14,6 @@ Minimal requirements and prerequisites 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. IPv6 is encouraged if available. Chatmail relay servers only require 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active @@ -28,6 +26,11 @@ You will need the following: (An ed25519 private key is required due to an `upstream bug in paramiko `_) +- Control over a domain through a DNS provider of your choice + (there is experimental support for :ref:`DNS-less relays `). + + +.. _setup: Setup with ``scripts/cmdeploy`` ------------------------------------- diff --git a/doc/source/index.rst b/doc/source/index.rst index 48fc1cc57..48768810c 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -19,3 +19,4 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay reverse_dns related faq + iponly diff --git a/doc/source/iponly.rst b/doc/source/iponly.rst new file mode 100644 index 000000000..f4813c563 --- /dev/null +++ b/doc/source/iponly.rst @@ -0,0 +1,29 @@ +.. _iponly: + +Hosting without DNS records +=========================== + +.. note:: + + This option is experimental and might change without notice. + +In case you don't have a domain, +for example in a local network, +you can run a chatmail relay with only an IPv4 address as well. + +To deploy a relay without a domain, +run ``cmdeploy init`` with only the IPv4 address +during the :ref:`installation steps `, +for example ``cmdeploy init 13.12.23.42``. + +Drawbacks +--------- + +- your transport encryption will only use self-signed TLS certificates, + which are vulnerable against MITM attacks. + the chatmail core's end-to-end encryption should suffice in most scenarios though. + +- your messages will not be DKIM-signed; + experimentally, most chatmail relays accept non-DKIM-signed messages from IPv4-only relays, + but some relays might not accept messages from yours. + From 03ec32f1273431d399a6c08e1f0283344576fb53 Mon Sep 17 00:00:00 2001 From: missytake Date: Thu, 16 Apr 2026 12:52:49 +0200 Subject: [PATCH 10/16] dns: warn if mail_domain is an IP, instead of checking DNS --- cmdeploy/src/cmdeploy/cmdeploy.py | 3 +++ cmdeploy/src/cmdeploy/tests/test_cmdeploy.py | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 3b1195b89..711e40242 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -144,6 +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.") + return 0 ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain sshexec = get_sshexec(ssh_host, verbose=args.verbose) tls_cert_mode = args.config.tls_cert_mode diff --git a/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py b/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py index b7f7b8731..c4513bc1d 100644 --- a/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py +++ b/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py @@ -39,6 +39,14 @@ def test_init_not_overwrite(self, capsys, tmp_path, monkeypatch): out, err = capsys.readouterr() assert "deleting config file" in out.lower() + def test_dns_skip_on_ip(self, capsys, tmp_path, monkeypatch): + monkeypatch.delenv("CHATMAIL_INI", raising=False) + inipath = tmp_path / "chatmail.ini" + assert main(["init", "--config", str(inipath), "1.3.3.7"]) == 0 + assert main(["dns", "--config", str(inipath)]) == 0 + out, err = capsys.readouterr() + assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n" + def test_www_folder(example_config, tmp_path): reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() From 788304fe634a1c6ef20313b22289ea0eeb7483bf Mon Sep 17 00:00:00 2001 From: missytake Date: Thu, 16 Apr 2026 14:31:52 +0200 Subject: [PATCH 11/16] config: validate domains when formatting them --- chatmaild/pyproject.toml | 1 + chatmaild/src/chatmaild/config.py | 3 ++ chatmaild/src/chatmaild/tests/test_config.py | 57 +++++++++++++++++++- cmdeploy/src/cmdeploy/tests/plugin.py | 2 +- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/chatmaild/pyproject.toml b/chatmaild/pyproject.toml index 01ef93d2f..c043f39be 100644 --- a/chatmaild/pyproject.toml +++ b/chatmaild/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "filelock", "requests", "crypt-r >= 3.13.1 ; python_version >= '3.11'", + "domain-validator", ] [tool.setuptools] diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 35733b576..fb0406835 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -2,6 +2,7 @@ from pathlib import Path import iniconfig +from domain_validator import DomainValidator from chatmaild.user import User @@ -194,10 +195,12 @@ def is_valid_ipv4(address: str) -> bool: def format_arpa_address(address: str) -> str: if is_valid_ipv4(address): return ipaddress.IPv4Address(address).reverse_pointer + DomainValidator().validate_domain_re(address) 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 diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index f23928e30..ca609000f 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -1,6 +1,14 @@ +from contextlib import nullcontext as does_not_raise + import pytest -from chatmaild.config import parse_size_mb, read_config +from chatmaild.config import ( + format_arpa_address, + format_deliverable_domain, + is_valid_ipv4, + parse_size_mb, + read_config, +) def test_read_config_basic(example_config): @@ -16,6 +24,11 @@ def test_read_config_basic(example_config): assert example_config.mail_domain_deliverable == "chat.example.org" +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_basic_using_defaults(tmp_path, maildomain): inipath = tmp_path.joinpath("chatmail.ini") inipath.write_text(f"[params]\nmail_domain = {maildomain}") @@ -136,3 +149,45 @@ def test_max_mailbox_size_mb(make_config): config = make_config("chat.example.org") assert config.max_mailbox_size == "500M" assert config.max_mailbox_size_mb == 500 + + +@pytest.mark.parametrize( + ["input", "result"], + [ + ("example.org", False), + ("1.3.3.7", True), + ("fe::1", False), + ("ad.1e.dag.adf", False), + ("12394142", False), + ], +) +def test_is_valid_ipv4(input, result): + assert result == is_valid_ipv4(input) + + +@pytest.mark.parametrize( + ["input", "result", "exception"], + [ + ("example.org", "example.org", does_not_raise()), + ("1.3.3.7", "7.3.3.1.in-addr.arpa", does_not_raise()), + ("fe::1", None, pytest.raises(ValueError)), + ("12394142", None, pytest.raises(ValueError)), + ], +) +def test_format_arpa_address(input, result, exception): + with exception: + assert result == format_arpa_address(input) + + +@pytest.mark.parametrize( + ["input", "result", "exception"], + [ + ("example.org", "example.org", does_not_raise()), + ("1.3.3.7", "[1.3.3.7]", does_not_raise()), + ("fe::1", None, pytest.raises(ValueError)), + ("12394142", None, pytest.raises(ValueError)), + ], +) +def test_format_deliverable_domain(input, result, exception): + with exception: + assert result == format_deliverable_domain(input) diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index f0d86d786..7deef20a2 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -297,7 +297,7 @@ def gen(domain=None): password = "".join( random.choices(alphanumeric, k=chatmail_config.password_min_length) ) - yield f"{user}@{addr_domain}", f"{password}" + yield f"{user}@{domain}", f"{password}" return lambda domain=None: next(gen(domain)) From 39aee24d031fe2385291d72a9d11b86a462cf4f7 Mon Sep 17 00:00:00 2001 From: missytake Date: Tue, 21 Apr 2026 14:45:19 +0200 Subject: [PATCH 12/16] ci: add cmlxc testing for no-DNS relays --- .github/workflows/ci.yaml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e80a9fc4e..a938e566b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: Run unit-tests and container-based deploy+test verification +name: CI on: # Triggers when a PR is merged into main or a direct push occurs @@ -76,3 +76,22 @@ jobs: cmlxc -v test-cmdeploy cm0 mad0 cmlxc -v test-mini cm0 mad0 cmlxc -v test-mini mad0 cm0 + + no-dns: + name: no-DNS LXC deploy and test + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.13.5 + with: + cmlxc_version: v0.13.5 + cmlxc_commands: | + cmlxc init + # single cmdeploy relay test + cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --no-dns cm0 + cmlxc -v test-cmdeploy --no-dns cm0 + + # cross cmdeploy relay test + cmlxc -v deploy-cmdeploy --source ./repo cm1 + cmlxc -v test-cmdeploy --no-dns cm0 cm1 + + # cross cmdeploy/madmail relay tests + cmlxc -v deploy-madmail mad0 + cmlxc -v test-cmdeploy --no-dns cm0 mad0 From de1b268a154d666bd2fe59d355777d15bca61761 Mon Sep 17 00:00:00 2001 From: missytake Date: Tue, 21 Apr 2026 23:01:04 +0200 Subject: [PATCH 13/16] ci: run no-dns and normal CI in parallel --- .github/workflows/ci-no-dns.yaml | 36 ++++++++++++++++++++++++++++++++ .github/workflows/ci.yaml | 18 ---------------- 2 files changed, 36 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/ci-no-dns.yaml diff --git a/.github/workflows/ci-no-dns.yaml b/.github/workflows/ci-no-dns.yaml new file mode 100644 index 000000000..620bf0838 --- /dev/null +++ b/.github/workflows/ci-no-dns.yaml @@ -0,0 +1,36 @@ +name: No-DNS + +on: + # Triggers when a PR is merged into main or a direct push occurs + push: + branches: [ "main" ] + + # Triggers for any PR (and its subsequent commits) targeting the main branch + pull_request: + branches: [ "main" ] + +# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + + +jobs: + no-dns: + name: LXC deploy and test + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.13.5 + with: + cmlxc_version: v0.13.5 + cmlxc_commands: | + cmlxc init + # single cmdeploy relay test + cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --no-dns cm0 + cmlxc -v test-cmdeploy --no-dns cm0 + + # cross cmdeploy relay test + cmlxc -v deploy-cmdeploy --source ./repo cm1 + cmlxc -v test-cmdeploy --no-dns cm0 cm1 + + # cross cmdeploy/madmail relay tests + cmlxc -v deploy-madmail mad0 + cmlxc -v test-cmdeploy --no-dns cm0 mad0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a938e566b..6241fbe0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -77,21 +77,3 @@ jobs: cmlxc -v test-mini cm0 mad0 cmlxc -v test-mini mad0 cm0 - no-dns: - name: no-DNS LXC deploy and test - uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.13.5 - with: - cmlxc_version: v0.13.5 - cmlxc_commands: | - cmlxc init - # single cmdeploy relay test - cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --no-dns cm0 - cmlxc -v test-cmdeploy --no-dns cm0 - - # cross cmdeploy relay test - cmlxc -v deploy-cmdeploy --source ./repo cm1 - cmlxc -v test-cmdeploy --no-dns cm0 cm1 - - # cross cmdeploy/madmail relay tests - cmlxc -v deploy-madmail mad0 - cmlxc -v test-cmdeploy --no-dns cm0 mad0 From 787d86f9e1e75abfc45ee5950f4819d6908a70b8 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Fri, 8 May 2026 00:08:57 +0200 Subject: [PATCH 14/16] 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 | 8 ++-- cmdeploy/src/cmdeploy/tests/plugin.py | 12 +++--- 16 files changed, 79 insertions(+), 80 deletions(-) diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index fb0406835..2767be753 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 @@ def __init__(self, inipath, params): 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 @@ def __init__(self, inipath, params): ) 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 b04104008..314e7348d 100644 --- a/chatmaild/src/chatmaild/doveauth.py +++ b/chatmaild/src/chatmaild/doveauth.py @@ -108,7 +108,7 @@ def handle_lookup(self, parts): 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 @@ def handle_lookup(self, parts): 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 7d2f42c1b..808882202 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 ca609000f..cd3f1efb2 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -4,7 +4,7 @@ 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 eb86b3075..3b18d97de 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 6d9d56cde..c87666564 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 6e5097123..f7046ca3e 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 711e40242..c4ea48152 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -13,7 +13,7 @@ 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 056838f82..64b57d81f 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -370,7 +370,7 @@ def install(self): def configure(self): _configure_remote_venv_with_chatmaild(self, self.config) - configure_remote_units(self, self.config.mail_domain, self.units) + configure_remote_units(self, self.config.mail_domain_bare, self.units) def activate(self): activate_remote_units(self, self.units) @@ -384,7 +384,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): self.put_file( @@ -417,7 +417,7 @@ def configure(self): 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" ], ) @@ -469,8 +469,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)]) @@ -527,7 +526,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), @@ -535,13 +534,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 fca4c2aa2..debb79041 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -68,7 +68,7 @@ def install(self): ) def configure(self): - configure_remote_units(self, self.config.mail_domain, self.units) + configure_remote_units(self, self.config.mail_domain_bare, self.units) _configure_dovecot(self, self.config) def activate(self): diff --git a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 index ddfa65a28..a7ce7b62c 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 2417df112..27b3876a2 100644 --- a/cmdeploy/src/cmdeploy/opendkim/deployer.py +++ b/cmdeploy/src/cmdeploy/opendkim/deployer.py @@ -12,8 +12,8 @@ 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 @@ def install(self): ) def configure(self): - domain = self.mail_domain_deliverable + domain = self.mail_domain dkim_selector = "opendkim" """Configures OpenDKIM""" diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index 77cbf1315..bb354c149 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 51e42b56b..bf108fecb 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 516aab08a..a1ff9407e 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_login.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_login.py @@ -12,7 +12,7 @@ def test_init(tmp_path, maildomain): inipath = tmp_path.joinpath("chatmail.ini") main(["init", "--config", str(inipath), maildomain]) config = read_config(inipath) - assert config.mail_domain == maildomain + assert config.mail_domain_bare == maildomain def test_capabilities(imap): @@ -89,14 +89,14 @@ def login_smtp_imap(smtp, imap): 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 7deef20a2..841ed65c9 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -10,7 +10,7 @@ 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 @@ def __init__(self, rpc, maildomain, gencreds, chatmail_config): 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 @@ def get_online_accounts(self, num, domain=None): 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, From 6c84efbaa5b2707137fee5edde55623080185cd9 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sun, 10 May 2026 20:51:30 +0200 Subject: [PATCH 15/16] ci: migrate from --no-dns to --type ipv4 for cmlxc compatibility --- .github/workflows/ci-no-dns.yaml | 17 +++++++++-------- .github/workflows/ci.yaml | 3 +-- chatmaild/src/chatmaild/config.py | 1 - 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci-no-dns.yaml b/.github/workflows/ci-no-dns.yaml index 620bf0838..bec903dfc 100644 --- a/.github/workflows/ci-no-dns.yaml +++ b/.github/workflows/ci-no-dns.yaml @@ -18,19 +18,20 @@ concurrency: jobs: no-dns: name: LXC deploy and test - uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.13.5 + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.1 with: - cmlxc_version: v0.13.5 cmlxc_commands: | cmlxc init # single cmdeploy relay test - cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --no-dns cm0 - cmlxc -v test-cmdeploy --no-dns cm0 + cmlxc -v deploy-cmdeploy --source ./repo --type ipv4 cm0 + cmlxc -v test-cmdeploy cm0 - # cross cmdeploy relay test - cmlxc -v deploy-cmdeploy --source ./repo cm1 - cmlxc -v test-cmdeploy --no-dns cm0 cm1 + # cross cmdeploy relay test (two ipv4 relays) + cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --type ipv4 cm1 + cmlxc -v test-cmdeploy cm0 cm1 # cross cmdeploy/madmail relay tests cmlxc -v deploy-madmail mad0 - cmlxc -v test-cmdeploy --no-dns cm0 mad0 + cmlxc -v test-cmdeploy cm0 mad0 + cmlxc -v test-mini mad0 cm0 + cmlxc -v test-mini cm0 mad0 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6241fbe0a..85c8f0fd6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,9 +57,8 @@ jobs: lxc-test: name: LXC deploy and test - uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.13.5 + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.1 with: - cmlxc_version: v0.13.5 cmlxc_commands: | cmlxc init # single cmdeploy relay test diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 2767be753..25e7a5b5e 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -200,7 +200,6 @@ 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 From 4f769759e161af340dfa5f0690a5badfff71942d Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 11 May 2026 01:57:34 +0200 Subject: [PATCH 16/16] cleanup dead code, fix docs, fixate cmlxc version --- .github/workflows/ci-no-dns.yaml | 5 ++- .github/workflows/ci.yaml | 3 +- chatmaild/src/chatmaild/config.py | 14 -------- chatmaild/src/chatmaild/tests/test_config.py | 32 ------------------ cmdeploy/src/cmdeploy/deployers.py | 2 +- cmdeploy/src/cmdeploy/external/deployer.py | 7 ++-- cmdeploy/src/cmdeploy/postfix/main.cf.j2 | 1 + .../src/cmdeploy/tests/online/test_1_basic.py | 10 ++++-- cmdeploy/src/cmdeploy/tests/plugin.py | 33 ++++++++----------- .../cmdeploy/tests/test_dovecot_deployer.py | 3 +- doc/source/faq.rst | 1 + doc/source/getting_started.rst | 2 +- doc/source/iponly.rst | 13 +++++++- doc/source/overview.rst | 3 +- 14 files changed, 47 insertions(+), 82 deletions(-) diff --git a/.github/workflows/ci-no-dns.yaml b/.github/workflows/ci-no-dns.yaml index bec903dfc..bc1e425df 100644 --- a/.github/workflows/ci-no-dns.yaml +++ b/.github/workflows/ci-no-dns.yaml @@ -9,6 +9,8 @@ on: pull_request: branches: [ "main" ] +permissions: {} + # Newest push wins: Prevents multiple runs from clashing and wasting runner efforts concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -18,8 +20,9 @@ concurrency: jobs: no-dns: name: LXC deploy and test - uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.1 + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6 with: + cmlxc_version: v0.14.6 cmlxc_commands: | cmlxc init # single cmdeploy relay test diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 85c8f0fd6..7025157e5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,8 +57,9 @@ jobs: lxc-test: name: LXC deploy and test - uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.1 + uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6 with: + cmlxc_version: v0.14.6 cmlxc_commands: | cmlxc init # single cmdeploy relay test diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 25e7a5b5e..de6704c09 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -198,17 +198,3 @@ def is_valid_ipv4(address: str) -> bool: return True except ValueError: return False - - -def format_arpa_address(address: str) -> str: - if is_valid_ipv4(address): - return ipaddress.IPv4Address(address).reverse_pointer - DomainValidator().validate_domain_re(address) - return address - - -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/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index cd3f1efb2..040b45fab 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -1,10 +1,6 @@ -from contextlib import nullcontext as does_not_raise - import pytest from chatmaild.config import ( - format_arpa_address, - format_mail_domain, is_valid_ipv4, parse_size_mb, read_config, @@ -163,31 +159,3 @@ def test_max_mailbox_size_mb(make_config): ) def test_is_valid_ipv4(input, result): assert result == is_valid_ipv4(input) - - -@pytest.mark.parametrize( - ["input", "result", "exception"], - [ - ("example.org", "example.org", does_not_raise()), - ("1.3.3.7", "7.3.3.1.in-addr.arpa", does_not_raise()), - ("fe::1", None, pytest.raises(ValueError)), - ("12394142", None, pytest.raises(ValueError)), - ], -) -def test_format_arpa_address(input, result, exception): - with exception: - assert result == format_arpa_address(input) - - -@pytest.mark.parametrize( - ["input", "result", "exception"], - [ - ("example.org", "example.org", does_not_raise()), - ("1.3.3.7", "[1.3.3.7]", does_not_raise()), - ("fe::1", None, pytest.raises(ValueError)), - ("12394142", None, pytest.raises(ValueError)), - ], -) -def test_format_mail_domain(input, result, exception): - with exception: - assert result == format_mail_domain(input) diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 64b57d81f..10bcd14d5 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -540,7 +540,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - WebsiteDeployer(config), ChatmailVenvDeployer(config), MtastsDeployer(), - OpendkimDeployer(config.mail_domain), + *([] if config.ipv4_relay else [OpendkimDeployer(bare_host)]), # Dovecot should be started before Postfix # because it creates authentication socket # required by Postfix. diff --git a/cmdeploy/src/cmdeploy/external/deployer.py b/cmdeploy/src/cmdeploy/external/deployer.py index 7d087c5b3..1cca32991 100644 --- a/cmdeploy/src/cmdeploy/external/deployer.py +++ b/cmdeploy/src/cmdeploy/external/deployer.py @@ -1,4 +1,3 @@ - from pyinfra import host from pyinfra.facts.files import File @@ -21,8 +20,8 @@ def __init__(self, cert_path, key_path): def configure(self): # Verify cert and key exist on the remote host using pyinfra facts. for path in (self.cert_path, self.key_path): - if host.get_fact(File, path=path) is None: - raise Exception(f"External TLS file not found on server: {path}") + if host.get_fact(File, path=path) is None: + raise Exception(f"External TLS file not found on server: {path}") self.ensure_systemd_unit( "external/tls-cert-reload.path.j2", @@ -40,5 +39,3 @@ def activate(self): running=True, enabled=True, ) - - diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index bb354c149..57ef64e09 100644 --- a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 @@ -60,6 +60,7 @@ alias_database = hash:/etc/aliases # When postfix receives mail for $mydestination, # it hands it over to dovecot via $local_transport. +# Note: IP literals must be handled via local delivery / mydestination. mydestination = {{ config.mail_domain }} local_transport = lmtp:unix:private/dovecot-lmtp # postfix doesn't check whether local users exist or not: diff --git a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py index 753cd4860..300b19fbb 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_1_basic.py @@ -5,10 +5,10 @@ import time import pytest +from chatmaild.config import is_valid_ipv4 from cmdeploy import remote from cmdeploy.cmdeploy import get_sshexec -from chatmaild.config import is_valid_ipv4 class TestSSHExecutor: @@ -64,8 +64,10 @@ def test_exception(self, sshexec, capsys): else: pytest.fail("didn't raise exception") - def test_opendkim_restarted(self, sshexec): + def test_opendkim_restarted(self, sshexec, maildomain): """check that opendkim is not running for longer than a day.""" + if is_valid_ipv4(maildomain): + pytest.skip(f"{maildomain} is an IPv4 relay, opendkim is not installed") cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp" out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd)) datestring = out.split("=")[1] @@ -293,4 +295,6 @@ def test_nginx_access_log_only_defined_once(sshdomain): kwargs=dict(command="nginx -T 2>/dev/null"), ) access_logs = [l for l in conf.splitlines() if l.strip().startswith("access_log")] - assert len(access_logs) == 1, f"expected 1 access_log, found {len(access_logs)}: {access_logs}" + assert len(access_logs) == 1, ( + f"expected 1 access_log, found {len(access_logs)}: {access_logs}" + ) diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index 841ed65c9..87fa55766 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -1,5 +1,4 @@ import imaplib -import ipaddress import itertools import os import random @@ -10,18 +9,17 @@ from pathlib import Path import pytest -from chatmaild.config import read_config, format_mail_domain, is_valid_ipv4 +from chatmaild.config import is_valid_ipv4, read_config +from domain_validator import DomainValidator -conftestdir = Path(__file__).parent - +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 -def _is_ip(domain): - try: - ipaddress.ip_address(domain) - return True - except ValueError: - return False +conftestdir = Path(__file__).parent def pytest_configure(config): @@ -62,11 +60,6 @@ def maildomain(chatmail_config): return chatmail_config.mail_domain_bare -@pytest.fixture(scope="session") -def maildomain_deliverable(maildomain): - return format_mail_domain(maildomain) - - @pytest.fixture(scope="session") def sshdomain(maildomain): return os.environ.get("CHATMAIL_SSH", maildomain) @@ -349,7 +342,7 @@ def get_online_accounts(self, num, domain=None): account = self.dc.add_account() domain_deliverable = format_mail_domain(domain) addr, password = self.gencreds(domain_deliverable) - if _is_ip(domain): + if is_valid_ipv4(domain): # Use DCLOGIN scheme with explicit server hosts, # matching how madmail presents its addresses to users. qr = ( @@ -423,10 +416,10 @@ def __init__(self, sshdomain): def iter_output(self, logcmd="", ready=None): getjournal = "journalctl -f" if not logcmd else logcmd print(self.sshdomain) - match self.sshdomain: - case "@local": command = [] - case "localhost": command = [] - case _: command = ["ssh", f"root@{self.sshdomain}"] + if self.sshdomain in ("@local", "localhost"): + command = [] + else: + command = ["ssh", f"root@{self.sshdomain}"] [command.append(arg) for arg in getjournal.split()] popen = subprocess.Popen( command, diff --git a/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py b/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py index f632912e0..8c6156711 100644 --- a/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py +++ b/cmdeploy/src/cmdeploy/tests/test_dovecot_deployer.py @@ -23,8 +23,7 @@ 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__}); " - f"only registered: {registered}" + f"unexpected get_fact({cls.__name__}); only registered: {registered}" ) return facts[cls] diff --git a/doc/source/faq.rst b/doc/source/faq.rst index 3fd369986..cc3c74b1a 100644 --- a/doc/source/faq.rst +++ b/doc/source/faq.rst @@ -15,6 +15,7 @@ goes beyond what classic email servers offer: streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch `_; - **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted + (DKIM is not enforced on :ref:`IP-only relays `) - **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating depends on established IETF standards and protocols. diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 76d7bacf3..05e6696ed 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -27,7 +27,7 @@ You will need the following: paramiko `_) - Control over a domain through a DNS provider of your choice - (there is experimental support for :ref:`DNS-less relays `). + (there is experimental support for :ref:`IP-only relays `). .. _setup: diff --git a/doc/source/iponly.rst b/doc/source/iponly.rst index f4813c563..f18e2a51c 100644 --- a/doc/source/iponly.rst +++ b/doc/source/iponly.rst @@ -24,6 +24,17 @@ Drawbacks the chatmail core's end-to-end encryption should suffice in most scenarios though. - your messages will not be DKIM-signed; - experimentally, most chatmail relays accept non-DKIM-signed messages from IPv4-only relays, + experimentally, most chatmail relays accept non-DKIM-signed messages from IP-only relays, but some relays might not accept messages from yours. + +Email addresses +--------------- + +When running without a domain, +your chatmail addresses will use the IPv4 address +in brackets as the domain part, +for example ``user@[13.12.23.42]``. +This is a valid email address format +according to :rfc:`5321`. + diff --git a/doc/source/overview.rst b/doc/source/overview.rst index 9b040b7fb..85aff1a50 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -265,7 +265,8 @@ from the chatmail relay server. Email domain authentication (DKIM) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails. +Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails +(except for :ref:`IP-only relays `). Incoming emails must have a valid DKIM signature with Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature header) equal to the ``From:`` header domain. This property is checked