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