Hand the SASL scratch buffers to the caller, and redo the gap survey
Surveying the gaps against the code rather than against the last list
turned up something the list did not have, because I had put it there:
the AUTH paths each held about 27 KB on the stack. `max_sasl_message` was
8192, base64 makes the encoded form 10924, and three such buffers were
live in one frame on both sides. Fine on a main thread; not fine on a
server handing each connection a 64 KB stack.
Both sides now take the scratch from the caller, for the same reason
`reply_buffer` is the caller's: how much room a mechanism needs is the
caller's to know, and the range is wide -- a few hundred bytes for the
classic mechanisms, several kilobytes for an OAuth token. An absent or
undersized one is `error.SaslBufferTooSmall` rather than a hidden
allocation or an array the caller cannot see.
Three buffers became two, and the two take turns. The split is
four-to-three, which is base64's expansion exactly, so the coded half
always holds the encoding of a full plaintext half. A challenge decodes
into the coded half; the mechanism consumes it while writing its answer
into the plain half; the answer encodes back over the challenge, which is
finished with. There is a test that runs a real CRAM-MD5 exchange through
the 896-byte minimum, where those halves are 512 and 384.
The survey also found that `Extensions.auth` is the one field of
`Extensions` that borrows -- it points into the reply buffer -- where
zig-pop3 answered the same question with a bounded copy. That one is
written down rather than fixed: the two libraries disagree on purpose
until one of them gives way.
And the README's code samples said `zig-smtp.Client`, which is not an
identifier. The rename replaced the name everywhere including inside the
examples; they say `smtp.` now, matching `@import("smtp")`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Hand the SASL scratch buffers to the caller, and redo the gap survey
Surveying the gaps against the code rather than against the last list
turned up something the list did not have, because I had put it there:
the AUTH paths each held about 27 KB on the stack. `max_sasl_message` was
8192, base64 makes the encoded form 10924, and three such buffers were
live in one frame on both sides. Fine on a main thread; not fine on a
server handing each connection a 64 KB stack.
Both sides now take the scratch from the caller, for the same reason
`reply_buffer` is the caller's: how much room a mechanism needs is the
caller's to know, and the range is wide -- a few hundred bytes for the
classic mechanisms, several kilobytes for an OAuth token. An absent or
undersized one is `error.SaslBufferTooSmall` rather than a hidden
allocation or an array the caller cannot see.
Three buffers became two, and the two take turns. The split is
four-to-three, which is base64's expansion exactly, so the coded half
always holds the encoding of a full plaintext half. A challenge decodes
into the coded half; the mechanism consumes it while writing its answer
into the plain half; the answer encodes back over the challenge, which is
finished with. There is a test that runs a real CRAM-MD5 exchange through
the 896-byte minimum, where those halves are 512 and 384.
The survey also found that `Extensions.auth` is the one field of
`Extensions` that borrows -- it points into the reply buffer -- where
zig-pop3 answered the same question with a bounded copy. That one is
written down rather than fixed: the two libraries disagree on purpose
until one of them gives way.
And the README's code samples said `zig-smtp.Client`, which is not an
identifier. The rename replaced the name everywhere including inside the
examples; they say `smtp.` now, matching `@import("smtp")`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SDrB41sGu5k1ubD1ufbxqC
Hand the SASL scratch buffers to the caller, and redo the gap survey
Surveying the gaps against the code rather than against the last list
turned up something the list did not have, because I had put it there:
the AUTH paths each held about 27 KB on the stack. `max_sasl_message` was
8192, base64 makes the encoded form 10924, and three such buffers were
live in one frame on both sides. Fine on a main thread; not fine on a
server handing each connection a 64 KB stack.
Both sides now take the scratch from the caller, for the same reason
`reply_buffer` is the caller's: how much room a mechanism needs is the
caller's to know, and the range is wide -- a few hundred bytes for the
classic mechanisms, several kilobytes for an OAuth token. An absent or
undersized one is `error.SaslBufferTooSmall` rather than a hidden
allocation or an array the caller cannot see.
Three buffers became two, and the two take turns. The split is
four-to-three, which is base64's expansion exactly, so the coded half
always holds the encoding of a full plaintext half. A challenge decodes
into the coded half; the mechanism consumes it while writing its answer
into the plain half; the answer encodes back over the challenge, which is
finished with. There is a test that runs a real CRAM-MD5 exchange through
the 896-byte minimum, where those halves are 512 and 384.
The survey also found that `Extensions.auth` is the one field of
`Extensions` that borrows -- it points into the reply buffer -- where
zig-pop3 answered the same question with a bounded copy. That one is
written down rather than fixed: the two libraries disagree on purpose
until one of them gives way.
And the README's code samples said `zig-smtp.Client`, which is not an
identifier. The rename replaced the name everywhere including inside the
examples; they say `smtp.` now, matching `@import("smtp")`.
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