Skip to main content

Our email CSS engine is open source. Give it a star on GitHub 

Emailens

Email Rendering Playground

The same HTML email reaches each reader in a different shape: Gmail drops styles it can't parse, Outlook Classic draws with Word. Below, what each client breaks and how to check yours, then a playground for the 5 ways apps display HTML email.

@emailens/engine 0.18.22 · data last changed 10 Nov 2026

2.4 KB · Gmail clips above 102.0 KB

See preview
Rendering paradigm

The email in a sandboxed srcdoc frame: styles stay in, scripts never run.

In your appStyles stay inside
In your appScripts blocked
This email
2.4 KB
<style> blocks
1
Scripts or inline handlers
1

The checker needs a computer screen.

Why the same email renders differently in each client

A broken email costs you the click. The button that vanishes in Outlook, the layout that collapses on a phone in Yahoo and the text that turns unreadable in Gmail's dark mode all reach real readers, and nothing tells you. Each email client runs its own sanitizer and renderer over your HTML, so each reader gets a different version. These are the problems that cost the most, client by client. Dated lines are from our own captures on the real client.

Gmail

Media queries and classes stop working, though the email is small.
Gmail keeps <style> only in the <head> and only up to 16KB. A block in the body, or past the limit, is removed. Gmail's apps also drop <style> for accounts that are not Google accounts, so inline the styles your layout needs.
Every style in the block vanishes at once.
A colour written in the space syntax, such as rgb(30 41 59 / 1), makes Gmail remove the whole <style> block. In a style attribute it drops every declaration on that element. Write rgb(30, 41, 59) with commas.
Columns built with flexbox collapse to their content.
The flex shorthand is dropped: an item set to flex: 0 0 34% lost its 34%. A width: 34% on the same item held. Size columns with width, or use a table. Measured 2026-10-28, Gmail web in Chrome
Green or brand-coloured text turns dark and hard to read in dark mode.
Gmail on Android flips the lightness of text below a set brightness, so mid-tone text gets darker: #16a34a becomes #008e37, which fails contrast. Our model of the rule matched the real app's contrast verdict on all 424 held-out colour pairs. Measured 2026-10-31, Gmail on Android

Outlook Classic on Windows

Cards lose their padding and the layout runs together.
Outlook Classic renders email with Word. Padding on a <div> or a <table> is dropped: put it on the <td>. Flexbox and grid do nothing, so columns need tables.
Hidden content shows up.
display: none hides a cell, a row and a span, but not a <div> or a <table>. Add mso-hide: all.
A photo is drawn huge, or a rounded picture is a blank box.
An image is sized by its width and height attributes only. Inside VML, a picture in a <v:fill> showed only its fallback colour on Outlook 2013, 2016 and 2019; <v:imagedata> drew it, rounded corners included. Measured 2026-10-20, Outlook 2007 to 2019
A web font turns into Times New Roman.
A font loaded with @font-face falls back to Times New Roman on Outlook 2007 to 2016. Name a web-safe font after it in the stack and set it in an Outlook-only rule.

Yahoo Mail and AOL

Phone readers get the desktop layout.
Yahoo drops a whole <style> block when an @media query lists conditions with commas. Write one condition per @media rule. Measured 2026-10-29, Yahoo Mail web
Flex columns shrink until words break letter by letter.
The flex shorthand is dropped. Size flex items with width and flex-shrink: 0, or use a table. Measured 2026-10-27, Yahoo Mail web
Everything after one rule loses its styles.
An attribute selector whose value holds a semicolon, such as [style*="width:100%;"], breaks the rest of that stylesheet in Yahoo and AOL.

Outlook.com, new Outlook and Outlook mobile

The second half of the stylesheet does nothing.
These clients discard every rule after two closing braces in a row, }}, which minified CSS produces at the end of a media query. Put a space or a newline between the braces.

New Outlook for Mac

A grid falls back to one column.
Plain track sizes work (1fr 1fr, 300px 300px). A value with repeat() or minmax() is dropped. The client also drops var(), calc(), gradients and inline SVG. Measured 2026-10-27, new Outlook for Mac

Proton Mail

A card loses its shadow, gap or flex layout but keeps its colour.
Proton drops flex, gap, box-shadow, transform and similar properties from a style attribute that also holds a property it keeps. It also sets line-height: 1.5 on every element, so nothing inherits your line-height. Measured 2026-11-04, Proton web, 53 cases

