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 tls_certificate = ${snakeoil}/cert.pem
78 tls_privatekey = ${snakeoil}/key.pem
79 acl_smtp_rcpt = acl_rcpt
80
81 begin acl
82
83 acl_rcpt:
84 accept
85
86 begin routers
87
88 local_users:
89 driver = accept
90 local_parts = alice : bob
91 transport = local_delivery
92
93 begin transports
94
95 local_delivery:
96 driver = appendfile
97 file = /var/spool/exim-mail/$local_part_data
98 user = exim
99 delivery_date_add
100 envelope_to_add
101 return_path_add
102
103 begin authenticators
104
105 plain_server:
106 driver = plaintext
107 public_name = PLAIN
108 server_prompts = :
109 server_condition = ''${if and{{eq{$auth2}{alice}}{eq{$auth3}{secret}}}}
110 server_set_id = $auth2
111
112 login_server:
113 driver = plaintext
114 public_name = LOGIN
115 server_prompts = Username:: : Password::
116 server_condition = ''${if and{{eq{$auth1}{alice}}{eq{$auth2}{secret}}}}
117 server_set_id = $auth1
118 '';
119 };
120
121 systemd.tmpfiles.rules = [
122 "d /var/spool/exim-mail 0755 exim exim -"
123 ];
124
125 systemd.services.zsmtp-server = {
126 description = "zsmtp debug server (plaintext)";
127 wantedBy = [ "multi-user.target" ];
128 serviceConfig = {
129 ExecStart = "${zsmtp}/bin/zsmtp serve 2525";
130 DynamicUser = true;
131 };
132 };
133
134 systemd.services.zsmtp-server-tls = {
135 description = "zsmtp debug server (STARTTLS)";
136 wantedBy = [ "multi-user.target" ];
137 serviceConfig = {
138 ExecStart = "${zsmtp}/bin/zsmtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem 2526";
139 DynamicUser = true;
140 };
141 };
142
143 systemd.services.zsmtp-server-tlsc = {
144 description = "zsmtp debug server (implicit TLS)";
145 wantedBy = [ "multi-user.target" ];
146 serviceConfig = {
147 ExecStart = "${zsmtp}/bin/zsmtp serve --tls-cert ${snakeoil}/cert.pem --tls-key ${snakeoil}/key.pem --implicit-tls 2528";
148 DynamicUser = true;
149 };
150 };
151
152 systemd.services.zsmtp-server-auth = {
153 description = "zsmtp debug server (authentication required)";
154 wantedBy = [ "multi-user.target" ];
155 serviceConfig = {
156 ExecStart = "${zsmtp}/bin/zsmtp serve --auth alice:secret 2527";
157 DynamicUser = true;
158 };
159 };
160 };
161
162 testScript = ''
163 machine.wait_for_unit("postfix.service")
164 machine.wait_for_open_port(25)
165 machine.wait_for_open_port(465)
166 machine.wait_for_unit("exim.service")
167 machine.wait_for_open_port(2625)
168 machine.wait_for_open_port(2626)
169 machine.wait_for_unit("zsmtp-server.service")
170 machine.wait_for_unit("zsmtp-server-tls.service")
171 machine.wait_for_unit("zsmtp-server-tlsc.service")
172 machine.wait_for_unit("zsmtp-server-auth.service")
173 machine.wait_for_open_port(2525)
174 machine.wait_for_open_port(2526)
175 machine.wait_for_open_port(2527)
176 machine.wait_for_open_port(2528)
177
178
179 def deliver(flags, port, needle, mailbox):
180 machine.succeed(
181 f"printf 'Subject: interop\\r\\n\\r\\n{needle}\\r\\n'"
182 f" | zsmtp send {flags} 127.0.0.1 {port}"
183 " bob@example.com alice@localhost"
184 )
185 machine.wait_until_succeeds(f"grep -r '{needle}' {mailbox}", timeout=60)
186
187
188 servers = {
189 "postfix": (25, 465, "/var/spool/mail/alice/"),
190 "exim": (2625, 2626, "/var/spool/exim-mail/alice"),
191 }
192
193 for name, (port, tls_port, mailbox) in servers.items():
194 with subtest(f"zsmtp client to {name}, plaintext"):
195 deliver("", port, f"zsmtp to {name} plain", mailbox)
196
197 with subtest(f"zsmtp client to {name}, STARTTLS"):
198 deliver(
199 "--starttls --insecure", port, f"zsmtp to {name} starttls", mailbox
200 )
201
202 with subtest(f"zsmtp client to {name}, implicit TLS"):
203 deliver("--tls --insecure", tls_port, f"zsmtp to {name} smtps", mailbox)
204
205 for name, (port, tls_port, mailbox) in servers.items():
206 with subtest(f"zsmtp client to {name}, CHUNKING"):
207 deliver("--chunking", port, f"zsmtp to {name} chunked", mailbox)
208
209 with subtest("zsmtp client to postfix, SMTPUTF8"):
210 machine.succeed(
211 "printf 'Subject: interop\\r\\n\\r\\nzsmtp to postfix utf8\\r\\n'"
212 " | zsmtp send --smtputf8 127.0.0.1 25"
213 " 'böb@example.com' alice@localhost"
214 )
215 machine.wait_until_succeeds(
216 "grep -r 'zsmtp to postfix utf8' /var/spool/mail/alice/", timeout=60
217 )
218
219 with subtest("zsmtp client to exim, AUTH PLAIN"):
220 deliver(
221 "--user alice --password secret --auth-method plain",
222 2625,
223 "zsmtp to exim auth plain",
224 "/var/spool/exim-mail/alice",
225 )
226
227 with subtest("zsmtp client to exim, AUTH LOGIN"):
228 deliver(
229 "--user alice --password secret --auth-method login",
230 2625,
231 "zsmtp to exim auth login",
232 "/var/spool/exim-mail/alice",
233 )
234
235 with subtest("zsmtp client to exim, wrong password is rejected"):
236 machine.fail(
237 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'"
238 " | zsmtp send --user alice --password wrong 127.0.0.1 2625"
239 " bob@example.com alice@localhost"
240 )
241
242 with subtest("swaks to zsmtp server, AUTH PLAIN"):
243 machine.succeed(
244 "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice"
245 " --auth-password secret --from bob@example.com"
246 " --to alice@example.net --body 'swaks to zsmtp auth plain'"
247 )
248 machine.wait_until_succeeds(
249 "journalctl -u zsmtp-server-auth | grep 'swaks to zsmtp auth plain'",
250 timeout=60,
251 )
252
253 with subtest("swaks to zsmtp server, AUTH LOGIN"):
254 machine.succeed(
255 "swaks --server 127.0.0.1:2527 --auth LOGIN --auth-user alice"
256 " --auth-password secret --from bob@example.com"
257 " --to alice@example.net --body 'swaks to zsmtp auth login'"
258 )
259 machine.wait_until_succeeds(
260 "journalctl -u zsmtp-server-auth | grep 'swaks to zsmtp auth login'",
261 timeout=60,
262 )
263
264 with subtest("swaks to zsmtp server, wrong password is rejected"):
265 machine.fail(
266 "swaks --server 127.0.0.1:2527 --auth PLAIN --auth-user alice"
267 " --auth-password wrong --from bob@example.com"
268 " --to alice@example.net --body nope"
269 )
270
271 with subtest("unauthenticated mail to auth-required server is rejected"):
272 machine.fail(
273 "printf 'Subject: interop\\r\\n\\r\\nnope\\r\\n'"
274 " | zsmtp send 127.0.0.1 2527 bob@example.com alice@example.net"
275 )
276
277 with subtest("swaks to zsmtp server, plaintext"):
278 machine.succeed(
279 "swaks --server 127.0.0.1:2525 --from bob@example.com"
280 " --to alice@example.net --header 'Subject: swaks plain'"
281 " --body 'swaks to zsmtp plain'"
282 )
283 machine.wait_until_succeeds(
284 "journalctl -u zsmtp-server | grep 'swaks to zsmtp plain'", timeout=60
285 )
286
287 with subtest("swaks to zsmtp server, implicit TLS"):
288 machine.succeed(
289 "swaks --tlsc --server 127.0.0.1:2528 --from bob@example.com"
290 " --to alice@example.net --header 'Subject: swaks tlsc'"
291 " --body 'swaks to zsmtp implicit tls'"
292 )
293 machine.wait_until_succeeds(
294 "journalctl -u zsmtp-server-tlsc | grep 'swaks to zsmtp implicit tls'",
295 timeout=60,
296 )
297
298 with subtest("swaks to zsmtp server, STARTTLS"):
299 machine.succeed(
300 "swaks --tls --server 127.0.0.1:2526 --from bob@example.com"
301 " --to alice@example.net --header 'Subject: swaks starttls'"
302 " --body 'swaks to zsmtp starttls'"
303 )
304 machine.wait_until_succeeds(
305 "journalctl -u zsmtp-server-tls | grep 'swaks to zsmtp starttls'", timeout=60
306 )
307 '';
308}