An SMTP and LMTP client and server library for Zig, with TLS, SASL, PIPELINING, CHUNKING, DSN and the PROXY protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

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.

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.

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. Not yet implemented: implicit TLS on the server side, streaming (non-slice) message bodies, 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