An SMTP client and server library for Zig implementing RFC 5321.
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}