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