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:
1 parent
96e624dd12
commit
641ab4bbe8
3 files changed
+49
-10
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user