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