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
98 lines
4.5 KiB
TypeScript
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();
|
|
}
|