An SMTP client and server library for Zig implementing RFC 5321.
11 kB
270 lines
1<!--
2SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
3SPDX-License-Identifier: MIT
4-->
5
6# zsmtp
7
8An SMTP client and server library for Zig (RFC 5321).
9
10Both the client and the server run over plain `std.Io.Reader`/`std.Io.Writer`
11pairs, so they are transport-agnostic: wrap a TCP stream for real use, or
12fixed in-memory buffers in tests. Requires Zig 0.16.
13
14## Client
15
16```zig
17const zsmtp = @import("zsmtp");
18
19var reply_buf: [1024]u8 = undefined;
20var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf);
21
22_ = try client.greet(); // read the 220 greeting
23_ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions
24try client.sendMail("me@example.com", &.{"you@example.net"}, message);
25try client.quit();
26```
27
28Line endings in the message are normalized to CRLF and leading dots are
29stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds
30the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also
31available individually.
32
33Message bodies can also be streamed instead of passed as a slice — from any
34reader via `sendMessageReader(&reader)`, or push-style via `data()`, which
35returns a writer that dot-stuffs and normalizes line endings as content
36flows through it:
37
38```zig
39var data_writer = try client.data();
40try data_writer.interface.print("Subject: report {d}\r\n\r\n", .{id});
41// ... stream as much as needed ...
42try data_writer.end(); // terminates the message, reads the verdict
43```
44
45### Authentication
46
47`hello` reports the server's advertised mechanisms in `extensions.auth`;
48`authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use
49`authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send
50credentials unprotected, so use TLS on real networks. A 535 rejection
51surfaces as `error.AuthenticationFailed` with the reply in `last_reply`.
52
53```zig
54const extensions = try client.hello("my-host.example.com");
55try client.authenticate(extensions, "user", "password");
56```
57
58### TLS
59
60`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and
61verifies against the system trust store by default (a caller-managed CA
62bundle and an insecure mode are also available). The stream reader/writer
63handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and
64`init` must run at the value's final address (the connection holds interior
65pointers). The standard library's TLS client is deliberately not used: it
66requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec
67record, which servers like Exim disable.
68
69Implicit TLS (port 465) — handshake first, then speak SMTP:
70
71```zig
72var tls: zsmtp.Tls = undefined;
73try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{
74 .host = "smtp.example.com",
75});
76defer tls.deinit(gpa);
77var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf);
78// ... greet, hello, sendMail ...
79try client.quit();
80try tls.end(); // close_notify, before closing the socket
81```
82
83STARTTLS (port 587) — upgrade mid-session, then EHLO again:
84
85```zig
86_ = try client.greet();
87_ = try client.hello("my-host.example.com"); // check .starttls in the result
88try client.starttls();
89var tls: zsmtp.Tls = undefined;
90try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{
91 .host = "smtp.example.com",
92});
93client.setTransport(tls.reader(), tls.writer());
94_ = try client.hello("my-host.example.com"); // server state was reset
95```
96
97## Server
98
99```zig
100var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{
101 .context = &my_state,
102 .vtable = &.{
103 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN
104 .rcptTo = onRcptTo, // optional; accept/reject each recipient
105 .message = onMessage, // required; receives envelope + message data
106 },
107}, .{ .hostname = "mx.example.com" });
108try session.run(gpa);
109```
110
111With an `authenticate` callback the session advertises and accepts AUTH
112PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL
113with 530 until the client has authenticated.
114
115Instead of `message` (which collects the whole body in memory, bounded by
116`max_message_size`), a handler can set `messageReader` to stream it: the
117callback receives an `Io.Reader` yielding the unstuffed message content,
118and anything left unread is drained by the session.
119
120`run` serves one connection until QUIT or disconnect, enforcing command
121sequencing, recipient and message-size limits, and un-stuffing message data.
122MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552
123when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152)
124are accepted, and unrecognized parameters get 555; the declared size and
125body type reach the handler via `Envelope`. Listening, accepting, and
126concurrency are up to the caller.
127
128To advertise and accept STARTTLS (TLS 1.3, via
129[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key
130pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` /
131`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them:
132
133```zig
134var auth: zsmtp.tls.config.CertKeyPair =
135 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem");
136defer auth.deinit(gpa);
137
138var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
139 .hostname = "mx.example.com",
140 .tls = .{ .io = io, .auth = &auth },
141});
142try session.run(gpa);
143```
144
145On STARTTLS the session answers 220, performs the server handshake, swaps
146its transport to the encrypted connection, and resets state per RFC 3207 (the
147client must EHLO again). With `.mode = .implicit` the handshake instead runs
148before the greeting (SMTPS, port 465 style):
149
150```zig
151var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
152 .hostname = "mx.example.com",
153 .tls = .{ .io = io, .auth = &auth, .mode = .implicit },
154});
155```
156
157## Demo CLI
158
159```sh
160zig build
161
162# Debug server that prints received messages to stdout
163# (with a cert/key pair it advertises and accepts STARTTLS):
164./zig-out/bin/zsmtp serve 2525
165./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525
166./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465
167
168# Send a message read from stdin:
169printf 'Subject: hi\r\n\r\nhello\r\n' | \
170 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net
171
172# Same, over implicit TLS or STARTTLS (--insecure skips cert verification):
173zsmtp send --tls smtp.example.com 465 me@example.com you@example.net
174zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net
175```
176
177## Status
178
179TLS is supported on both sides via
180[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit
181TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and
182implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the
183client and PLAIN and LOGIN on the server. Message bodies can be streamed on
184both sides, and the server validates MAIL parameters (SIZE=, BODY=).
185
186## Standards
187
188- [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail
189 Transfer Protocol: the command/reply protocol, multiline replies,
190 dot-stuffing, reply classes, and ESMTP parameter syntax (client and
191 server).
192- [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE:
193 advertised and enforced by the server (oversize declarations are rejected
194 with 552 before DATA); parsed from EHLO by the client.
195- [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME:
196 advertised by the server and `BODY=` validated; parsed by the client.
197- [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING:
198 advertised by the server, whose strictly sequential command loop handles
199 pipelined clients naturally; parsed by the client.
200- [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS:
201 client and server, including the mandatory post-handshake state reset.
202- [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS
203 (SMTPS): client (`Tls` before any SMTP traffic) and server
204 (`.mode = .implicit`).
205- [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client
206 and server, including initial responses and `*` cancellation.
207- [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN
208 SASL mechanism (client and server).
209- [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5
210 (client only; the server would need plaintext-equivalent credentials).
211- [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00)
212 — the de-facto AUTH LOGIN mechanism (client and server).
213- [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) /
214 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced
215 status codes: carried in every server reply and advertised via
216 ENHANCEDSTATUSCODES; detected by the client.
217- [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8:
218 detected by the client in EHLO; not implemented by the server.
219
220TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446))
221is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig).
222
223## Tests
224
225```sh
226zig build test
227zig build test --fuzz # run the fuzz tests under the fuzzer (endless)
228```
229
230The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`),
231whole-session robustness against arbitrary bytes on both the client and
232server side, and two differential properties: the streaming `DataWriter`
233must produce byte-identical output to the slice-based `writeStuffed` under
234fuzzer-chosen chunk boundaries, and the collecting and streaming server
235DATA paths must yield identical message content.
236
237### Protocol torture testing with exim's test client
238
239Exim's scriptable SMTP test client (`test/src/client.c` in the exim
240source) sends raw protocol lines and asserts reply prefixes. The exim
241source is declared as a *lazy* Zig dependency, fetched only on demand:
242
243```sh
244zig build -Dexim-client # fetches exim, installs zig-out/bin/exim-client
245./zig-out/bin/zsmtp serve 2525 &
246./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script
247```
248
249`test/protocol-torture.script` is a 28-reply dialogue distilled from
250exim's own test suite (syntax errors, sequencing violations, parameter
251validation, dot-stuffing); the same dialogue is asserted byte-for-byte
252as a unit test in `Server.zig`.
253
254Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled
255test runner fails to compile in fuzz mode, and the coverage server panics
256on a test binary with no fuzz tests); both are fixed on Zig master. Until
257then, fuzzing needs a patched copy of the standard library via
258`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests
259themselves also run once per invocation as part of the normal
260`zig build test` suite.
261
262Interoperability against third-party implementations is covered by a NixOS
263VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix
264and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks
265delivers to the zsmtp server over plaintext and STARTTLS.
266
267```sh
268nix build .#zsmtp # build the package
269nix build .#checks.x86_64-linux.interop # run the VM interop test
270```