An .eml file is a saved email. Open one and you get raw MIME: headers, a plain-text part, an HTML part, maybe some base64 attachments. The HTML part is the interesting one, because it was written by whoever sent the email. It can contain scripts, forms, tracking pixels, CSS that pulls images from a server, and links that try to take over the page.
I recently built a small tool that previews .eml and Outlook .msg files in the browser and turns them into PDFs, without uploading anything. The preview was the part that needed the most care. If you just parse the email and drop its HTML into your page with innerHTML, you are running a stranger's markup inside your own origin.
Here is the setup I ended up with. It is three locks, each covering what the previous one might miss.
Lock 1: sanitize the HTML before it goes anywhere
The first step is DOMPurify. On top of its defaults I forbid a list of tags that have no business in an email preview:
const FORBIDDEN_TAGS = [
'svg', 'math', 'form', 'input', 'button', 'select', 'textarea', 'option', 'iframe', 'frame', 'frameset',
'object', 'embed', 'applet', 'link', 'meta', 'base', 'video', 'audio', 'source', 'track', 'picture', 'canvas', 'dialog',
];
const FORBIDDEN_ATTRIBUTES = ['srcset', 'formaction', 'ping', 'autofocus', 'poster'];
meta and base matter more than they look: a <meta http-equiv="refresh"> can redirect, and a <base href> changes where every relative link points.
Two hooks do the email-specific work:
-
Images.
cid:references (images embedded in the email itself) are resolved to data URLs from the attachments. Anyhttp(s)://image is replaced with a small grey placeholder, and the count is shown to the user with a "Load remote images" button. Remote images are how senders know you opened the mail, so they stay off until you ask. -
Links. Every
<a href>getstarget="_blank"andrel="noopener noreferrer nofollow", so a click opens a new tab and the email can't reach back into the page that opened it.
Inline style attributes and <style> blocks get their url(https://...) values and @import rules stripped while remote images are blocked. That is the part where sanitizing alone starts to feel shaky: CSS is a whole language, and a regex over it is a best effort, not a guarantee.
Lock 2: render it in a sandboxed iframe
The cleaned HTML never touches the main document. It goes into an iframe through srcdoc:
const frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-same-origin allow-popups allow-popups-to-escape-sandbox');
frame.setAttribute('referrerpolicy', 'no-referrer');
frame.srcdoc = frameDocument(cleanHtml, loadRemoteImages);
What matters is what is not in that sandbox list: no allow-scripts, no allow-forms, no allow-top-navigation, no allow-modals. Even if something slipped past DOMPurify, it could not run a script, submit a form or navigate the tab. The popup flags are there only so a link the reader clicks can open in a normal new tab.
Lock 3: a CSP inside the iframe
The document written into srcdoc carries its own Content Security Policy:
function policy(loadRemoteImages: boolean): string {
const images = loadRemoteImages ? 'img-src data: blob: https:' : 'img-src data: blob:';
return `default-src 'none'; ${images}; style-src 'unsafe-inline'`;
}
default-src 'none' means the frame can't fetch anything except what is listed. With remote images blocked, only data: and blob: images load, so a background image hidden in CSS that the sanitizer missed still goes nowhere. This is the lock that makes the CSS regex in Lock 1 a convenience rather than the last line of defence. The parent page has its own CSP as well (script-src 'self'), so there are two policies between the email and the network.
The trade-off: a preview that grows with the email
The first version of the preview was a fixed-height box. Short emails sat at the top of a big empty frame, which looked broken. Making the iframe fit its content means the parent page has to read the height of the document inside it, and you can't read a sandboxed frame's document unless the sandbox includes allow-same-origin.
allow-same-origin sounds alarming, and combined with allow-scripts it is: a script inside the frame could then remove its own sandbox. Without allow-scripts, though, nothing inside the frame can execute, so being same-origin gives the email no new powers. It only lets my page measure it:
const fit = (): void => {
frame.style.height = '0px'; // shrink first: scrollHeight never reports less than the frame's own height
frame.style.height = `${Math.min(doc.documentElement.scrollHeight + 2, FRAME_MAX_HEIGHT)}px`;
};
Long emails stop at a maximum height and scroll inside the frame. The height is recalculated when images inside the email finish loading and when the card changes width.
The risk with this kind of setup is not today's code. It's someone, possibly me in six months, adding allow-scripts to fix an unrelated bug. So there is a test that reads the source and fails if the sandbox ever includes a dangerous flag:
it('never allows scripts, forms or top navigation', () => {
for (const value of values) {
for (const flag of ['allow-scripts', 'allow-forms', 'allow-top-navigation', 'allow-modals']) {
expect(value.split(/\s+/)).not.toContain(flag);
}
}
});
It is a blunt test, and that is the point. The comment next to the sandbox line explains why allow-same-origin is there, and the test makes sure the reason stays true.
Parsing happens in the browser too
None of this would matter much if the file went to a server first. Parsing is done client-side: postal-mime for .eml, @kenjiuno/msgreader for Outlook .msg files, and pdfmake to build the PDF. You can check it the usual way: open the Network tab in DevTools, pick a file, and watch that no request carries it anywhere.
Summary
- Sanitize with DOMPurify, forbid the tags an email doesn't need, rewrite images and links in hooks.
- Render in a
srcdociframe with a sandbox that has noallow-scripts. - Put a
default-src 'none'CSP inside the frame, so anything the sanitizer misses still can't load. - If you add
allow-same-originto size the frame, write a test that keepsallow-scriptsout.
I wrapped all of this into a free tool at emltopdf.net, if you ever need to open an .eml file without installing a mail client.
Top comments (0)