More symptoms and their fixes: email rendering bugs by client.

How to check an email before you send it

  1. Lint the HTML. The open-source CLI flags each construct a client drops, in your terminal or CI: npx @emailens/cli lint email.html.
  2. Preview every client. The checker shows what each client does to your HTML in about 1 second. It is measured against more than 2,200 real-inbox screenshots, and more arrive every day. The method is on the accuracy page.
  3. Confirm on real devices. Before a big send, get screenshots from real Outlook on Windows and Mac, Apple Mail on Mac and iPad, Gmail and Yahoo on the web and Android, and Proton.
  4. Automate it. The email rendering API takes your HTML and returns each client's version, the issues and a score per client, for a build step or a service. Coding assistants call the same engine through the MCP server.

Showing email inside your own app: 5 ways, with code

The playground at the top of this page is for a different job: displaying an email in a web app, such as an inbox, an email platform's template list or a support tool. How you embed it decides whether its CSS leaks into your page and whether its scripts run.

1

Direct Light DOM Injection

The email HTML is injected directly into a standard container element (like a div) using dynamic markup insertion. This is the simplest strategy but carries substantial risks: email styles leak outwards and contaminate the parent application layout, while global dashboard CSS rules bleed inwards and corrupt the email preview.

Implementation Code
LightDOMRenderer.tsx
// Paradigm 1: React Light DOM Renderer

export function LightDOMRenderer({ emailHtml }: { emailHtml: string }) {
  return (
    <div 
      className="email-light-dom-container"
      dangerouslySetInnerHTML={{ __html: emailHtml }} 
    />
  );
}
How emailens Detects It
detect.ts
// Chrome Extension Scanner Fallback
// Scans the top-level body document (weighted with a penalty since it's not isolated)
const html = document.documentElement.outerHTML;
const score = calculateScore(html) - 50;
2

Shadow DOM Encapsulation

The email is loaded inside a browser-isolated Shadow DOM root attached to a host container. This completely solves the style bleeding issue without spinning up separate heavy iframe window threads. However, script blocks can still run unless intercepted, and default web tools like document.querySelectorAll will not see the email markup unless they explicitly drill through open shadowRoot nodes.

Implementation Code
ShadowDOMRenderer.tsx
// Paradigm 2: React Shadow DOM Renderer (Encapsulated)

export function ShadowDOMRenderer({ emailHtml }: { emailHtml: string }) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!containerRef.current) return;
    
    // Attaching or recycling open shadow root
    let shadow = containerRef.current.shadowRoot;
    if (!shadow) {
      shadow = containerRef.current.attachShadow({ mode: 'open' });
    }
    shadow.innerHTML = `
      <style>
        :host { display: block; width: 100%; min-height: 500px; }
      </style>
      ${emailHtml}
    `;
  }, [emailHtml]);

  return <div ref={containerRef} className="w-full" />;
}
How emailens Detects It
detect.ts
// Chrome Extension Shadow Root Scanner
function deepSearchShadowRoots(root: ParentNode | ShadowRoot): string[] {
  const results: string[] = [];
  const elements = root.querySelectorAll("*");
  
  for (const el of Array.from(elements)) {
    if (el.shadowRoot) {
      results.push(el.shadowRoot.innerHTML);
      results.push(...deepSearchShadowRoots(el.shadowRoot));
    }
  }
  return results;
}
3

Static CORS Iframe Preview

An iframe with a static src attribute loading from an external rendering API or storage URL. This provides maximum security and isolation since the third-party domain operates outside the main site origin. The downside is that browser security restricts content scripts from accessing the iframe's DOM directly. To read it anyway, the emailens extension's background worker fetches the frame's URL itself and reads the response.

Implementation Code
CORSPreview.html
<!-- Paradigm 3: Cross-Origin Iframe Template Preview -->
<iframe 
  src="https://preview.klaviyo.com/email/render/template_12345" 
  width="100%" 
  height="600px" 
  sandbox="allow-popups allow-same-origin"
  className="border-none"
></iframe>
How emailens Detects It
detect.ts
// Chrome Extension cross-origin iframe scanner
// The page cannot read the frame, so the extension's background worker fetches its URL
try {
  const innerDoc = iframe.contentDocument || iframe.contentWindow?.document;
  if (!innerDoc) throw new Error("Blocked by CORS");
} catch {
  // The page can't read it: hand the URL to the extension's background worker
  chrome.runtime.sendMessage({ 
    type: "FETCH_CROSS_ORIGIN", 
    url: iframe.src 
  }, (response) => {
    const rawEmailHtml = response.html; // Fetched by the extension, not read from the frame
  });
}
4

