An SMTP client and server library for Zig implementing RFC 5321.
0

Configure Feed

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

27 1 0

Clone this repository

https://tangled.org/jcollie.dev/zig-smtp https://tangled.org/did:plc:suwqfrrdasbpgx636vreo5jg
git@knot.jcollie.dev:jcollie.dev/zig-smtp git@knot.jcollie.dev:did:plc:suwqfrrdasbpgx636vreo5jg

For self-hosted knots, clone URLs may differ based on your setup.


README.md

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.

Where this lives#

The canonical repository is on Forgejo, with mirrors on Tangled and Radicle:

git clone https://git.jcollie.dev/jeff/zsmtp.git

On Radicle, the peer-to-peer forge, the repository is rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1, which is the only name it has there — a Radicle repository is found by its ID and nothing else — so seeding or cloning it goes:

rad clone rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1

Cloning also seeds the repository, which helps keep it available on the network.

The API documentation is generated from the doc comments and published at https://jeff.jcollie.page/zsmtp/; zig build docs builds it locally and zig build docs-serve serves it for reading.

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.

Addresses and the EHLO domain are checked before they are written: a value containing CR, LF or NUL is rejected with error.UnsafeArgument rather than sent, since it would otherwise end the command line early and let the rest of it be read as further SMTP commands. The check is protocol.isSafeArgument, and it is framing only — it does not claim the address is a well-formed mailbox.

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

mail and rcpt are the parameterized forms of mailFrom and rcptTo, carrying the ESMTP parameters the server advertised — today SMTPUTF8 and the DSN set of RFC 3461:

try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" });
try client.rcpt("bob@example.net", .{
    .notify = .{ .on = .{ .failure = true, .delay = true } },
    .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" },
});

ENVID and the ORCPT address are xtext-encoded on the way out, so any bytes are safe to pass; the length limits RFC 3461 puts on the encoded form (100 and 500 characters) are checked and surface as error.ArgumentTooLong. Check extensions.dsn first — a conforming server answers an unrecognized parameter with 555.

When the server advertises CHUNKING (extensions.chunking), bdat and sendMessageChunked transmit the message with length-framed BDAT chunks instead of DATA — verbatim, with no dot-stuffing, so content must already use CRLF line endings.

Authentication#

hello reports the server's advertised mechanisms in extensions.auth; authenticate picks the best one, or use authPlain/authLogin/ authCramMd5 directly. 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");

PLAIN and LOGIN send the password in the clear — base64 is not encryption — so the client refuses them unless client.security is .encrypted, returning error.InsecureTransport instead. The library is handed a reader and a writer and cannot see what is underneath them, so it assumes the worst: setTransport records the answer for a STARTTLS upgrade, and a session speaking TLS from the first byte sets client.security = .encrypted itself. Which mechanism authenticate picks follows from that — PLAIN, then LOGIN, then CRAM-MD5 once encrypted, and CRAM-MD5 first when it is not, since that is the one mechanism of the three that never puts the password on the wire.

For a connection protected by something the library cannot see — a unix socket, an SSH tunnel, a loopback test — client.allow_cleartext_auth = true permits the cleartext mechanisms without claiming the transport is encrypted.

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(io, gpa, &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);
client.security = .encrypted; // the transport is TLS; `init` cannot tell
// ... 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(io, gpa, &stream_reader.interface, &stream_writer.interface, .{
    .host = "smtp.example.com",
});
client.setTransport(tls.reader(), tls.writer(), .encrypted);
_ = 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. Messages may also arrive via BDAT chunks (CHUNKING is advertised); both the collecting and streaming handler paths receive the reassembled content. MAIL parameters are validated: SIZE= (RFC 1870) is rejected early with 552 when it exceeds max_message_size, BODY=7BIT/BODY=8BITMIME (RFC 6152) are accepted, and unrecognized parameters get 555; the declared size and body type reach the handler via Envelope. Listening, accepting, and concurrency are up to the caller.

DSN (RFC 3461) is advertised. RET= and ENVID= on MAIL arrive as Envelope.ret and Envelope.envid, and NOTIFY= and ORCPT= on RCPT arrive as Recipient.notify and Recipient.orcpt — at the rcptTo callback, which receives the whole Recipient, and again on the Envelope afterwards. The xtext values are decoded, the length limits enforced, and a malformed value answered with 501. Like everything else handed to a callback, those slices live only for the duration of the call; keep what you need by copying it.

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",
    .tls = .{ .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). With .mode = .implicit the handshake instead runs before the greeting (SMTPS, port 465 style):

