Safe SMTP

Wire-protocol parsing helpers for SMTP (RFC 5321) bytes. Operators consuming the framework's MX listener (b.mail.server.mx), submission listener (slice that follows), or building their own SMTP-shaped tooling (proxies, log analyzers, test fixtures) reach for these primitives rather than reinventing the dot-terminator scan + dot-stuffing reversal.

Separates the "what shape is the wire data" parsing concern from the "is this wire data hostile" guard concern (which lives in b.guardSmtpCommand). A safe-* parser primitive returns a bounded shape or -1; a guard-* primitive returns a boolean threat verdict or throws a typed error.

Wire-protocol references: - RFC 5321 §2.3.8 — line termination MUST be CRLF - RFC 5321 §4.5.2 — dot-stuffing on the SMTP body - RFC 5321 §4.1.1.4 — DATA command terminates with . - CVE-2023-51764 / -51765 / -51766 — SMTP smuggling (parsers that accept bare-LF dot-terminators). The guard primitive b.guardSmtpCommand.detectBodySmuggling owns smuggling detection; the safe-* terminator scanner here is strict CRLF-only by construction.

b.safeSmtp.findDotTerminator(buf, atBodyStart?) #

stable0.9.46

Scan buf for the canonical RFC 5321 §4.1.1.4 DATA-body terminator . (5 bytes: 0x0d 0x0a 0x2e 0x0d 0x0a). Returns the byte index where the mail data ends (exclusive), or -1 if the terminator is not yet present.

That index is two octets past the byte the terminator starts on. §4.1.1.4 says the first of the terminator's two CRLFs "is actually the terminator of the previous line", and §2.3.7 defines a line as a string ending in CRLF, so that CRLF is the last line's own and belongs to the mail data. Slicing it off shortens every message by two octets, drops a blank final line outright, and leaves DATA storing something other than what BDAT and IMAP APPEND store for the same message.

atBodyStart says whether index 0 of buf is the first octet of the mail data, and defaults to true. Empty mail data is a bare .\r\n: the CRLF that opens the terminator ended the DATA command line, so it is not in the body at all and the five-byte sequence never appears. At the body start that shape returns 0 — a terminated message with no octets. A caller pushing a window that begins mid-body passes false, so a dot-line arriving at a chunk boundary is not read as the end of the message.

Strict CRLF-only by construction — bare-LF alternate terminators are NOT honored. Operators worried about smuggling shape route the SAME body through b.guardSmtpCommand.detectBodySmuggling before trusting the terminator index returned here.

var body = Buffer.from("Hello world.\r\n.\r\n");
b.safeSmtp.findDotTerminator(body);
// → 14  (the mail data is "Hello world.\r\n")

b.safeSmtp.findDotTerminator(Buffer.from(".\r\n"));
// → 0   (terminated, no mail data)

b.safeSmtp.findDotTerminator(Buffer.from("incomplete body"));
// → -1

b.safeSmtp.dotUnstuff(buf) #

stable0.9.46

Reverse RFC 5321 §4.5.2 dot-stuffing on a DATA-body buffer. SMTP senders that need to transmit a body line beginning with . MUST prepend an extra . (so the line on the wire begins with ..); the receiver strips the leading . from any body line that begins with one before storing the message. Returns a fresh Buffer with the dots reversed; the input is never mutated. Result length is always <= input length.

var wire = Buffer.from("hello\r\n..secret\r\nworld\r\n");
b.safeSmtp.dotUnstuff(wire).toString("utf8");
// → "hello\r\n.secret\r\nworld\r\n"

b.safeSmtp.dotStuff(buf) #

stable0.9.57

Apply RFC 5321 §4.5.2 / RFC 1939 §3 dot-stuffing to a DATA / RETR body buffer. Lines that start with . get an extra . prepended so the receiver's parser doesn't mistake them for the terminator.

Strict CRLF-aware: a line boundary is any of: - start of buffer - byte sequence \r\n (canonical CRLF)

Bare LF inside a line is NOT treated as a line boundary, so a body containing \n (CVE-2023-51764 smuggling shape) doesn't gain spurious dot-stuffing that would confuse a downstream parser. The upstream caller is expected to either canonicalize or refuse bare-LF via b.guardSmtpCommand.detectBodySmuggling.

Stuffing is all this does. It appends nothing, so the output ends with a CRLF only if the input did, and the caller owns the line that the terminator needs: RFC 5321 §4.1.1.4 and RFC 1939 §3 both put a CRLF before the ., and a body whose last line is unfinished does not carry one. A caller that writes .\r\n straight after this output emits a final line running into the terminator, which the peer reads as one message where two were sent, or waits out as a terminator that never arrives.

b.mail.server.pop3's RETR shows the shape: write the stuffed body, write a CRLF when it does not already end with one, then write the terminator.

var body = Buffer.from(".secret\r\n.\r\nmore\r\n");
b.safeSmtp.dotStuff(body).toString("utf8");
// → "..secret\r\n..\r\nmore\r\n"

b.safeSmtp.createBodyScanner() #

stable0.18.55

Watch a DATA body for its terminator and for the smuggling shape as it ARRIVES, in work proportional to each chunk rather than to everything received so far.

A listener that answers "has the body ended yet?" by re-deriving the whole accumulated buffer on every chunk does O(n) work per chunk and O(n²) over the message. The byte cap still holds — the collector refuses a chunk that would cross it — but processor cost is not what a byte cap bounds, so a message inside the limit could still cost minutes. Measured before this existed: 1 MiB accepted in 143 ms and 8 MiB in 4949 ms, a growth of 4.67x for each doubling where linear would hold at 2.

Each push scans only the new bytes plus a four-byte overlap from the previous chunk. Four is exact, not generous: the longest shape either screen matches spans five bytes (\r\n.\r\n, and the smuggling scan's widest lookahead), and a pattern of length L needs L-1 bytes of overlap to be caught across a boundary.

var scan = b.safeSmtp.createBodyScanner();
var seen = scan.push(chunk);
if (seen.smuggling)          refuse();
if (seen.terminatorAt !== -1) finish(collector.result(), seen.terminatorAt);

b.safeSmtp.canonicalizeEol(buf) #

stable0.20.9

Rewrite every line ending in a buffer to canonical CRLF: a bare LF and a bare CR each become CRLF, an existing CRLF is unchanged. It brings a stored message body to the wire form a POP3 or SMTP multiline response requires before dot-stuffing, and gives the octet count the peer receives, which is what RETR advertises rather than the stored, possibly non-canonical, byte length.

b.safeSmtp.canonicalizeEol(Buffer.from("a\nb\rc\r\nd")).toString("utf8");
// → "a\r\nb\r\nc\r\nd"

b.safeSmtp.dotStuffForWire(buf) #

stable0.20.9

Prepare a stored message body for a POP3 RETR / TOP or an SMTP DATA multiline response: canonicalize every line ending to CRLF, then apply RFC 5321 §4.5.2 / RFC 1939 §3 dot-stuffing.

dotStuff on its own doubles a line-initial . only at a canonical CRLF boundary, so a body stored with bare-LF or bare-CR line endings (a Maildir / mbox artifact) emits a lone . line un-stuffed. A peer that treats a bare LF or CR as a line boundary then reads that . as the multiline terminator and the bytes after it as a fresh message or response. This runs the canonicalization that dotStuff documents its caller must do first, so every line ending is CRLF before stuffing and every line-initial . is doubled.

b.safeSmtp.dotStuffForWire(Buffer.from("hello\n.\nrest")).toString("utf8");
// → "hello\r\n..\r\nrest"

Last updated 2026-10-12T14:21:56.879Z by seeder.