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.

zig-smtp#

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/smtp.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/zig-smtp/; zig build docs builds it locally and zig build docs-serve serves it for reading.

Client#

const smtp = @import("smtp");

var reply_buf: [1024]u8 = undefined;
var client: smtp.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();

Reply carries more than three digits. enhanced() reads the class.subject.detail code RFC 3463 defines and RFC 2034 puts at the front of the text, and message() gives the text without it:

const reply = client.last_reply.?;
if (reply.enhanced()) |status| switch (status.subjectClass()) {
    .addressing => {},     // 5.1.x — something about the address
    .security => {},       // 5.7.x — policy, nothing to do with the address
    else => {},
}

550 is "no"; 5.1.1 is "no, that mailbox does not exist" and 5.7.1 is "no, and not because of anything about the address". Check status.agrees(reply.code) before acting on it — a 250 carrying a 5.x.x code is a server contradicting itself. The greeting, the EHLO response and any 3xx carry no code, by RFC 2034's own exclusions, so enhanced() answers null there and is right to.

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

envelope sends MAIL FROM and every RCPT TO at once and reads all their replies, which against a server advertising PIPELINING (RFC 2920) turns an envelope of n recipients from n+1 round trips into one. hello sets client.pipelining from the EHLO response and envelope falls back to waiting for each reply when it is false, so the result is the same either way:

var codes: [3]u16 = undefined;
const accepted = try client.envelope(from, recipients, &codes, .{});
// codes[i] is the RCPT reply code for recipients[i].

A refused recipient is not an error — with several of them the caller is the one who can say whether what remains is worth sending — so compare accepted against recipients.len. sendMail makes that decision the strict way: if any recipient was refused it sends RSET and returns error.UnexpectedReply without delivering to the others.

DATA is deliberately left out of the group, though RFC 2920 allows it as the last command of one. Once a server has answered DATA with 354 the transaction is committed, and the only ways out are to send the message or to send an empty one to whichever recipients were accepted; stopping the group before DATA keeps that choice with the caller, and costs one round trip out of the n+1 saved.

A relay carrying somebody else's mail names the original submitter with AUTH= (RFC 4954 §5):

try client.mail(from, .{ .auth = .{ .mailbox = "alice@example.com" } });
try client.mail(from, .{ .auth = .unknown });   // sends AUTH=<>

<> is a claim of its own — "I considered the question and cannot vouch for anybody" — and RFC 4954 asks a relay to send it rather than leave the parameter off. On the receiving side it arrives as Envelope.submitter, and a server that advertises AUTH must accept the parameter even from a client that has not authenticated, then behave as though <> had been sent. So a .mailbox in an envelope always means an authenticated peer asserted it, and Envelope.authenticated_as says which peer, which is what a handler needs to decide whether to believe it.

A sender that would rather have a message bounce than travel in the clear says so with REQUIRETLS (RFC 8689):

try client.mail(from, .{ .require_tls = true });

It is refused with error.InsecureTransport on a session this client does not believe is encrypted, because a guarantee about an unprotected channel guarantees nothing. That is the precondition the library can check; the rest of §4.1's are the caller's and are not visible from here — the server's certificate must have been validated by a trust chain or DANE, so not with Tls.Options.ca = .insecure, and the MX must have been vouched for by DNSSEC or MTA-STS, which nothing here resolves. The demo CLI refuses --requiretls alongside --insecure for exactly that reason.

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.

Setting client.mode = .lmtp before hello speaks LMTP: LHLO goes out in place of EHLO, and the end of a message brings back one verdict per accepted recipient, in the order the RCPT commands were issued. endResults is how to read them:

var data_writer = try client.data();
try data_writer.interface.writeAll(message);
var verdicts = try data_writer.endResults();
while (try verdicts.next()) |reply| {
    // verdicts.index counts the recipients as they are answered.
    std.log.info("{s}: {d} {s}", .{ recipients[verdicts.index - 1], reply.code, reply.text });
}

