shared/mail-content.ts

import { Parser } from "htmlparser2";
import type { Mail } from "./types";

export const embeddedImageTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/avif"]);

// Older imports lack CID metadata and may contain parser-expanded data URLs.
export function needsEmbeddedContentRecovery(mail: Pick<Mail, "html" | "attachments">): boolean {
  let embedded = false;
  if (mail.html && mail.html.length <= MAIL_HTML_LIMIT) {
    const parser = new Parser({ onopentag(tag, attrs) {
      if (tag === "img" && /^(?:cid:|data:image\/)/i.test(attrs.src || "")) { embedded = true; parser.pause(); }
    } });
    parser.end(mail.html);
  }
  return embedded && mail.attachments === undefined || (!mail.html || embedded) &&
    !!mail.attachments?.some((file) => file.contentId === undefined && embeddedImageTypes.has(file.contentType.toLowerCase()));
}

export const MAIL_HTML_LIMIT = 256_000;
// Plain-text conversion leaves image sources as [url]; previews drop them,
// including one cut off by the stored preview length. One left-to-right pass
// drops markers; the expression rescanned runs of unmatched "[" once each.
export const previewText = (text: string) => {
  const bounded = text.slice(0, 8192);
  let result = "", at = 0;
  while (at < bounded.length) {
    const open = bounded.indexOf("[", at);
    if (open < 0) { result += bounded.slice(at); break; }
    const prefix = bounded.slice(open + 1, open + 7).toLowerCase();
    const marker = prefix.startsWith("https:") ? 7 : prefix.startsWith("http:") ? 6 : prefix.startsWith("cid:") ? 5 : 0;
    if (!marker) { result += bounded.slice(at, open + 1); at = open + 1; continue; }
    let close = open + marker;
    while (close < bounded.length && bounded[close] !== "]" && !/\s/.test(bounded[close])) close++;
    if (close >= bounded.length || bounded[close] === "]") { result += `${bounded.slice(at, open)} `; at = close + 1; }
    else { result += bounded.slice(at, open + 1); at = open + 1; }
  }
  return result.replace(/\s+/g, " ").trim();
};
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, selectors = false): 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) || selectors && (key === "class" || key === "id"))),
        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.
    let input = declaration.slice(colon + 1).trim().toLowerCase();
    const priority = input.lastIndexOf("!");
    if (priority >= 0 && /^\s*important\s*$/.test(input.slice(priority + 1))) input = input.slice(0, priority).trimEnd();
    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|inline-table|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|min-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";
  // The digit groups cannot overlap, so a non-match fails without backtracking.
  const match = value.length <= 32 && /^(\d+(?:\.\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 {
  if (value.length > 128) return 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;
}

// Only resource-free declarations enter the isolated reader. Original selector
// names stay in memory; the document receives opaque per-message identifiers.
export function mailStylesheet(html: string) {
  const identifiers = new Map<string, string>();
  if (boundedMailHtml(html) === null) return { css: "", identifiers };
  let source = "", inStyle = false;
  const parser = new Parser({
    onopentag(tag) { if (tag === "style") inStyle = true; },
    onclosetag(tag) { if (tag === "style") { inStyle = false; source += "\n"; } },
    ontext(text) { if (inStyle) source += text; },
  });
  parser.end(html);
  if (source.length > 32_000) return { css: "", identifiers };
  source = source.replace(/\/\*[\s\S]*?\*\//g, "");
  let rules = 0;
  const declarations = (raw: string) => Object.entries(mailStyle(raw)).filter(([name, value]) =>
    // A stylesheet must not conceal text or create tiny overflowing boxes.
    !["height", "maxHeight"].includes(name) &&
    (!['fontSize', 'lineHeight'].includes(name) || parseFloat(String(value)) >= 6 || !String(value).endsWith('px') && Number(value) >= 1)
  ).map(([name, value]) => `${name.replace(/[A-Z]/g, letter => '-' + letter.toLowerCase())}:${name === 'fontSize' ? `calc(${value} * var(--text-scale, 1))` : value}`).join(';');
  const selector = (raw: string) => {
    if (raw.length > 500 || !/^[a-zA-Z0-9_.#*\s,>+~-]+$/.test(raw) || !raw.trim()) return null;
    if (raw.trim().split(/[\s,>+~]+/).some(part => !/^(?:[a-zA-Z][\w-]*|\*)?(?:[.#][a-zA-Z_][\w-]*)*$/.test(part))) return null;
    let valid = true;
    const result = raw.trim().replace(/([.#]?)([a-zA-Z_][\w-]*)/g, (key: string, prefix: string, name: string) => {
      if (!prefix) {
        if (!allowedTags.has(name.toLowerCase()) && name !== 'body' && name !== 'html') valid = false;
        return name.toLowerCase();
      }
      if (!identifiers.has(key)) identifiers.set(key, `sender-${identifiers.size}`);
      return prefix + identifiers.get(key);
    });
    return valid ? result : null;
  };
  function block(input: string, nested = false): string {
    let css = '', at = 0;
    while (at < input.length && rules < 128) {
      const open = input.indexOf('{', at), semi = input.indexOf(';', at);
      if (semi >= 0 && (open < 0 || semi < open)) { at = semi + 1; continue; }
      if (open < 0) break;
      let close = open + 1, depth = 1;
      for (; close < input.length && depth; close++) {
        if (input[close] === '{') depth++;
        if (input[close] === '}') depth--;
      }
      if (depth) break;
      const head = input.slice(at, open).trim(), body = input.slice(open + 1, close - 1);
      at = close; rules++;
      if (/^@media\s/i.test(head)) {
        const media = head.slice(6).trim().toLowerCase();
        if (!nested && /^(?:(?:only )?screen(?: and )?)?\((?:min|max)-width:\s*\d{1,4}px\)(?: and \((?:min|max)-width:\s*\d{1,4}px\))*$/.test(media)) {
          const children = block(body, true);
          if (children) css += `@media ${media}{${children}}`;
        }
      } else if (!head.startsWith('@') && !/[{}]/.test(body)) {
        const safe = selector(head), values = declarations(body);
        if (safe && values) css += `${safe}{${values}}`;
      }
    }
    return css;
  }
  return { css: block(source), identifiers };
}