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 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 pkgs.python3
48 ];
49
50 users.users.alice.isNormalUser = true;
51
52 # A real LMTP server for the zsmtp 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 zsmtp 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.zsmtp-server = {
193 description = "zsmtp debug server (plaintext)";
194 wantedBy = [ "multi-user.target" ];
195 serviceConfig = {
196 ExecStart = "${zsmtp}/bin/zsmtp serve 2525";
197 DynamicUser = true;
198 };
199 };
200
201 systemd.services.zsmtp-server-tls = {
202 description = "zsmtp debug server (STARTTLS)";
203 wantedBy = [ "multi-user.target" ];
204 serviceConfig = {
205 ExecStart = "${zsmtp}/bin/zsmtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem 2526";
206 DynamicUser = true;
207 };
208 };
209
210 systemd.services.zsmtp-server-tlsc = {
211 description = "zsmtp debug server (implicit TLS)";
212 wantedBy = [ "multi-user.target" ];
213 serviceConfig = {
214 ExecStart = "${zsmtp}/bin/zsmtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem --implicit-tls 2528";
215 DynamicUser = true;
216 };
217 };
218
219 systemd.services.zsmtp-server-lmtp = {
220 description = "zsmtp debug server (LMTP)";
221 wantedBy = [ "multi-user.target" ];
222 serviceConfig = {
223 ExecStart = "${zsmtp}/bin/zsmtp serve --lmtp --fail-delivery bad@lmtp.test 2529";
224 DynamicUser = true;
225 };
226 };
227
228 systemd.services.zsmtp-server-auth = {
229 description = "zsmtp debug server (authentication required)";
230 wantedBy = [ "multi-user.target" ];
231 serviceConfig = {
232 ExecStart = "${zsmtp}/bin/zsmtp 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("zsmtp-server.service")
246 machine.wait_for_unit("zsmtp-server-tls.service")
247 machine.wait_for_unit("zsmtp-server-tlsc.service")
248 machine.wait_for_unit("zsmtp-server-auth.service")
249 machine.wait_for_unit("zsmtp-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" | zsmtp 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"zsmtp client to {name}, plaintext"):
275 deliver("", port, f"zsmtp to {name} plain", mailbox)
276
277 with subtest(f"zsmtp client to {name}, STARTTLS"):
278 deliver(
279 "--starttls --insecure", port, f"zsmtp to {name} starttls", mailbox
280 )
281
282 with subtest(f"zsmtp client to {name}, implicit TLS"):
283 deliver("--tls --insecure", tls_port, f"zsmtp to {name} smtps", mailbox)
284
285 for name, (port, tls_port, mailbox) in servers.items():
286 with subtest(f"zsmtp client to {name}, CHUNKING"):
287 deliver("--chunking", port, f"zsmtp 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"zsmtp client to {name}, DSN parameters"):
292 deliver(
293 "--ret hdrs --envid batch7 --notify success,failure"
294 " --orcpt team@example.net",
295 port,
296 f"zsmtp to {name} dsn",
297 mailbox,
298 )
299
300 with subtest("zsmtp client to zsmtp server, DSN parameters round trip"):
301 machine.succeed(
302 "printf 'Subject: interop\\r\\n\\r\\nzsmtp dsn round trip\\r\\n'"
303 " | zsmtp 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 zsmtp-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 zsmtp 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 zsmtp 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 zsmtp-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("zsmtp LMTP client to dovecot"):
344 machine.succeed(
345 "printf 'Subject: interop\\r\\n\\r\\nzsmtp to dovecot lmtp\\r\\n'"
346 " | zsmtp send --lmtp 127.0.0.1 2024 bob@example.com alice@localhost"
347 )
348 machine.wait_until_succeeds(
349 "grep -r 'zsmtp to dovecot lmtp' /var/spool/dovecot-mail/alice/", timeout=60
350 )
351
352 with subtest("zsmtp 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 " | zsmtp 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 zsmtp'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("zsmtp 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' | zsmtp 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("zsmtp to zsmtp, 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 "zsmtp 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 zsmtp-server | grep -F 'to <alice@example.net> (266 bytes)'",
400 timeout=60,
401 )
402
403 with subtest("zsmtp client to postfix, SMTPUTF8"):
404 machine.succeed(
405 "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix utf8\\r\\n'"
406 " | zsmtp send --smtputf8 127.0.0.1 25"
407 " 'böb@example.com' alice@localhost"
408 )
409 machine.wait_until_succeeds(
410 "grep -r 'zsmtp 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("zsmtp client to exim, AUTH PLAIN"):
417 deliver(
418 "--allow-cleartext-auth --user alice --password secret --auth-method plain",
419 2625,
420 "zsmtp to exim auth plain",
421 "/var/spool/exim-mail/alice",
422 )
423
424 with subtest("zsmtp client to exim, AUTH LOGIN"):
425 deliver(
426 "--allow-cleartext-auth --user alice --password secret --auth-method login",
427 2625,
428 "zsmtp to exim auth login",
429 "/var/spool/exim-mail/alice",
430 )
431
432 with subtest("zsmtp client to exim, wrong password is rejected"):
433 machine.fail(
434 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'"
435 " | zsmtp 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("zsmtp client refuses cleartext AUTH without the opt-in"):
442 machine.fail(
443 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'"
444 " | zsmtp 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 "zsmtp to exim auth over starttls",
452 "/var/spool/exim-mail/alice",
453 )
454
455 with subtest("swaks to zsmtp 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 zsmtp auth plain'"
460 )
461 machine.wait_until_succeeds(
462 "journalctl -u zsmtp-server-auth | grep 'swaks to zsmtp auth plain'",
463 timeout=60,
464 )
465
466 with subtest("swaks to zsmtp 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 zsmtp auth login'"
471 )
472 machine.wait_until_succeeds(
473 "journalctl -u zsmtp-server-auth | grep 'swaks to zsmtp auth login'",
474 timeout=60,
475 )
476
477 with subtest("swaks to zsmtp 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 " | zsmtp send 127.0.0.1 2527 bob@example.com alice@example.net"
488 )
489
490 with subtest("swaks to zsmtp 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 zsmtp plain'"
495 )
496 machine.wait_until_succeeds(
497 "journalctl -u zsmtp-server | grep 'swaks to zsmtp plain'", timeout=60
498 )
499
500 with subtest("swaks to zsmtp 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 zsmtp implicit tls'"
505 )
506 machine.wait_until_succeeds(
507 "journalctl -u zsmtp-server-tlsc | grep 'swaks to zsmtp implicit tls'",
508 timeout=60,
509 )
510
511 with subtest("swaks to zsmtp 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 zsmtp starttls'"
516 )
517 machine.wait_until_succeeds(
518 "journalctl -u zsmtp-server-tls | grep 'swaks to zsmtp starttls'", timeout=60
519 )
520 '';
521}