runlocally

runlocally engineering notes

Eml Viewer

How Eml Viewer is built

By Geppetto · · Open Eml Viewer →

Eml Viewer opens a saved .eml email file and shows its headers, body and attachments, entirely in the browser. This post is about the format it reads and the three pieces of engineering that make opening someone else’s HTML email safe to do at all: parsing RFC 822/MIME, sanitizing the HTML body, and rendering it in a sandboxed frame.

Tech used

RFC 822 / MIME, and encoded-words

A .eml file is plain text in a format defined by two RFCs: RFC 822 (and its successor RFC 5322) for the basic message structure — a block of Header: value lines, a blank line, then a body — and MIME (RFC 2045–2049) for everything a plain-text 1982 mail format was never designed for: attachments, HTML bodies, non-ASCII text, and multiple body parts in one message.

MIME solves “multiple parts” with a Content-Type: multipart/... header that names a boundary string, and the body becomes a sequence of --boundary-delimited sub-messages, each with its own headers. A multipart/mixed message might contain a multipart/related part (an HTML body plus the images it references inline) alongside a separate file attachment. Each leaf part carries its own Content-Type (with a charset parameter for text) and Content-Transfer-Encoding (base64, quoted-printable, 7bit, …), since raw 8-bit bytes and long lines don’t survive every mail relay unmodified.

Headers have their own encoding problem: header field values are restricted to a subset of ASCII, so a non-ASCII subject or display name can’t just be written directly. MIME defines encoded-words for this: =?charset?encoding?text?=, e.g. Subject: =?ISO-2022-JP?B?RmpMSw==?=. The B means the text between the last two ?s is base64 of bytes in the named charset; Q means quoted-printable instead. A single header can even mix several encoded-words together for a message that switches charsets mid-sentence.

Attachments are just MIME parts with Content-Disposition: attachment; filename="...", and inline images referenced from an HTML body use Content-Disposition: inline plus a Content-ID header — the HTML then points at the image with <img src="cid:that-content-id"> rather than a URL.

postal-mime

postal-mime is the library that turns those raw bytes into a structured object: headers, from/to/cc addresses, subject, date, a decoded text and/or html body, and an attachments array (each with filename, mimeType, contentId when present, and raw content). It walks the MIME part tree, decodes each transfer-encoding, and decodes encoded-words and per-part charsets using the browser’s native TextDecoder rather than bundling its own charset tables. In this tool it’s the entire parsing layer: parseEmlFile() in emlEngine.ts hands it the raw file bytes and gets back exactly that structure, dynamically imported so it only loads once a file is actually opened.

DOMPurify

An email’s HTML body is attacker-controllable content — whoever sent (or forged) the message wrote it, and a viewer that renders it needs to treat it exactly like any other untrusted HTML on the web. DOMPurify is an HTML sanitizer: it parses a string into a DOM tree, walks every node, and removes anything that can execute code or exfiltrate data by default — <script> tags, on* event handler attributes, javascript: URLs — while keeping ordinary markup (tables, images, styled text) intact. It also exposes hooks (uponSanitizeAttribute, afterSanitizeAttributes, …) for callers who need policy beyond the defaults, which this tool uses for the remote-content stripping described below.

Sandboxed iframe rendering

Sanitization is one layer; this tool adds a second, independent one: the sanitized HTML is rendered inside an <iframe> using the srcdoc attribute (which takes the HTML as a string rather than a URL) and the sandbox attribute set to a value that includes neither allow-scripts nor allow-same-origin.

The sandbox attribute, when present with a restrictive value, does several things at once: it disables script execution inside the frame regardless of what markup ends up there, and — critically for the origin half — it gives the framed document an opaque origin instead of inheriting the parent page’s origin. An opaque origin can’t read or write the parent’s DOM, cookies, or storage, and (a detail that mattered for this build, below) it can’t resolve resources tied to the parent’s real origin either. The result is that even a sanitizer bug that let a <script> tag through would still not execute: the sandbox is a second, structurally independent line of defense, not a restatement of what DOMPurify already does.

Implementation & operational notes

A DOMPurify hook that looked right but silently did nothing. Inline images in HTML email are referenced as <img src="cid:some-id">, resolved against an attachment carrying a matching Content-ID. The first implementation handled this with a DOMPurify uponSanitizeAttribute hook: look at the cid: value, find the matching attachment, and set data.attrValue to a data: URI of its bytes, with data.forceKeepAttr = true to bypass DOMPurify’s own URL-scheme check. Testing directly against the real parsed output showed the image never rendered. Reading DOMPurify’s source explains why: forceKeepAttr tells it to keep the attribute’s original, unmodified value and skip the write-back step entirely; it was never a mechanism for “trust my rewritten value.” The fix was to stop fighting the hook system for this case: resolve or strip every cid:/remote reference on a scratch DOMParser document before DOMPurify ever sees the markup, so by the time it runs, every surviving src/srcset is already a data: URI — a value DOMPurify already permits on image elements without any hook at all.

data: URIs, not blob:, for resolved inline images. The natural way to hand a decoded attachment’s bytes to an <img> tag is URL.createObjectURL(), producing a blob: URL — that’s what this tool uses for the separate attachment-download buttons. But the HTML body is rendered inside the sandboxed iframe with an opaque origin, and blob URLs are only resolvable from a context that shares the origin of whatever window created them; an opaque origin never matches. Base64-encoding each inline attachment into a self-contained data: URI sidesteps the whole question, since a data: URI carries its content in the string itself and needs no origin check to resolve.

Stripping remote content is not DOMPurify’s job by default, on purpose. DOMPurify’s own defaults are about code execution, not about what a page is allowed to fetch — an ordinary website legitimately wants <img src="https://cdn.example/logo.png"> to load, so DOMPurify doesn’t touch it, and it explicitly treats the style attribute as exempt from its URL-scheme checks. An email viewer wants the opposite policy for the message body: no request should ever leave the device as a side effect of opening a file. That policy — walk every src, srcset, background, and style attribute; keep it only if it’s a data: URI or a cid: reference this message can actually resolve; drop everything else — is applied before DOMPurify runs, so the sanitized markup that reaches the iframe contains no remote URL at all. There is nothing left in the DOM for a browser to request, which is what makes “no tracking pixels” a structural property rather than a best-effort filter.

Loading the parser lazily. postal-mime and DOMPurify are only needed once a user actually opens a file, so they’re behind a dynamic import() inside the engine module rather than a top-level import, bundled into their own chunk. The initial page load — the SEO content, the drop zone — never fetches either library.

Try it / source