An SMTP client and server library for Zig implementing RFC 5321.
31 kB
648 lines
1<!--
2SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
3SPDX-License-Identifier: MIT
4-->
5
6# zsmtp
7
8An SMTP client and server library for Zig (RFC 5321).
9
10Both the client and the server run over plain `std.Io.Reader`/`std.Io.Writer`
11pairs, so they are transport-agnostic: wrap a TCP stream for real use, or
12fixed in-memory buffers in tests. Requires Zig 0.16.
13
14## Where this lives
15
16The canonical repository is on Forgejo, with mirrors on Tangled and Radicle:
17
18- <https://git.jcollie.dev/jeff/zsmtp> — issues and pull requests
19- <https://tangled.org/jcollie.dev/zsmtp>
20
21```sh
22git clone https://git.jcollie.dev/jeff/zsmtp.git
23```
24
25On [Radicle](https://radicle.xyz/), the peer-to-peer forge, the repository is
26`rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1`, which is the only name it has there — a
27Radicle repository is found by its ID and nothing else — so seeding or cloning
28it goes:
29
30```sh
31rad clone rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1
32```
33
34Cloning also seeds the repository, which helps keep it available on the
35network.
36
37The API documentation is generated from the doc comments and published at
38<https://jeff.jcollie.page/zsmtp/>; `zig build docs` builds it locally and
39`zig build docs-serve` serves it for reading.
40
41## Client
42
43```zig
44const zsmtp = @import("zsmtp");
45
46var reply_buf: [1024]u8 = undefined;
47var client: zsmtp.Client = .init(&stream_reader.interface, &stream_writer.interface, &reply_buf);
48
49_ = try client.greet(); // read the 220 greeting
50_ = try client.hello("my-host.example.com"); // EHLO (HELO fallback), returns extensions
51try client.sendMail("me@example.com", &.{"you@example.net"}, message);
52try client.quit();
53```
54
55Line endings in the message are normalized to CRLF and leading dots are
56stuffed automatically. On `error.UnexpectedReply`, `client.last_reply` holds
57the server's actual code and text. `mailFrom`/`rcptTo`/`sendMessage` are also
58available individually.
59
60Addresses and the EHLO domain are checked before they are written: a value
61containing CR, LF or NUL is rejected with `error.UnsafeArgument` rather than
62sent, since it would otherwise end the command line early and let the rest of
63it be read as further SMTP commands. The check is `protocol.isSafeArgument`,
64and it is framing only — it does not claim the address is a well-formed
65mailbox.
66
67Message bodies can also be streamed instead of passed as a slice — from any
68reader via `sendMessageReader(&reader)`, or push-style via `data()`, which
69returns a writer that dot-stuffs and normalizes line endings as content
70flows through it:
71
72```zig
73var data_writer = try client.data();
74try data_writer.interface.print("Subject: report {d}\r\n\r\n", .{id});
75// ... stream as much as needed ...
76try data_writer.end(); // terminates the message, reads the verdict
77```
78
79`envelope` sends MAIL FROM and every RCPT TO at once and reads all their
80replies, which against a server advertising PIPELINING
81([RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920)) turns an envelope
82of *n* recipients from *n*+1 round trips into one. `hello` sets
83`client.pipelining` from the EHLO response and `envelope` falls back to
84waiting for each reply when it is false, so the result is the same either
85way:
86
87```zig
88var codes: [3]u16 = undefined;
89const accepted = try client.envelope(from, recipients, &codes, .{});
90// codes[i] is the RCPT reply code for recipients[i].
91```
92
93A refused recipient is not an error — with several of them the caller is the
94one who can say whether what remains is worth sending — so compare `accepted`
95against `recipients.len`. `sendMail` makes that decision the strict way: if
96any recipient was refused it sends RSET and returns `error.UnexpectedReply`
97without delivering to the others.
98
99DATA is deliberately left out of the group, though RFC 2920 allows it as the
100last command of one. Once a server has answered DATA with 354 the transaction
101is committed, and the only ways out are to send the message or to send an
102empty one to whichever recipients were accepted; stopping the group before
103DATA keeps that choice with the caller, and costs one round trip out of the
104*n*+1 saved.
105
106`mail` and `rcpt` are the parameterized forms of `mailFrom` and `rcptTo`,
107carrying the ESMTP parameters the server advertised — today SMTPUTF8 and the
108DSN set of [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461):
109
110```zig
111try client.mail("me@example.com", .{ .ret = .hdrs, .envid = "batch 7" });
112try client.rcpt("bob@example.net", .{
113 .notify = .{ .on = .{ .failure = true, .delay = true } },
114 .orcpt = .{ .addr_type = "rfc822", .address = "team@example.net" },
115});
116```
117
118`ENVID` and the `ORCPT` address are xtext-encoded on the way out, so any
119bytes are safe to pass; the length limits RFC 3461 puts on the encoded form
120(100 and 500 characters) are checked and surface as
121`error.ArgumentTooLong`. Check `extensions.dsn` first — a conforming server
122answers an unrecognized parameter with 555.
123
124Setting `client.mode = .lmtp` before `hello` speaks LMTP: `LHLO` goes out in
125place of `EHLO`, and the end of a message brings back one verdict per
126accepted recipient, in the order the RCPT commands were issued. `endResults`
127is how to read them:
128
129```zig
130var data_writer = try client.data();
131try data_writer.interface.writeAll(message);
132var verdicts = try data_writer.endResults();
133while (try verdicts.next()) |reply| {
134 // verdicts.index counts the recipients as they are answered.
135 std.log.info("{s}: {d} {s}", .{ recipients[verdicts.index - 1], reply.code, reply.text });
136}
137```
138
139Every verdict must be read before the session is used again, or the next
140command is answered by a leftover reply. The simpler `end` reads them all
141and reports `error.RecipientRejected` if any was a refusal — without saying
142which, because the replies share one buffer and reading the next overwrites
143the previous.
144
145When the server advertises CHUNKING (`extensions.chunking`), `bdat` and
146`sendMessageChunked` transmit the message with length-framed BDAT chunks
147instead of DATA — verbatim, with no dot-stuffing, so content must already
148use CRLF line endings.
149
150### Authentication
151
152`hello` reports the server's advertised mechanisms in `extensions.auth`;
153`authenticate` picks the best one, or use `authPlain`/`authLogin`/
154`authCramMd5` directly. A 535 rejection surfaces as
155`error.AuthenticationFailed` with the reply in `last_reply`.
156
157```zig
158const extensions = try client.hello("my-host.example.com");
159try client.authenticate(extensions, "user", "password");
160```
161
162PLAIN and LOGIN send the password in the clear — base64 is not encryption —
163so the client refuses them unless `client.security` is `.encrypted`,
164returning `error.InsecureTransport` instead. The library is handed a reader
165and a writer and cannot see what is underneath them, so it assumes the worst:
166`setTransport` records the answer for a STARTTLS upgrade, and a session
167speaking TLS from the first byte sets `client.security = .encrypted` itself.
168Which mechanism `authenticate` picks follows from that — PLAIN, then LOGIN,
169then CRAM-MD5 once encrypted, and CRAM-MD5 first when it is not, since that
170is the one mechanism of the three that never puts the password on the wire.
171
172For a connection protected by something the library cannot see — a unix
173socket, an SSH tunnel, a loopback test — `client.allow_cleartext_auth = true`
174permits the cleartext mechanisms without claiming the transport is encrypted.
175
176### TLS
177
178`zsmtp.Tls` wraps [ianic/tls.zig](https://github.com/ianic/tls.zig) and
179verifies against the system trust store by default (a caller-managed CA
180bundle and an insecure mode are also available). The stream reader/writer
181handed to it need buffers of at least `zsmtp.Tls.min_buffer_len` bytes, and
182`init` must run at the value's final address (the connection holds interior
183pointers). The standard library's TLS client is deliberately not used: it
184requires the optional TLS 1.3 middlebox-compatibility ChangeCipherSpec
185record, which servers like Exim disable.
186
187Implicit TLS (port 465) — handshake first, then speak SMTP:
188
189```zig
190var tls: zsmtp.Tls = undefined;
191try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{
192 .host = "smtp.example.com",
193});
194defer tls.deinit(gpa);
195var client: zsmtp.Client = .init(tls.reader(), tls.writer(), &reply_buf);
196client.security = .encrypted; // the transport is TLS; `init` cannot tell
197// ... greet, hello, sendMail ...
198try client.quit();
199try tls.end(); // close_notify, before closing the socket
200```
201
202STARTTLS (port 587) — upgrade mid-session, then EHLO again:
203
204```zig
205_ = try client.greet();
206_ = try client.hello("my-host.example.com"); // check .starttls in the result
207try client.starttls();
208var tls: zsmtp.Tls = undefined;
209try tls.init(io, gpa, &stream_reader.interface, &stream_writer.interface, .{
210 .host = "smtp.example.com",
211});
212client.setTransport(tls.reader(), tls.writer(), .encrypted);
213_ = try client.hello("my-host.example.com"); // server state was reset
214```
215
216## Server
217
218```zig
219var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, .{
220 .context = &my_state,
221 .vtable = &.{
222 .authenticate = onAuth, // optional; enables AUTH PLAIN and LOGIN
223 .rcptTo = onRcptTo, // optional; accept/reject each Recipient
224 .message = onMessage, // required; receives envelope + message data
225 },
226}, .{ .hostname = "mx.example.com" });
227try session.run(gpa);
228```
229
230With an `authenticate` callback the session advertises and accepts AUTH
231PLAIN and AUTH LOGIN (RFC 4954); setting `Options.require_auth` rejects MAIL
232with 530 until the client has authenticated.
233
234Instead of `message` (which collects the whole body in memory, bounded by
235`max_message_size`), a handler can set `messageReader` to stream it: the
236callback receives an `Io.Reader` yielding the unstuffed message content,
237and anything left unread is drained by the session.
238
239`run` serves one connection until QUIT or disconnect, enforcing command
240sequencing, recipient and message-size limits, and un-stuffing message data.
241Messages may also arrive via BDAT chunks (CHUNKING is advertised); both
242the collecting and streaming handler paths receive the reassembled content.
243MAIL parameters are validated: `SIZE=` (RFC 1870) is rejected early with 552
244when it exceeds `max_message_size`, `BODY=7BIT`/`BODY=8BITMIME` (RFC 6152)
245are accepted, and unrecognized parameters get 555; the declared size and
246body type reach the handler via `Envelope`. Listening, accepting, and
247concurrency are up to the caller.
248
249The server holds back the replies that RFC 2920 §3.2 permits — RSET, MAIL
250FROM and RCPT TO — so that a pipelined group is answered in one write, and
251sends everything pending the moment its input is empty. The condition is what
252makes that safe rather than a deadlock: a reply is only ever held while there
253is another command already waiting to be answered.
254
255Setting `Options.protocol = .lmtp` makes the session speak LMTP
256([RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033)) instead: `LHLO`
257greets and `HELO`/`EHLO` are refused with 500, and the end of a message
258draws one reply per accepted recipient rather than one for the message —
259including a second reply for a recipient named twice. The `recipientResult`
260callback supplies each verdict:
261
262```zig
263fn onRecipientResult(ctx: ?*anyopaque, envelope: zsmtp.Server.Envelope, index: usize) zsmtp.Server.Decision {
264 return if (mailboxIsFull(envelope.recipients[index].address))
265 .{ .reject = .{ .code = 452, .text = "4.2.2 Mailbox full" } }
266 else
267 .accept;
268}
269```
270
271Without it every recipient is told the same thing, which is correct but
272gains nothing over SMTP. A message the handler rejected outright is reported
273as that rejection for each recipient, since it failed for all of them. LMTP
274is meant for the hop between a queueing MTA and whatever writes to mailboxes;
275RFC 2033 §5 forbids it on TCP port 25 and advises against wide-area use.
276
277DSN ([RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461)) is
278advertised. `RET=` and `ENVID=` on MAIL arrive as `Envelope.ret` and
279`Envelope.envid`, and `NOTIFY=` and `ORCPT=` on RCPT arrive as
280`Recipient.notify` and `Recipient.orcpt` — at the `rcptTo` callback, which
281receives the whole `Recipient`, and again on the `Envelope` afterwards. The
282xtext values are decoded, the length limits enforced, and a malformed value
283answered with 501. Like everything else handed to a callback, those slices
284live only for the duration of the call; keep what you need by copying it.
285
286To advertise and accept STARTTLS (TLS 1.3, via
287[ianic/tls.zig](https://github.com/ianic/tls.zig)), pass a certificate key
288pair; the stream buffers must then be at least `zsmtp.tls.input_buffer_len` /
289`zsmtp.tls.output_buffer_len` bytes, since the handshake runs over them:
290
291```zig
292var auth: zsmtp.tls.config.CertKeyPair =
293 try .fromFilePath(gpa, io, .cwd(), "cert.pem", "key.pem");
294defer auth.deinit(gpa);
295
296var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
297 .hostname = "mx.example.com",
298 .tls = .{ .io = io, .auth = &auth },
299});
300try session.run(gpa);
301```
302
303On STARTTLS the session answers 220, performs the server handshake, swaps
304its transport to the encrypted connection, and resets state per RFC 3207 (the
305client must EHLO again). With `.mode = .implicit` the handshake instead runs
306before the greeting (SMTPS, port 465 style):
307
308```zig
309var session: zsmtp.Server = .init(&stream_reader.interface, &stream_writer.interface, handler, .{
310 .hostname = "mx.example.com",
311 .tls = .{ .io = io, .auth = &auth, .mode = .implicit },
312});
313```
314
315## Demo CLI
316
317```sh
318zig build
319
320# Debug server that prints received messages to stdout
321# (with a cert/key pair it advertises and accepts STARTTLS):
322./zig-out/bin/zsmtp serve 2525
323./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem 2525
324./zig-out/bin/zsmtp serve --tls-cert cert.pem --tls-key key.pem --implicit-tls 2465
325
326# Send a message read from stdin:
327printf 'Subject: hi\r\n\r\nhello\r\n' | \
328 ./zig-out/bin/zsmtp send 127.0.0.1 2525 me@example.com you@example.net
329
330# Same, over implicit TLS or STARTTLS (--insecure skips cert verification):
331zsmtp send --tls smtp.example.com 465 me@example.com you@example.net
332zsmtp send --starttls smtp.example.com 587 me@example.com you@example.net
333
334# Speak LMTP (RFC 2033) instead of SMTP. The server reports one verdict per
335# recipient, and --fail-delivery makes one of them fail to show it:
336./zig-out/bin/zsmtp serve --lmtp --fail-delivery bad@example.net 2529
337printf 'Subject: hi\r\n\r\nhello\r\n' | \
338 ./zig-out/bin/zsmtp send --lmtp 127.0.0.1 2529 me@example.com \
339 good@example.net bad@example.net
340
341# Request a delivery status notification (RFC 3461):
342zsmtp send --ret hdrs --envid 'batch 7' --notify success,failure \
343 --orcpt team@example.net 127.0.0.1 2525 me@example.com you@example.net
344
345# Authenticate. Over a plaintext connection this refuses PLAIN and LOGIN
346# rather than put the password on the wire; --allow-cleartext-auth overrides
347# that for a connection protected by other means:
348zsmtp send --starttls --user me --password secret smtp.example.com 587 \
349 me@example.com you@example.net
350```
351
352## Status
353
354TLS is supported on both sides via
355[ianic/tls.zig](https://github.com/ianic/tls.zig): the client does implicit
356TLS and STARTTLS via `zsmtp.Tls`, and the server accepts both STARTTLS and
357implicit TLS (TLS 1.3 only). AUTH covers PLAIN, LOGIN, and CRAM-MD5 on the
358client and PLAIN and LOGIN on the server. Message bodies can be streamed on
359both sides, and the server validates MAIL and RCPT parameters (SIZE=, BODY=,
360and the DSN set RET=, ENVID=, NOTIFY=, ORCPT=). Both sides also speak LMTP,
361where a message ends with one verdict per recipient rather than one for the
362message, and both use PIPELINING, which collapses an envelope into a single
363round trip.
364
365## Known gaps
366
367Measured against the implementations people are likely to be coming from —
368Postfix, Exim and Haraka on the server side, Go's `net/smtp`, Python's
369`smtplib`, lettre and Nodemailer on the client side. Kept here so the list
370is one thing rather than a rediscovery each time.
371
372### Out of scope, not missing
373
374- **Message composition.** No MIME builder, headers, attachments, transfer
375 encodings, `Message-ID` or `Date` generation. zsmtp carries a message that
376 already exists; building one is RFC 5322's job and belongs in a library of
377 its own.
378- **DSN report generation**
379 ([RFC 3464](https://datatracker.ietf.org/doc/html/rfc3464)). The SMTP half
380 of DSN — RFC 3461's `RET`, `ENVID`, `NOTIFY` and `ORCPT` — is implemented
381 on both sides, but nothing here builds the `multipart/report` message that
382 carries a delivery status back to the sender. That is message composition
383 by another name, so it goes with the library above.
384- **Everything an MTA does around a session.** No queue, no retry schedule,
385 no MX resolution, no routing, no mailbox store. "Server" here means a
386 session handler: listening, accepting and concurrency are the caller's.
387
388### Protocol
389
390- **BINARYMIME** — CHUNKING is implemented but `BODY=BINARYMIME` is refused,
391 which is the other half of
392 [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030).
393- **Modern SASL** — no XOAUTH2 or OAUTHBEARER
394 ([RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628)), which is what
395 Gmail and Microsoft 365 now require; no SCRAM-SHA-256
396 ([RFC 7677](https://datatracker.ietf.org/doc/html/rfc7677)), no EXTERNAL,
397 no `AUTH=` on MAIL FROM. CRAM-MD5 is the most modern mechanism present.
398- **Client certificates** — neither side can present or verify one.
399- **No enhanced status code accessor** — the server emits `x.y.z` on every
400 reply, but `Reply` exposes only `code` and the raw text.
401- `EXPN` is unrecognized rather than unimplemented, so it answers 500 where
402 [RFC 5321 §4.2.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.2.4)
403 wants 502.
404- Niche and absent: REQUIRETLS, MT-PRIORITY, DELIVERBY, FUTURERELEASE, ETRN.
405
406### Server
407
408- **No `Received:` header.**
409 [RFC 5321 §4.4](https://datatracker.ietf.org/doc/html/rfc5321#section-4.4)
410 requires a receiving server to stamp one.
411- **The handler never sees the connection** — no connect callback, no peer
412 address, no TLS state. Greylisting, DNSBLs, SPF and per-IP policy cannot
413 be built on top, and a `Received:` header cannot be written without it.
414- **No timeouts**, so a client that connects and says nothing holds the
415 session forever;
416 [RFC 5321 §4.5.3.2](https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.2)
417 specifies per-command limits.
418- **No abuse limits** beyond `max_recipients`: unlimited failed AUTH
419 attempts, no error-count disconnect, no command budget.
420- **No `require_tls`** to go with `require_auth`.
421- **No PROXY protocol, XCLIENT or XFORWARD**, so the real peer address is
422 lost behind a load balancer.
423- No filter or milter hook, and so no DKIM, SPF, DMARC or ARC.
424- No logging or tracing hooks.
425- `max_message_size` is not enforced in `messageReader` mode.
426
427### Client
428
429- **`sendMail` is all-or-nothing on recipients** — a refused RCPT abandons
430 the transaction, where `smtplib.sendmail` delivers to the rest and reports
431 the refusals. `envelope` gives a caller the per-recipient codes to decide
432 for itself, but no higher-level call does that decision for it.
433- **No `SIZE=` or `BODY=` on MAIL**, though the client parses both
434 capabilities off EHLO; `max_size` in particular is read and never used, so
435 nothing checks that a message fits before transmitting it.
436- No MX resolution or connect helper, no 4xx retry or backoff, no connection
437 reuse helper.
438
439## Standards
440
441- [RFC 5321](https://datatracker.ietf.org/doc/html/rfc5321) — Simple Mail
442 Transfer Protocol: the command/reply protocol, multiline replies,
443 dot-stuffing, reply classes, and ESMTP parameter syntax (client and
444 server).
445- [RFC 1870](https://datatracker.ietf.org/doc/html/rfc1870) — SIZE:
446 advertised and enforced by the server (oversize declarations are rejected
447 with 552 before DATA); parsed from EHLO by the client.
448- [RFC 6152](https://datatracker.ietf.org/doc/html/rfc6152) — 8BITMIME:
449 advertised by the server and `BODY=` validated; parsed by the client.
450- [RFC 3030](https://datatracker.ietf.org/doc/html/rfc3030) — CHUNKING
451 (BDAT): client and server, with length-based framing and no dot-stuffing;
452 the companion BINARYMIME extension is not implemented (`BODY=BINARYMIME`
453 is rejected).
454- [RFC 3461](https://datatracker.ietf.org/doc/html/rfc3461) — DSN:
455 advertised by the server, which parses and validates `RET=`/`ENVID=` on
456 MAIL and `NOTIFY=`/`ORCPT=` on RCPT and hands them to the handler; the
457 client sends them through `mail`/`rcpt`. Includes the xtext codec of §4.
458 Generating the report message itself (RFC 3464) is out of scope.
459- [RFC 2033](https://datatracker.ietf.org/doc/html/rfc2033) — LMTP: client
460 and server, via `Client.mode` and `Server.Options.protocol`. `LHLO`
461 replaces `EHLO` and the end of a message draws one reply per accepted
462 recipient instead of one for the message, after DATA and after `BDAT
463 LAST` alike.
464- [RFC 2920](https://datatracker.ietf.org/doc/html/rfc2920) — PIPELINING:
465 the client sends a whole envelope as one group through `envelope`, and the
466 server holds back the replies it is allowed to (RSET, MAIL, RCPT) so they
467 leave together, sending everything pending the moment its input runs dry.
468- [RFC 3207](https://datatracker.ietf.org/doc/html/rfc3207) — STARTTLS:
469 client and server, including the mandatory post-handshake state reset.
470- [RFC 8314](https://datatracker.ietf.org/doc/html/rfc8314) — implicit TLS
471 (SMTPS): client (`Tls` before any SMTP traffic) and server
472 (`.mode = .implicit`).
473- [RFC 4954](https://datatracker.ietf.org/doc/html/rfc4954) — AUTH: client
474 and server, including initial responses and `*` cancellation.
475- [RFC 4616](https://datatracker.ietf.org/doc/html/rfc4616) — the PLAIN
476 SASL mechanism (client and server).
477- [RFC 2195](https://datatracker.ietf.org/doc/html/rfc2195) — CRAM-MD5
478 (client only; the server would need plaintext-equivalent credentials).
479- [draft-murchison-sasl-login](https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00)
480 — the de-facto AUTH LOGIN mechanism (client and server).
481- [RFC 3463](https://datatracker.ietf.org/doc/html/rfc3463) /
482 [RFC 2034](https://datatracker.ietf.org/doc/html/rfc2034) — enhanced
483 status codes: carried in every server reply and advertised via
484 ENHANCEDSTATUSCODES; detected by the client.
485- [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) — SMTPUTF8:
486 client (`mailFromUtf8`) and server (advertised; non-ASCII addresses
487 require the parameter and must be valid UTF-8, rejected with 553 5.6.7
488 per [RFC 6533](https://datatracker.ietf.org/doc/html/rfc6533) otherwise;
489 the flag reaches handlers via `Envelope.smtputf8`).
490
491TLS itself (TLS 1.3, [RFC 8446](https://datatracker.ietf.org/doc/html/rfc8446))
492is provided by [ianic/tls.zig](https://github.com/ianic/tls.zig).
493
494## References cited
495
496The specifications this implementation was written against, and the outside
497work it borrows from, in the RFC citation format so that a reference here
498matches one anywhere else. The **Standards** section above says what is
499implemented of each; this one says what each document *is*. Every entry is
500also filed in the project bibliography, so a citation can be taken from there
501rather than composed; the RFCs are keyed by their DOIs (`10.17487/RFC5321`
502and so on).
503
504- **[RFC1870]** Klensin, J., Freed, N., and K. Moore, "SMTP Service
505 Extension for Message Size Declaration", RFC 1870, November 1995,
506 <https://www.rfc-editor.org/info/rfc1870>.
507- **[RFC2033]** Myers, J., "Local Mail Transfer Protocol", RFC 2033,
508 October 1996, <https://www.rfc-editor.org/info/rfc2033>.
509- **[RFC2034]** Freed, N., "SMTP Service Extension for Returning Enhanced
510 Error Codes", RFC 2034, October 1996,
511 <https://www.rfc-editor.org/info/rfc2034>.
512- **[RFC2195]** Klensin, J., Catoe, R., and P. Krumviede, "IMAP/POP
513 AUTHorize Extension for Simple Challenge/Response", RFC 2195,
514 September 1997, <https://www.rfc-editor.org/info/rfc2195>.
515- **[RFC2920]** Freed, N., "SMTP Service Extension for Command Pipelining",
516 RFC 2920, September 2000, <https://www.rfc-editor.org/info/rfc2920>.
517- **[RFC3030]** Vaudreuil, G., "SMTP Service Extensions for Transmission of
518 Large and Binary MIME Messages", RFC 3030, December 2000,
519 <https://www.rfc-editor.org/info/rfc3030>.
520- **[RFC3207]** Hoffman, P., "SMTP Service Extension for Secure SMTP over
521 Transport Layer Security", RFC 3207, February 2002,
522 <https://www.rfc-editor.org/info/rfc3207>.
523- **[RFC3461]** Moore, K., "Simple Mail Transfer Protocol (SMTP) Service
524 Extension for Delivery Status Notifications (DSNs)", RFC 3461,
525 January 2003, <https://www.rfc-editor.org/info/rfc3461>.
526- **[RFC3463]** Vaudreuil, G., "Enhanced Mail System Status Codes",
527 RFC 3463, January 2003, <https://www.rfc-editor.org/info/rfc3463>.
528- **[RFC3464]** Moore, K. and G. Vaudreuil, "An Extensible Message Format
529 for Delivery Status Notifications", RFC 3464, January 2003,
530 <https://www.rfc-editor.org/info/rfc3464>. *(Cited as out of scope: the
531 report message itself.)*
532- **[RFC4616]** Zeilenga, K., "The PLAIN Simple Authentication and Security
533 Layer (SASL) Mechanism", RFC 4616, August 2006,
534 <https://www.rfc-editor.org/info/rfc4616>.
535- **[RFC4954]** Siemborski, R. and A. Melnikov, "SMTP Service Extension for
536 Authentication", RFC 4954, July 2007,
537 <https://www.rfc-editor.org/info/rfc4954>.
538- **[RFC5321]** Klensin, J., "Simple Mail Transfer Protocol", RFC 5321,
539 October 2008, <https://www.rfc-editor.org/info/rfc5321>.
540- **[RFC5322]** Resnick, P., Ed., "Internet Message Format", RFC 5322,
541 October 2008, <https://www.rfc-editor.org/info/rfc5322>. *(Cited as out
542 of scope: the format of the message this library carries.)*
543- **[RFC6152]** Klensin, J., Freed, N., Rose, M., and D. Crocker, "SMTP
544 Service Extension for 8-bit MIME Transport", RFC 6152, March 2011,
545 <https://www.rfc-editor.org/info/rfc6152>.
546- **[RFC6531]** Yao, J. and W. Mao, "SMTP Extension for Internationalized
547 Email", RFC 6531, February 2012,
548 <https://www.rfc-editor.org/info/rfc6531>.
549- **[RFC6533]** Hansen, T., Ed., Newman, C., and A. Melnikov,
550 "Internationalized Delivery Status and Disposition Notifications",
551 RFC 6533, February 2012, <https://www.rfc-editor.org/info/rfc6533>.
552- **[RFC7628]** Mills, W., Showalter, T., and H. Tschofenig, "A Set of
553 Simple Authentication and Security Layer (SASL) Mechanisms for OAuth",
554 RFC 7628, August 2015, <https://www.rfc-editor.org/info/rfc7628>.
555 *(Cited as a gap.)*
556- **[RFC7677]** Hansen, T., "SCRAM-SHA-256 and SCRAM-SHA-256-PLUS Simple
557 Authentication and Security Layer (SASL) Mechanisms", RFC 7677,
558 November 2015, <https://www.rfc-editor.org/info/rfc7677>. *(Cited as a
559 gap.)*
560- **[RFC8314]** Moore, K. and C. Newman, "Cleartext Considered Obsolete:
561 Use of Transport Layer Security (TLS) for Email Submission and Access",
562 RFC 8314, January 2018, <https://www.rfc-editor.org/info/rfc8314>.
563- **[RFC8446]** Rescorla, E., "The Transport Layer Security (TLS) Protocol
564 Version 1.3", RFC 8446, August 2018,
565 <https://www.rfc-editor.org/info/rfc8446>.
566- **[SASL-LOGIN]** Murchison, K. and M. Crispin, "The LOGIN SASL
567 Mechanism", Work in Progress, Internet-Draft,
568 draft-murchison-sasl-login-00, August 2003,
569 <https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00>.
570 The draft expired and LOGIN was never standardized; it is implemented
571 here because servers still ask for it.
572- **[TLS.ZIG]** Ianic, "tls.zig — TLS 1.2/1.3 implementation in Zig",
573 <https://github.com/ianic/tls.zig>. Provides the TLS on both sides; see
574 the **TLS** section for why the standard library's client is not used.
575- **[ISEMAIL]** Sayers, D., "is_email — an email address validator and its
576 test suite", BSD-3-Clause, <https://github.com/dominicsayers/isemail>.
577 The address corpus the path parser is checked against; see **Tests**.
578- **[EXIM]** The Exim Maintainers, "Exim Internet Mailer",
579 GPL-2.0-or-later, <https://www.exim.org/>. The protocol torture script
580 and the gauntlet unit test's dialogue are adapted from its test suite.
581
582## Tests
583
584```sh
585zig build test
586zig build test --fuzz # run the fuzz tests under the fuzzer (endless)
587```
588
589The fuzz tests cover parser crash-safety (`Command.parse`, `Reply.read`),
590whole-session robustness against arbitrary bytes on both the client and
591server side, and two differential properties: the streaming `DataWriter`
592must produce byte-identical output to the slice-based `writeStuffed` under
593fuzzer-chosen chunk boundaries, and the collecting and streaming server
594DATA paths must yield identical message content.
595
596### Protocol torture testing with exim's test client
597
598Exim's scriptable SMTP test client (`test/src/client.c` in the exim
599source) sends raw protocol lines and asserts reply prefixes. The exim
600source is declared as a *lazy* Zig dependency, fetched only on demand:
601
602```sh
603zig build -Dexim-client # fetches exim, installs zig-out/bin/exim-client
604./zig-out/bin/zsmtp serve 2525 &
605./zig-out/bin/exim-client 127.0.0.1 2525 < test/protocol-torture.script
606```
607
608### Address corpus testing with the is_email suite
609
610Dominic Sayers' [is_email](https://github.com/dominicsayers/isemail) test
611suite (BSD-3-Clause) is declared as a *lazy* Zig dependency; nothing from
612it is copied into this repository. On demand, the corpus test embeds its
613XML test files, extracts the 125 addresses valid at the RFC 5321 layer,
614and checks that each passes through the path parser byte-for-byte:
615
616```sh
617zig build test -Disemail-corpus # fetches the suite and runs the corpus test
618```
619
620Without the option the corpus test is skipped.
621
622`test/protocol-torture.script` is a 28-reply dialogue distilled from
623exim's own test suite (syntax errors, sequencing violations, parameter
624validation, dot-stuffing); the same dialogue is asserted byte-for-byte
625as a unit test in `Server.zig`.
626
627The library is MIT-licensed; the small amount of test-only material adapted
628from exim's test suite (the torture script and the gauntlet unit test's
629dialogue) is GPL-2.0-or-later, marked with SPDX snippet tags and REUSE.toml
630annotations.
631
632Note: Zig 0.16.0's fuzz *driver* is broken out of the box (its bundled
633test runner fails to compile in fuzz mode, and the coverage server panics
634on a test binary with no fuzz tests); both are fixed on Zig master. Until
635then, fuzzing needs a patched copy of the standard library via
636`zig build --zig-lib-dir <patched-lib> test --fuzz`. The fuzz tests
637themselves also run once per invocation as part of the normal
638`zig build test` suite.
639
640Interoperability against third-party implementations is covered by a NixOS
641VM test (`nix/interop-test.nix`): the zsmtp client delivers mail to Postfix
642and Exim over plaintext, STARTTLS, and implicit TLS against each, and swaks
643delivers to the zsmtp server over plaintext and STARTTLS.
644
645```sh
646nix build .#zsmtp # build the package
647nix build .#checks.x86_64-linux.interop # run the VM interop test
648```