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> <style>
html { background:#ffffff; } html { background:#ffffff; }
body { 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-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 /* Mail is written against desktop widths. Without these a newsletter
pushes a horizontal scrollbar across its own content. */ pushes a horizontal scrollbar across its own content. */
overflow-wrap:anywhere; word-break:break-word; 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, video, table, pre { max-width:100% !important; }
img { height:auto; } img { height:auto; }
table { border-collapse:collapse; } table { border-collapse:collapse; }
/* A layout table with a fixed pixel width is the usual overflow culprit. */ /* A layout table keeps the width its author gave it - forcing "auto" is what
table[width] { width:auto !important; max-width:100% !important; } collapsed designed messages into a narrow column. max-width above is what
stops one from overflowing a small screen. */
blockquote { blockquote {
margin:0 0 0 4px; padding-left:12px; margin:0 0 0 4px; padding-left:12px;
border-left:3px solid #cbd5e1; color:#475569; 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; 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; let hookInstalled = false;
function installDataUriHook(): void { function installDataUriHook(): void {
@@ -30,6 +55,11 @@ function installDataUriHook(): void {
data.keepAttr = false; data.keepAttr = false;
} }
}); });
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
if (data.tagName === "style" && node.textContent) {
node.textContent = scrubCss(node.textContent);
}
});
hookInstalled = true; hookInstalled = true;
} }
@@ -49,19 +79,23 @@ export function sanitizeEmailHtml(rawHtml: string): string {
ALLOWED_TAGS: [ ALLOWED_TAGS: [
"a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3", "a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "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: [ ALLOWED_ATTR: [
"align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite", "align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite",
"background", "class", "color", "colspan", "dir", "height", "href", "lang", "rowspan", "background", "class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
"src", "style", "title", "width", "target", "rel", "data-blocked-src", "src", "style", "title", "width", "target", "rel", "data-blocked-src",
], ],
// style, link, meta and base are gone: a stylesheet in a message can cover // link, meta and base are gone: <link> fetches a stylesheet from outside,
// the rest of the frame, and <base> rewrites every relative URL in it. // 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. // 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"], FORBID_ATTR: ["srcset", "formaction", "ping", "id"],
ALLOW_DATA_ATTR: false, 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 // 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 // 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 // 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"); 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(`<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"); expect(sanitizeEmailHtml(`<base href="http://evil.test/"><p>x</p>`)).not.toContain("<base");
}); });