zsmtp#
An SMTP client and server library for Zig (RFC 5321).
Both the client and the server run over plain std.Io.Reader/std.Io.Writer
pairs, so they are transport-agnostic: wrap a TCP stream for real use, or
fixed in-memory buffers in tests. Requires Zig 0.16.
Client#
const zsmtp = @import("zsmtp");
var reply_buf: [1024]u8 = undefined;
var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf);
_ = try client.greet(); // read the 220 greeting
_ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions
try client.sendMail("me@example.com", &.{"you@example.net"}, message);
try client.quit();
Line endings in the message are normalized to CRLF and leading dots are
stuffed automatically. On error.UnexpectedReply, client.last_reply holds
the server's actual code and text. mailFrom/rcptTo/sendMessage are also
available individually.
Message bodies can also be streamed instead of passed as a slice — from any
reader via sendMessageReader(&reader), or push-style via data(), which
returns a writer that dot-stuffs and normalizes line endings as content
flows through it:
var data_writer = try client.data();
try data_writer.interface.print("Subject: report {d}\r\n\r\n", .{id});
// ... stream as much as needed ...
try data_writer.end(); // terminates the message, reads the verdict
Authentication#
hello reports the server's advertised mechanisms in extensions.auth;
authenticate picks the best one (PLAIN, then LOGIN, then CRAM-MD5), or use
authPlain/authLogin/authCramMd5 directly. PLAIN and LOGIN send
credentials unprotected, so use TLS on real networks. A 535 rejection
surfaces as error.AuthenticationFailed with the reply in last_reply.
const extensions = try client.hello("my-host.example.com");
try client.authenticate(extensions, "user", "password");
TLS#
zsmtp.Tls wraps ianic/tls.zig and
verifies against the system trust store by default (a caller-managed CA
bundle and an insecure mode are also available). The stream reader/writer
handed to it need buffers of at least zsmtp.Tls.min_buffer_len bytes, and
init must run at the value's final address (the connection holds interior
pointers). The standard library's TLS client is deliberately not used: it
requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec
record, which servers like Exim disable.
Implicit TLS (port 465) — handshake first, then speak SMTP:
var tls: zsmtp.Tls = undefined;
try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{
.host = "smtp.example.com",
});
defer tls.deinit(gpa);
var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf);
// ... greet, hello, sendMail ...
try client.quit();
try tls.end(); // close_notify, before closing the socket
STARTTLS (port 587) — upgrade mid-session, then EHLO again:
_ = try client.greet();
_ = try client.hello("my-host.example.com"); // check .starttls in the result
try client.starttls();
var tls: zsmtp.Tls = undefined;
try tls.init(gpa, io, &stream_reader.interface, &stream_writer.interface, .{
.host = "smtp.example.com",
});
client.setTransport(tls.reader(), tls.writer());
_ = try client.hello("my-host.example.com"); // server state was reset
Server#
var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{
.context = &my_state,
.vtable = &.{
.authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN
.rcptTo = onRcptTo, // optional; accept/reject each recipient
.message = onMessage, // required; receives envelope + message data
},
}, .{ .hostname = "mx.example.com" });
try session.run(gpa);
With an authenticate callback the session advertises and accepts AUTH
PLAIN and AUTH LOGIN (RFC 4954); setting Options.require_auth rejects MAIL
with 530 until the client has authenticated.
Instead of message (which collects the whole body in memory, bounded by
max_message_size), a handler can set messageReader to stream it: the
callback receives an Io.Reader yielding the unstuffed message content,
and anything left unread is drained by the session.
run serves one connection until QUIT or disconnect, enforcing command
sequencing, recipient and message-size limits, and un-stuffing message data.
Listening, accepting, and concurrency are up to the caller.
To advertise and accept STARTTLS (TLS 1.3, via
ianic/tls.zig), pass a certificate key
pair; the stream buffers must then be at least zsmtp.tls.input_buffer_len /
zsmtp.tls.output_buffer_len bytes, since the handshake runs over them:
var auth: zsmtp.tls.config.CertKeyPair =
try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem");
defer auth.deinit(gpa);
var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
.hostname = "mx.example.com",
.starttls = .{ .io = io, .auth = &auth },
});
try session.run(gpa);
On STARTTLS the session answers 220, performs the server handshake, swaps its transport to the encrypted connection, and resets state per RFC 3207 (the client must EHLO again).
Demo CLI#
zig build
# Debug server that prints received messages to stdout
# (with a cert/key pair it advertises and accepts STARTTLS):
./zig-out/bin/zsmtp serve 2525
./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525
# Send a message read from stdin:
printf 'Subject: hi\r\n\r\nhello\r\n' | \
./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net
# Same, over implicit TLS or STARTTLS (--insecure skips cert verification):
zsmtp send --tls smtp.example.com 465 me@example.com you@example.net
zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net
Status#
TLS is supported on both sides via
ianic/tls.zig: the client does implicit
TLS and STARTTLS via zsmtp.Tls, and the server accepts STARTTLS (TLS 1.3
only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the client and PLAIN and
LOGIN on the server. Message bodies can be streamed on both sides. Not yet
implemented: implicit TLS on the server side, and ESMTP parameter handling
(SIZE=, BODY=) on the server side.
Tests#
zig build test
Interoperability against third-party implementations is covered by a NixOS
VM test (nix/interop-test.nix): the zsmtp client delivers mail to Postfix
and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks
delivers to the zsmtp server over plaintext and STARTTLS.
nix build .#zsmtp # build the package
nix build .#checks.x86_64-linux.interop # run the VM interop test