Every verdict must be read before the session is used again, or the next command is answered by a leftover reply. The simpler end reads them all and reports error.RecipientRejected if any was a refusal — without saying which, because the replies share one buffer and reading the next overwrites the previous.

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 text content must already use CRLF line endings.

That framing is also what makes binary content possible. mail(from, .{ .body = .binary_mime }) declares it (RFC 3030, needs extensions.binary_mime), after which the message may hold any octets at all — NULs, bare CR, a line that is nothing but a dot — and data refuses to open a DATA phase for it with error.BinaryRequiresChunking, which is the 503 the server would have sent, made one round trip earlier. RFC 3030 is absolute that binary must not be sent to a server that did not advertise it, so check the capability first.

Authentication#

The mechanisms themselves live in zig-sasl, re-exported here as smtp.sasl, because nothing about PLAIN or CRAM-MD5 or XOAUTH2 is specific to SMTP — POP3 and IMAP want the same ones, and one implementation of each is better than three. What is specific to SMTP is authenticate: the AUTH command, the 334 challenges, the * that cancels, and the 235 that ends it.

hello reports the server's advertised mechanism names in extensions.auth, exactly as it sent them, for sasl.Client.selectFromList:

var sasl_scratch: [smtp.Client.sasl_buffer_suggested]u8 = undefined;
client.sasl_buffer = &sasl_scratch;

var plain: smtp.sasl.Plain = .init("user", "password");
var cram: smtp.sasl.CramMd5 = .init("user", "password");

const extensions = try client.hello("my-host.example.com");
const mechanism = smtp.sasl.Client.selectFromList(
    &.{ plain.client(), cram.client() },   // in order of preference
    extensions.auth,
    client.security == .encrypted,
) orelse return error.NoSupportedMechanism;
try client.authenticate(mechanism);

The scratch buffer is the caller's, like reply_buffer: how much room a mechanism needs is the caller's to know, and the range is wide — the classic mechanisms want a few hundred bytes, an OAuth token several kilobytes. It is split four-to-three between base64 and plaintext, which is base64's expansion exactly, and the two halves take turns rather than coexisting: a challenge decodes into the coded half, the answer is written into the plain half, and that answer encodes back over the challenge. sasl_buffer_min is the floor and sasl_buffer_suggested fits everything short of an unusually fat token. Server.Options.sasl_buffer is the same arrangement on the other side.

A 535 rejection surfaces as error.AuthenticationFailed with the reply in last_reply.

PLAIN, LOGIN and the OAuth mechanisms put a credential on the wire that an eavesdropper could reuse — base64 is not encryption, and a bearer token is worth more than a password because it authorizes elsewhere too. The client refuses those unless client.security is .encrypted, returning error.InsecureTransport before anything is sent, and selectFromList skips them for the same reason: on a plaintext session the preference order above falls through PLAIN to CRAM-MD5, which sends a proof rather than the secret.

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. 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 them without claiming the transport is encrypted.

One error is worth knowing about even if it never fires for PLAIN: error.ServerNotAuthenticated means the server reported success while the mechanism had not finished proving what it set out to prove. For a one-way mechanism that cannot happen. For SCRAM (via zig-scram's scram-sasl module) it means the server never produced its own signature — which is what something in the middle, holding no verifier, would do.

TLS#

smtp.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 smtp.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: smtp.Tls = undefined;
try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{
    .host = "smtp.example.com",
});
defer tls.deinit(gpa);
var client: smtp.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: smtp.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: smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{
    .context = &my_state,
    .vtable = &.{
        .rcptTo = onRcptTo,   // optional; accept/reject each Recipient
        .message = onMessage, // required; receives envelope + message data
    },
}, .{ .hostname = "mx.example.com" });
try session.run(gpa);

Options.auth_mechanisms is what the session offers for AUTH (RFC 4954), advertised by name in the EHLO response and drawn from zig-sasl — so a server can offer CRAM-MD5 or EXTERNAL, which it could not when the mechanisms were built in:

const check: smtp.sasl.Server.PasswordCheck = .{ .context = &app, .verify = verify };
var plain: smtp.sasl.PlainServer = .init(check);
var login: smtp.sasl.LoginServer = .init(check);
// ... .auth_mechanisms = &.{ plain.server(), login.server() }

The mechanisms hold per-exchange state, so each session needs its own. Sharing a set between two connections would have them overwrite each other's challenges; Server.init is per-connection anyway, so building them beside it is the natural place.

Where the credential comes from is the mechanism's business, which is why there is no longer one callback for it. PLAIN and LOGIN share a PasswordCheck — asked whether a password is right and told nothing, so an application may store a hash — while CRAM-MD5 needs a PasswordLookup, because it has to compute the same HMAC the client did and therefore needs the password itself. That is the argument against offering CRAM-MD5 at all, and it is now visible in the types rather than buried.

Whatever the mechanism reports as the authenticated identity reaches every Envelope as authenticated_as, which is what a handler deciding whether to relay wants — the envelope sender is whatever the client chose to write.

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. The commands it declines it declines precisely: EXPN is answered 502, "known and not implemented", where a verb it has never heard of gets 500; VRFY is answered 252, which is the compliant reply for a server that will not check an address in advance but will take the mail, and which RFC 5321 §4.5.1 requires of it. 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.

The server holds back the replies that RFC 2920 §3.2 permits — RSET, MAIL FROM and RCPT TO — so that a pipelined group is answered in one write, and sends everything pending the moment its input is empty. The condition is what makes that safe rather than a deadlock: a reply is only ever held while there is another command already waiting to be answered.

Setting Options.protocol = .lmtp makes the session speak LMTP (RFC 2033) instead: LHLO greets and HELO/EHLO are refused with 500, and the end of a message draws one reply per accepted recipient rather than one for the message — including a second reply for a recipient named twice. The recipientResult callback supplies each verdict:

fn onRecipientResult(ctx: ?*anyopaque, envelope: smtp.Server.Envelope, index: usize) smtp.Server.Decision {
    return if (mailboxIsFull(envelope.recipients[index].address))
        .{ .reject = .{ .code = 452, .text = "4.2.2 Mailbox full" } }
    else
        .accept;
}

Without it every recipient is told the same thing, which is correct but gains nothing over SMTP. A message the handler rejected outright is reported as that rejection for each recipient, since it failed for all of them. LMTP is meant for the hop between a queueing MTA and whatever writes to mailboxes; RFC 2033 §5 forbids it on TCP port 25 and advises against wide-area use.

BINARYMIME (RFC 3030) is advertised alongside CHUNKING, which the RFC requires of anything offering it. BODY=BINARYMIME arrives as Envelope.body, DATA for such a message is refused with 503, and the content reaches the handler exactly as it was sent — the BDAT path copies octets and has no line structure to normalize.

Options.requiretls offers REQUIRETLS (RFC 8689), and setting it is a promise. RFC 8689 requires a server advertising the keyword to honour the requirement, and a client that does not see it must quit and try another MX — refusing the domain entirely if no host offers it — so the keyword is load-bearing in a way most are not. This library cannot keep any part of that promise itself: it does not relay, so honouring the request is whatever the handler does with Envelope.require_tls. It is advertised only while the session is TLS-protected, and a client sending the parameter to a session that was not offered it gets 555 rather than being quietly disregarded — silently accepting it would turn a sender's refusal to be downgraded into a downgrade.

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.

Setting Options.received has the session compose a Received: field for every message, using zig-mime's received helper to lay it out and zig-datetime for the timestamp:

var session: smtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
    .hostname = "mx.example.com",
    .received = .{
        .io = io,
        // What this server observed, not what the client claimed.
        .peer = "client.example.com [192.0.2.1]",
        .by_info = "zig-smtp",
    },
});