var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
    .hostname = "mx.example.com",
    .tls = .{ .io = io, .auth = &auth, .mode = .implicit },
});

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
./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465

# 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

# Request a delivery status notification (RFC 3461):
zsmtp send --ret hdrs --envid 'batch 7' --notify success,failure \
    --orcpt team@example.net 127.0.0.1 2525 me@example.com you@example.net

# Authenticate. Over a plaintext connection this refuses PLAIN and LOGIN
# rather than put the password on the wire; --allow-cleartext-auth overrides
# that for a connection protected by other means:
zsmtp send --starttls --user me --password secret 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 both STARTTLS and implicit TLS (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, and the server validates MAIL and RCPT parameters (SIZE=, BODY=, and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=).

Known gaps#

Measured against the implementations people are likely to be coming from — Postfix, Exim and Haraka on the server side, Go's net/smtp, Python's smtplib, lettre and Nodemailer on the client side. Kept here so the list is one thing rather than a rediscovery each time.

Out of scope, not missing#

  • Message composition. No MIME builder, headers, attachments, transfer encodings, Message-ID or Date generation. zsmtp carries a message that already exists; building one is RFC 5322's job and belongs in a library of its own.
  • DSN report generation (RFC 3464). The SMTP half of DSN — RFC 3461's RET, ENVID, NOTIFY and ORCPT — is implemented on both sides, but nothing here builds the multipart/report message that carries a delivery status back to the sender. That is message composition by another name, so it goes with the library above.
  • Everything an MTA does around a session. No queue, no retry schedule, no MX resolution, no routing, no mailbox store. "Server" here means a session handler: listening, accepting and concurrency are the caller's.

Protocol#

  • LMTP (RFC 2033) — no LHLO, no per-recipient reply after the final dot. The missing mode for anyone wanting a delivery agent behind Postfix.
  • PIPELINING (RFC 2920) — advertised and parsed by both sides, used by neither. sendMail is strictly request-response.
  • BINARYMIME — CHUNKING is implemented but BODY=BINARYMIME is refused, which is the other half of RFC 3030.
  • Modern SASL — no XOAUTH2 or OAUTHBEARER (RFC 7628), which is what Gmail and Microsoft 365 now require; no SCRAM-SHA-256 (RFC 7677), no EXTERNAL, no AUTH= on MAIL FROM. CRAM-MD5 is the most modern mechanism present.
  • Client certificates — neither side can present or verify one.
  • No enhanced status code accessor — the server emits x.y.z on every reply, but Reply exposes only code and the raw text.
  • EXPN is unrecognized rather than unimplemented, so it answers 500 where RFC 5321 §4.2.4 wants 502.
  • Niche and absent: REQUIRETLS, MT-PRIORITY, DELIVERBY, FUTURERELEASE, ETRN.

Server#

  • No Received: header. RFC 5321 §4.4 requires a receiving server to stamp one.
  • The handler never sees the connection — no connect callback, no peer address, no TLS state. Greylisting, DNSBLs, SPF and per-IP policy cannot be built on top, and a Received: header cannot be written without it.
  • No timeouts, so a client that connects and says nothing holds the session forever; RFC 5321 §4.5.3.2 specifies per-command limits.
  • No abuse limits beyond max_recipients: unlimited failed AUTH attempts, no error-count disconnect, no command budget.
  • No require_tls to go with require_auth.
  • No PROXY protocol, XCLIENT or XFORWARD, so the real peer address is lost behind a load balancer.
  • No filter or milter hook, and so no DKIM, SPF, DMARC or ARC.
  • No logging or tracing hooks.
  • max_message_size is not enforced in messageReader mode.

Client#

  • sendMail is all-or-nothing on recipients — the first rejected RCPT aborts the transaction, where smtplib.sendmail reports the refused ones and fails only when every one is refused.
  • No SIZE= or BODY= on MAIL, though the client parses both capabilities off EHLO; max_size in particular is read and never used, so nothing checks that a message fits before transmitting it.
  • No MX resolution or connect helper, no 4xx retry or backoff, no connection reuse helper, no pipelined sendMail.

Standards#

  • RFC 5321 — Simple Mail Transfer Protocol: the command/reply protocol, multiline replies, dot-stuffing, reply classes, and ESMTP parameter syntax (client and server).
  • RFC 1870 — SIZE: advertised and enforced by the server (oversize declarations are rejected with 552 before DATA); parsed from EHLO by the client.
  • RFC 6152 — 8BITMIME: advertised by the server and BODY= validated; parsed by the client.
  • RFC 3030 — CHUNKING (BDAT): client and server, with length-based framing and no dot-stuffing; the companion BINARYMIME extension is not implemented (BODY=BINARYMIME is rejected).
  • RFC 3461 — DSN: advertised by the server, which parses and validates RET=/ENVID= on MAIL and NOTIFY=/ORCPT= on RCPT and hands them to the handler; the client sends them through mail/rcpt. Includes the xtext codec of §4. Generating the report message itself (RFC 3464) is out of scope.
  • RFC 2920 — PIPELINING: advertised by the server, whose strictly sequential command loop handles pipelined clients naturally; parsed by the client.
  • RFC 3207 — STARTTLS: client and server, including the mandatory post-handshake state reset.
  • RFC 8314 — implicit TLS (SMTPS): client (Tls before any SMTP traffic) and server (.mode = .implicit).
  • RFC 4954 — AUTH: client and server, including initial responses and * cancellation.
  • RFC 4616 — the PLAIN SASL mechanism (client and server).
  • RFC 2195 — CRAM-MD5 (client only; the server would need plaintext-equivalent credentials).
  • draft-murchison-sasl-login — the de-facto AUTH LOGIN mechanism (client and server).
  • RFC 3463 / RFC 2034 — enhanced status codes: carried in every server reply and advertised via ENHANCEDSTATUSCODES; detected by the client.
  • RFC 6531 — SMTPUTF8: client (mailFromUtf8) and server (advertised; non-ASCII addresses require the parameter and must be valid UTF-8, rejected with 553 5.6.7 per RFC 6533 otherwise; the flag reaches handlers via Envelope.smtputf8).

TLS itself (TLS 1.3, RFC 8446) is provided by ianic/tls.zig.

Tests#

zig build test
zig build test --fuzz   # run the fuzz tests under the fuzzer (endless)

The fuzz tests cover parser crash-safety (Command.parse, Reply.read), whole-session robustness against arbitrary bytes on both the client and server side, and two differential properties: the streaming DataWriter must produce byte-identical output to the slice-based writeStuffed under fuzzer-chosen chunk boundaries, and the collecting and streaming server DATA paths must yield identical message content.

Protocol torture testing with exim's test client#

Exim's scriptable SMTP test client (test/src/client.c in the exim source) sends raw protocol lines and asserts reply prefixes. The exim source is declared as a lazy Zig dependency, fetched only on demand:

zig build -Dexim-client        # fetches exim, installs zig-out/bin/exim-client
./zig-out/bin/zsmtp serve 2525 &
./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script

Address corpus testing with the is_email suite#

Dominic Sayers' is_email test suite (BSD-3-Clause) is declared as a lazy Zig dependency; nothing from it is copied into this repository. On demand, the corpus test embeds its XML test files, extracts the 125 addresses valid at the RFC 5321 layer, and checks that each passes through the path parser byte-for-byte:

zig build test -Disemail-corpus   # fetches the suite and runs the corpus test

Without the option the corpus test is skipped.

test/protocol-torture.script is a 28-reply dialogue distilled from exim's own test suite (syntax errors, sequencing violations, parameter validation, dot-stuffing); the same dialogue is asserted byte-for-byte as a unit test in Server.zig.

The library is MIT-licensed; the small amount of test-only material adapted from exim's test suite (the torture script and the gauntlet unit test's dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml annotations.

Note: Zig 0.16.0's fuzz driver is broken out of the box (its bundled test runner fails to compile in fuzz mode, and the coverage server panics on a test binary with no fuzz tests); both are fixed on Zig master. Until then, fuzzing needs a patched copy of the standard library via zig build --zig-lib-dir <patched-lib> test --fuzz. The fuzz tests themselves also run once per invocation as part of the normal zig build test suite.

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