shared/mail-content.ts
import { Parser } from "htmlparser2";
export const MAIL_HTML_LIMIT = 256_000;
export const MAIL_NODE_LIMIT = 4_000;
// Newsletters nest tables about 50 deep; the node limit bounds the total work.
export const MAIL_DEPTH_LIMIT = 96;
// Keep decoded source bytes intact inside the limit; larger mail uses its text body.
export function boundedMailHtml(value: string | false | undefined): string | null {
return typeof value === "string" && value.length <= MAIL_HTML_LIMIT && value.trim() ? value : null;
}
export type MailContentNode = string | {
tag: string;
attributes: Record<string, string>;
children: MailContentNode[];
};
const allowedTags = new Set(("div span p br hr h1 h2 h3 h4 h5 h6 strong b em i u s del center " +
"blockquote pre code ul ol li table thead tbody tfoot tr td th caption colgroup col a img font").split(" "));
const droppedTags = new Set(("head script style title iframe frame frameset object embed svg math " +
"form input button select option textarea template noscript audio video source link meta base").split(" "));
const allowedAttributes = new Set(("style href src alt width height align valign bgcolor color size " +
"colspan rowspan cellpadding cellspacing border start").split(" "));
// Unknown wrappers keep their text. Active content and its children are discarded.
export function parseMailContent(html: string): MailContentNode[] | null {
if (boundedMailHtml(html) === null) return null;
const roots: MailContentNode[] = [];
const stack: { children: MailContentNode[]; dropped: boolean }[] = [];
let count = 0, exceeded = false;
const budget = () => {
if (++count <= MAIL_NODE_LIMIT && stack.length < MAIL_DEPTH_LIMIT) return true;
exceeded = true;
parser.pause();
return false;
};
const parser = new Parser({
onopentag(tag, attributes) {
if (!budget()) return;
const parent = stack.at(-1);
const children: MailContentNode[] = [];
const dropped = !!parent?.dropped || droppedTags.has(tag);
if (!dropped) (parent?.children ?? roots).push({
tag: allowedTags.has(tag) ? tag : "span",
attributes: Object.fromEntries(Object.entries(attributes).filter(([key]) => allowedAttributes.has(key))),
children,
});
stack.push({ children, dropped });
},
onclosetag() { stack.pop(); },
ontext(text) {
if (!budget() || stack.at(-1)?.dropped) return;
const children = stack.at(-1)?.children ?? roots;
const last = children.at(-1);
if (typeof last === "string") children[children.length - 1] = last + text;
else children.push(text);
},
}, { decodeEntities: true });
parser.end(html);
return exceeded ? null : roots;
}
// CSS values use a small grammar so escapes, functions that fetch, and custom properties never pass.
export function mailStyle(value: string): Record<string, string | number> {
const style: Record<string, string | number> = {};
for (const declaration of value.slice(0, 8_000).split(";")) {
const colon = declaration.indexOf(":");
if (colon < 0) continue;
const name = declaration.slice(0, colon).trim().toLowerCase();
// Inline styles cannot be overridden here, so the priority flag carries no meaning.
const input = declaration.slice(colon + 1).trim().toLowerCase().replace(/\s*!\s*important$/, "");
let output: string | number | null = null;
if (["color", "background-color", "border-color"].includes(name)) output = color(input);
if (name === "background") output = color(input);
if (name === "font-size") output = length(input, 72);
if (name === "font-weight" && /^(normal|bold|[1-9]00)$/.test(input)) output = input;
if (name === "font-style" && /^(normal|italic|oblique)$/.test(input)) output = input;
if (name === "text-align" && /^(left|center|right|justify)$/.test(input)) output = input;
if (name === "vertical-align" && /^(top|middle|bottom|baseline)$/.test(input)) output = input;
if (name === "text-decoration" && /^(none|underline|line-through)$/.test(input)) output = input;
if (name === "white-space" && /^(normal|pre-wrap|pre-line)$/.test(input)) output = input;
if (name === "border-collapse" && /^(collapse|separate)$/.test(input)) output = input;
// Layout modes that newsletters use for columns and icon rows. Values that
// hide content (none, visibility) stay rejected so all sender text is visible.
if (name === "display" && /^(block|inline|inline-block|table|table-row|table-cell)$/.test(input)) output = input;
if (name === "float" && /^(left|right|none)$/.test(input)) output = input;
if (name === "clear" && /^(left|right|both|none)$/.test(input)) output = input;
if (name === "line-height") {
if (/^\d+(?:\.\d+)?$/.test(input) && Number(input) >= 1 && Number(input) <= 3) output = input;
else output = length(input, 96);
}
if (/^(width|max-width|height|max-height)$/.test(name)) {
output = length(input, 1200, name.includes("width"));
// Zero boxes hide text (preview lines) and would wrap it one letter per line.
if (output !== null && parseFloat(output) === 0) output = null;
}
if (/^(margin|padding)(-(top|right|bottom|left))?$/.test(name)) {
// `margin: 0 auto` is how most newsletters center their column.
const parts = input.split(/\s+/).map((part) => part === "auto" && name.startsWith("margin") ? part : length(part, 48));
if (parts.length <= 4 && parts.every((part) => part !== null)) output = parts.join(" ");
}
if (name === "border" || /^border-(top|right|bottom|left)$/.test(name)) output = border(input);
if (output !== null) {
const property = (name === "background" ? "background-color" : name)
.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
style[property] = output;
}
}
return style;
}
function color(value: string): string | null {
return value.length <= 80 && /^(#[\da-f]{3,4}|#[\da-f]{6}|#[\da-f]{8}|[a-z]{1,20}|(?:rgb|rgba|hsl|hsla)\([\d.,%\s+-]+\))$/.test(value) ? value : null;
}
function length(value: string, maximum: number, percent = false): string | null {
if (value === "0") return "0";
const match = /^(\d*\.?\d+)(px|pt|em|rem|ex|ch|%)$/.exec(value);
if (!match) return null;
const amount = Number(match[1]);
if (match[2] === "%") return percent && amount <= 100 ? value : null;
// Mail uses ex and ch for quote insets; at the 16px body size each is about half an em.
const scale = { px: 1, pt: 4 / 3, em: 16, rem: 16, ex: 8, ch: 8 }[match[2] as "px"];
const pixels = Math.round(amount * scale * 100) / 100;
return pixels <= maximum ? `${pixels}px` : null;
}
// A border shorthand in any word order, as Gmail writes `1px #ccc solid`. The
// result is always `width style color`, so the renderer can swap the color.
function border(value: string): string | null {
const functional = /(?:rgba?|hsla?)\([^)]*\)/.exec(value);
let colorValue = functional ? color(functional[0]) : null;
if (functional && !colorValue) return null;
let width: string | null = null, style: string | null = null;
const rest = functional ? value.replace(functional[0], " ") : value;
for (const token of rest.trim().split(/\s+/)) {
if (!style && /^(solid|dashed|dotted)$/.test(token)) style = token;
else if (!width && /\d/.test(token) && length(token, 4) !== null) width = length(token, 4);
else if (!colorValue && color(token)) colorValue = token;
else return null;
}
return style && colorValue ? `${width === "0" ? "0px" : width ?? "1px"} ${style} ${colorValue}` : null;
}
export function mailLink(value: string): string | null {
if (value.length > 2048 || !/^https?:\/\//i.test(value) || /[\\\s\u0000-\u001f\u007f]|%(?![\da-f]{2})/i.test(value)) return null;
try {
const url = new URL(value);
return url.hostname && !url.username && !url.password ? url.href : null;
} catch { return null; }
}
// Check both literal and decoded URL text; malformed UTF-8 must not conceal %40.
export function privateMailLink(value: string, redactText: (text: string) => string): boolean {
for (let pass = 0; pass < 5; pass++) {
if (redactText(value) !== value || redactText(value.replace(/\+/g, " ")) !== value.replace(/\+/g, " ")) return true;
let decoded: string;
try { decoded = decodeURIComponent(value); }
catch { decoded = value.replace(/%([\da-f]{2})/gi, (_, hex: string) => String.fromCharCode(parseInt(hex, 16))); }
if (decoded === value) return false;
value = decoded;
}
// Deeply encoded destinations cannot be proven free of hidden text.
return true;
}