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:
- https://git.jcollie.dev/jeff/zsmtp — issues and pull requests
- https://tangled.org/jcollie.dev/zsmtp
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
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.
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
zsmtp.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 plain: zsmtp.sasl.Plain = .init("user", "password");
var cram: zsmtp.sasl.CramMd5 = .init("user", "password");
const extensions = try client.hello("my-host.example.com");
const mechanism = zsmtp.sasl.Client.selectFromList(
&.{ plain.client(), cram.client() }, // in order of preference
extensions.auth,
client.security == .encrypted,
) orelse return error.NoSupportedMechanism;
try client.authenticate(mechanism);
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#
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.
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: zsmtp.Server.Envelope, index: usize) zsmtp.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.
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
# Send arbitrary binary content (RFC 3030), framed by BDAT rather than DATA:
./zig-out/bin/zsmtp 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/zsmtp serve --lmtp --fail-delivery bad@example.net 2529
printf 'Subject: hi\r\n\r\nhello\r\n' | \
./zig-out/bin/zsmtp send --lmtp 127.0.0.1 2529 me@example.com \
good@example.net bad@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=). 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-IDorDategeneration. 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,NOTIFYandORCPT— is implemented on both sides, but nothing here builds themultipart/reportmessage 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#
AUTH=on MAIL FROM (RFC 4954 §5), which a trusted relay uses to forward the identity that originally authenticated. The client-side mechanisms are no longer a gap — PLAIN, LOGIN, CRAM-MD5, EXTERNAL, XOAUTH2, OAUTHBEARER and SCRAM all come from zig-sasl — but the server still understands only PLAIN and LOGIN, and only against a plaintext password.- Client certificates — neither side can present or verify one.
- No enhanced status code accessor — the server emits
x.y.zon every reply, butReplyexposes onlycodeand the raw text. EXPNis 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_tlsto go withrequire_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_sizeis not enforced inmessageReadermode.
Client#
sendMailis all-or-nothing on recipients — a refused RCPT abandons the transaction, wheresmtplib.sendmaildelivers to the rest and reports the refusals.envelopegives 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_sizeis 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.
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) and BINARYMIME: client and server, with length-based framing and no
dot-stuffing.
BODY=BINARYMIMEis 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 3461 — DSN:
advertised by the server, which parses and validates
RET=/ENVID=on MAIL andNOTIFY=/ORCPT=on RCPT and hands them to the handler; the client sends them throughmail/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.modeandServer.Options.protocol.LHLOreplacesEHLOand the end of a message draws one reply per accepted recipient instead of one for the message, after DATA and afterBDAT LASTalike. - 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 (
Tlsbefore any SMTP traffic) and server (.mode = .implicit). - RFC 4954 — AUTH: client
and server, including initial responses, empty challenges and
*cancellation. The client drives any mechanism from zig-sasl; the server still implements PLAIN (RFC 4616) and the de-facto LOGIN itself, because zig-sasl's server side does not yet reach past PLAIN. - 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 viaEnvelope.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).
- [RFC1870] Klensin, J., Freed, N., and K. Moore, "SMTP Service Extension for Message Size Declaration", RFC 1870, November 1995, https://www.rfc-editor.org/info/rfc1870.
- [RFC2033] Myers, J., "Local Mail Transfer Protocol", RFC 2033, October 1996, https://www.rfc-editor.org/info/rfc2033.
- [RFC2034] Freed, N., "SMTP Service Extension for Returning Enhanced Error Codes", RFC 2034, October 1996, https://www.rfc-editor.org/info/rfc2034.
- [RFC2195] Klensin, J., Catoe, R., and P. Krumviede, "IMAP/POP AUTHorize Extension for Simple Challenge/Response", RFC 2195, September 1997, https://www.rfc-editor.org/info/rfc2195.
- [RFC2920] Freed, N., "SMTP Service Extension for Command Pipelining", RFC 2920, September 2000, https://www.rfc-editor.org/info/rfc2920.
- [RFC3030] Vaudreuil, G., "SMTP Service Extensions for Transmission of Large and Binary MIME Messages", RFC 3030, December 2000, https://www.rfc-editor.org/info/rfc3030.
- [RFC3207] Hoffman, P., "SMTP Service Extension for Secure SMTP over Transport Layer Security", RFC 3207, February 2002, https://www.rfc-editor.org/info/rfc3207.
- [RFC3461] Moore, K., "Simple Mail Transfer Protocol (SMTP) Service Extension for Delivery Status Notifications (DSNs)", RFC 3461, January 2003, https://www.rfc-editor.org/info/rfc3461.
- [RFC3463] Vaudreuil, G., "Enhanced Mail System Status Codes", RFC 3463, January 2003, https://www.rfc-editor.org/info/rfc3463.
- [RFC3464] Moore, K. and G. Vaudreuil, "An Extensible Message Format for Delivery Status Notifications", RFC 3464, January 2003, https://www.rfc-editor.org/info/rfc3464. (Cited as out of scope: the report message itself.)
- [RFC4616] Zeilenga, K., "The PLAIN Simple Authentication and Security Layer (SASL) Mechanism", RFC 4616, August 2006, https://www.rfc-editor.org/info/rfc4616.
- [RFC4954] Siemborski, R. and A. Melnikov, "SMTP Service Extension for Authentication", RFC 4954, July 2007, https://www.rfc-editor.org/info/rfc4954.
- [RFC5321] Klensin, J., "Simple Mail Transfer Protocol", RFC 5321, October 2008, https://www.rfc-editor.org/info/rfc5321.
- [RFC5322] Resnick, P., Ed., "Internet Message Format", RFC 5322, October 2008, https://www.rfc-editor.org/info/rfc5322. (Cited as out of scope: the format of the message this library carries.)
- [RFC6152] Klensin, J., Freed, N., Rose, M., and D. Crocker, "SMTP Service Extension for 8-bit MIME Transport", RFC 6152, March 2011, https://www.rfc-editor.org/info/rfc6152.
- [RFC6531] Yao, J. and W. Mao, "SMTP Extension for Internationalized Email", RFC 6531, February 2012, https://www.rfc-editor.org/info/rfc6531.
- [RFC6533] Hansen, T., Ed., Newman, C., and A. Melnikov, "Internationalized Delivery Status and Disposition Notifications", RFC 6533, February 2012, https://www.rfc-editor.org/info/rfc6533.
- [RFC7628] Mills, W., Showalter, T., and H. Tschofenig, "A Set of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth", RFC 7628, August 2015, https://www.rfc-editor.org/info/rfc7628. (Cited as a gap.)
- [RFC7677] Hansen, T., "SCRAM-SHA-256 and SCRAM-SHA-256-PLUS Simple Authentication and Security Layer (SASL) Mechanisms", RFC 7677, November 2015, https://www.rfc-editor.org/info/rfc7677. (Cited as a gap.)
- [RFC8314] Moore, K. and C. Newman, "Cleartext Considered Obsolete: Use of Transport Layer Security (TLS) for Email Submission and Access", RFC 8314, January 2018, https://www.rfc-editor.org/info/rfc8314.
- [RFC8446] Rescorla, E., "The Transport Layer Security (TLS) Protocol Version 1.3", RFC 8446, August 2018, https://www.rfc-editor.org/info/rfc8446.
- [SASL-LOGIN] Murchison, K. and M. Crispin, "The LOGIN SASL Mechanism", Work in Progress, Internet-Draft, draft-murchison-sasl-login-00, August 2003, https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00. The draft expired and LOGIN was never standardized; it is implemented here because servers still ask for it.
- [TLS.ZIG] Ianic, "tls.zig — TLS 1.2/1.3 implementation in Zig", https://github.com/ianic/tls.zig. Provides the TLS on both sides; see the TLS section for why the standard library's client is not used.
- [ISEMAIL] Sayers, D., "is_email — an email address validator and its test suite", BSD-3-Clause, https://github.com/dominicsayers/isemail. The address corpus the path parser is checked against; see Tests.
- [EXIM] The Exim Maintainers, "Exim Internet Mailer", GPL-2.0-or-later, https://www.exim.org/. The protocol torture script and the gauntlet unit test's dialogue are adapted from its test suite.
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