Internals: SMTP

How @opentf/esrun-smtp is put together, and why each rule it enforces is there. The decision and what was rejected are in docs/DECISIONS.md (D136).

A package, not a runtime module

Everything SMTP needs is already in the runtime: runtime:net opens a socket with TLS from the first byte (secureTransport: "on") or upgrades one in place ("starttls" and startTls()), and verifies the certificate either way. So the client is JavaScript over that API, as the database drivers are — no new code in esrun, and no new capability: reaching the server is --allow-net, scoped to its host like any other socket.

That also decides what it cannot do. It reaches only hosts the run was granted, and a server's certificate is checked by the same verifier every other TLS connection uses, with no option to switch it off.

The layers

LayerDoes
Reply readerSplits the byte stream into lines and lines into replies; lifts out the enhanced status code (5.1.1); times out.
FramingNormalises line endings to CRLF, dot-stuffs, appends CRLF . CRLF, and refuses a line over 998 octets.
SessionGreeting, EHLO (or HELO), STARTTLS and EHLO again, login, one mail transaction at a time.
MessageBuilds the MIME tree and headers from the fields.
TransportThe pool, and the one retry.

Framing belongs to the transport, not the message builder, so that no message — built here or passed raw — can be framed wrongly. A line beginning with . in a raw message is stuffed on the way out like one in a built message.

Why every message is 7-bit

A built message uses quoted-printable for text, base64 for attachments and RFC 2047 encoded-words for headers, so every octet is ASCII and every line is short. 8BITMIME would save the encoding's size overhead, but a message relayed onward can meet a hop that lacks it, and the failure then happens where the sender cannot see it. The encoding cost is paid once, at the sender, in exchange for a message every relay accepts. 8BITMIME and SMTPUTF8 are still declared when a raw message or a non-ASCII mailbox needs them — and refused, before anything is sent, when the server does not offer them.

Why STARTTLS is required, not attempted

Opportunistic STARTTLS — upgrade if the server offers it, carry on in plaintext if not — is what an attacker on the path relies on: removing STARTTLS from the EHLO reply is enough to downgrade the session, credentials included. So security: "starttls" fails when the offer is missing, and plaintext is something a transport says (security: "none"), not something it falls into.

After the upgrade, the extension list is read again from a fresh EHLO: the first one travelled in plaintext and could have been edited (RFC 3207 §4.2). Any bytes the server sent after agreeing to encrypt but before the handshake are refused rather than read, since they can only be an injection.

Logins over plaintext

PLAIN, LOGIN and XOAUTH2 all send the secret itself, base64-encoded rather than protected. A login over an unencrypted session is therefore refused before anything is sent, unless allowPlaintextAuth is set for a relay on the same host. It is the rule the MySQL driver (mysql in runtime:db) applies to fetching a server's public key over plaintext. Error messages name the command (AUTH PLAIN), never the line, so a refused login does not put the credential in a log.

Pipelining

Without PIPELINING, a message to n recipients costs n + 2 round trips before the body: MAIL FROM, each RCPT TO, DATA. With it, those commands go in one write and their replies are read in order — one round trip however many recipients. The client stops early without it: a refused sender means no RCPT TO, and no accepted recipient means no DATA.

A pipelined DATA can be answered 354 even when every recipient was refused. The server is then reading message text, and an RSET sent next would become part of it, so the client ends that empty message first.

The pool, and the one retry

A TLS handshake and a login are most of the cost of sending one message, so sessions are kept. A kept session can be closed by the server at any time — an idle limit, a restart — and the first command on it then fails with a lost connection. That is the pool's failure, not the message's, so the message is sent once more on a fresh session. Only once, and only for a lost connection: a refused recipient is an answer, and retrying it would send it twice.

The idle timer is unreferenced, so a script that has sent its mail exits rather than waiting out idleTimeout.

DKIM

A signature is added after the message is built and before it is framed, so it covers the bytes the server receives: line breaks are already CRLF, and the dot-stuffing the framing adds is removed again by the receiver. Raw messages are signed the same way, as octets, so 8-bit content is hashed exactly as sent.

Canonicalization is relaxed/relaxed because relays refold headers and strip trailing whitespace, and simple fails on both. The signed headers are oversigned: each is listed in h= once more than it occurs. A verifier reads a listed header that is not there as empty, so a second From or an added Cc breaks the signature instead of changing what the reader sees. l=, which signs only a prefix of the body, is never written, because it lets anyone append content that still verifies.

The key decides the algorithm. RSA keys under 1024 bits are refused, since receivers ignore their signatures (RFC 8301). Ed25519 signs the SHA-256 hash of the data rather than the data (RFC 8463 §3); receivers that do not know ed25519-sha256 ignore such a signature, which is why a transport takes a list of signers and an RSA and Ed25519 pair is the usual deployment. A PKCS#1 RSA key (BEGIN RSA PRIVATE KEY, which older openssl genrsa writes) is wrapped into PKCS#8 before import, since WebCrypto imports only the latter.

Both algorithms are deterministic, so the tests reproduce RFC 8463's published signatures byte for byte from its keys and message.

Not here

Delivery status notifications (DSN), CHUNKING/BDAT, and receiving mail.

Last updated on
Edit this page