Implement REQUIRETLS, and say why the other four stay out
The five "niche extensions" were one bullet, which hid that they are two
different kinds of thing and that four of them were already answered
correctly.
MT-PRIORITY, DELIVERBY, FUTURERELEASE and ETRN are queue features: one
orders a queue, one bounces from it on a deadline, one holds in it until a
time, one flushes it on demand. This library has no queue, so their wire
syntax without their semantics would advertise something nothing here
could honour. They are already answered right -- 555 for a parameter
never advertised, per RFC 5321 §4.1.1.11, and 500 for a command from an
extension never offered -- and the README now says that rather than
listing them as an oversight. Ignoring them is the one answer that would
be wrong.
REQUIRETLS is different in kind, which is why it is implemented: it is a
security feature, not a queue feature, and the sender's meaning is
"bounce this rather than let it travel in the clear".
Advertising it is a promise, and the code says so out loud. RFC 8689
requires a server offering the keyword to honour the requirement, and a
client that does not see it must quit and try another MX, refusing the
domain outright if none offers it -- so the keyword is load-bearing in a
way most are not. This library can keep no part of that promise itself;
it does not relay. `Options.requiretls` is therefore the caller
undertaking to honour it, defaults false, and is advertised only while
the session employs TLS. A parameter arriving where it was never offered
gets 555 rather than being quietly disregarded: accepting and ignoring it
would turn a sender's refusal to be downgraded into a downgrade.
The client refuses to send it over a session it does not believe is
encrypted. The rest of §4.1's preconditions -- a certificate validated by
a trust chain or DANE, an MX vouched for by DNSSEC or MTA-STS -- are not
visible from a library that is handed a reader and a writer and resolves
nothing, so they are documented as the caller's. The demo CLI knows more
than the library does and refuses --requiretls beside --insecure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Compose the Received: field for the handler to write
RFC 5321 §4.4 requires a receiving server to stamp a trace field, and this
one had no way to produce one: composing it needs a clock, the peer address
and the session's own state, and the session had the last of those only.
`Options.received` supplies the other two — an `Io` to read the clock from
and the peer description, which the caller has because it accepted the
connection and this library has not, since it is handed a reader and a
writer and never sees an address. With it set, every message arrives at the
handler with `Envelope.received` filled in: the complete field, `Received: `
prefix and trailing CRLF included.
The handler writes it. The library never touches the message bytes — it has
no idea whether they are being spooled, relayed or parsed — and the field
belongs at the beginning of the content, which only the handler can arrange.
Left unset, nothing is composed, and that is the caller's choice rather than
a default worth having.
The layout comes from zig-mime's `received` helper and the timestamp from
zig-datetime, pinned to the same revision zig-mime uses so the two do not
become two incompatible copies of one type. What goes in the field is read
off the session: the greeting name the client gave (escaped, since the peer
chose it), the RFC 3848 protocol name for whether the hop was encrypted,
authenticated, LMTP or SMTPUTF8, and a `for` clause only when there is
exactly one recipient — with more than one it would tell each about the
others.
The demo server writes it ahead of the body, which is what the live check
looked like:
Received: from localhost ([127.0.0.1:32946])
by localhost (zig-smtp) with ESMTP
for <b@example.net>; Sun, 13 Sep 2026 03:23:04 +0000
README: the gap is closed, the Status and Server sections say how it works,
RFC 3848 joins the standards list, and RFC 8689 — implemented but never
cited — joins the references. RFC 3848, RFC 8689, zig-mime and zig-datetime
are filed in the Zotero collection, which is now named for the project
rather than for what it used to be called.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Compose the Received: field for the handler to write
RFC 5321 §4.4 requires a receiving server to stamp a trace field, and this
one had no way to produce one: composing it needs a clock, the peer address
and the session's own state, and the session had the last of those only.
`Options.received` supplies the other two — an `Io` to read the clock from
and the peer description, which the caller has because it accepted the
connection and this library has not, since it is handed a reader and a
writer and never sees an address. With it set, every message arrives at the
handler with `Envelope.received` filled in: the complete field, `Received: `
prefix and trailing CRLF included.
The handler writes it. The library never touches the message bytes — it has
no idea whether they are being spooled, relayed or parsed — and the field
belongs at the beginning of the content, which only the handler can arrange.
Left unset, nothing is composed, and that is the caller's choice rather than
a default worth having.
The layout comes from zig-mime's `received` helper and the timestamp from
zig-datetime, pinned to the same revision zig-mime uses so the two do not
become two incompatible copies of one type. What goes in the field is read
off the session: the greeting name the client gave (escaped, since the peer
chose it), the RFC 3848 protocol name for whether the hop was encrypted,
authenticated, LMTP or SMTPUTF8, and a `for` clause only when there is
exactly one recipient — with more than one it would tell each about the
others.
The demo server writes it ahead of the body, which is what the live check
looked like:
Received: from localhost ([127.0.0.1:32946])
by localhost (zig-smtp) with ESMTP
for <b@example.net>; Sun, 13 Sep 2026 03:23:04 +0000
README: the gap is closed, the Status and Server sections say how it works,
RFC 3848 joins the standards list, and RFC 8689 — implemented but never
cited — joins the references. RFC 3848, RFC 8689, zig-mime and zig-datetime
are filed in the Zotero collection, which is now named for the project
rather than for what it used to be called.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Decline EXPN rather than disown it
EXPN fell through to the unknown-command arm and answered 500, which
claims never to have heard of a command this server can perfectly well
parse. RFC 5321 §4.2.4 permits either 500 or 502 for a command that is
not implemented, and 502 is the one that is true here: the verb was
recognized and the service is not offered. A client can tell the
difference and act on it -- 500 means stop asking, 502 means this server
in this configuration.
VRFY keeps its 252, which is the compliant answer for a server that will
not check an address in advance but will accept the mail, and which
§4.5.1 requires of it -- 500 or 502 there would put this out of
compliance, since VRFY is one of the commands a server must support.
Both now require their argument, which the ABNF makes mandatory: `vrfy =
"VRFY" SP String CRLF`, and EXPN the same shape. Neither has anything to
act on without one, so a bare VRFY is a syntax error rather than a
command with an empty operand.
The RFC 2034 conformance walk covers EXPN now too, so its 502 is checked
for a status code whose class agrees, along with everything else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Rename the project to zig-smtp
It sits beside zig-pop3, zig-ftp, zig-scram and zig-sasl, and "zsmtp" was
the odd one out.
Three names come out of it rather than one, following what those siblings
do. The repository and the package are `zig-smtp`; the Zig module is
`smtp`, so callers write `@import("smtp")` the way zig-pop3's callers
write `@import("pop3")`; and the demo CLI is `zig-smtp`, matching zig-ftp
rather than pop3's bare `pop3`, because `smtp` is too generic a name to
put on somebody's PATH.
The manifest fingerprint had to change with the package name -- Zig
derives it from that name and refuses the old one -- so this is a new
package as far as the package manager is concerned, not a renamed one.
The Radicle identity was renamed in place, so the RID is unchanged and
every `rad clone rad:z3ZKHgoDKEue8FT7sV6fHZdtjxRx1` in the wild still
works. The Tangled mirror could not be renamed and was recreated.
The published documentation moves with it, from jeff.jcollie.page/zsmtp/
to jeff.jcollie.page/zig-smtp/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC