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
11{
12 testers,
13 callPackage,
14 runCommand,
15 openssl,
16}:
17let
18 zsmtp = callPackage ../package.nix { };
19
20 snakeoil =
21 runCommand "zsmtp-test-cert"
22 {
23 nativeBuildInputs = [ openssl ];
24 }
25 ''
26 mkdir -p $out
27 openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 \
28 -keyout $out/key.pem -out $out/cert.pem -days 36500 -nodes \
29 -subj "/CN=localhost" \
30 -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
31 '';
32in
33testers.runNixOSTest {
34 name = "zsmtp-interop";
35
36 nodes.machine =
37 { pkgs, ... }:
38 {
39 environment.systemPackages = [
40 zsmtp
41 pkgs.swaks
42 ];
43
44 users.users.alice.isNormalUser = true;
45
46 services.postfix = {
47 enable = true;
48 # Implicit-TLS smtpd on port 465; allow loopback without SASL.
49 enableSubmissions = true;
50 submissionsOptions = {
51 smtpd_client_restrictions = "permit_mynetworks,reject";
52 };
53 settings.main = {
54 mydestination = [
55 "localhost"
56 "$myhostname"
57 ];
58 smtpd_tls_security_level = "may";
59 smtpd_tls_chain_files = [
60 "${snakeoil}/key.pem"
61 "${snakeoil}/cert.pem"
62 ];
63 };
64 };
65
66 # Exim: plaintext + STARTTLS on 2625, implicit TLS on 2626, delivering
67 # to /var/spool/exim-mail/<user>.
68 services.exim = {
69 enable = true;
70 config = ''
71 primary_hostname = machine.test
72 qualify_domain = localhost
73 local_interfaces = 127.0.0.1
74 daemon_smtp_ports = 2625 : 2626
75 tls_on_connect_ports = 2626
76 tls_advertise_hosts = *
77 # Off by default in exim, and the point of the DSN subtest below.
78 dsn_advertise_hosts = *
79 tls_certificate = ${snakeoil}/cert.pem
80 tls_privatekey = ${snakeoil}/key.pem
81 acl_smtp_rcpt = acl_rcpt
82
83 begin acl
84
85 acl_rcpt:
86 accept
87
88 begin routers
89
90 local_users:
91 driver = accept
92 local_parts = alice : bob
93 transport = local_delivery
94
95 begin transports
96
97 local_delivery:
98 driver = appendfile
99 file = /var/spool/exim-mail/$local_part_data
100 user = exim
101 delivery_date_add
102 envelope_to_add
103 return_path_add
104
105 begin authenticators
106
107 plain_server:
108 driver = plaintext
109 public_name = PLAIN
110 server_prompts = :
111 server_condition = ''${if and{{eq{$auth2}{alice}}{eq{$auth3}{secret}}}}
112 server_set_id = $auth2
113
114 login_server:
115 driver = plaintext
116 public_name = LOGIN
117 server_prompts = Username:: : Password::
118 server_condition = ''${if and{{eq{$auth1}{alice}}{eq{$auth2}{secret}}}}
119 server_set_id = $auth1
120 '';
121 };
122
123 systemd.tmpfiles.rules = [
124 "d /var/spool/exim-mail 0755 exim exim -"
125 ];
126
127 systemd.services.zsmtp-server = {
128 description = "zsmtp debug server (plaintext)";
129 wantedBy = [ "multi-user.target" ];
130 serviceConfig = {
131 ExecStart = "${zsmtp}/bin/zsmtp serve 2525";
132 DynamicUser = true;
133 };
134 };
135
136 systemd.services.zsmtp-server-tls = {
137 description = "zsmtp debug server (STARTTLS)";
138 wantedBy = [ "multi-user.target" ];
139 serviceConfig = {
140 ExecStart = "${zsmtp}/bin/zsmtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem 2526";
141 DynamicUser = true;
142 };
143 };
144
145 systemd.services.zsmtp-server-tlsc = {
146 description = "zsmtp debug server (implicit TLS)";
147 wantedBy = [ "multi-user.target" ];
148 serviceConfig = {
149 ExecStart = "${zsmtp}/bin/zsmtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem --implicit-tls 2528";
150 DynamicUser = true;
151 };
152 };
153
154 systemd.services.zsmtp-server-auth = {
155 description = "zsmtp debug server (authentication required)";
156 wantedBy = [ "multi-user.target" ];
157 serviceConfig = {
158 ExecStart = "${zsmtp}/bin/zsmtp serve --auth alice:secret 2527";
159 DynamicUser = true;
160 };
161 };
162 };
163
164 testScript = ''
165 machine.wait_for_unit("postfix.service")
166 machine.wait_for_open_port(25)
167 machine.wait_for_open_port(465)
168 machine.wait_for_unit("exim.service")
169 machine.wait_for_open_port(2625)
170 machine.wait_for_open_port(2626)
171 machine.wait_for_unit("zsmtp-server.service")
172 machine.wait_for_unit("zsmtp-server-tls.service")
173 machine.wait_for_unit("zsmtp-server-tlsc.service")
174 machine.wait_for_unit("zsmtp-server-auth.service")
175 machine.wait_for_open_port(2525)
176 machine.wait_for_open_port(2526)
177 machine.wait_for_open_port(2527)
178 machine.wait_for_open_port(2528)
179
180
181 def deliver(flags, port, needle, mailbox):
182 machine.succeed(
183 f"printf 'Subject: interop\\r\\n\\r\\n{needle}\\r\\n'"
184 f" | zsmtp send {flags} 127.0.0.1 {port}"
185 " bob@example.com alice@localhost"
186 )
187 machine.wait_until_succeeds(f"grep -r '{needle}' {mailbox}", timeout=60)
188
189
190 servers = {
191 "postfix": (25, 465, "/var/spool/mail/alice/"),
192 "exim": (2625, 2626, "/var/spool/exim-mail/alice"),
193 }
194
195 for name, (port, tls_port, mailbox) in servers.items():
196 with subtest(f"zsmtp client to {name}, plaintext"):
197 deliver("", port, f"zsmtp to {name} plain", mailbox)
198
199 with subtest(f"zsmtp client to {name}, STARTTLS"):
200 deliver(
201 "--starttls --insecure", port, f"zsmtp to {name} starttls", mailbox
202 )
203
204 with subtest(f"zsmtp client to {name}, implicit TLS"):
205 deliver("--tls --insecure", tls_port, f"zsmtp to {name} smtps", mailbox)
206
207 for name, (port, tls_port, mailbox) in servers.items():
208 with subtest(f"zsmtp client to {name}, CHUNKING"):
209 deliver("--chunking", port, f"zsmtp to {name} chunked", mailbox)
210
211 # RFC 3461. Postfix advertises DSN out of the box; exim is told to above.
212 for name, (port, tls_port, mailbox) in servers.items():
213 with subtest(f"zsmtp client to {name}, DSN parameters"):
214 deliver(
215 "--ret hdrs --envid batch7 --notify success,failure"
216 " --orcpt team@example.net",
217 port,
218 f"zsmtp to {name} dsn",
219 mailbox,
220 )
221
222 with subtest("zsmtp client to zsmtp server, DSN parameters round trip"):
223 machine.succeed(
224 "printf 'Subject: interop\\r\\n\\r\\nzsmtp dsn round trip\\r\\n'"
225 " | zsmtp send --ret full --envid 'batch 7'"
226 " --notify success,delay --orcpt 'team+list@example.net'"
227 " 127.0.0.1 2525 bob@example.com alice@example.net"
228 )
229 # The server prints what it parsed: the ENVID comes back with its
230 # space, and the ORCPT with the '+' that had to be xtext-encoded.
231 machine.wait_until_succeeds(
232 "journalctl -u zsmtp-server | grep -F"
233 " 'NOTIFY=SUCCESS,DELAY ORCPT=rfc822;team+2Blist@example.net"
234 " RET=FULL ENVID=batch 7'",
235 timeout=60,
236 )
237
238 with subtest("zsmtp client to postfix, SMTPUTF8"):
239 machine.succeed(
240 "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix utf8\\r\\n'"
241 " | zsmtp send --smtputf8 127.0.0.1 25"
242 " 'böb@example.com' alice@localhost"
243 )
244 machine.wait_until_succeeds(
245 "grep -r 'zsmtp to postfix utf8' /var/spool/mail/alice/", timeout=60
246 )
247
248 # Exim advertises only PLAIN and LOGIN, both of which put the password
249 # on the wire, so the client refuses them over this plaintext loopback
250 # connection unless it is told to allow it.
251 with subtest("zsmtp client to exim, AUTH PLAIN"):
252 deliver(
253 "--allow-cleartext-auth --user alice --password secret --auth-method plain",
254 2625,
255 "zsmtp to exim auth plain",
256 "/var/spool/exim-mail/alice",
257 )
258
259 with subtest("zsmtp client to exim, AUTH LOGIN"):
260 deliver(
261 "--allow-cleartext-auth --user alice --password secret --auth-method login",
262 2625,
263 "zsmtp to exim auth login",
264 "/var/spool/exim-mail/alice",
265 )
266
267 with subtest("zsmtp client to exim, wrong password is rejected"):
268 machine.fail(
269 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'"
270 " | zsmtp send --allow-cleartext-auth --user alice --password wrong"
271 " 127.0.0.1 2625 bob@example.com alice@localhost"
272 )
273
274 # The refusal itself, which is what stops a password reaching the network
275 # by accident: the same delivery without the opt-in must not go through.
276 with subtest("zsmtp client refuses cleartext AUTH without the opt-in"):
277 machine.fail(
278 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'"
279 " | zsmtp send --user alice --password secret 127.0.0.1 2625"
280 " bob@example.com alice@localhost"
281 )
282 # ...and over STARTTLS it goes through with no opt-in at all.
283 deliver(
284 "--starttls --insecure --user alice --password secret",
285 2625,
286 "zsmtp to exim auth over starttls",
287 "/var/spool/exim-mail/alice",
288 )
289
290 with subtest("swaks to zsmtp server, AUTH PLAIN"):
291 machine.succeed(
292 "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice"
293 " --auth-password secret --from bob@example.com"
294 " --to alice@example.net --body 'swaks to zsmtp auth plain'"
295 )
296 machine.wait_until_succeeds(
297 "journalctl -u zsmtp-server-auth | grep 'swaks to zsmtp auth plain'",
298 timeout=60,
299 )
300
301 with subtest("swaks to zsmtp server, AUTH LOGIN"):
302 machine.succeed(
303 "swaks --server 127.0.0.1:2527 --auth LOGIN --auth-user alice"
304 " --auth-password secret --from bob@example.com"
305 " --to alice@example.net --body 'swaks to zsmtp auth login'"
306 )
307 machine.wait_until_succeeds(
308 "journalctl -u zsmtp-server-auth | grep 'swaks to zsmtp auth login'",
309 timeout=60,
310 )
311
312 with subtest("swaks to zsmtp server, wrong password is rejected"):
313 machine.fail(
314 "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice"
315 " --auth-password wrong --from bob@example.com"
316 " --to alice@example.net --body nope"
317 )
318
319 with subtest("unauthenticated mail to auth-required server is rejected"):
320 machine.fail(
321 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'"
322 " | zsmtp send 127.0.0.1 2527 bob@example.com alice@example.net"
323 )
324
325 with subtest("swaks to zsmtp server, plaintext"):
326 machine.succeed(
327 "swaks --server 127.0.0.1:2525 --from bob@example.com"
328 " --to alice@example.net --header 'Subject: swaks plain'"
329 " --body 'swaks to zsmtp plain'"
330 )
331 machine.wait_until_succeeds(
332 "journalctl -u zsmtp-server | grep 'swaks to zsmtp plain'", timeout=60
333 )
334
335 with subtest("swaks to zsmtp server, implicit TLS"):
336 machine.succeed(
337 "swaks --tlsc --server 127.0.0.1:2528 --from bob@example.com"
338 " --to alice@example.net --header 'Subject: swaks tlsc'"
339 " --body 'swaks to zsmtp implicit tls'"
340 )
341 machine.wait_until_succeeds(
342 "journalctl -u zsmtp-server-tlsc | grep 'swaks to zsmtp implicit tls'",
343 timeout=60,
344 )
345
346 with subtest("swaks to zsmtp server, STARTTLS"):
347 machine.succeed(
348 "swaks --tls --server 127.0.0.1:2526 --from bob@example.com"
349 " --to alice@example.net --header 'Subject: swaks starttls'"
350 " --body 'swaks to zsmtp starttls'"
351 )
352 machine.wait_until_succeeds(
353 "journalctl -u zsmtp-server-tls | grep 'swaks to zsmtp starttls'", timeout=60
354 )
355 '';
356}