It arrives as Envelope.received, complete with its Received: prefix and its trailing CRLF, and the handler is the one that writes it. RFC 5321 §4.4 wants the field at the beginning of the content, so a handler puts it in front of whatever it does with the message:

try file.writeAll(envelope.received);
try file.writeAll(message);

The division is deliberate. Composing the field needs the clock, the peer and the session's own state, none of which the handler has; inserting it needs to know what is being done with the message, which the library does not — it hands over the bytes it received and transforms nothing. Left unset, Envelope.received is empty and nothing is composed, which is a choice the caller is making rather than a default worth having.

What goes in it is decided from the session: the from name is what the client gave in its greeting (escaped if it has to be, since it is a string the peer chose), the with protocol follows RFC 3848ESMTP, ESMTPA when the client authenticated, ESMTPS under TLS, ESMTPSA for both, the LMTP forms under .lmtp and the UTF8 forms for a SMTPUTF8 transaction — and a for clause appears only when there is exactly one recipient, because with more than one it would disclose the others to all of them.

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 smtp.tls.input_buffer_len / smtp.tls.output_buffer_len bytes, since the handshake runs over them:

var auth: smtp.tls.config.CertKeyPair =
    try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem");
defer auth.deinit(gpa);

var session: smtp.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: smtp.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/zig-smtp serve 2525
./zig-out/bin/zig-smtp serve --tls-cert cert.pem --tls-key key.pem 2525
./zig-out/bin/zig-smtp 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/zig-smtp send 127.0.0.1 2525 me@example.com you@example.net

# Same, over implicit TLS or STARTTLS (--insecure skips cert verification):
zig-smtp send --tls smtp.example.com 465 me@example.com you@example.net
zig-smtp send --starttls smtp.example.com 587 me@example.com you@example.net

# Send arbitrary binary content (RFC 3030), framed by BDAT rather than DATA:
./zig-out/bin/zig-smtp send --binarymime 127.0.0.1 2525 me@example.com you@example.net \
    < some-binary-file

# Speak LMTP (RFC 2033) instead of SMTP. The server reports one verdict per
# recipient, and --fail-delivery makes one of them fail to show it:
./zig-out/bin/zig-smtp serve --lmtp --fail-delivery bad@example.net 2529
printf 'Subject: hi\r\n\r\nhello\r\n' | \
    ./zig-out/bin/zig-smtp send --lmtp 127.0.0.1 2529 me@example.com \
        good@example.net bad@example.net

# Request a delivery status notification (RFC 3461):
zig-smtp 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:
zig-smtp 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 smtp.Tls, and the server accepts both STARTTLS and implicit TLS (TLS 1.3 only). AUTH drives any mechanism from zig-sasl on either side, so which ones a session offers is the caller's choice rather than this library's. 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=) and will compose the Received: field for the handler to write. Both sides also speak LMTP, where a message ends with one verdict per recipient rather than one for the message, and both use PIPELINING, which collapses an envelope into a single round trip.

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. zig-smtp 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#

  • Client certificates — neither side can present or verify one.

  • MT-PRIORITY (RFC 6710), DELIVERBY (RFC 2852), FUTURERELEASE (RFC 4865) and ETRN (RFC 1985) are absent on purpose rather than overlooked, and they are all the same thing: queue features. One orders a queue, one bounces from it on a deadline, one holds in it until a time, and one flushes it on demand. This library has no queue — see the first section — so implementing their wire syntax would advertise a capability nothing here could honour.

    They are already answered correctly. The three parameters are not advertised, so a client sending one gets 555, which RFC 5321 §4.1.1.11 defines for a parameter the server cannot implement; ETRN is a command from an extension never offered, so it gets 500. Neither is ignored, and ignoring is the one answer that would be wrong.

