An SMTP and LMTP client and server library for Zig, with TLS, SASL, PIPELINING, CHUNKING, DSN and the PROXY protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

zig-smtp / nix / interop-test.nix
22 kB 553 lines
1# SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us> 2# SPDX-License-Identifier: MIT 3 4# NixOS VM test exercising zig-smtp against third-party implementations: 5# - zig-smtp client -> Postfix (25/465) and Exim (2625/2626): plaintext, 6# STARTTLS, and implicit TLS against each, verified by checking local 7# delivery to alice's mailbox 8# - swaks -> zig-smtp server: plaintext and STARTTLS, verified by checking 9# the received message in the server's journal 10# - exim -> zig-smtp LMTP server (2529): a two-recipient delivery where the 11# server accepts one mailbox and refuses the other, which is the thing 12# LMTP exists to express and which exim has to read correctly 13# - zig-smtp LMTP client -> dovecot LMTP (2024) 14 15{ 16 testers, 17 callPackage, 18 runCommand, 19 openssl, 20}: 21let 22 zig-smtp = callPackage ../package.nix { }; 23 24 snakeoil = 25 runCommand "zig-smtp-test-cert" 26 { 27 nativeBuildInputs = [ openssl ]; 28 } 29 '' 30 mkdir -p $out 31 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \ 32 -keyout $out/key.pem -out $out/cert.pem -days 36500 -nodes \ 33 -subj "/CN=localhost" \ 34 -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 35 ''; 36in 37testers.runNixOSTest { 38 name = "zig-smtp-interop"; 39 40 nodes.machine = 41 { pkgs, ... }: 42 { 43 environment.systemPackages = [ 44 zig-smtp 45 pkgs.swaks 46 pkgs.netcat 47 pkgs.python3 48 ]; 49 50 users.users.alice.isNormalUser = true; 51 52 # A real LMTP server for the zig-smtp LMTP client to deliver to. 53 services.dovecot2 = { 54 enable = true; 55 # alice is a normal user declared above, not one for dovecot to make. 56 createMailUser = false; 57 settings = { 58 protocols = [ "lmtp" ]; 59 # Dovecot 2.4 requires both of these to be stated rather than 60 # inferred, so that a version bump cannot silently change meaning. 61 dovecot_config_version = "2.4.5"; 62 dovecot_storage_version = "2.4.5"; 63 mail_driver = "maildir"; 64 mail_path = "/var/spool/dovecot-mail/%{user}"; 65 # Deliveries arrive addressed to alice@localhost; the mailbox is 66 # alice, so the domain is stripped before the userdb lookup. 67 auth_username_format = "%{user | username}"; 68 mail_uid = "alice"; 69 mail_gid = "users"; 70 # LMTP resolves each recipient through the userdb, and dovecot's 71 # auth process refuses to start without a passdb beside it even 72 # though nothing here authenticates. 73 "userdb passwd" = { }; 74 "passdb pam" = { }; 75 # Dovecot 2.4 takes the address from the global `listen` rather 76 # than from the listener block, which only names the port. 77 listen = "127.0.0.1"; 78 service = [ 79 { 80 _section.name = "lmtp"; 81 "inet_listener lmtp".port = 2024; 82 } 83 ]; 84 }; 85 }; 86 87 services.postfix = { 88 enable = true; 89 # Implicit-TLS smtpd on port 465; allow loopback without SASL. 90 enableSubmissions = true; 91 submissionsOptions = { 92 smtpd_client_restrictions = "permit_mynetworks,reject"; 93 }; 94 settings.main = { 95 mydestination = [ 96 "localhost" 97 "$myhostname" 98 ]; 99 smtpd_tls_security_level = "may"; 100 smtpd_tls_chain_files = [ 101 "${snakeoil}/key.pem" 102 "${snakeoil}/cert.pem" 103 ]; 104 }; 105 }; 106 107 # Exim: plaintext + STARTTLS on 2625, implicit TLS on 2626, delivering 108 # to /var/spool/exim-mail/<user>. 109 services.exim = { 110 enable = true; 111 config = '' 112 primary_hostname = machine.test 113 qualify_domain = localhost 114 local_interfaces = 127.0.0.1 115 daemon_smtp_ports = 2625 : 2626 116 tls_on_connect_ports = 2626 117 tls_advertise_hosts = * 118 # Off by default in exim, and the point of the DSN subtest below. 119 dsn_advertise_hosts = * 120 tls_certificate = ${snakeoil}/cert.pem 121 tls_privatekey = ${snakeoil}/key.pem 122 acl_smtp_rcpt = acl_rcpt 123 124 begin acl 125 126 acl_rcpt: 127 accept 128 129 begin routers 130 131 # Everything for lmtp.test goes to the zig-smtp LMTP server, which 132 # accepts one of the two mailboxes below and refuses the other. 133 # Listed first because the first matching router wins. 134 lmtp_route: 135 driver = manualroute 136 domains = lmtp.test 137 transport = lmtp_out 138 route_list = * 127.0.0.1 139 # 127.0.0.1 is this machine, which exim otherwise refuses to 140 # route to; `self = send` and the transport's allow_localhost 141 # are the two halves of saying "yes, really, deliver there". 142 self = send 143 144 local_users: 145 driver = accept 146 local_parts = alice : bob 147 transport = local_delivery 148 149 begin transports 150 151 lmtp_out: 152 driver = smtp 153 protocol = lmtp 154 # Stated numerically because exim otherwise looks up the 155 # service name "lmtp", which /etc/services does not have. 156 port = 2529 157 # Without this exim refuses to deliver to its own machine. 158 allow_localhost 159 hosts_try_fastopen = 160 161 local_delivery: 162 driver = appendfile 163 file = /var/spool/exim-mail/$local_part_data 164 user = exim 165 delivery_date_add 166 envelope_to_add 167 return_path_add 168 # Exim keeps the MAIL AUTH= value in $authenticated_sender but 169 # does not log it, so the delivered message is where a test can 170 # see that it arrived and was kept. 171 headers_add = X-Authenticated-Sender: $authenticated_sender 172 173 begin authenticators 174 175 plain_server: 176 driver = plaintext 177 public_name = PLAIN 178 server_prompts = : 179 server_condition = ''${if and{{eq{$auth2}{alice}}{eq{$auth3}{secret}}}} 180 server_set_id = $auth2 181 182 login_server: 183 driver = plaintext 184 public_name = LOGIN 185 server_prompts = Username:: : Password:: 186 server_condition = ''${if and{{eq{$auth1}{alice}}{eq{$auth2}{secret}}}} 187 server_set_id = $auth1 188 ''; 189 }; 190 191 systemd.tmpfiles.rules = [ 192 "d /var/spool/exim-mail 0755 exim exim -" 193 "d /var/spool/dovecot-mail 0755 alice users -" 194 ]; 195 196 systemd.services.zig-smtp-server = { 197 description = "zig-smtp debug server (plaintext)"; 198 wantedBy = [ "multi-user.target" ]; 199 serviceConfig = { 200 ExecStart = "${zig-smtp}/bin/zig-smtp serve 2525"; 201 DynamicUser = true; 202 }; 203 }; 204 205 systemd.services.zig-smtp-server-tls = { 206 description = "zig-smtp debug server (STARTTLS)"; 207 wantedBy = [ "multi-user.target" ]; 208 serviceConfig = { 209 ExecStart = "${zig-smtp}/bin/zig-smtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem 2526"; 210 DynamicUser = true; 211 }; 212 }; 213 214 systemd.services.zig-smtp-server-tlsc = { 215 description = "zig-smtp debug server (implicit TLS)"; 216 wantedBy = [ "multi-user.target" ]; 217 serviceConfig = { 218 ExecStart = "${zig-smtp}/bin/zig-smtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem --implicit-tls 2528"; 219 DynamicUser = true; 220 }; 221 }; 222 223 systemd.services.zig-smtp-server-lmtp = { 224 description = "zig-smtp debug server (LMTP)"; 225 wantedBy = [ "multi-user.target" ]; 226 serviceConfig = { 227 ExecStart = "${zig-smtp}/bin/zig-smtp serve --lmtp --fail-delivery bad@lmtp.test 2529"; 228 DynamicUser = true; 229 }; 230 }; 231 232 systemd.services.zig-smtp-server-auth = { 233 description = "zig-smtp debug server (authentication required)"; 234 wantedBy = [ "multi-user.target" ]; 235 serviceConfig = { 236 ExecStart = "${zig-smtp}/bin/zig-smtp serve --auth alice:secret 2527"; 237 DynamicUser = true; 238 }; 239 }; 240 }; 241 242 testScript = '' 243 machine.wait_for_unit("postfix.service") 244 machine.wait_for_open_port(25) 245 machine.wait_for_open_port(465) 246 machine.wait_for_unit("exim.service") 247 machine.wait_for_open_port(2625) 248 machine.wait_for_open_port(2626) 249 machine.wait_for_unit("zig-smtp-server.service") 250 machine.wait_for_unit("zig-smtp-server-tls.service") 251 machine.wait_for_unit("zig-smtp-server-tlsc.service") 252 machine.wait_for_unit("zig-smtp-server-auth.service") 253 machine.wait_for_unit("zig-smtp-server-lmtp.service") 254 machine.wait_for_unit("dovecot.service") 255 machine.wait_for_open_port(2529) 256 machine.wait_for_open_port(2024) 257 machine.wait_for_open_port(2525) 258 machine.wait_for_open_port(2526) 259 machine.wait_for_open_port(2527) 260 machine.wait_for_open_port(2528) 261 262 263 def deliver(flags, port, needle, mailbox): 264 machine.succeed( 265 f"printf 'Subject: interop\\r\\n\\r\\n{needle}\\r\\n'" 266 f" | zig-smtp send {flags} 127.0.0.1 {port}" 267 " bob@example.com alice@localhost" 268 ) 269 machine.wait_until_succeeds(f"grep -r '{needle}' {mailbox}", timeout=60) 270 271 272 servers = { 273 "postfix": (25, 465, "/var/spool/mail/alice/"), 274 "exim": (2625, 2626, "/var/spool/exim-mail/alice"), 275 } 276 277 for name, (port, tls_port, mailbox) in servers.items(): 278 with subtest(f"zig-smtp client to {name}, plaintext"): 279 deliver("", port, f"zig-smtp to {name} plain", mailbox) 280 281 with subtest(f"zig-smtp client to {name}, STARTTLS"): 282 deliver( 283 "--starttls --insecure", port, f"zig-smtp to {name} starttls", mailbox 284 ) 285 286 with subtest(f"zig-smtp client to {name}, implicit TLS"): 287 deliver("--tls --insecure", tls_port, f"zig-smtp to {name} smtps", mailbox) 288 289 for name, (port, tls_port, mailbox) in servers.items(): 290 with subtest(f"zig-smtp client to {name}, CHUNKING"): 291 deliver("--chunking", port, f"zig-smtp to {name} chunked", mailbox) 292 293 # RFC 3461. Postfix advertises DSN out of the box; exim is told to above. 294 for name, (port, tls_port, mailbox) in servers.items(): 295 with subtest(f"zig-smtp client to {name}, DSN parameters"): 296 deliver( 297 "--ret hdrs --envid batch7 --notify success,failure" 298 " --orcpt team@example.net", 299 port, 300 f"zig-smtp to {name} dsn", 301 mailbox, 302 ) 303 304 with subtest("zig-smtp client to zig-smtp server, DSN parameters round trip"): 305 machine.succeed( 306 "printf 'Subject: interop\\r\\n\\r\\nzig-smtp dsn round trip\\r\\n'" 307 " | zig-smtp send --ret full --envid 'batch 7'" 308 " --notify success,delay --orcpt 'team+list@example.net'" 309 " 127.0.0.1 2525 bob@example.com alice@example.net" 310 ) 311 # The server prints what it parsed: the ENVID comes back with its 312 # space, and the ORCPT with the '+' that had to be xtext-encoded. 313 machine.wait_until_succeeds( 314 "journalctl -u zig-smtp-server | grep -F" 315 " 'NOTIFY=SUCCESS,DELAY ORCPT=rfc822;team+2Blist@example.net" 316 " RET=FULL ENVID=batch 7'", 317 timeout=60, 318 ) 319 320 # RFC 2033. The point of LMTP is a separate verdict per mailbox, so the 321 # cases that matter are the ones where those verdicts differ. 322 with subtest("exim to zig-smtp LMTP server, one mailbox accepted and one refused"): 323 machine.succeed( 324 "swaks --server 127.0.0.1:2625 --from bob@example.com" 325 # One --to with both, since a second --to replaces the first. 326 " --to good@lmtp.test,bad@lmtp.test" 327 " --header 'Subject: lmtp' --body 'exim to zig-smtp lmtp'" 328 ) 329 # Both recipients in one transaction, which is what makes the two 330 # differing verdicts possible. 331 machine.wait_until_succeeds( 332 "journalctl -u zig-smtp-server-lmtp | grep -F" 333 " '<good@lmtp.test> <bad@lmtp.test>'", 334 timeout=60, 335 ) 336 # Exim read the two replies and applied them separately: '=>' is a 337 # delivery and '**' a permanent failure, both for the one message, 338 # which is exactly what SMTP could not have told it. 339 machine.wait_until_succeeds( 340 "journalctl -u exim | grep -F '=> good@lmtp.test'", timeout=60 341 ) 342 machine.wait_until_succeeds( 343 "journalctl -u exim | grep -F '** bad@lmtp.test'", timeout=60 344 ) 345 machine.succeed("journalctl -u exim | grep -F 'Mailbox disabled'") 346 347 with subtest("zig-smtp LMTP client to dovecot"): 348 machine.succeed( 349 "printf 'Subject: interop\\r\\n\\r\\nzig-smtp to dovecot lmtp\\r\\n'" 350 " | zig-smtp send --lmtp 127.0.0.1 2024 bob@example.com alice@localhost" 351 ) 352 machine.wait_until_succeeds( 353 "grep -r 'zig-smtp to dovecot lmtp' /var/spool/dovecot-mail/alice/", timeout=60 354 ) 355 356 with subtest("zig-smtp LMTP client reports dovecot's refusal of one recipient"): 357 # Two recipients, one of whom does not exist: dovecot accepts the 358 # RCPT for alice and refuses nosuchuser outright, so this fails at 359 # RCPT rather than at the end of data -- still per-recipient, and 360 # still the client's job to report which. 361 status, output = machine.execute( 362 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 363 " | zig-smtp send --lmtp 127.0.0.1 2024 bob@example.com nosuchuser@localhost 2>&1" 364 ) 365 assert status != 0, f"expected a failure, got: {output}" 366 367 with subtest("dovecot refuses EHLO, as an LMTP server must"): 368 status, output = machine.execute( 369 "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2024" 370 ) 371 assert "250-" not in output, f"dovecot answered EHLO positively: {output}" 372 # And zig-smtp's own LMTP server says the same thing. 373 status, output = machine.execute( 374 "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2529" 375 ) 376 assert "500" in output, f"expected a 500 for EHLO, got: {output}" 377 378 # RFC 3030 BINARYMIME. Neither postfix nor exim offers it, which is 379 # what makes them the test of the refusal: RFC 3030 is absolute that 380 # binary must not be sent to a server that did not advertise it. 381 with subtest("zig-smtp refuses to send binary to a server that lacks BINARYMIME"): 382 for name, (port, _tls, _mailbox) in servers.items(): 383 status, output = machine.execute( 384 f"printf 'x' | zig-smtp send --binarymime 127.0.0.1 {port}" 385 " bob@example.com alice@localhost 2>&1" 386 ) 387 assert status != 0, f"{name} was sent binary anyway: {output}" 388 assert "BINARYMIME" in output, f"{name}: unexpected failure: {output}" 389 390 with subtest("zig-smtp to zig-smtp, every octet survives BINARYMIME"): 391 # All 256 byte values plus a lone dot line and a bare CR: content 392 # that DATA could not carry at all, and that must arrive unchanged. 393 machine.succeed( 394 "python3 -c \"import sys;" 395 "sys.stdout.buffer.write(bytes(range(256))+b'\\r\\n.\\r\\n\\rtail')\"" 396 " > /tmp/binary.in" 397 ) 398 machine.succeed( 399 "zig-smtp send --binarymime 127.0.0.1 2525 bob@example.com alice@example.net" 400 " < /tmp/binary.in" 401 ) 402 machine.wait_until_succeeds( 403 "journalctl -u zig-smtp-server | grep -F 'to <alice@example.net> (266 bytes)'", 404 timeout=60, 405 ) 406 407 # RFC 4954 section 5: a relay carrying somebody else's mail names the 408 # original submitter, and the receiving server takes it only from a peer 409 # it has authenticated. Exim is the one here that offers AUTH. 410 with subtest("zig-smtp client to exim, AUTH= on MAIL FROM"): 411 deliver( 412 "--allow-cleartext-auth --user alice --password secret" 413 " --submitter 'e=mc2@example.com'", 414 2625, 415 "zig-smtp to exim auth= parameter", 416 "/var/spool/exim-mail/alice", 417 ) 418 # Exim kept the submitter this client asserted, which means it both 419 # decoded the xtext and believed an authenticated peer. 420 machine.wait_until_succeeds( 421 "grep -r 'X-Authenticated-Sender: e=mc2@example.com'" 422 " /var/spool/exim-mail/alice", 423 timeout=60, 424 ) 425 426 with subtest("zig-smtp refuses AUTH= to a server that does not offer AUTH"): 427 status, output = machine.execute( 428 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 429 " | zig-smtp send --submitter alice@example.com 127.0.0.1 25" 430 " bob@example.com alice@localhost 2>&1" 431 ) 432 assert status != 0, f"postfix was sent AUTH= anyway: {output}" 433 assert "does not advertise AUTH" in output, f"unexpected failure: {output}" 434 435 with subtest("zig-smtp client to postfix, SMTPUTF8"): 436 machine.succeed( 437 "printf 'Subject: interop\\r\\n\\r\\nzig-smtp to postfix utf8\\r\\n'" 438 " | zig-smtp send --smtputf8 127.0.0.1 25" 439 " 'böb@example.com' alice@localhost" 440 ) 441 machine.wait_until_succeeds( 442 "grep -r 'zig-smtp to postfix utf8' /var/spool/mail/alice/", timeout=60 443 ) 444 445 # Exim advertises only PLAIN and LOGIN, both of which put the password 446 # on the wire, so the client refuses them over this plaintext loopback 447 # connection unless it is told to allow it. 448 with subtest("zig-smtp client to exim, AUTH PLAIN"): 449 deliver( 450 "--allow-cleartext-auth --user alice --password secret --auth-method plain", 451 2625, 452 "zig-smtp to exim auth plain", 453 "/var/spool/exim-mail/alice", 454 ) 455 456 with subtest("zig-smtp client to exim, AUTH LOGIN"): 457 deliver( 458 "--allow-cleartext-auth --user alice --password secret --auth-method login", 459 2625, 460 "zig-smtp to exim auth login", 461 "/var/spool/exim-mail/alice", 462 ) 463 464 with subtest("zig-smtp client to exim, wrong password is rejected"): 465 machine.fail( 466 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 467 " | zig-smtp send --allow-cleartext-auth --user alice --password wrong" 468 " 127.0.0.1 2625 bob@example.com alice@localhost" 469 ) 470 471 # The refusal itself, which is what stops a password reaching the network 472 # by accident: the same delivery without the opt-in must not go through. 473 with subtest("zig-smtp client refuses cleartext AUTH without the opt-in"): 474 machine.fail( 475 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 476 " | zig-smtp send --user alice --password secret 127.0.0.1 2625" 477 " bob@example.com alice@localhost" 478 ) 479 # ...and over STARTTLS it goes through with no opt-in at all. 480 deliver( 481 "--starttls --insecure --user alice --password secret", 482 2625, 483 "zig-smtp to exim auth over starttls", 484 "/var/spool/exim-mail/alice", 485 ) 486 487 with subtest("swaks to zig-smtp server, AUTH PLAIN"): 488 machine.succeed( 489 "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice" 490 " --auth-password secret --from bob@example.com" 491 " --to alice@example.net --body 'swaks to zig-smtp auth plain'" 492 ) 493 machine.wait_until_succeeds( 494 "journalctl -u zig-smtp-server-auth | grep 'swaks to zig-smtp auth plain'", 495 timeout=60, 496 ) 497 498 with subtest("swaks to zig-smtp server, AUTH LOGIN"): 499 machine.succeed( 500 "swaks --server 127.0.0.1:2527 --auth LOGIN --auth-user alice" 501 " --auth-password secret --from bob@example.com" 502 " --to alice@example.net --body 'swaks to zig-smtp auth login'" 503 ) 504 machine.wait_until_succeeds( 505 "journalctl -u zig-smtp-server-auth | grep 'swaks to zig-smtp auth login'", 506 timeout=60, 507 ) 508 509 with subtest("swaks to zig-smtp server, wrong password is rejected"): 510 machine.fail( 511 "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice" 512 " --auth-password wrong --from bob@example.com" 513 " --to alice@example.net --body nope" 514 ) 515 516 with subtest("unauthenticated mail to auth-required server is rejected"): 517 machine.fail( 518 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 519 " | zig-smtp send 127.0.0.1 2527 bob@example.com alice@example.net" 520 ) 521 522 with subtest("swaks to zig-smtp server, plaintext"): 523 machine.succeed( 524 "swaks --server 127.0.0.1:2525 --from bob@example.com" 525 " --to alice@example.net --header 'Subject: swaks plain'" 526 " --body 'swaks to zig-smtp plain'" 527 ) 528 machine.wait_until_succeeds( 529 "journalctl -u zig-smtp-server | grep 'swaks to zig-smtp plain'", timeout=60 530 ) 531 532 with subtest("swaks to zig-smtp server, implicit TLS"): 533 machine.succeed( 534 "swaks --tlsc --server 127.0.0.1:2528 --from bob@example.com" 535 " --to alice@example.net --header 'Subject: swaks tlsc'" 536 " --body 'swaks to zig-smtp implicit tls'" 537 ) 538 machine.wait_until_succeeds( 539 "journalctl -u zig-smtp-server-tlsc | grep 'swaks to zig-smtp implicit tls'", 540 timeout=60, 541 ) 542 543 with subtest("swaks to zig-smtp server, STARTTLS"): 544 machine.succeed( 545 "swaks --tls --server 127.0.0.1:2526 --from bob@example.com" 546 " --to alice@example.net --header 'Subject: swaks starttls'" 547 " --body 'swaks to zig-smtp starttls'" 548 ) 549 machine.wait_until_succeeds( 550 "journalctl -u zig-smtp-server-tls | grep 'swaks to zig-smtp starttls'", timeout=60 551 ) 552 ''; 553}