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>
|
<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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user