# SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie # SPDX-License-Identifier: MIT # NixOS VM test exercising zig-smtp against third-party implementations: # - zig-smtp client -> Postfix (25/465) and Exim (2625/2626): plaintext, # STARTTLS, and implicit TLS against each, verified by checking local # delivery to alice's mailbox # - swaks -> zig-smtp server: plaintext and STARTTLS, verified by checking # the received message in the server's journal # - exim -> zig-smtp LMTP server (2529): a two-recipient delivery where the # server accepts one mailbox and refuses the other, which is the thing # LMTP exists to express and which exim has to read correctly # - zig-smtp LMTP client -> dovecot LMTP (2024) { testers, callPackage, runCommand, openssl, }: let zig-smtp = callPackage ../package.nix { }; snakeoil = runCommand "zig-smtp-test-cert" { nativeBuildInputs = [ openssl ]; } '' mkdir -p $out openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ -keyout $out/key.pem -out $out/cert.pem -days 36500 -nodes \ -subj "/CN=localhost" \ -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" ''; in testers.runNixOSTest { name = "zig-smtp-interop"; nodes.machine = { pkgs, ... }: { environment.systemPackages = [ zig-smtp pkgs.swaks pkgs.netcat pkgs.python3 ]; users.users.alice.isNormalUser = true; # A real LMTP server for the zig-smtp LMTP client to deliver to. services.dovecot2 = { enable = true; # alice is a normal user declared above, not one for dovecot to make. createMailUser = false; settings = { protocols = [ "lmtp" ]; # Dovecot 2.4 requires both of these to be stated rather than # inferred, so that a version bump cannot silently change meaning. dovecot_config_version = "2.4.5"; dovecot_storage_version = "2.4.5"; mail_driver = "maildir"; mail_path = "/var/spool/dovecot-mail/%{user}"; # Deliveries arrive addressed to alice@localhost; the mailbox is # alice, so the domain is stripped before the userdb lookup. auth_username_format = "%{user | username}"; mail_uid = "alice"; mail_gid = "users"; # LMTP resolves each recipient through the userdb, and dovecot's # auth process refuses to start without a passdb beside it even # though nothing here authenticates. "userdb passwd" = { }; "passdb pam" = { }; # Dovecot 2.4 takes the address from the global `listen` rather # than from the listener block, which only names the port. listen = "127.0.0.1"; service = [ { _section.name = "lmtp"; "inet_listener lmtp".port = 2024; } ]; }; }; services.postfix = { enable = true; # Implicit-TLS smtpd on port 465; allow loopback without SASL. enableSubmissions = true; submissionsOptions = { smtpd_client_restrictions = "permit_mynetworks,reject"; }; settings.main = { mydestination = [ "localhost" "$myhostname" ]; smtpd_tls_security_level = "may"; smtpd_tls_chain_files = [ "${snakeoil}/key.pem" "${snakeoil}/cert.pem" ]; }; }; # Exim: plaintext + STARTTLS on 2625, implicit TLS on 2626, delivering # to /var/spool/exim-mail/. services.exim = { enable = true; config = '' primary_hostname = machine.test qualify_domain = localhost local_interfaces = 127.0.0.1 daemon_smtp_ports = 2625 : 2626 tls_on_connect_ports = 2626 tls_advertise_hosts = * # Off by default in exim, and the point of the DSN subtest below. dsn_advertise_hosts = * tls_certificate = ${snakeoil}/cert.pem tls_privatekey = ${snakeoil}/key.pem acl_smtp_rcpt = acl_rcpt begin acl acl_rcpt: accept begin routers # Everything for lmtp.test goes to the zig-smtp LMTP server, which # accepts one of the two mailboxes below and refuses the other. # Listed first because the first matching router wins. lmtp_route: driver = manualroute domains = lmtp.test transport = lmtp_out route_list = * 127.0.0.1 # 127.0.0.1 is this machine, which exim otherwise refuses to # route to; `self = send` and the transport's allow_localhost # are the two halves of saying "yes, really, deliver there". self = send local_users: driver = accept local_parts = alice : bob transport = local_delivery begin transports lmtp_out: driver = smtp protocol = lmtp # Stated numerically because exim otherwise looks up the # service name "lmtp", which /etc/services does not have. port = 2529 # Without this exim refuses to deliver to its own machine. allow_localhost hosts_try_fastopen = local_delivery: driver = appendfile file = /var/spool/exim-mail/$local_part_data user = exim delivery_date_add envelope_to_add return_path_add begin authenticators plain_server: driver = plaintext public_name = PLAIN server_prompts = : server_condition = ''${if and{{eq{$auth2}{alice}}{eq{$auth3}{secret}}}} server_set_id = $auth2 login_server: driver = plaintext public_name = LOGIN server_prompts = Username:: : Password:: server_condition = ''${if and{{eq{$auth1}{alice}}{eq{$auth2}{secret}}}} server_set_id = $auth1 ''; }; systemd.tmpfiles.rules = [ "d /var/spool/exim-mail 0755 exim exim -" "d /var/spool/dovecot-mail 0755 alice users -" ]; systemd.services.zig-smtp-server = { description = "zig-smtp debug server (plaintext)"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${zig-smtp}/bin/zig-smtp serve 2525"; DynamicUser = true; }; }; systemd.services.zig-smtp-server-tls = { description = "zig-smtp debug server (STARTTLS)"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${zig-smtp}/bin/zig-smtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem 2526"; DynamicUser = true; }; }; systemd.services.zig-smtp-server-tlsc = { description = "zig-smtp debug server (implicit TLS)"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${zig-smtp}/bin/zig-smtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem --implicit-tls 2528"; DynamicUser = true; }; }; systemd.services.zig-smtp-server-lmtp = { description = "zig-smtp debug server (LMTP)"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${zig-smtp}/bin/zig-smtp serve --lmtp --fail-delivery bad@lmtp.test 2529"; DynamicUser = true; }; }; systemd.services.zig-smtp-server-auth = { description = "zig-smtp debug server (authentication required)"; wantedBy = [ "multi-user.target" ]; serviceConfig = { ExecStart = "${zig-smtp}/bin/zig-smtp serve --auth alice:secret 2527"; DynamicUser = true; }; }; }; testScript = '' machine.wait_for_unit("postfix.service") machine.wait_for_open_port(25) machine.wait_for_open_port(465) machine.wait_for_unit("exim.service") machine.wait_for_open_port(2625) machine.wait_for_open_port(2626) machine.wait_for_unit("zig-smtp-server.service") machine.wait_for_unit("zig-smtp-server-tls.service") machine.wait_for_unit("zig-smtp-server-tlsc.service") machine.wait_for_unit("zig-smtp-server-auth.service") machine.wait_for_unit("zig-smtp-server-lmtp.service") machine.wait_for_unit("dovecot.service") machine.wait_for_open_port(2529) machine.wait_for_open_port(2024) machine.wait_for_open_port(2525) machine.wait_for_open_port(2526) machine.wait_for_open_port(2527) machine.wait_for_open_port(2528) def deliver(flags, port, needle, mailbox): machine.succeed( f"printf 'Subject: interop\\r\\n\\r\\n{needle}\\r\\n'" f" | zig-smtp send {flags} 127.0.0.1 {port}" " bob@example.com alice@localhost" ) machine.wait_until_succeeds(f"grep -r '{needle}' {mailbox}", timeout=60) servers = { "postfix": (25, 465, "/var/spool/mail/alice/"), "exim": (2625, 2626, "/var/spool/exim-mail/alice"), } for name, (port, tls_port, mailbox) in servers.items(): with subtest(f"zig-smtp client to {name}, plaintext"): deliver("", port, f"zig-smtp to {name} plain", mailbox) with subtest(f"zig-smtp client to {name}, STARTTLS"): deliver( "--starttls --insecure", port, f"zig-smtp to {name} starttls", mailbox ) with subtest(f"zig-smtp client to {name}, implicit TLS"): deliver("--tls --insecure", tls_port, f"zig-smtp to {name} smtps", mailbox) for name, (port, tls_port, mailbox) in servers.items(): with subtest(f"zig-smtp client to {name}, CHUNKING"): deliver("--chunking", port, f"zig-smtp to {name} chunked", mailbox) # RFC 3461. Postfix advertises DSN out of the box; exim is told to above. for name, (port, tls_port, mailbox) in servers.items(): with subtest(f"zig-smtp client to {name}, DSN parameters"): deliver( "--ret hdrs --envid batch7 --notify success,failure" " --orcpt team@example.net", port, f"zig-smtp to {name} dsn", mailbox, ) with subtest("zig-smtp client to zig-smtp server, DSN parameters round trip"): machine.succeed( "printf 'Subject: interop\\r\\n\\r\\nzig-smtp dsn round trip\\r\\n'" " | zig-smtp send --ret full --envid 'batch 7'" " --notify success,delay --orcpt 'team+list@example.net'" " 127.0.0.1 2525 bob@example.com alice@example.net" ) # The server prints what it parsed: the ENVID comes back with its # space, and the ORCPT with the '+' that had to be xtext-encoded. machine.wait_until_succeeds( "journalctl -u zig-smtp-server | grep -F" " 'NOTIFY=SUCCESS,DELAY ORCPT=rfc822;team+2Blist@example.net" " RET=FULL ENVID=batch 7'", timeout=60, ) # RFC 2033. The point of LMTP is a separate verdict per mailbox, so the # cases that matter are the ones where those verdicts differ. with subtest("exim to zig-smtp LMTP server, one mailbox accepted and one refused"): machine.succeed( "swaks --server 127.0.0.1:2625 --from bob@example.com" # One --to with both, since a second --to replaces the first. " --to good@lmtp.test,bad@lmtp.test" " --header 'Subject: lmtp' --body 'exim to zig-smtp lmtp'" ) # Both recipients in one transaction, which is what makes the two # differing verdicts possible. machine.wait_until_succeeds( "journalctl -u zig-smtp-server-lmtp | grep -F" " ' '", timeout=60, ) # Exim read the two replies and applied them separately: '=>' is a # delivery and '**' a permanent failure, both for the one message, # which is exactly what SMTP could not have told it. machine.wait_until_succeeds( "journalctl -u exim | grep -F '=> good@lmtp.test'", timeout=60 ) machine.wait_until_succeeds( "journalctl -u exim | grep -F '** bad@lmtp.test'", timeout=60 ) machine.succeed("journalctl -u exim | grep -F 'Mailbox disabled'") with subtest("zig-smtp LMTP client to dovecot"): machine.succeed( "printf 'Subject: interop\\r\\n\\r\\nzig-smtp to dovecot lmtp\\r\\n'" " | zig-smtp send --lmtp 127.0.0.1 2024 bob@example.com alice@localhost" ) machine.wait_until_succeeds( "grep -r 'zig-smtp to dovecot lmtp' /var/spool/dovecot-mail/alice/", timeout=60 ) with subtest("zig-smtp LMTP client reports dovecot's refusal of one recipient"): # Two recipients, one of whom does not exist: dovecot accepts the # RCPT for alice and refuses nosuchuser outright, so this fails at # RCPT rather than at the end of data -- still per-recipient, and # still the client's job to report which. status, output = machine.execute( "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" " | zig-smtp send --lmtp 127.0.0.1 2024 bob@example.com nosuchuser@localhost 2>&1" ) assert status != 0, f"expected a failure, got: {output}" with subtest("dovecot refuses EHLO, as an LMTP server must"): status, output = machine.execute( "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2024" ) assert "250-" not in output, f"dovecot answered EHLO positively: {output}" # And zig-smtp's own LMTP server says the same thing. status, output = machine.execute( "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2529" ) assert "500" in output, f"expected a 500 for EHLO, got: {output}" # RFC 3030 BINARYMIME. Neither postfix nor exim offers it, which is # what makes them the test of the refusal: RFC 3030 is absolute that # binary must not be sent to a server that did not advertise it. with subtest("zig-smtp refuses to send binary to a server that lacks BINARYMIME"): for name, (port, _tls, _mailbox) in servers.items(): status, output = machine.execute( f"printf 'x' | zig-smtp send --binarymime 127.0.0.1 {port}" " bob@example.com alice@localhost 2>&1" ) assert status != 0, f"{name} was sent binary anyway: {output}" assert "BINARYMIME" in output, f"{name}: unexpected failure: {output}" with subtest("zig-smtp to zig-smtp, every octet survives BINARYMIME"): # All 256 byte values plus a lone dot line and a bare CR: content # that DATA could not carry at all, and that must arrive unchanged. machine.succeed( "python3 -c \"import sys;" "sys.stdout.buffer.write(bytes(range(256))+b'\\r\\n.\\r\\n\\rtail')\"" " > /tmp/binary.in" ) machine.succeed( "zig-smtp send --binarymime 127.0.0.1 2525 bob@example.com alice@example.net" " < /tmp/binary.in" ) machine.wait_until_succeeds( "journalctl -u zig-smtp-server | grep -F 'to (266 bytes)'", timeout=60, ) with subtest("zig-smtp client to postfix, SMTPUTF8"): machine.succeed( "printf 'Subject: interop\\r\\n\\r\\nzig-smtp to postfix utf8\\r\\n'" " | zig-smtp send --smtputf8 127.0.0.1 25" " 'böb@example.com' alice@localhost" ) machine.wait_until_succeeds( "grep -r 'zig-smtp to postfix utf8' /var/spool/mail/alice/", timeout=60 ) # Exim advertises only PLAIN and LOGIN, both of which put the password # on the wire, so the client refuses them over this plaintext loopback # connection unless it is told to allow it. with subtest("zig-smtp client to exim, AUTH PLAIN"): deliver( "--allow-cleartext-auth --user alice --password secret --auth-method plain", 2625, "zig-smtp to exim auth plain", "/var/spool/exim-mail/alice", ) with subtest("zig-smtp client to exim, AUTH LOGIN"): deliver( "--allow-cleartext-auth --user alice --password secret --auth-method login", 2625, "zig-smtp to exim auth login", "/var/spool/exim-mail/alice", ) with subtest("zig-smtp client to exim, wrong password is rejected"): machine.fail( "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" " | zig-smtp send --allow-cleartext-auth --user alice --password wrong" " 127.0.0.1 2625 bob@example.com alice@localhost" ) # The refusal itself, which is what stops a password reaching the network # by accident: the same delivery without the opt-in must not go through. with subtest("zig-smtp client refuses cleartext AUTH without the opt-in"): machine.fail( "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" " | zig-smtp send --user alice --password secret 127.0.0.1 2625" " bob@example.com alice@localhost" ) # ...and over STARTTLS it goes through with no opt-in at all. deliver( "--starttls --insecure --user alice --password secret", 2625, "zig-smtp to exim auth over starttls", "/var/spool/exim-mail/alice", ) with subtest("swaks to zig-smtp server, AUTH PLAIN"): machine.succeed( "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice" " --auth-password secret --from bob@example.com" " --to alice@example.net --body 'swaks to zig-smtp auth plain'" ) machine.wait_until_succeeds( "journalctl -u zig-smtp-server-auth | grep 'swaks to zig-smtp auth plain'", timeout=60, ) with subtest("swaks to zig-smtp server, AUTH LOGIN"): machine.succeed( "swaks --server 127.0.0.1:2527 --auth LOGIN --auth-user alice" " --auth-password secret --from bob@example.com" " --to alice@example.net --body 'swaks to zig-smtp auth login'" ) machine.wait_until_succeeds( "journalctl -u zig-smtp-server-auth | grep 'swaks to zig-smtp auth login'", timeout=60, ) with subtest("swaks to zig-smtp server, wrong password is rejected"): machine.fail( "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice" " --auth-password wrong --from bob@example.com" " --to alice@example.net --body nope" ) with subtest("unauthenticated mail to auth-required server is rejected"): machine.fail( "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" " | zig-smtp send 127.0.0.1 2527 bob@example.com alice@example.net" ) with subtest("swaks to zig-smtp server, plaintext"): machine.succeed( "swaks --server 127.0.0.1:2525 --from bob@example.com" " --to alice@example.net --header 'Subject: swaks plain'" " --body 'swaks to zig-smtp plain'" ) machine.wait_until_succeeds( "journalctl -u zig-smtp-server | grep 'swaks to zig-smtp plain'", timeout=60 ) with subtest("swaks to zig-smtp server, implicit TLS"): machine.succeed( "swaks --tlsc --server 127.0.0.1:2528 --from bob@example.com" " --to alice@example.net --header 'Subject: swaks tlsc'" " --body 'swaks to zig-smtp implicit tls'" ) machine.wait_until_succeeds( "journalctl -u zig-smtp-server-tlsc | grep 'swaks to zig-smtp implicit tls'", timeout=60, ) with subtest("swaks to zig-smtp server, STARTTLS"): machine.succeed( "swaks --tls --server 127.0.0.1:2526 --from bob@example.com" " --to alice@example.net --header 'Subject: swaks starttls'" " --body 'swaks to zig-smtp starttls'" ) machine.wait_until_succeeds( "journalctl -u zig-smtp-server-tls | grep 'swaks to zig-smtp starttls'", timeout=60 ) ''; }