An SMTP client and server library for Zig implementing RFC 5321.
7.0 kB
188 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.
122Listening, accepting, and concurrency are up to the caller.
123
124To advertise and accept STARTTLS (TLS 1.3, via
125[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key
126pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` /
127`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them:
128
129```zig
130var auth: zsmtp.tls.config.CertKeyPair =
131 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem");
132defer auth.deinit(gpa);
133
134var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
135 .hostname = "mx.example.com",
136 .starttls = .{ .io = io, .auth = &auth },
137});
138try session.run(gpa);
139```
140
141On STARTTLS the session answers 220, performs the server handshake, swaps
142its transport to the encrypted connection, and resets state per RFC 3207 (the
143client must EHLO again).
144
145## Demo CLI
146
147```sh
148zig build
149
150# Debug server that prints received messages to stdout
151# (with a cert/key pair it advertises and accepts STARTTLS):
152./zig-out/bin/zsmtp serve 2525
153./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525
154
155# Send a message read from stdin:
156printf 'Subject: hi\r\n\r\nhello\r\n' | \
157 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net
158
159# Same, over implicit TLS or STARTTLS (--insecure skips cert verification):
160zsmtp send --tls smtp.example.com 465 me@example.com you@example.net
161zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net
162```
163
164## Status
165
166TLS is supported on both sides via
167[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit
168TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3
169only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and
170LOGIN on the server. Message bodies can be streamed on both sides. Not yet
171implemented: implicit TLS on the server side, and ESMTP parameter handling
172(SIZE=, BODY=) on the server side.
173
174## Tests
175
176```sh
177zig build test
178```
179
180Interoperability against third-party implementations is covered by a NixOS
181VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix
182and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks
183delivers to the zsmtp server over plaintext and STARTTLS.
184
185```sh
186nix build .#zsmtp # build the package
187nix build .#checks.x86_64-linux.interop # run the VM interop test
188```