Take the AUTH mechanisms from zig-sasl
PLAIN, LOGIN and CRAM-MD5 are gone from the client. They were three of
the four copies in this tree -- zig-pop3 has its own, an IMAP library
would have made a third, and zig-scram's SCRAM was reachable from none of
them. They live in zig-sasl now, re-exported here as `zsmtp.sasl` so a
caller does not need a second import to say `sasl.Plain`.
What is left behind is the part that was ever specific to SMTP, and it is
one loop: the AUTH command, the 334 challenges, the `*` that cancels, the
235 that ends it. `authenticate` takes a `sasl.Client` and drives it.
`Extensions.auth` is now the mechanism names as the server sent them,
which is what `sasl.Client.selectFromList` reads -- so the preference
order and the don't-send-a-password-in-the-clear rule are one
implementation instead of one per protocol.
Two things the old code could not express now work. A mechanism may
answer a challenge with nothing, which is how SCRAM acknowledges the
server's proof and how XOAUTH2 acknowledges a failure report. And
`error.ServerNotAuthenticated` is the server reporting success while the
mechanism says it never finished proving what it set out to -- for SCRAM,
a peer that took the client's proof and offered none of its own, which is
precisely what something in the middle without the verifier would do.
Nothing here could tell that from a real success before.
A mechanism failing mid-exchange now cancels with `*` and reads the 501,
rather than leaving the server waiting for a line that is not coming.
The server side is unchanged: it still implements PLAIN and LOGIN itself,
against a plaintext password, because zig-sasl's server side does not yet
reach past PLAIN. That is the next thing.
Verified against postfix, exim and dovecot: the whole interop suite
passes, including AUTH PLAIN and LOGIN to exim over both a cleartext
opt-in and STARTTLS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Take the server's AUTH mechanisms from zig-sasl too
`Handler.authenticate` is gone. `fn (username, password) bool` could
carry PLAIN and LOGIN and nothing else, because it assumed the server
held something a password could be compared against -- so the server
offered exactly those two and could never offer a third.
`Options.auth_mechanisms` replaces it: a list of `sasl.Server`,
advertised by name in the EHLO response and driven by the loop that is
left here, which is the SMTP part -- the 334 challenges, the `*` that
cancels, 235, and the 504 for a name nothing answers to. The server can
now do CRAM-MD5, which there is a test for against RFC 2195's published
response, and EXTERNAL, and anything else zig-sasl grows.
Where the credential comes from moved to the mechanism, which is the
whole reason this works. PLAIN and LOGIN share a `PasswordCheck` and are
told only whether a password was right; CRAM-MD5 needs a
`PasswordLookup` and gets the password itself, because it must compute
the same HMAC the client did. That has always been the argument against
offering CRAM-MD5 and it is now visible in the types.
The mechanisms hold per-exchange state, so a session needs its own set
rather than a shared one -- the demo server builds a fresh CRAM-MD5
challenge per connection for exactly that reason.
`Envelope.authenticated_as` is new, and had to be: the identity used to
reach the handler because the handler did the checking, and now the
mechanism does. It is what the mechanism reported rather than what the
client typed, which for PLAIN's authorization identity is not the same
thing, and it is what a handler deciding whether to relay actually wants.
Verified end to end: the demo server advertises PLAIN LOGIN CRAM-MD5, a
CRAM-MD5 login succeeds over a plaintext session with no cleartext
opt-in, swaks still logs in with LOGIN, and the whole interop suite
passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Take the server's AUTH mechanisms from zig-sasl too
`Handler.authenticate` is gone. `fn (username, password) bool` could
carry PLAIN and LOGIN and nothing else, because it assumed the server
held something a password could be compared against -- so the server
offered exactly those two and could never offer a third.
`Options.auth_mechanisms` replaces it: a list of `sasl.Server`,
advertised by name in the EHLO response and driven by the loop that is
left here, which is the SMTP part -- the 334 challenges, the `*` that
cancels, 235, and the 504 for a name nothing answers to. The server can
now do CRAM-MD5, which there is a test for against RFC 2195's published
response, and EXTERNAL, and anything else zig-sasl grows.
Where the credential comes from moved to the mechanism, which is the
whole reason this works. PLAIN and LOGIN share a `PasswordCheck` and are
told only whether a password was right; CRAM-MD5 needs a
`PasswordLookup` and gets the password itself, because it must compute
the same HMAC the client did. That has always been the argument against
offering CRAM-MD5 and it is now visible in the types.
The mechanisms hold per-exchange state, so a session needs its own set
rather than a shared one -- the demo server builds a fresh CRAM-MD5
challenge per connection for exactly that reason.
`Envelope.authenticated_as` is new, and had to be: the identity used to
reach the handler because the handler did the checking, and now the
mechanism does. It is what the mechanism reported rather than what the
client typed, which for PLAIN's authorization identity is not the same
thing, and it is what a handler deciding whether to relay actually wants.
Verified end to end: the demo server advertises PLAIN LOGIN CRAM-MD5, a
CRAM-MD5 login succeeds over a plaintext session with no cleartext
opt-in, swaks still logs in with LOGIN, and the whole interop suite
passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Implement BINARYMIME, the other half of RFC 3030
CHUNKING was here and BINARYMIME was refused with 555, which left the
framing without the thing it exists to frame. BDAT carries a length, so
it can carry content that holds what would otherwise be a terminator;
that is the whole reason RFC 3030 defines the two together and says
BINARYMIME can only be used with CHUNKING.
The server advertises it beside CHUNKING, takes `BODY=BINARYMIME`, and
answers DATA for such a message with 503 as §3 requires -- binary has no
line structure, so a line holding a single dot cannot mean the end of it.
Nothing had to change in the receive path: BDAT already copied octets
without touching line endings, which is what "preserve all bits in each
octet" asks for, and there is now a test that walks all 256 byte values
through it to keep that true.
The client gained `MailOptions.body`, so `BODY=` is sayable at all --
7BIT and 8BITMIME as well, which the client could parse off EHLO and
never send. Declaring `.binary_mime` commits the transaction to BDAT, and
`data` refuses it with `error.BinaryRequiresChunking` rather than making
the round trip to be told 503. The demo CLI exposes it as --binarymime,
which implies --chunking because there is no other way to send it.
`Envelope.Body` moved to `protocol.Body` and lost its `unspecified`
variant in favour of `?protocol.Body`, since the client needs the same
enum and the envelope's other optional parameters are already spelled
that way. That is the breaking part.
The torture dialogue asserted 555 for BODY=BINARYMIME, which was exim's
answer and is no longer ours; those cases now use BODY=BINARY, which is
still not a body-value, so they go on testing what they were written to
test.
Verified against postfix and exim, neither of which offers BINARYMIME:
the client refuses to send binary to them at all, which is what RFC 3030
demands without qualification. Bit-exactness is checked end to end
through the CLI, all 256 octets plus a bare CR and a lone dot line.
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