Files
top-tickets/src/lib/mail/sanitize-html.ts
T
ogrechkoandClaude Sonnet 5 95522fdedd Fix data-URI screenshots not opening on click (Chrome blocks data: URL navigation)
The click-to-zoom feature from the previous commit worked for cid:-referenced
images but silently failed for images embedded directly as a data: URI —
confirmed with a real Playwright click: Chrome refuses to navigate a tab
(even a new one, even from a direct user click) to a data: URL, so
<a href="data:...' target="_blank"> just does nothing. The cursor still
showed zoom-in on hover since that's plain CSS, which is exactly the "лупа
появляется, но не нажимается" symptom reported.

Fix: imap.ts now extracts every data:image src out of an inbound email's
HTML into a real attachment file (deduping identical images embedded more
than once), the same way cid: images already were, and rewrites the HTML to
point at that attachment's normal /api/attachments/... URL instead. That
also shrinks messages.body_html (data URIs can be hundreds of KB sitting in
a DB column) and gets data-URI images the same "no duplicate chip below"
treatment cid: images already had.

attachments.isInline is now its own real column (backfilled from the
existing content_id-based cases) instead of being derived from content_id,
since a data-URI-derived attachment is inline but was never cid-referenced.
sanitizeEmailHtml's link-wrapping step now skips any residual data: src
defensively (unwrapped-but-visible beats a link that looks clickable but
isn't).

Verified against the real deployment with actual browser clicks (Playwright):
both a data-URI image and a cid: image now open their full-resolution
attachment in a new tab; before this fix the data-URI one silently did
nothing. Added a vitest.config.ts (needed for the new test file's @/ import
aliases) and unit tests for the extraction/dedup logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
2026-08-19 09:07:40 +00:00

98 lines
4.5 KiB
TypeScript

import sanitizeHtml from "sanitize-html";
import * as cheerio from "cheerio";
// Formatting-only tags — no <script>/<style>/<iframe>/<object>/<form>/svg/etc.
// Unknown tags (Outlook's <o:p>, VML <v:*>) are unwrapped by sanitize-html
// (tag dropped, text content kept), which is what we want for stray markup.
const ALLOWED_TAGS = [
"a", "p", "br", "div", "span", "b", "strong", "i", "em", "u", "s", "strike", "small",
"ul", "ol", "li", "blockquote", "pre", "code", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "tr", "td", "th",
"img", "sub", "sup", "font",
];
// Only visual/typographic properties — nothing that can reposition or
// resize content out of the message bubble (position, width/height on
// non-img elements, etc. are simply not in this allowlist and get dropped).
const ALLOWED_STYLES = {
"*": {
color: [/^#[0-9a-fA-F]{3,8}$/, /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/, /^[a-zA-Z]+$/],
"background-color": [/^#[0-9a-fA-F]{3,8}$/, /^rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)$/, /^[a-zA-Z]+$/],
"font-family": [/^[\w\s,'"-]+$/],
"font-size": [/^\d+(\.\d+)?(px|pt|em|%)$/],
"font-weight": [/^(normal|bold|\d{3})$/],
"font-style": [/^(normal|italic)$/],
"text-align": [/^(left|right|center|justify)$/],
"text-decoration": [/^[\w\s-]+$/],
"line-height": [/^[\d.]+(px|em|%)?$/],
},
};
/**
* Renders an inbound email's HTML part safely for display in the ticket
* thread — preserves fonts/colors/layout/inline images (the whole point:
* "как человек на почту отправил, ровно также оно и пришло"), while
* stripping anything that could execute script or break the page layout.
* `cid:` image references must already be rewritten to real attachment URLs
* before calling this (see imap.ts) — `cid`/`javascript` are not in the
* allowed URL schemes, so any that slip through as-is are dropped.
*/
export function sanitizeEmailHtml(html: string): string {
const clean = sanitizeHtml(html, {
allowedTags: ALLOWED_TAGS,
allowedAttributes: {
a: ["href", "title", "target", "rel"],
img: ["src", "alt", "width", "height", "style"],
td: ["colspan", "rowspan", "style"],
th: ["colspan", "rowspan", "style"],
table: ["style", "border", "cellpadding", "cellspacing"],
"*": ["style"],
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: { img: ["http", "https", "data"] },
allowedStyles: ALLOWED_STYLES,
transformTags: {
a: sanitizeHtml.simpleTransform("a", { target: "_blank", rel: "noopener noreferrer" }),
},
});
return wrapBareImagesInLinks(clean);
}
/**
* A plain <img> with no href around it isn't clickable — the message
* bubble shows it shrunk to fit, and the only way to see it at full
* resolution was the browser's right-click "open image in new tab". Wrap
* every image not already inside a real link (a sender-linked banner, say)
* in one pointing at its own src, so a normal click does the same thing.
* Runs after sanitizeHtml, on already-sanitized output — the href is just
* the img's own already-scheme-validated src, so this can't reintroduce
* anything sanitizeHtml would have stripped.
*
* data: URIs are skipped here — imap.ts extracts every data:image src into
* a real attachment (and a real /api/attachments/... URL) before this ever
* runs, specifically because Chrome silently refuses to navigate a tab to a
* data: URL, so a link built from one would look clickable but do nothing.
* Anything still starting with "data:" at this point is a residual case
* that extraction didn't catch — better left unwrapped (a plain image) than
* wrapped in a link that appears to work but doesn't.
*/
function wrapBareImagesInLinks(html: string): string {
const $ = cheerio.load(html, null, false);
$("img").each((_, el) => {
const $img = $(el);
if ($img.closest("a").length > 0) return;
const src = $img.attr("src");
if (!src || src.startsWith("data:")) return;
// Built via .attr(), not string-interpolated HTML — src is
// attacker-influenced (an already scheme-validated but otherwise
// arbitrary data:/http(s) URL), and interpolating it into an HTML
// string for cheerio to re-parse would let a crafted `src` (e.g. one
// containing an embedded `">`) break out of the attribute and inject
// markup. Setting it as a DOM attribute value keeps it inert.
const $a = $("<a>").attr({ href: src, target: "_blank", rel: "noopener noreferrer" });
$img.wrap($a);
});
return $.html();
}