An SMTP client and server library for Zig implementing RFC 5321.
8.2 kB
208 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 .starttls = .{ .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).
148
149## Demo CLI
150
151```sh
152zig build
153
154# Debug server that prints received messages to stdout
155# (with a cert/key pair it advertises and accepts STARTTLS):
156./zig-out/bin/zsmtp serve 2525
157./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525
158
159# Send a message read from stdin:
160printf 'Subject: hi\r\n\r\nhello\r\n' | \
161 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net
162
163# Same, over implicit TLS or STARTTLS (--insecure skips cert verification):
164zsmtp send --tls smtp.example.com 465 me@example.com you@example.net
165zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net
166```
167
168## Status
169
170TLS is supported on both sides via
171[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit
172TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3
173only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and
174LOGIN on the server. Message bodies can be streamed on both sides, and the
175server validates MAIL parameters (SIZE=, BODY=). Not yet implemented:
176implicit TLS on the server side.
177
178## Tests
179
180```sh
181zig build test
182zig build test --fuzz # run the fuzz tests under the fuzzer (endless)
183```
184
185The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`),
186whole-session robustness against arbitrary bytes on both the client and
187server side, and two differential properties: the streaming `DataWriter`
188must produce byte-identical output to the slice-based `writeStuffed` under
189fuzzer-chosen chunk boundaries, and the collecting and streaming server
190DATA paths must yield identical message content.
191
192Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled
193test runner fails to compile in fuzz mode, and the coverage server panics
194on a test binary with no fuzz tests); both are fixed on Zig master. Until
195then, fuzzing needs a patched copy of the standard library via
196`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests
197themselves also run once per invocation as part of the normal
198`zig build test` suite.
199
200Interoperability against third-party implementations is covered by a NixOS
201VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix
202and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks
203delivers to the zsmtp server over plaintext and STARTTLS.
204
205```sh
206nix build .#zsmtp # build the package
207nix build .#checks.x86_64-linux.interop # run the VM interop test
208```