Skip to content
Lauren DormanDesign Engineer

Notes

Hosting HTML you didn’t write

Every email is a web page written by a stranger. At Tatem, that was the problem underneath every other feature. A newsletter arrives with a stylesheet written as if it owned the whole document: body gets a background, * gets Georgia, p turns red and uppercase, .button becomes a black pill. Put that markup straight into the reading pane and it doesn’t just style the message. It restyles the client: the toolbar, the headers, the reply button that happens to share a class name. [Confirm: a real message that broke the client before isolation, if you have one.]

Most writing about isolation takes the embed’s side: your widget, dropped into a page you don’t control, trying to survive that page’s reset. A mail client sits on the other side. You own the page, and the message is careless, hostile or just old. It was built for whatever clients its sender tested against, often with table layouts and inline styles, and it assumes it’s the only thing on the page.

So the rule from the client’s side is this: a message should look the way its sender meant it to, and touch nothing else.


Choosing the boundary

There are three ways to put a message on the screen.

ApproachWhat it containsMain cost
Light DOM with prefixed selectorsNothing reliablyEvery *, body and element selector has to be rewritten, and one miss restyles the client
Shadow rootSelectors, in both directionsScripts, URLs and inherited properties still cross
Sandboxed iframeStyles, scripts, navigationOne document per message, sized from outside

An iframe is the strongest wall the platform has. With sandbox="allow-same-origin" and no allow-scripts, nothing in the message can run, and the parent can still reach in to measure its height. (Never add both flags. Together they let the framed document script its parent and remove its own sandbox.)1 The cost shows up in a thread. Thirty messages become thirty documents, each sized from outside, and each its own island for focus and text selection. A mail client also lives on keyboard shortcuts, and key presses inside a frame never reach the parent’s listeners.

A shadow root is one document. The message flows in the thread, sizes itself, and the shortcuts keep working. Its selectors stop at the boundary in both directions: the email’s .button can’t reach the client’s toolbar, and the client’s p rules can’t reach the email. What it shares is everything else: the JavaScript realm, the network, and inherited styles.

[Confirm: whether Tatem’s shadow roots lived directly in the app document or inside a sandboxed frame, and why.]

Light DOM

Email styles restyle the host toolbar and headers.

Shadow root

Host chrome untouched. Inherited properties still cross the boundary, hence all: initial on :host.

The same newsletter in two mock clients. Edit the email CSS, or uncheck the reset under the shadow root to let the reading pane’s text styles in.

What crosses in

Selectors stop at the boundary. Inheritance doesn’t. Color, font family, font size and line height flow from the client’s reading pane into the message as if the shadow root weren’t there. Uncheck the reset in the demo above and the newsletter picks up the pane’s gray 13-pixel UI text, which its sender never saw.

The fix is a reset on :host, declared inside the shadow root:

:host {
  all: initial;
  display: block;
}

all: initial resets every property except direction, unicode-bidi and custom properties.2 For email that’s a better starting point than it sounds. The message gets browser defaults (16 pixels, the default font, plain text color) back, which is close to the blank document its author designed against.

One placement detail matters. Rules the outer page sets on the element that :host matches beat :host in the cascade,3 so the reading pane’s typography belongs on an ancestor, where it only arrives by inheritance and the reset can undo it.


What the boundary doesn’t stop

A shadow root is a style boundary, not a security boundary. Markup inside it runs in the same realm as the client. Scripts inserted as HTML don’t execute, but an onerror handler on an image does,4 a javascript: link runs when clicked, and a form can post a password anywhere. So every message went through a sanitizer before it reached the shadow root. [Confirm: which sanitizer, and whether it ran on the server, the client or both.]

The sanitizer works from an allowlist: the tags, attributes and URL schemes email actually uses (tables, inline formatting, images, and links to http, https and mailto), and nothing else. A blocklist is a list of the attacks someone has already thought of.

One problem survives any sanitizer that only reads markup: position: fixed. A message can pin a fake “Your session has expired” panel over the entire client, including the parts it was never allowed to style. Containment on :host closes it:

:host {
  all: initial;
  display: block;
  contain: content;
  overflow-x: auto;
}

contain: content turns on layout and paint containment, which makes the message’s element the containing block for fixed-position descendants and the root of their stacking context. A fixed panel now sits inside the message’s own box, and no z-index can lift it out.5 Paint containment also clips, so overflow-x: auto lets a 600-pixel table scroll inside a narrower pane instead of getting cut off.

Containment has one exit: the top layer. A popover or a modal dialog renders above everything, outside every clip and stacking context, and it doesn’t need script to open. A popovertarget or commandfor button does it on click.6 So the allowlist leaves out dialog and those attributes, along with popover itself, because containment can’t hold what they open.


What the boundary breaks

Isolation fixes the client’s problem and creates a few for the message. Email CSS was written for a whole document, and a shadow root isn’t one. [Confirm: which of these rewrites Tatem’s pipeline handled, and where it ran.]