Dynamic Document Stream Writer

An empty same-origin blank iframe is mounted in the viewport, and JavaScript synchronously writes the raw email HTML directly to its open document stream. It solves style bleeding and is fully readable by content scripts immediately. Developers must be careful to call doc.close() to finalize parsing and stop loading animations.

Implementation Code
DynamicIframe.tsx
// Paradigm 4: React Dynamic Stream Iframe Writer

export function DynamicIframeRenderer({ emailHtml }: { emailHtml: string }) {
  const iframeRef = useRef<HTMLIFrameElement>(null);

  useEffect(() => {
    const iframe = iframeRef.current;
    if (!iframe) return;

    const doc = iframe.contentDocument || iframe.contentWindow?.document;
    if (!doc) return;

    // Direct synchronous document stream injection
    doc.open();
    doc.write(emailHtml);
    doc.close();
  }, [emailHtml]);

  return (
    <iframe
      ref={iframeRef}
      width="100%"
      height="600px"
      className="border-none"
      title="Dynamic Preview"
    />
  );
}
How emailens Detects It
detect.ts
// Chrome Extension Same-Origin Iframe Scanner
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc && doc.documentElement) {
  const emailHtml = doc.documentElement.outerHTML;
  const score = calculateScore(emailHtml, iframe);
}
5

The srcdoc Attribute Iframe (Highly Recommended)

The modern standard. Email HTML is embedded inside the srcdoc attribute of an iframe. When paired with standard sandboxing parameters (e.g. sandbox="allow-same-origin"), this achieves perfect style encapsulation, blocks script execution, and loads completely offline. The emailens engine extracts the raw unescaped content directly from the iframe's DOM attribute, bypassing loading delays.

Implementation Code
SrcdocIframe.html
<!-- Paradigm 5: The Secure Offline srcdoc Iframe -->
<iframe 
  srcdoc="<!DOCTYPE html><html><body><h1>Safe Preview</h1></body></html>" 
  width="100%" 
  height="600px" 
  sandbox="allow-popups allow-same-origin"
  className="border-none rounded-xl"
></iframe>
How emailens Detects It
detect.ts
// Chrome Extension direct srcdoc extractor
const srcdoc = iframe.getAttribute("srcdoc");
if (srcdoc && srcdoc.trim() !== "") {
  // Instantly read raw escaped HTML without waiting for iframe DOM rendering threads
  const emailHtml = srcdoc; 
}

Your next question

Other free tools: HTML Email Checker · CSS Support Lookup · CSS Inliner · Dark Mode Preview · Outlook Ghost Table Generator · Outlook VML Background Generator · Bulletproof Button Generator · Client Targeting Generator

Run this check on every email

The open-source CLI checks every template, free: npx @emailens/cli lint email.html (CLI docs).

GitHub Action (Pro). Pro is $19 a month. The pre-send gate in CI, and the history behind every check. See the plans →

Questions

Why does my HTML email look different in every email client?

Each client runs its own sanitizer and its own renderer. Gmail rewrites your CSS and drops whole <style> blocks it can't parse, Outlook Classic on Windows draws email with Word, and webmail strips properties that could break its own page. The same HTML reaches each reader in a different shape.

Which email client causes the most rendering problems?

Outlook Classic on Windows. It renders with Word, so padding on divs and tables, flexbox and border-radius are dropped, and display:none does not hide a div or a table. Gmail is next: it keeps <style> only in the head, up to 16KB, and removes the whole block over one colour it can't read.

Is there an email rendering API?

Yes. POST your HTML to the Emailens API and it returns each client's version of the email, the issues it found and a score per client. The same engine runs in the MCP server for coding assistants and in the open-source CLI.

Why does my email look different inside my app than in the inbox?

Because your app's CSS and the email's CSS meet. In the Light DOM they leak into each other; a Shadow DOM or a sandboxed srcdoc iframe keeps them apart.

Which way is safest for showing user-supplied email in an app?

A sandboxed srcdoc iframe without allow-scripts. Styles stay inside and scripts never run.

Does this playground send my email anywhere?

No. It renders in your browser. Only 'Check your email, free' sends it, to the checker, which opens it in five clients.