Inline MIME Parser
Parse a raw MIME message — an .eml, a saved email, a multipart body — into a structured tree with zero dependencies. parseMime(raw) unfolds folded headers, parses the Content-Type and its parameters, decodes each leaf body per its Content-Transfer-Encoding (base64, quoted-printable, 7bit/8bit) and charset (utf-8, latin1), splits multipart/* on its boundary, and recurses to any depth. RFC 2047 encoded words in headers (=?utf-8?B?..?=) are decoded too. Node or browser, no DOM, no filesystem.
The honest edge
It parses, it does not validate — a message with a missing closing boundary or a header with no body is parsed as far as it reasonably can, never thrown at, so the tree reflects what was there rather than what should have been. Charset support is honest about its scope: utf-8 (full multibyte) and the byte-preserving ascii/iso-8859-1/windows-1252 family decode faithfully; ANY OTHER charset falls back to utf-8 rather than transcoding from native tables — exotic legacy charsets are the edge. An unknown Content-Transfer-Encoding is treated as identity. Header values are RFC-2047-decoded in the `headers` map only; `rawHeaders` keeps the ordered, undecoded originals for anything that must see the wire bytes.
Run it
node ratchet-inline-mime.js --demo
test_ratchet-inline-mime.js (36/36: header unfold + params + base64/QP/7bit + utf-8/latin1 + multipart split + nested + RFC2047 B/Q + codec probes + determinism + mutation-bite)
Node / browser, no dependencies
The code — every file that ships
ratchet-inline-mime.js297 lineson GitHub →
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
"use strict";
/* ratchet-inline-mime.js — a zero-dependency MIME message parser.
Hand it a raw RFC 822 / MIME message (an .eml, a saved email, a raw HTTP
multipart body) and it returns a structured tree: unfolded headers, a parsed
Content-Type with its parameters, and — for each leaf — a body decoded per its
Content-Transfer-Encoding (base64 / quoted-printable / 7bit / 8bit / binary)
and charset (utf-8 / ascii / latin1). multipart/* bodies are split on their
boundary and each part parsed recursively, to any depth. RFC 2047 encoded
words in header values (=?utf-8?B?..?= / =?..?Q?..?=) are decoded too.
Pure functions, no dependencies, no filesystem, no DOM — the same code runs in
Node (module.exports) or a browser (window.LoopGifts.parseMime). It parses a
message you already hold as text; it does not fetch, connect, or read files. */
// ---- base64 (pure; tolerates whitespace and missing padding) ---------------
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function decodeBase64(str) {
var lut = decodeBase64._lut;
if (!lut) { lut = decodeBase64._lut = {}; for (var i = 0; i < B64.length; i++) lut[B64.charAt(i)] = i; }
var out = [], buffer = 0, bits = 0;
for (var j = 0; j < str.length; j++) {
var c = str.charAt(j);
if (c === "=") break;
var v = lut[c];
if (v === undefined) continue; // skip newlines/whitespace/stray chars
buffer = (buffer << 6) | v; bits += 6;
if (bits >= 8) { bits -= 8; out.push((buffer >> bits) & 0xff); }
}
return out;
}
// ---- quoted-printable (=XX bytes, soft line breaks) ------------------------
function decodeQuotedPrintable(str) {
var out = [];
for (var i = 0; i < str.length; i++) {
var c = str.charAt(i);
if (c === "=") {
if (str.charAt(i + 1) === "\r" && str.charAt(i + 2) === "\n") { i += 2; continue; } // soft break
if (str.charAt(i + 1) === "\n") { i += 1; continue; } // soft break (LF)
var hex = str.substr(i + 1, 2);
if (/^[0-9A-Fa-f]{2}$/.test(hex)) { out.push(parseInt(hex, 16)); i += 2; continue; }
out.push(0x3d); // stray '=' kept literal
} else {
out.push(c.charCodeAt(0) & 0xff);
}
}
return out;
}
// ---- bytes -> string, honest about the charsets it actually decodes ---------
function utf8Decode(bytes) {
var out = "", i = 0, n = bytes.length;
while (i < n) {
var b = bytes[i++];
if (b < 0x80) { out += String.fromCharCode(b); }
else if (b >= 0xc0 && b < 0xe0) { out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)); }
else if (b >= 0xe0 && b < 0xf0) { var c1 = bytes[i++] & 0x3f, c2 = bytes[i++] & 0x3f; out += String.fromCharCode(((b & 0x0f) << 12) | (c1 << 6) | c2); }
else if (b >= 0xf0) {
var d1 = bytes[i++] & 0x3f, d2 = bytes[i++] & 0x3f, d3 = bytes[i++] & 0x3f;
var cp = (((b & 0x07) << 18) | (d1 << 12) | (d2 << 6) | d3) - 0x10000;
out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff));
} else { out += "\ufffd"; }
}
return out;
}
function bytesToString(bytes, charset) {
charset = (charset || "utf-8").toLowerCase();
if (charset === "us-ascii" || charset === "ascii" || charset === "latin1" ||
charset === "iso-8859-1" || charset === "iso8859-1" ||
charset === "windows-1252" || charset === "cp1252") {
var s = "";
for (var k = 0; k < bytes.length; k++) s += String.fromCharCode(bytes[k]); // byte-preserving
return s;
}
return utf8Decode(bytes); // default and any unknown charset: utf-8 (see README edge)
}
// ---- structured header values ("text/plain; charset=utf-8; name=\"a b\"") ---
function splitSemicolons(s) {
var out = [], cur = "", inq = false;
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i);
if (c === '"') { inq = !inq; cur += c; }
else if (c === ";" && !inq) { out.push(cur); cur = ""; }
else cur += c;
}
out.push(cur);
return out;
}
function unquote(v) {
v = v.replace(/^[ \t]+|[ \t]+$/g, "");
if (v.length >= 2 && v.charAt(0) === '"' && v.charAt(v.length - 1) === '"') {
return v.substring(1, v.length - 1).replace(/\\(.)/g, "$1");
}
return v;
}
function parseStructured(value) {
var parts = splitSemicolons(value);
var main = (parts.shift() || "").replace(/^[ \t]+|[ \t]+$/g, "");
var params = {};
for (var i = 0; i < parts.length; i++) {
var eq = parts[i].indexOf("=");
if (eq === -1) continue;
var k = parts[i].substring(0, eq).replace(/^[ \t]+|[ \t]+$/g, "").toLowerCase();
if (k) params[k] = unquote(parts[i].substring(eq + 1));
}
return { value: main, params: params };
}
// ---- RFC 2047 encoded words in header text ---------------------------------
function decodeEncodedWords(str) {
return str.replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, function (m, charset, enc, text) {
var bytes = (enc === "B" || enc === "b")
? decodeBase64(text)
: decodeQuotedPrintable(text.replace(/_/g, " ")); // Q: '_' is space
return bytesToString(bytes, charset);
});
}
// ---- headers ---------------------------------------------------------------
function splitHeadersBody(raw) {
var norm = String(raw).replace(/\r\n/g, "\n");
var idx = norm.indexOf("\n\n");
if (idx === -1) return { headerBlock: norm, body: "" };
return { headerBlock: norm.substring(0, idx), body: norm.substring(idx + 2) };
}
function unfoldHeaders(headerBlock) {
var lines = headerBlock.split("\n"), headers = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line === "") continue;
if ((line.charAt(0) === " " || line.charAt(0) === "\t") && headers.length) {
headers[headers.length - 1][1] += " " + line.replace(/^[ \t]+/, ""); // unfold
} else {
var ci = line.indexOf(":");
if (ci === -1) continue; // not a header line — skip
headers.push([line.substring(0, ci).replace(/[ \t]+$/, ""), line.substring(ci + 1).replace(/^[ \t]+/, "")]);
}
}
return headers;
}
function getRawHeader(rawHeaders, name) {
name = name.toLowerCase();
for (var i = 0; i < rawHeaders.length; i++) {
if (rawHeaders[i][0].toLowerCase() === name) return rawHeaders[i][1];
}
return null;
}
// ---- multipart split -------------------------------------------------------
function splitMultipart(body, boundary) {
var delim = "--" + boundary, close = delim + "--";
var lines = body.split("\n"), parts = [], cur = null;
for (var i = 0; i < lines.length; i++) {
var t = lines[i].replace(/\r$/, "");
if (t === delim) { if (cur !== null) parts.push(cur.join("\n")); cur = []; }
else if (t === close) { if (cur !== null) parts.push(cur.join("\n")); cur = null; break; }
else if (cur !== null) cur.push(lines[i]);
// lines before the first delim are the preamble (cur === null) — ignored
}
if (cur !== null) parts.push(cur.join("\n")); // unterminated: keep what we have
return parts;
}
// ---- body decode -----------------------------------------------------------
function decodeBody(body, cte, charset) {
var bytes;
if (cte === "base64") bytes = decodeBase64(body);
else if (cte === "quoted-printable") bytes = decodeQuotedPrintable(body);
else { bytes = []; for (var i = 0; i < body.length; i++) bytes.push(body.charCodeAt(i) & 0xff); }
return bytesToString(bytes, charset);
}
// ---- the parser ------------------------------------------------------------
function parseMime(raw) {
var sb = splitHeadersBody(raw);
var rawHeaders = unfoldHeaders(sb.headerBlock);
var headers = {};
for (var i = 0; i < rawHeaders.length; i++) {
var name = rawHeaders[i][0].toLowerCase();
if (headers[name] === undefined) headers[name] = decodeEncodedWords(rawHeaders[i][1]); // last raw kept; first decoded value wins for the map
}
var ct = parseStructured(getRawHeader(rawHeaders, "content-type") || "text/plain");
var cte = (getRawHeader(rawHeaders, "content-transfer-encoding") || "7bit").replace(/^[ \t]+|[ \t]+$/g, "").toLowerCase();
var node = {
headers: headers,
rawHeaders: rawHeaders,
contentType: ct.value.toLowerCase(),
contentTypeParams: ct.params,
encoding: cte,
isMultipart: false
};
if (node.contentType.indexOf("multipart/") === 0 && ct.params.boundary) {
node.isMultipart = true;
node.parts = [];
var raws = splitMultipart(sb.body, ct.params.boundary);
for (var p = 0; p < raws.length; p++) node.parts.push(parseMime(raws[p]));
} else {
node.bodyRaw = sb.body;
node.body = decodeBody(sb.body, cte, ct.params.charset);
}
return node;
}
// ---- a small human summary for the CLI -------------------------------------
function summarize(node, depth) {
depth = depth || 0;
var pad = new Array(depth + 1).join(" ");
var line = pad + node.contentType + (node.encoding && node.encoding !== "7bit" ? " [" + node.encoding + "]" : "");
var lines = [line];
if (depth === 0) {
var interesting = ["from", "to", "subject", "date"];
for (var i = 0; i < interesting.length; i++) {
if (node.headers[interesting[i]] !== undefined) lines.push(pad + " " + interesting[i] + ": " + node.headers[interesting[i]]);
}
}
if (node.isMultipart) {
for (var p = 0; p < node.parts.length; p++) lines = lines.concat(summarize(node.parts[p], depth + 1));
} else {
var body = node.body || "";
var preview = body.length > 200 ? body.substring(0, 200) + "\u2026" : body;
lines.push(pad + " body(" + body.length + "): " + JSON.stringify(preview));
}
return lines;
}
// ---- dual-runtime export ---------------------------------------------------
if (typeof window !== "undefined") {
window.LoopGifts = window.LoopGifts || {};
window.LoopGifts.parseMime = parseMime;
window.LoopGifts.decodeEncodedWords = decodeEncodedWords;
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
parseMime: parseMime,
decodeBase64: decodeBase64,
decodeQuotedPrintable: decodeQuotedPrintable,
decodeEncodedWords: decodeEncodedWords,
bytesToString: bytesToString,
parseStructured: parseStructured,
summarize: summarize
};
}
// ---- CLI (value-arg or stdin; never touches the filesystem) ----------------
var DEMO = [
"From: Alice <alice@example.com>",
"To: Bob <bob@example.com>",
"Subject: =?utf-8?B?SGVsbG8sIOKCrA==?=",
"MIME-Version: 1.0",
"Content-Type: multipart/alternative; boundary=\"b0undary\"",
"",
"This preamble is ignored by MIME readers.",
"--b0undary",
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: quoted-printable",
"",
"Coffee costs 5=E2=82=AC. This line is soft-wrapped mid-wo=",
"rd.",
"--b0undary",
"Content-Type: text/html; charset=utf-8",
"Content-Transfer-Encoding: base64",
"",
"PGI+SGk8L2I+",
"--b0undary--",
""
].join("\r\n");
if (typeof require !== "undefined" && require.main === module) {
var args = process.argv.slice(2);
function run(raw, label) {
var node = parseMime(raw);
if (label) process.stdout.write(label + "\n");
process.stdout.write(summarize(node).join("\n") + "\n");
process.exit(0);
}
if (args[0] === "--help" || args[0] === "-h") {
process.stdout.write(
"ratchet-inline-mime — parse a raw MIME message into a decoded tree\n\n" +
" node ratchet-inline-mime.js --demo parse a built-in sample and print its tree\n" +
" node ratchet-inline-mime.js '<raw mime>' parse the message given as one argument\n" +
" cat message.eml | node ratchet-inline-mime.js parse a message piped on stdin\n\n" +
"Prints the content-type tree with decoded leaf bodies. Exit 0. No files are read.\n");
process.exit(0);
}
if (args[0] === "--demo") { run(DEMO, "# demo message"); }
else if (args.length >= 1) { run(args[0]); }
else {
var chunks = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", function (d) { chunks += d; });
process.stdin.on("end", function () { run(chunks || DEMO); });
}
}
test_ratchet-inline-mime.js156 lineson GitHub →
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
/* test_ratchet-inline-mime.js — proves the parser implements its stated contract.
THE ORACLE. There is no Node stdlib that says "is this MIME parsed right?" — the
RFCs are the spec, so the oracle is CURATED VECTORS expressed directly against
what parseMime promises: (1) header unfolding + case-insensitive access,
(2) Content-Type value + parameter parsing (quoted params), (3) body decode per
Content-Transfer-Encoding (base64, quoted-printable incl. soft breaks, identity),
(4) charset decode (utf-8 multibyte, latin1 byte-preserving), (5) multipart split
with preamble/epilogue discarded, (6) recursive nested multipart, (7) RFC 2047
encoded words in headers (B and Q). Plus determinism and a mutation-bite so a
degenerate core (ignores input / constant verdict) cannot pass green.
Exit 0 = all pass; exit 1 = a failure (loud). stdlib only. */
"use strict";
var m = require("./ratchet-inline-mime.js");
var parseMime = m.parseMime;
var pass = 0, fail = 0;
function ok(label, cond) { if (cond) { pass++; } else { fail++; console.error("FAIL " + label); } }
function eq(label, a, b) { ok(label + " (=" + JSON.stringify(b) + ")", a === b); }
function deepEqual(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
var CRLF = "\r\n";
function msg(lines) { return lines.join(CRLF); }
// ---- 1. headers: unfolding + case-insensitive map --------------------------
var h = parseMime(msg([
"Subject: a very long subject that the",
"\tsender folded across two lines",
"X-Mixed-Case: Value",
"Content-Type: text/plain",
"",
"body"
]));
eq("unfolded subject", h.headers.subject, "a very long subject that the sender folded across two lines");
eq("case-insensitive header key", h.headers["x-mixed-case"], "Value");
eq("leaf body plain", h.body, "body");
eq("plain is not multipart", h.isMultipart, false);
// ---- 2. Content-Type value + params (quoted) -------------------------------
var ct = parseMime(msg([ "Content-Type: text/plain; charset=\"UTF-8\"; format=flowed", "", "x" ]));
eq("content-type lowercased", ct.contentType, "text/plain");
eq("param charset (unquoted)", ct.contentTypeParams.charset, "UTF-8");
eq("param format", ct.contentTypeParams.format, "flowed");
// ---- 3. transfer-encodings -------------------------------------------------
var b64 = parseMime(msg([ "Content-Type: text/plain", "Content-Transfer-Encoding: base64", "", "SGVsbG8sIHdvcmxk" ]));
eq("base64 decoded", b64.body, "Hello, world");
eq("encoding recorded", b64.encoding, "base64");
var qp = parseMime(msg([ "Content-Type: text/plain; charset=utf-8", "Content-Transfer-Encoding: quoted-printable", "", "Price: 5=E2=82=AC end" ]));
eq("quoted-printable euro", qp.body, "Price: 5\u20ac end");
var qpSoft = parseMime(msg([ "Content-Transfer-Encoding: quoted-printable", "", "soft=", "wrap" ]));
eq("qp soft line break joins", qpSoft.body, "softwrap");
var seven = parseMime(msg([ "Content-Type: text/plain", "", "plain 7bit text" ]));
eq("7bit identity", seven.body, "plain 7bit text");
// ---- 4. charset ------------------------------------------------------------
// utf-8 multibyte: e2 98 83 = snowman U+2603
var utf = parseMime(msg([ "Content-Type: text/plain; charset=utf-8", "Content-Transfer-Encoding: base64", "", "4piD" ]));
eq("utf-8 multibyte snowman", utf.body, "\u2603");
// latin1: byte 0xE9 = é (byte-preserving, not utf-8)
var latin = parseMime(msg([ "Content-Type: text/plain; charset=iso-8859-1", "Content-Transfer-Encoding: quoted-printable", "", "caf=E9" ]));
eq("latin1 byte-preserving", latin.body, "caf\u00e9");
// ---- 5. multipart split, preamble/epilogue discarded -----------------------
var mp = parseMime(msg([
"Content-Type: multipart/alternative; boundary=\"BND\"",
"",
"PREAMBLE — must be ignored",
"--BND",
"Content-Type: text/plain",
"",
"first part",
"--BND",
"Content-Type: text/html",
"",
"<p>second</p>",
"--BND--",
"EPILOGUE — must be ignored"
]));
eq("multipart flagged", mp.isMultipart, true);
eq("part count (preamble/epilogue dropped)", mp.parts.length, 2);
eq("part 0 body", mp.parts[0].body, "first part");
eq("part 0 type", mp.parts[0].contentType, "text/plain");
eq("part 1 body", mp.parts[1].body, "<p>second</p>");
eq("part 1 type", mp.parts[1].contentType, "text/html");
ok("multipart leaf has no body field", mp.body === undefined);
// ---- 6. nested multipart (mixed containing alternative) --------------------
var nested = parseMime(msg([
"Content-Type: multipart/mixed; boundary=OUT",
"",
"--OUT",
"Content-Type: multipart/alternative; boundary=IN",
"",
"--IN",
"Content-Type: text/plain",
"",
"inner text",
"--IN--",
"--OUT",
"Content-Type: application/octet-stream",
"Content-Transfer-Encoding: base64",
"",
"QUJD",
"--OUT--"
]));
eq("outer multipart", nested.isMultipart, true);
eq("outer part count", nested.parts.length, 2);
eq("inner is multipart", nested.parts[0].isMultipart, true);
eq("inner leaf body", nested.parts[0].parts[0].body, "inner text");
eq("sibling base64 leaf", nested.parts[1].body, "ABC");
// ---- 7. RFC 2047 encoded words in headers ----------------------------------
var ew = parseMime(msg([
"Subject: =?utf-8?B?SGVsbG8sIOKCrA==?=",
"From: =?utf-8?Q?Andr=C3=A9?= <a@x.io>",
"Content-Type: text/plain",
"",
"x"
]));
eq("encoded-word B subject", ew.headers.subject, "Hello, \u20ac");
eq("encoded-word Q from", ew.headers.from, "Andr\u00e9 <a@x.io>");
eq("Q underscore is space", m.decodeEncodedWords("=?utf-8?Q?a_b?="), "a b");
// ---- direct codec probes ---------------------------------------------------
eq("decodeBase64 -> bytes", m.bytesToString(m.decodeBase64("QUJD"), "ascii"), "ABC");
eq("base64 tolerates newlines", m.bytesToString(m.decodeBase64("QU\nJD"), "ascii"), "ABC");
eq("base64 missing padding", m.bytesToString(m.decodeBase64("QUJDRA"), "ascii"), "ABCD");
// ---- determinism -----------------------------------------------------------
ok("parse is deterministic", deepEqual(parseMime(mp === mp ? msg([
"Content-Type: multipart/mixed; boundary=Z", "", "--Z", "Content-Type: text/plain", "", "a", "--Z--"
]) : ""), parseMime(msg([
"Content-Type: multipart/mixed; boundary=Z", "", "--Z", "Content-Type: text/plain", "", "a", "--Z--"
]))));
// ---- mutation-bite: the core must actually discriminate --------------------
// A parser that ignored its input, or hard-coded a verdict, would collapse these.
ok("bite: multipart vs simple differ", parseMime("Content-Type: multipart/x; boundary=b\r\n\r\n--b\r\n\r\nq\r\n--b--").isMultipart !==
parseMime("Content-Type: text/plain\r\n\r\nq").isMultipart);
ok("bite: base64 body != its raw", (function () {
var n = parseMime("Content-Transfer-Encoding: base64\r\n\r\nSGk=");
return n.body === "Hi" && n.bodyRaw !== "Hi";
})());
ok("bite: distinct headers -> distinct values",
parseMime("Subject: one\r\n\r\n").headers.subject !== parseMime("Subject: two\r\n\r\n").headers.subject);
console.log("\nratchet-inline-mime: " + pass + " passed, " + fail + " failed" +
" (header unfold + params + base64/QP/7bit + utf-8/latin1 + multipart split + " +
"nested + RFC2047 B/Q + codec probes + determinism + mutation-bite)");
process.exit(fail === 0 ? 0 : 1);