This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
What's Actually Inside This Email?parse← all gifts

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);
Take the whole folder → MIT Node / browser, no dependencies