Safe MIME

Bounded MIME parser substrate for the mail stack. Walks RFC 5322 + 2045 / 2046 / 2047 / 6532 (EAI) / 6533 (i18n-DSN) message structure into a part tree with caps on every dimension an attacker can grow to DoS the framework.

Foundation for everything above:

- b.mailStore.appendMessage parses inbound bytes via b.safeMime.parse(...) to extract headers + body parts before sealing per-column. - b.mail.server.mx runs every received message through b.safeMime.parse before SPF / DKIM / DMARC / ARC verification. - b.guardEmail.validateMessage already operates on raw bytes at the line-shape level; b.safeMime.parse is the structured follow-up that lets b.guardHtml / b.guardArchive / b.guardSvg inspect individual MIME parts. - b.mail.crypto.{pgp,smime} (v0.9.34a) parses signed/encrypted containers via this primitive before reaching the underlying crypto.

Defends CVE-2024-39929 (Exim MIME multipart parser) and CVE-2026-26312 (Stalwart nested message/rfc822 MIME OOM) by capping total parts, nesting depth, boundary length, header bytes, header-line bytes, decoded body bytes, message bytes — plus charset + transfer-encoding allowlists.

Throws SafeMimeError on every cap exceeded, malformed boundary, unknown charset, unknown transfer-encoding, NUL byte in headers, bidi/control chars in header values.

The parser is purely functional — no I/O, no async, no side effects. Operators run it in b.workerPool workers for any incoming message above a threshold.

b.safeMime.parse(bytes, opts?) #

stable0.9.19
{
    maxParts:                 number,     // default 64
    maxNestingDepth:          number,     // default 16
    maxBoundary:              number,     // default 70 (RFC 2046 §5.1.1)
    maxHeaderBytes:           number,     // default 64 KiB
    maxHeaderLineBytes:       number,     // default 998 (RFC 5322 §2.1.1)
    maxHeaderCount:           number,     // default 512 (DoS bound)
    maxBodyBytes:             number,     // default 25 MiB
    maxMessageBytes:          number,     // default 50 MiB
    charsetAllowlist:         string[],   // default UTF-8 / US-ASCII / common legacy 8-bit
    transferEncodingAllowlist: string[],  // default 7bit/8bit/quoted-printable/base64 (binary is opt-in, RFC 3030 BINARYMIME)
    structureOnly:            boolean,    // default false — walk the tree without decoding bodies

  `structureOnly` answers what SHAPE a message has, for a caller that wants
  the part tree, the content types, the filenames or whether there is an
  attachment, and not the content. Each leaf's `body` is `null` and its
  `encodedSize` is the size on the wire; `decoded` is `null`. Every cap and
  allowlist still applies, so a hostile shape is refused as it would be in
  the ordinary mode. `b.mailStore` records the attachment facts this way at
  append, which is the one moment the answer is free.
}

Parse bytes into a MIME part tree. Returns { headers, parts, leaf, decoded }. Multipart parts have non-null parts; leaf parts have non-null leaf carrying decoded body.

A leaf's charset is the effective one, defaulting to us-ascii per RFC 2045 section 5.2 when the header names none. Its contentTypeParams is the frozen parameter map exactly as the header wrote it, so a caller can tell a written charset from a defaulted one and can read the other parameters, format and delsp (RFC 3676) among them.

Throws SafeMimeError. The input itself is refused with safe-mime/bad-input for bytes it cannot take and safe-mime/bad-opt for an option it does not accept.

Every budget has its own code, so an oversize message says which bound it crossed: safe-mime/oversize-message, safe-mime/oversize-part-count, safe-mime/oversize-nesting, safe-mime/oversize-boundary, safe-mime/oversize-headers, safe-mime/oversize-header-line, safe-mime/oversize-body and safe-mime/too-many-headers.

The structure is refused with safe-mime/malformed-boundary, safe-mime/malformed-content-type, safe-mime/malformed-headers, safe-mime/bad-header-name, safe-mime/control-char-in-header for a control byte inside a header, and safe-mime/rfc2047-header-injection for an encoded word carrying a header break. An unreadable encoding is refused with safe-mime/unknown-charset or safe-mime/unknown-transfer-encoding rather than decoded as bytes.

var msg = b.safeMime.parse(messageBuffer);
msg.headers.get("subject");
msg.parts.length;
msg.parts[0].leaf.body.toString("utf8");

var shape = b.safeMime.parse(messageBuffer, { structureOnly: true });
b.safeMime.extractAttachments(shape).length;   // → 2, without decoding either file

b.safeMime.walk(tree, visitor) #

stable0.9.19

Depth-first walk. Invokes visitor(part, path) for every part where path is the position array ([] for root, [0] for first child). Visitor returning false short-circuits.

b.safeMime.walk(tree, function (part) {
  if (part.leaf && part.leaf.contentType === "application/pdf") {
    console.log("pdf", part.leaf.body.length);
  }
});

