An SMTP client and server library for Zig implementing RFC 5321.
6.2 kB
171 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
33### Authentication
34
35`hello` reports the server's advertised mechanisms in `extensions.auth`;
36`authenticate` picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use
37`authPlain`/`authLogin`/`authCramMd5` directly. PLAIN and LOGIN send
38credentials unprotected, so use TLS on real networks. A 535 rejection
39surfaces as `error.AuthenticationFailed` with the reply in `last_reply`.
40
41```zig
42const extensions = try client.hello("my-host.example.com");
43try client.authenticate(extensions, "user", "password");
44```
45
46### TLS
47
48`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and
49verifies against the system trust store by default (a caller-managed CA
50bundle and an insecure mode are also available). The stream reader/writer
51handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and
52`init` must run at the value's final address (the connection holds interior
53pointers). The standard library's TLS client is deliberately not used: it
54requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec
55record, which servers like Exim disable.
56
57Implicit TLS (port 465) — handshake first, then speak SMTP:
58
59```zig
60var tls: zsmtp.Tls = undefined;
61try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{
62 .host = "smtp.example.com",
63});
64defer tls.deinit(gpa);
65var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf);
66// ... greet, hello, sendMail ...
67try client.quit();
68try tls.end(); // close_notify, before closing the socket
69```
70
71STARTTLS (port 587) — upgrade mid-session, then EHLO again:
72
73```zig
74_ = try client.greet();
75_ = try client.hello("my-host.example.com"); // check .starttls in the result
76try client.starttls();
77var tls: zsmtp.Tls = undefined;
78try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{
79 .host = "smtp.example.com",
80});
81client.setTransport(tls.reader(), tls.writer());
82_ = try client.hello("my-host.example.com"); // server state was reset
83```
84
85## Server
86
87```zig
88var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{
89 .context = &my_state,
90 .vtable = &.{
91 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN
92 .rcptTo = onRcptTo, // optional; accept/reject each recipient
93 .message = onMessage, // required; receives envelope + message data
94 },
95}, .{ .hostname = "mx.example.com" });
96try session.run(gpa);
97```
98
99With an `authenticate` callback the session advertises and accepts AUTH
100PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL
101with 530 until the client has authenticated.
102
103`run` serves one connection until QUIT or disconnect, enforcing command
104sequencing, recipient and message-size limits, and un-stuffing message data.
105Listening, accepting, and concurrency are up to the caller.
106
107To advertise and accept STARTTLS (TLS 1.3, via
108[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key
109pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` /
110`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them:
111
112```zig
113var auth: zsmtp.tls.config.CertKeyPair =
114 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem");
115defer auth.deinit(gpa);
116
117var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
118 .hostname = "mx.example.com",
119 .starttls = .{ .io = io, .auth = &auth },
120});
121try session.run(gpa);
122```
123
124On STARTTLS the session answers 220, performs the server handshake, swaps
125its transport to the encrypted connection, and resets state per RFC 3207 (the
126client must EHLO again).
127
128## Demo CLI
129
130```sh
131zig build
132
133# Debug server that prints received messages to stdout
134# (with a cert/key pair it advertises and accepts STARTTLS):
135./zig-out/bin/zsmtp serve 2525
136./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525
137
138# Send a message read from stdin:
139printf 'Subject: hi\r\n\r\nhello\r\n' | \
140 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net
141
142# Same, over implicit TLS or STARTTLS (--insecure skips cert verification):
143zsmtp send --tls smtp.example.com 465 me@example.com you@example.net
144zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net
145```
146
147## Status
148
149TLS is supported on both sides via
150[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit
151TLS and STARTTLS via `zsmtp.Tls`, and the server accepts STARTTLS (TLS 1.3
152only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and
153LOGIN on the server. Not yet implemented: implicit TLS on the server side,
154streaming (non-slice) message bodies, and ESMTP parameter handling (SIZE=,
155BODY=) on the server side.
156
157## Tests
158
159```sh
160zig build test
161```
162
163Interoperability against third-party implementations is covered by a NixOS
164VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix
165and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks
166delivers to the zsmtp server over plaintext and STARTTLS.
167
168```sh
169nix build .#zsmtp # build the package
170nix build .#checks.x86_64-linux.interop # run the VM interop test
171```