An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

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

zig-smtp / nix / interop-test.nix
21 kB 521 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 169 begin authenticators 170 171 plain_server: 172 driver = plaintext 173 public_name = PLAIN 174 server_prompts = : 175 server_condition = ''${if and{{eq{$auth2}{alice}}{eq{$auth3}{secret}}}} 176 server_set_id = $auth2 177 178 login_server: 179 driver = plaintext 180 public_name = LOGIN 181 server_prompts = Username:: : Password:: 182 server_condition = ''${if and{{eq{$auth1}{alice}}{eq{$auth2}{secret}}}} 183 server_set_id = $auth1 184 ''; 185 }; 186 187 systemd.tmpfiles.rules = [ 188 "d /var/spool/exim-mail 0755 exim exim -" 189 "d /var/spool/dovecot-mail 0755 alice users -" 190 ]; 191 192 systemd.services.zig-smtp-server = { 193 description = "zig-smtp debug server (plaintext)"; 194 wantedBy = [ "multi-user.target" ]; 195 serviceConfig = { 196 ExecStart = "${zig-smtp}/bin/zig-smtp serve 2525"; 197 DynamicUser = true; 198 }; 199 }; 200 201 systemd.services.zig-smtp-server-tls = { 202 description = "zig-smtp debug server (STARTTLS)"; 203 wantedBy = [ "multi-user.target" ]; 204 serviceConfig = { 205 ExecStart = "${zig-smtp}/bin/zig-smtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem 2526"; 206 DynamicUser = true; 207 }; 208 }; 209 210 systemd.services.zig-smtp-server-tlsc = { 211 description = "zig-smtp debug server (implicit TLS)"; 212 wantedBy = [ "multi-user.target" ]; 213 serviceConfig = { 214 ExecStart = "${zig-smtp}/bin/zig-smtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem --implicit-tls 2528"; 215 DynamicUser = true; 216 }; 217 }; 218 219 systemd.services.zig-smtp-server-lmtp = { 220 description = "zig-smtp debug server (LMTP)"; 221 wantedBy = [ "multi-user.target" ]; 222 serviceConfig = { 223 ExecStart = "${zig-smtp}/bin/zig-smtp serve --lmtp --fail-delivery bad@lmtp.test 2529"; 224 DynamicUser = true; 225 }; 226 }; 227 228 systemd.services.zig-smtp-server-auth = { 229 description = "zig-smtp debug server (authentication required)"; 230 wantedBy = [ "multi-user.target" ]; 231 serviceConfig = { 232 ExecStart = "${zig-smtp}/bin/zig-smtp serve --auth alice:secret 2527"; 233 DynamicUser = true; 234 }; 235 }; 236 }; 237 238 testScript = '' 239 machine.wait_for_unit("postfix.service") 240 machine.wait_for_open_port(25) 241 machine.wait_for_open_port(465) 242 machine.wait_for_unit("exim.service") 243 machine.wait_for_open_port(2625) 244 machine.wait_for_open_port(2626) 245 machine.wait_for_unit("zig-smtp-server.service") 246 machine.wait_for_unit("zig-smtp-server-tls.service") 247 machine.wait_for_unit("zig-smtp-server-tlsc.service") 248 machine.wait_for_unit("zig-smtp-server-auth.service") 249 machine.wait_for_unit("zig-smtp-server-lmtp.service") 250 machine.wait_for_unit("dovecot.service") 251 machine.wait_for_open_port(2529) 252 machine.wait_for_open_port(2024) 253 machine.wait_for_open_port(2525) 254 machine.wait_for_open_port(2526) 255 machine.wait_for_open_port(2527) 256 machine.wait_for_open_port(2528) 257 258 259 def deliver(flags, port, needle, mailbox): 260 machine.succeed( 261 f"printf 'Subject: interop\\r\\n\\r\\n{needle}\\r\\n'" 262 f" | zig-smtp send {flags} 127.0.0.1 {port}" 263 " bob@example.com alice@localhost" 264 ) 265 machine.wait_until_succeeds(f"grep -r '{needle}' {mailbox}", timeout=60) 266 267 268 servers = { 269 "postfix": (25, 465, "/var/spool/mail/alice/"), 270 "exim": (2625, 2626, "/var/spool/exim-mail/alice"), 271 } 272 273 for name, (port, tls_port, mailbox) in servers.items(): 274 with subtest(f"zig-smtp client to {name}, plaintext"): 275 deliver("", port, f"zig-smtp to {name} plain", mailbox) 276 277 with subtest(f"zig-smtp client to {name}, STARTTLS"): 278 deliver( 279 "--starttls --insecure", port, f"zig-smtp to {name} starttls", mailbox 280 ) 281 282 with subtest(f"zig-smtp client to {name}, implicit TLS"): 283 deliver("--tls --insecure", tls_port, f"zig-smtp to {name} smtps", mailbox) 284 285 for name, (port, tls_port, mailbox) in servers.items(): 286 with subtest(f"zig-smtp client to {name}, CHUNKING"): 287 deliver("--chunking", port, f"zig-smtp to {name} chunked", mailbox) 288 289 # RFC 3461. Postfix advertises DSN out of the box; exim is told to above. 290 for name, (port, tls_port, mailbox) in servers.items(): 291 with subtest(f"zig-smtp client to {name}, DSN parameters"): 292 deliver( 293 "--ret hdrs --envid batch7 --notify success,failure" 294 " --orcpt team@example.net", 295 port, 296 f"zig-smtp to {name} dsn", 297 mailbox, 298 ) 299 300 with subtest("zig-smtp client to zig-smtp server, DSN parameters round trip"): 301 machine.succeed( 302 "printf 'Subject: interop\\r\\n\\r\\nzig-smtp dsn round trip\\r\\n'" 303 " | zig-smtp send --ret full --envid 'batch 7'" 304 " --notify success,delay --orcpt 'team+list@example.net'" 305 " 127.0.0.1 2525 bob@example.com alice@example.net" 306 ) 307 # The server prints what it parsed: the ENVID comes back with its 308 # space, and the ORCPT with the '+' that had to be xtext-encoded. 309 machine.wait_until_succeeds( 310 "journalctl -u zig-smtp-server | grep -F" 311 " 'NOTIFY=SUCCESS,DELAY ORCPT=rfc822;team+2Blist@example.net" 312 " RET=FULL ENVID=batch 7'", 313 timeout=60, 314 ) 315 316 # RFC 2033. The point of LMTP is a separate verdict per mailbox, so the 317 # cases that matter are the ones where those verdicts differ. 318 with subtest("exim to zig-smtp LMTP server, one mailbox accepted and one refused"): 319 machine.succeed( 320 "swaks --server 127.0.0.1:2625 --from bob@example.com" 321 # One --to with both, since a second --to replaces the first. 322 " --to good@lmtp.test,bad@lmtp.test" 323 " --header 'Subject: lmtp' --body 'exim to zig-smtp lmtp'" 324 ) 325 # Both recipients in one transaction, which is what makes the two 326 # differing verdicts possible. 327 machine.wait_until_succeeds( 328 "journalctl -u zig-smtp-server-lmtp | grep -F" 329 " '<good@lmtp.test> <bad@lmtp.test>'", 330 timeout=60, 331 ) 332 # Exim read the two replies and applied them separately: '=>' is a 333 # delivery and '**' a permanent failure, both for the one message, 334 # which is exactly what SMTP could not have told it. 335 machine.wait_until_succeeds( 336 "journalctl -u exim | grep -F '=> good@lmtp.test'", timeout=60 337 ) 338 machine.wait_until_succeeds( 339 "journalctl -u exim | grep -F '** bad@lmtp.test'", timeout=60 340 ) 341 machine.succeed("journalctl -u exim | grep -F 'Mailbox disabled'") 342 343 with subtest("zig-smtp LMTP client to dovecot"): 344 machine.succeed( 345 "printf 'Subject: interop\\r\\n\\r\\nzig-smtp to dovecot lmtp\\r\\n'" 346 " | zig-smtp send --lmtp 127.0.0.1 2024 bob@example.com alice@localhost" 347 ) 348 machine.wait_until_succeeds( 349 "grep -r 'zig-smtp to dovecot lmtp' /var/spool/dovecot-mail/alice/", timeout=60 350 ) 351 352 with subtest("zig-smtp LMTP client reports dovecot's refusal of one recipient"): 353 # Two recipients, one of whom does not exist: dovecot accepts the 354 # RCPT for alice and refuses nosuchuser outright, so this fails at 355 # RCPT rather than at the end of data -- still per-recipient, and 356 # still the client's job to report which. 357 status, output = machine.execute( 358 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 359 " | zig-smtp send --lmtp 127.0.0.1 2024 bob@example.com nosuchuser@localhost 2>&1" 360 ) 361 assert status != 0, f"expected a failure, got: {output}" 362 363 with subtest("dovecot refuses EHLO, as an LMTP server must"): 364 status, output = machine.execute( 365 "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2024" 366 ) 367 assert "250-" not in output, f"dovecot answered EHLO positively: {output}" 368 # And zig-smtp's own LMTP server says the same thing. 369 status, output = machine.execute( 370 "printf 'EHLO x\\r\\nQUIT\\r\\n' | timeout 5 nc 127.0.0.1 2529" 371 ) 372 assert "500" in output, f"expected a 500 for EHLO, got: {output}" 373 374 # RFC 3030 BINARYMIME. Neither postfix nor exim offers it, which is 375 # what makes them the test of the refusal: RFC 3030 is absolute that 376 # binary must not be sent to a server that did not advertise it. 377 with subtest("zig-smtp refuses to send binary to a server that lacks BINARYMIME"): 378 for name, (port, _tls, _mailbox) in servers.items(): 379 status, output = machine.execute( 380 f"printf 'x' | zig-smtp send --binarymime 127.0.0.1 {port}" 381 " bob@example.com alice@localhost 2>&1" 382 ) 383 assert status != 0, f"{name} was sent binary anyway: {output}" 384 assert "BINARYMIME" in output, f"{name}: unexpected failure: {output}" 385 386 with subtest("zig-smtp to zig-smtp, every octet survives BINARYMIME"): 387 # All 256 byte values plus a lone dot line and a bare CR: content 388 # that DATA could not carry at all, and that must arrive unchanged. 389 machine.succeed( 390 "python3 -c \"import sys;" 391 "sys.stdout.buffer.write(bytes(range(256))+b'\\r\\n.\\r\\n\\rtail')\"" 392 " > /tmp/binary.in" 393 ) 394 machine.succeed( 395 "zig-smtp send --binarymime 127.0.0.1 2525 bob@example.com alice@example.net" 396 " < /tmp/binary.in" 397 ) 398 machine.wait_until_succeeds( 399 "journalctl -u zig-smtp-server | grep -F 'to <alice@example.net> (266 bytes)'", 400 timeout=60, 401 ) 402 403 with subtest("zig-smtp client to postfix, SMTPUTF8"): 404 machine.succeed( 405 "printf 'Subject: interop\\r\\n\\r\\nzig-smtp to postfix utf8\\r\\n'" 406 " | zig-smtp send --smtputf8 127.0.0.1 25" 407 " 'böb@example.com' alice@localhost" 408 ) 409 machine.wait_until_succeeds( 410 "grep -r 'zig-smtp to postfix utf8' /var/spool/mail/alice/", timeout=60 411 ) 412 413 # Exim advertises only PLAIN and LOGIN, both of which put the password 414 # on the wire, so the client refuses them over this plaintext loopback 415 # connection unless it is told to allow it. 416 with subtest("zig-smtp client to exim, AUTH PLAIN"): 417 deliver( 418 "--allow-cleartext-auth --user alice --password secret --auth-method plain", 419 2625, 420 "zig-smtp to exim auth plain", 421 "/var/spool/exim-mail/alice", 422 ) 423 424 with subtest("zig-smtp client to exim, AUTH LOGIN"): 425 deliver( 426 "--allow-cleartext-auth --user alice --password secret --auth-method login", 427 2625, 428 "zig-smtp to exim auth login", 429 "/var/spool/exim-mail/alice", 430 ) 431 432 with subtest("zig-smtp client to exim, wrong password is rejected"): 433 machine.fail( 434 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 435 " | zig-smtp send --allow-cleartext-auth --user alice --password wrong" 436 " 127.0.0.1 2625 bob@example.com alice@localhost" 437 ) 438 439 # The refusal itself, which is what stops a password reaching the network 440 # by accident: the same delivery without the opt-in must not go through. 441 with subtest("zig-smtp client refuses cleartext AUTH without the opt-in"): 442 machine.fail( 443 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 444 " | zig-smtp send --user alice --password secret 127.0.0.1 2625" 445 " bob@example.com alice@localhost" 446 ) 447 # ...and over STARTTLS it goes through with no opt-in at all. 448 deliver( 449 "--starttls --insecure --user alice --password secret", 450 2625, 451 "zig-smtp to exim auth over starttls", 452 "/var/spool/exim-mail/alice", 453 ) 454 455 with subtest("swaks to zig-smtp server, AUTH PLAIN"): 456 machine.succeed( 457 "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice" 458 " --auth-password secret --from bob@example.com" 459 " --to alice@example.net --body 'swaks to zig-smtp auth plain'" 460 ) 461 machine.wait_until_succeeds( 462 "journalctl -u zig-smtp-server-auth | grep 'swaks to zig-smtp auth plain'", 463 timeout=60, 464 ) 465 466 with subtest("swaks to zig-smtp server, AUTH LOGIN"): 467 machine.succeed( 468 "swaks --server 127.0.0.1:2527 --auth LOGIN --auth-user alice" 469 " --auth-password secret --from bob@example.com" 470 " --to alice@example.net --body 'swaks to zig-smtp auth login'" 471 ) 472 machine.wait_until_succeeds( 473 "journalctl -u zig-smtp-server-auth | grep 'swaks to zig-smtp auth login'", 474 timeout=60, 475 ) 476 477 with subtest("swaks to zig-smtp server, wrong password is rejected"): 478 machine.fail( 479 "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice" 480 " --auth-password wrong --from bob@example.com" 481 " --to alice@example.net --body nope" 482 ) 483 484 with subtest("unauthenticated mail to auth-required server is rejected"): 485 machine.fail( 486 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'" 487 " | zig-smtp send 127.0.0.1 2527 bob@example.com alice@example.net" 488 ) 489 490 with subtest("swaks to zig-smtp server, plaintext"): 491 machine.succeed( 492 "swaks --server 127.0.0.1:2525 --from bob@example.com" 493 " --to alice@example.net --header 'Subject: swaks plain'" 494 " --body 'swaks to zig-smtp plain'" 495 ) 496 machine.wait_until_succeeds( 497 "journalctl -u zig-smtp-server | grep 'swaks to zig-smtp plain'", timeout=60 498 ) 499 500 with subtest("swaks to zig-smtp server, implicit TLS"): 501 machine.succeed( 502 "swaks --tlsc --server 127.0.0.1:2528 --from bob@example.com" 503 " --to alice@example.net --header 'Subject: swaks tlsc'" 504 " --body 'swaks to zig-smtp implicit tls'" 505 ) 506 machine.wait_until_succeeds( 507 "journalctl -u zig-smtp-server-tlsc | grep 'swaks to zig-smtp implicit tls'", 508 timeout=60, 509 ) 510 511 with subtest("swaks to zig-smtp server, STARTTLS"): 512 machine.succeed( 513 "swaks --tls --server 127.0.0.1:2526 --from bob@example.com" 514 " --to alice@example.net --header 'Subject: swaks starttls'" 515 " --body 'swaks to zig-smtp starttls'" 516 ) 517 machine.wait_until_succeeds( 518 "journalctl -u zig-smtp-server-tls | grep 'swaks to zig-smtp starttls'", timeout=60 519 ) 520 ''; 521}