feat(webmail): keep a message's own stylesheet so designed mail renders as sent

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 14:45:34 -03:00
1 parent 96e624dd12
commit 641ab4bbe8
3 files changed
+49 -10

No files matched your search

+5 -4
View File
@@ -55,9 +55,9 @@ export function buildReaderDocument(bodyHtml: string): string {
<style>
html { background:#ffffff; }
body {
margin:0; padding:20px; background:#ffffff; color:#1e293b;
margin:0; padding:24px 28px; background:#ffffff; color:#1d1d1f;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size:14px; line-height:1.6;
font-size:15px; line-height:1.55; -webkit-font-smoothing:antialiased;
/* Mail is written against desktop widths. Without these a newsletter
pushes a horizontal scrollbar across its own content. */
overflow-wrap:anywhere; word-break:break-word;
@@ -65,8 +65,9 @@ export function buildReaderDocument(bodyHtml: string): string {
img, video, table, pre { max-width:100% !important; }
img { height:auto; }
table { border-collapse:collapse; }
/* A layout table with a fixed pixel width is the usual overflow culprit. */
table[width] { width:auto !important; max-width:100% !important; }
/* A layout table keeps the width its author gave it - forcing "auto" is what
collapsed designed messages into a narrow column. max-width above is what
stops one from overflowing a small screen. */
blockquote {
margin:0 0 0 4px; padding-left:12px;
border-left:3px solid #cbd5e1; color:#475569;
+38 -4
View File
@@ -21,6 +21,31 @@ import DOMPurify from "dompurify";
*/
const ALLOWED_DATA_IMAGE = /^data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);/i;
/**
* What a message's own stylesheet may keep.
*
* Stylesheets are where nearly every designed message keeps its look: columns,
* fonts, colours, the rules that reflow it on a phone. Dropping them is what
* made mail look like unstyled text. They are safe to keep because the reader
* frame is sandboxed with an opaque origin - a rule can only restyle the
* message it came in, never the app around it.
*
* What is still removed is what fetches or runs something: @import pulls in a
* stylesheet the reader never saw, and the legacy script-in-CSS forms are
* refused outright. Remote url() references are held back like any other
* remote image, by blockRemoteImages.
*/
export function scrubCss(css: string): string {
return css
.replace(/@import\b[^;]*;?/gi, "")
.replace(/@charset\b[^;]*;?/gi, "")
.replace(/expression\s*\(/gi, "blocked(")
.replace(/behaviou?r\s*:/gi, "blocked:")
.replace(/-moz-binding\s*:/gi, "blocked:")
.replace(/(?:java|vb)script\s*:/gi, "blocked:")
.replace(/<\/?style/gi, "");
}
let hookInstalled = false;
function installDataUriHook(): void {
@@ -30,6 +55,11 @@ function installDataUriHook(): void {
data.keepAttr = false;
}
});
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
if (data.tagName === "style" && node.textContent) {
node.textContent = scrubCss(node.textContent);
}
});
hookInstalled = true;
}
@@ -49,19 +79,23 @@ export function sanitizeEmailHtml(rawHtml: string): string {
ALLOWED_TAGS: [
"a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong",
"table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "font", "sub", "sup",
"table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "font", "sub", "sup", "style", "center",
],
ALLOWED_ATTR: [
"align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite",
"background", "class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
"src", "style", "title", "width", "target", "rel", "data-blocked-src",
],
// style, link, meta and base are gone: a stylesheet in a message can cover
// the rest of the frame, and <base> rewrites every relative URL in it.
// link, meta and base are gone: <link> fetches a stylesheet from outside,
// and <base> rewrites every relative URL in the message. <style> stays,
// scrubbed (see scrubCss), because a message's look lives in it.
// id goes too, so a message cannot collide with the host document's ids.
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "style", "link", "meta", "base"],
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "link", "meta", "base"],
FORBID_ATTR: ["srcset", "formaction", "ping", "id"],
ALLOW_DATA_ATTR: false,
// A message's <style> sits in its <head>. Without this DOMPurify discards
// everything before the first body element, stylesheet included.
FORCE_BODY: true,
// data: is how an anchor smuggles a document onto this origin, so only the
// raster image types are let through - which is how this app delivers a
// message's own inline parts, and rejecting them stripped the src from
+6 -2
View File
@@ -31,9 +31,13 @@ describe("email HTML sanitizer", () => {
expect(sanitizeEmailHtml(`<a href="https://example.test/x">x</a>`)).toContain("https://example.test/x");
});
it("removes forms, styles and base tags", () => {
it("removes forms and base tags, and keeps a scrubbed stylesheet", () => {
expect(sanitizeEmailHtml(`<form action="http://evil.test"><input name="p"></form>`)).not.toContain("<form");
expect(sanitizeEmailHtml(`<style>body{display:none}</style><p>x</p>`)).not.toContain("<style");
const out = sanitizeEmailHtml(`<style>@import url(https://evil.test/x.css);.a{color:red}p{behavior:url(x)}</style><p class="a">x</p>`);
expect(out).toContain("<style");
expect(out).toContain("color:red");
expect(out).not.toContain("@import");
expect(out).not.toMatch(/behaviou?r\s*:/);
expect(sanitizeEmailHtml(`<base href="http://evil.test/"><p>x</p>`)).not.toContain("<base");
});