Server#

  • The handler sees the identity but not the connection. Envelope.authenticated_as and Server.identity() say who authenticated; nothing says where from. No connect callback, no peer address, no TLS state — so greylisting, DNSBLs, SPF and per-IP policy cannot be built on top. The Received: field wants the peer too, and gets it only because ReceivedOptions.peer makes the caller supply it: whoever accepted the connection knows the address, and passes it in when it builds the session.
  • No timeouts, so a client that connects and says nothing holds the session forever; RFC 5321 §4.5.3.2 specifies per-command limits. This matters more since LMTP arrived: an LMTP server is what a queueing MTA hands mail to, so it is likelier to be somewhere a stuck peer costs something.
  • No abuse limits beyond max_recipients: no error-count disconnect, no command budget, and no cap on failed AUTH attempts — which also matters more now, since a session may offer several mechanisms and a client can try each in turn without limit.
  • 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 — a refused RCPT abandons the transaction, where smtplib.sendmail delivers to the rest and reports the refusals. envelope gives a caller the per-recipient codes to decide for itself, but no higher-level call does that decision for it.
  • No SIZE= on MAIL, though the client parses the capability off EHLO: max_size 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.
  • Extensions.auth is the one field that borrows. It points into the client's reply buffer and is valid only until the next reply is read, which is long enough for the hello-then-authenticate sequence and no longer. Everything else on Extensions is self-contained, so a caller storing one across commands gets a dangling slice with no compiler help. zig-pop3 answered the same question the other way, with a bounded copy, because its capabilities() promises nothing borrows the read buffer — the two libraries disagree about this on purpose, and one of them should probably give way.

Standards#

  • RFC 5321 — Simple Mail Transfer Protocol: the command/reply protocol, multiline replies, dot-stuffing, reply classes, and ESMTP parameter syntax (client and server). §4.4's Received: field is composed by the server when Options.received is set, using zig-mime to lay it out and zig-datetime for the timestamp, and handed to the handler to write.
  • RFC 3848 — ESMTP and LMTP transmission types: the with clause of that field names the protocol the message arrived over, which is where a reader learns whether the hop was encrypted and authenticated.
  • 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) and BINARYMIME: client and server, with length-based framing and no dot-stuffing. BODY=BINARYMIME is advertised, accepted and delivered bit for bit, and DATA is refused with 503 for a message that declared it, since binary content cannot be framed by a line holding a single dot.
  • RFC 8689 — REQUIRETLS: offered by the server when Options.requiretls is set and the session is TLS-protected, and reaching the handler as Envelope.require_tls; sent by the client through MailOptions.require_tls, which is refused on a session that is not encrypted.
  • 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 2033 — LMTP: client and server, via Client.mode and Server.Options.protocol. LHLO replaces EHLO and the end of a message draws one reply per accepted recipient instead of one for the message, after DATA and after BDAT LAST alike.
  • RFC 2920 — PIPELINING: the client sends a whole envelope as one group through envelope, and the server holds back the replies it is allowed to (RSET, MAIL, RCPT) so they leave together, sending everything pending the moment its input runs dry.
  • 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, empty challenges, * cancellation, and §5's AUTH= parameter to MAIL FROM — which the server takes from an unauthenticated client and disregards, as §5 requires. The client drives any mechanism from zig-sasl, and the server offers whichever of their server halves it is handed — PLAIN (RFC 4616), the de-facto LOGIN, CRAM-MD5 and EXTERNAL among them.
  • RFC 3463 / RFC 2034 — enhanced status codes: advertised and attached to every reply RFC 2034 asks for, with a test that walks a whole session and checks each one against that rule; read back by the client through Reply.enhanced.
  • 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.

References cited#

The specifications this implementation was written against, and the outside work it borrows from, in the RFC citation format so that a reference here matches one anywhere else. The Standards section above says what is implemented of each; this one says what each document is. Every entry is also filed in the project bibliography, so a citation can be taken from there rather than composed; the RFCs are keyed by their DOIs (10.17487/RFC5321 and so on).

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/zig-smtp 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 zig-smtp client delivers mail to Postfix and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks delivers to the zig-smtp server over plaintext and STARTTLS.

nix build .#zig-smtp                        # build the package
nix build .#checks.x86_64-linux.interop  # run the VM interop test