html and body match nothing. A shadow tree has no html or body element, so the newsletter’s body { background: #fff4d6 } silently disappears. Look at the shadow root pane in the demo: the cream background is gone. Emails also put styles on the body tag itself (bgcolor, style, a class), and those vanish when you keep the body’s children and drop the tag. Its <style> blocks usually sit in <head>, which goes the same way. The fix is a wrapper element that stands in for the body. Copy the body’s attributes onto it, move every style block into the shadow root, and rewrite html and body selectors to target the wrapper.

Width media queries answer the wrong question. @media (max-width: 600px) asks about the viewport, but the message lives in a reading pane. On a wide monitor with a narrow pane, the email lays out its desktop version in a column that can’t hold it. The rewrite turns width conditions into container queries against the message’s element:7

/* What the message sent */
@media (max-width: 600px) {
  .column { display: block !important; width: 100% !important; }
}

/* What the shadow root gets */
:host { display: block; container-type: inline-size; }

@container (max-width: 600px) {
  .column { display: block !important; width: 100% !important; }
}

(The !important is the sender’s. Email puts it in media queries to beat inline styles, and the rewrite leaves it alone. The display: block is required: all: initial makes :host inline, and size containment does nothing on an inline box, so the query would never match.) Other media features keep their meaning and stay where they are, with one exception.


Dark mode for messages that never asked for it

Most email is designed light: white backgrounds, dark gray text, logos exported on white. A dark reading pane has to decide what to do with each message, and no single answer fits all of them.

MessageTreatmentWhy
Declares dark styles (a color-scheme meta tag or prefers-color-scheme rules)Use its dark styles, keyed to the client’s themeThe sender designed it
Plain text or minimal HTML with no colors of its ownLet it take the client’s schemeThere’s nothing to break
A hardcoded light designKeep it light on its own surface, or adjust only near-white and near-blackInverting breaks logos and brand colors

The first row needs another rewrite. A prefers-color-scheme query inside the shadow root asks the browser’s color preference, not the client,8 so it fires on the wrong signal whenever the client’s theme and the system’s disagree. The rewrite turns those blocks into rules scoped to an attribute the client controls:

/* What the message sent */
@media (prefers-color-scheme: dark) {
  .card { background: #1c1c1e; color: #f2f2f7; }
}

/* What the shadow root gets */
:host([data-scheme="dark"]) .card { background: #1c1c1e; color: #f2f2f7; }

The reset matters here too. all: initial sets color-scheme back to normal,9 so the message stops following the client’s dark scheme. For the first two rows, set color-scheme on the body wrapper so scrollbars, form controls and default text color match the pane around them.

The last row is the hard one. Rewriting colors in a message that never declared dark support works on simple layouts and fails on designed ones. A dark logo on a transparent background disappears against the darkened surface. A brand’s orange button turns muddy. The rule I’d hold: adjust neutrals, leave saturated colors alone, never touch images. [Confirm: which treatment Tatem used for hardcoded light messages, and whether people could switch a message back to its original colors.]


Remote images are requests the sender can see

Every remote image is a request the sender can watch: when the message was opened, from which IP address, roughly where, and in what client. A tracking pixel is just a remote image too small to notice. There are two defenses. Block remote content until the person allows it, or proxy every image through your own server so the sender sees the proxy instead of the reader. [Confirm: which Tatem did, block, proxy or both, and whether proxying happened when the message arrived or when it was opened.]

Either way, the hard part is coverage. A message can make a request from:

  • src and srcset on img and source
  • the background attribute on table cells, which older templates still use
  • poster on video
  • url() in a style attribute or a <style> block, including @import and @font-face
  • <link rel="stylesheet">

Every one of those goes through the same rewrite, or the pixel just moves into the CSS. Rewriting img and leaving url() alone looks private and isn’t.

Web fonts are remote too, and they’re inconsistent inside a shadow root. In Chrome and Firefox, an @font-face rule declared inside one doesn’t apply to the shadow tree at all. In Safari it applies, but registers for the whole document, so a message’s font face can leak into the client.10 Either register approved faces at the document level or let the message fall back to its font stack. Blocked images need a plan as well. Email images usually carry width and height attributes, so a blocked image can hold its box and show its alt text instead of collapsing the layout around it.

Paste any email’s HTML. It’s parsed in an inert document, and the preview runs no scripts and loads nothing remote.

Remote content

Drag the divider, or focus it and use the arrow keys, to resize the reading pane.

 

Each rewrite traces back to one line in the original. Resize the pane, not the window, to switch the layout.

The worst message decides

A mail client doesn’t choose its content. Every message is someone else’s page, built for some other client, carrying assumptions you find out about one broken newsletter at a time. The shadow root keeps those assumptions out of the client. The rest (the sanitizer, the CSS rewrites, the URL rewriting, the dark mode rules) makes sure the message still looks like what its sender meant and does nothing else. The client is only as good as how it handles the worst message in someone’s inbox.

Footnotes

  1. Removing the attribute takes effect on the frame’s next navigation, which the frame can trigger itself. A srcdoc frame takes the parent’s origin once allow-same-origin is set. See the HTML spec on sandbox and MDN. ↩︎

  2. Custom properties are left out on purpose, which is what makes them a theming API across the boundary. See the all shorthand in CSS Cascade 5. ↩︎

  3. The cascade compares encapsulation contexts before layers or specificity. For normal declarations the outer context wins, and for !important ones the inner context does. See cascade context. ↩︎

  4. MDN’s own example is <img src='x' onerror='alert(1)'>. See innerHTML security considerations. ↩︎

  5. Paint containment is the part that clips. contain: layout alone still makes the containing block, but a fixed panel could paint outside the box. See CSS Containment 2. ↩︎

  6. command="show-modal" opens a dialog modally and command="toggle-popover" opens a popover, both declaratively. See the Invoker Commands API and the top layer. ↩︎

  7. Container lookup follows the flat tree, so the element a shadow root is attached to counts as an ancestor of everything inside it. See the @container rule and the shadow DOM tests. ↩︎

  8. Media queries inside a shadow root evaluate against the document’s environment, like any other on the page. See prefers-color-scheme. ↩︎

  9. color-scheme is inherited, and its initial value is normal. See CSS Color Adjustment. ↩︎

  10. Tracked as Chromium issue 41085401 and Firefox bug 1077396. The spec scopes font names declared in a shadow tree to that tree, and the @font-face tests show how far each engine is from it. ↩︎