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 ];
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}