b.safeMime.findFirst(tree, predicate) #

stable0.9.19

Return the first part for which predicate(part) is truthy, or null. Common use: pull the first text/plain or text/html.

var t = b.safeMime.findFirst(tree, function (p) {
  return p.leaf && p.leaf.contentType === "text/plain";
});

b.safeMime.extractText(tree, opts?) #

stable0.9.19
{
  prefer:  "plain" | "html" | "any",   // default "plain"
}

Pull the rendering-preferred text payload. Honors RFC 2046 §5.1.4 "last wins" semantics for multipart/alternative. Returns { contentType, charset, body } (body is decoded string) or null.

var tree = b.safeMime.parse(messageBuffer);
var text = b.safeMime.extractText(tree, { prefer: "plain" });
text.body;          // → "Hello, world!"
text.contentType;   // → "text/plain"

b.safeMime.extractAttachments(tree, opts?) #

stable0.9.19
{
  includeInline: boolean,    // default false: the displayed body is not a file
}

Return array of attachment-shaped parts. Each entry is { filename, contentType, body, headers, path }. Operators pipe each attachment through b.fileType.detect then through the per-type guard (b.guardArchive / b.guardPdf / etc.).

A part is a file when the sender marked it Content-Disposition: attachment, when it is inline and carries a name, or when it is a leaf that is not displayed body text, which is the classification b.safeMime.selectBodyParts applies and RFC 8621 section 4.1.4 describes. includeInline answers with every leaf in the tree instead, in document order, including the parts a reader displays and the ones an alternative nobody displays carries: it is the caller asking for the parts rather than for the files.

path is the part's position in the tree, the same array b.safeMime.walk hands its visitor: indices from the root, so tree.parts[path[0]].parts[path[1]] reaches the part an entry came from. A consumer serving one part at a URL keys on it rather than running a second walk and pairing the two lists by index.

var tree = b.safeMime.parse(messageBuffer);
var atts = b.safeMime.extractAttachments(tree);
atts[0].filename;       // → "report.pdf"
atts[0].contentType;    // → "application/pdf"
atts[0].body.length;    // → 12345 (decoded bytes)
atts[0].path;           // → [1] (tree.parts[1])

b.safeMime.selectBodyParts(tree) #

stable0.20.32

Sort a parsed tree's leaves into the text a reader displays, the HTML it displays, and the files it offers. Returns { text, html, files }, each an array of { part, path, filename, kind } in document order.

The parts of a multipart/alternative are one body written several ways, ordered simplest to richest (RFC 2046 section 5.1.4), so the LAST representation offering plain text and the last offering HTML are the displayed ones. A representation wrapped in another multipart counts as that one choice, keeping all of its sections and its files.

Which representation is displayed decides what the BODY is, not which leaves exist: the files of a representation nobody displays are offered like any other, whichever branch they sit in. The superseded text and HTML themselves are the body written again and are offered as neither.

A leaf is a file when its disposition is attachment, when it is inline and carries a name, or when it is not displayed body text, which is the classification RFC 8621 section 4.1.4 describes.

var tree = b.safeMime.parse(bytes);
b.safeMime.selectBodyParts(tree).files.length;
// → 1

b.safeMime.filenameFromHeaders(headers) #

stable0.20.32

Read the filename a part declares, from its Content-Disposition or, when that carries none, from the name parameter of its Content-Type. Returns null when neither names one.

The extended filename* wins over the plain filename when both are present, which is the pairing RFC 6266 section 4.3 asks a sender for and writes in that order, and its RFC 8187 ext-value is percent-decoded. A sender that left an apostrophe unescaped in that value, which is what encodeURIComponent does, still reads back whole.

A name split across RFC 2231 section 3 continuations (filename*0*, filename*1*, …) is assembled in index order first, with the charset'language' prefix on the first segment as section 4.1 writes it and each *-flagged segment percent-decoded. A run missing a segment names nothing, so it reads as null rather than as the pieces that did arrive.

extractAttachments reads filenames with this, so a caller walking the tree itself gets the same answer rather than writing another Content-Disposition reader.

b.safeMime.walk(tree, function (part) {
  if (part.leaf) console.log(b.safeMime.filenameFromHeaders(part.headers));
});

b.safeMime.isHeaderFieldName(name) #

stable0.20.32

Is this the RFC 5322 section 2.2 name of a header field: one or more ftext, the printable US-ASCII characters 33 through 126 except the colon? The bytes are read as they were carried, so the space in From : ops@example.com reads false, which is what a receiving parser makes of it: a line that is not a field at all.

parse applies this to every field it reads. It is exported because a consumer reading a header block itself has the same question to answer, and answering it a second way is how two readers come to disagree about which fields a message carries.

b.safeMime.isHeaderFieldName("From");                            // → true
b.safeMime.isHeaderFieldName("From ");                           // → false

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