Preserve email formatting in ticket display; paste-to-attach screenshots in replies

Inbound HTML emails were being flattened through html-to-text for display,
which lost all formatting and — for images embedded as data: URIs — leaked
the raw base64 as visible "[data:image/png;base64,...]" link text (an
Outlook/webmail screenshot-paste artifact). Customer messages now also
store a sanitized HTML rendering (messages.body_html) that preserves the
sender's fonts/colors/layout and shows inline images (data: URIs render
natively; cid: references are rewritten to the matching attachment's
serving URL via a new attachments.content_id column). Sanitization is a
tag/style allowlist (lib/mail/sanitize-html.ts, covered by
sanitize-html.test.ts) — no script/event handlers/javascript: hrefs, no
layout-breaking CSS. The plain-text fallback (used for channels other than
email) also drops image/data-URI link text via html-to-text selectors, for
the same underlying bug on that path.

Also: pasting a screenshot (Ctrl+V) into the agent reply box now attaches
it directly, reusing the existing attachment-upload/email-delivery path —
no more "save to disk, then click attach" round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-19 07:40:19 +00:00
1 parent 019f4a1b53
commit 955fdeee41
14 files changed
+1329 -5

No files matched your search

+63 -2
View File
@@ -21,6 +21,7 @@
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"sanitize-html": "^2.13.1",
"telegraf": "^4.16.3", "telegraf": "^4.16.3",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -34,6 +35,7 @@
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
@@ -2879,6 +2881,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"dev": true,
"dependencies": {
"htmlparser2": "^10.1"
}
},
"node_modules/@types/web-push": { "node_modules/@types/web-push": {
"version": "3.6.4", "version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
@@ -4434,6 +4445,14 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true "dev": true
}, },
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@@ -5010,7 +5029,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -6373,6 +6391,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -7620,6 +7646,11 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"node_modules/parseley": { "node_modules/parseley": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
@@ -7742,7 +7773,6 @@
"version": "8.5.23", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -8214,6 +8244,37 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/sanitize-html": {
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.13.1.tgz",
"integrity": "sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+2
View File
@@ -27,6 +27,7 @@
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"sanitize-html": "^2.13.1",
"telegraf": "^4.16.3", "telegraf": "^4.16.3",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"zod": "^4.4.3" "zod": "^4.4.3"
@@ -40,6 +41,7 @@
"@types/nodemailer": "^8.0.1", "@types/nodemailer": "^8.0.1",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"eslint": "^9", "eslint": "^9",
@@ -8,6 +8,7 @@ import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format"; import { formatRelativeTime } from "@/lib/format";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { AttachmentChip } from "@/components/attachment-chip"; import { AttachmentChip } from "@/components/attachment-chip";
import { MessageBodyHtml } from "@/components/message-body-html";
import { TagPicker } from "./tag-picker"; import { TagPicker } from "./tag-picker";
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types"; import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
@@ -234,6 +235,19 @@ export function TicketThread({
sendReply(); sendReply();
} }
}} }}
onPaste={(e) => {
// Ctrl+V a screenshot straight from the clipboard — no need to
// save it to disk first and click "attach file". Only kicks in
// when the clipboard actually holds image data; a normal text
// paste falls through untouched.
const item = Array.from(e.clipboardData.items).find((i) => i.kind === "file" && i.type.startsWith("image/"));
if (!item) return;
const file = item.getAsFile();
if (!file) return;
e.preventDefault();
const extension = item.type.split("/")[1] ?? "png";
setPendingFile(new File([file], `screenshot-${Date.now()}.${extension}`, { type: item.type }));
}}
rows={2} rows={2}
placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"} placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"}
className={`flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none ${ className={`flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none ${
@@ -314,7 +328,11 @@ function MessageBubble({ message }: { message: MessageDTO }) {
<StickyNote size={11} /> <StickyNote size={11} />
Внутренняя заметка Внутренняя заметка
</p> </p>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p> <p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p>
)}
{message.attachments.map((a) => ( {message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} /> <AttachmentChip key={a.id} attachment={a} />
))} ))}
@@ -334,7 +352,11 @@ function MessageBubble({ message }: { message: MessageDTO }) {
className={`mb-3 flex ${isAgent ? "justify-end" : "justify-start"}`} className={`mb-3 flex ${isAgent ? "justify-end" : "justify-start"}`}
> >
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isAgent ? "bg-accent text-white" : "bg-surface-hover"}`}> <div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isAgent ? "bg-accent text-white" : "bg-surface-hover"}`}>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<p className="mb-1 whitespace-pre-wrap">{message.body}</p> <p className="mb-1 whitespace-pre-wrap">{message.body}</p>
)}
{message.attachments.map((a) => ( {message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} /> <AttachmentChip key={a.id} attachment={a} />
))} ))}
@@ -7,6 +7,7 @@ import { ArrowLeft, Send, Paperclip, X } from "lucide-react";
import { useTicketEvents } from "@/lib/events/use-ticket-events"; import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format"; import { formatRelativeTime } from "@/lib/format";
import { AttachmentChip } from "@/components/attachment-chip"; import { AttachmentChip } from "@/components/attachment-chip";
import { MessageBodyHtml } from "@/components/message-body-html";
import type { TicketDTO, MessageDTO } from "@/lib/tickets/types"; import type { TicketDTO, MessageDTO } from "@/lib/tickets/types";
export function PortalThread({ export function PortalThread({
@@ -153,7 +154,11 @@ function MessageBubble({
className={`mb-3 flex ${isMine ? "justify-end" : "justify-start"}`} className={`mb-3 flex ${isMine ? "justify-end" : "justify-start"}`}
> >
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isMine ? "bg-accent text-white" : "bg-surface-hover"}`}> <div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isMine ? "bg-accent text-white" : "bg-surface-hover"}`}>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<p className="mb-1 whitespace-pre-wrap">{message.body}</p> <p className="mb-1 whitespace-pre-wrap">{message.body}</p>
)}
{message.attachments.map((a) => ( {message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} token={token} /> <AttachmentChip key={a.id} attachment={a} token={token} />
))} ))}
+12
View File
@@ -0,0 +1,12 @@
// message.bodyHtml is already sanitized server-side (see
// lib/mail/sanitize-html.ts) before it's ever stored — this only adds
// layout containment so an email's inline styles/tables can't blow out the
// message bubble's width.
export function MessageBodyHtml({ html, className = "" }: { html: string; className?: string }) {
return (
<div
className={`max-w-full overflow-x-auto text-sm break-words [&_img]:max-w-full [&_table]:max-w-full [&_td]:break-words [&_th]:break-words ${className}`}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
@@ -0,0 +1,2 @@
ALTER TABLE `attachments` ADD `content_id` text;--> statement-breakpoint
ALTER TABLE `messages` ADD `body_html` text;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -50,6 +50,13 @@
"when": 1786130418783, "when": 1786130418783,
"tag": "0006_big_daredevil", "tag": "0006_big_daredevil",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1787124609684,
"tag": "0007_neat_archangel",
"breakpoints": true
} }
] ]
} }
+10
View File
@@ -89,6 +89,11 @@ export const messages = sqliteTable("messages", {
authorId: text("author_id"), authorId: text("author_id"),
authorName: text("author_name").notNull(), authorName: text("author_name").notNull(),
body: text("body").notNull(), body: text("body").notNull(),
// Sanitized HTML, set only for inbound email messages that had an HTML
// part — preserves the sender's original formatting (fonts, layout,
// inline images) instead of the flattened plain-text `body`. Null for
// every other channel/direction, which keeps rendering the plain body.
bodyHtml: text("body_html"),
direction: text("direction", { enum: ["in", "out"] }).notNull(), direction: text("direction", { enum: ["in", "out"] }).notNull(),
// "internal" notes are agent-only — never sent to the customer over any // "internal" notes are agent-only — never sent to the customer over any
// channel, and filtered out of every customer-facing read path (portal, // channel, and filtered out of every customer-facing read path (portal,
@@ -177,6 +182,11 @@ export const attachments = sqliteTable("attachments", {
sizeBytes: integer("size_bytes").notNull(), sizeBytes: integer("size_bytes").notNull(),
// Relative path under DATA_DIR/attachments/ — see src/lib/attachments/storage.ts. // Relative path under DATA_DIR/attachments/ — see src/lib/attachments/storage.ts.
storageKey: text("storage_key").notNull(), storageKey: text("storage_key").notNull(),
// The email's Content-ID header (no angle brackets) for an inline image —
// only set for attachments extracted from an HTML email body, used to
// rewrite `cid:` references in messages.bodyHtml to this attachment's
// serving URL. Null for regular (non-inline) attachments.
contentId: text("content_id"),
createdAt: timestamps.createdAt, createdAt: timestamps.createdAt,
}); });
+42 -2
View File
@@ -1,9 +1,11 @@
import crypto from "node:crypto";
import { ImapFlow } from "imapflow"; import { ImapFlow } from "imapflow";
import { simpleParser, type ParsedMail } from "mailparser"; import { simpleParser, type ParsedMail } from "mailparser";
import { htmlToText } from "html-to-text"; import { htmlToText } from "html-to-text";
import { getMailboxSettings, saveMailboxSettings, disableMailboxConfig, type MailboxSettings } from "@/lib/mail/config"; import { getMailboxSettings, saveMailboxSettings, disableMailboxConfig, type MailboxSettings } from "@/lib/mail/config";
import { recordEmailInboundMessage } from "@/lib/tickets/service"; import { recordEmailInboundMessage } from "@/lib/tickets/service";
import { saveAttachment } from "@/lib/attachments/storage"; import { saveAttachment } from "@/lib/attachments/storage";
import { sanitizeEmailHtml } from "@/lib/mail/sanitize-html";
interface ListenerState { interface ListenerState {
client: ImapFlow | null; client: ImapFlow | null;
@@ -33,15 +35,44 @@ function createClient(settings: MailboxSettings): ImapFlow {
// blindly — some senders (Outlook in particular) produce a text/plain // blindly — some senders (Outlook in particular) produce a text/plain
// alternative that isn't actually clean, while html-to-text reliably drops // alternative that isn't actually clean, while html-to-text reliably drops
// <head>/<style> content (e.g. Outlook's VML behavior: url(...) rules) and // <head>/<style> content (e.g. Outlook's VML behavior: url(...) rules) and
// decodes entities, which a naive tag-stripping regex cannot do. // decodes entities, which a naive tag-stripping regex cannot do. Images and
// any link whose href is itself a cid:/data: URI are skipped entirely — the
// alternative is a multi-KB base64 blob or a bare `cid:...` token leaking
// into the plain-text fallback as visible "text". The real HTML rendering
// (bodyHtml, built in handleRawMessage) is what actually shows the images.
function extractBody(parsed: ParsedMail): string { function extractBody(parsed: ParsedMail): string {
if (typeof parsed.html === "string") { if (typeof parsed.html === "string") {
return htmlToText(parsed.html, { baseElements: { selectors: ["body"] }, wordwrap: false }).trim(); return htmlToText(parsed.html, {
baseElements: { selectors: ["body"] },
wordwrap: false,
selectors: [
{ selector: "img", format: "skip" },
{ selector: 'a[href^="data:"]', format: "skip" },
{ selector: 'a[href^="cid:"]', format: "skip" },
],
}).trim();
} }
if (parsed.text) return parsed.text; if (parsed.text) return parsed.text;
return "(пустое письмо)"; return "(пустое письмо)";
} }
/**
* Sanitizes the HTML part for display and rewrites `cid:` inline-image
* references to the URL the matching attachment will be served from —
* `attachmentIdByCid` must already reflect the IDs the attachments are about
* to be inserted with (see handleRawMessage, which generates them upfront
* for exactly this reason). Data-URI images need no rewriting; the browser
* renders those natively once real HTML (not flattened text) reaches the UI.
*/
function extractHtmlBody(parsed: ParsedMail, attachmentIdByCid: Map<string, string>): string | null {
if (typeof parsed.html !== "string") return null;
let html = parsed.html;
for (const [cid, attachmentId] of attachmentIdByCid) {
html = html.split(`cid:${cid}`).join(`/api/attachments/${attachmentId}`);
}
return sanitizeEmailHtml(html);
}
async function handleRawMessage(source: Buffer): Promise<void> { async function handleRawMessage(source: Buffer): Promise<void> {
const parsed = await simpleParser(source); const parsed = await simpleParser(source);
const from = parsed.from?.value[0]; const from = parsed.from?.value[0];
@@ -53,15 +84,23 @@ async function handleRawMessage(source: Buffer): Promise<void> {
...(Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : []), ...(Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : []),
]; ];
// IDs are generated here rather than left to the DB default so bodyHtml's
// cid: rewrite (which happens before anything is inserted) can point at
// the exact attachment row this loop is about to create.
const attachmentIdByCid = new Map<string, string>();
const attachmentInputs = []; const attachmentInputs = [];
for (const [i, part] of (parsed.attachments ?? []).entries()) { for (const [i, part] of (parsed.attachments ?? []).entries()) {
try { try {
const { storageKey, sizeBytes } = await saveAttachment(part.content); const { storageKey, sizeBytes } = await saveAttachment(part.content);
const attachmentId = crypto.randomUUID();
if (part.cid) attachmentIdByCid.set(part.cid, attachmentId);
attachmentInputs.push({ attachmentInputs.push({
id: attachmentId,
filename: part.filename || `attachment-${i + 1}`, filename: part.filename || `attachment-${i + 1}`,
mimeType: part.contentType || "application/octet-stream", mimeType: part.contentType || "application/octet-stream",
sizeBytes, sizeBytes,
storageKey, storageKey,
contentId: part.cid ?? null,
}); });
} catch (err) { } catch (err) {
console.error("[mail] failed to save attachment:", err); console.error("[mail] failed to save attachment:", err);
@@ -73,6 +112,7 @@ async function handleRawMessage(source: Buffer): Promise<void> {
fromName: from.name || from.address, fromName: from.name || from.address,
subject: parsed.subject ?? "", subject: parsed.subject ?? "",
body: extractBody(parsed), body: extractBody(parsed),
bodyHtml: extractHtmlBody(parsed, attachmentIdByCid),
messageId: parsed.messageId ?? null, messageId: parsed.messageId ?? null,
referencedMessageIds: referencedIds, referencedMessageIds: referencedIds,
attachments: attachmentInputs, attachments: attachmentInputs,
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { sanitizeEmailHtml } from "./sanitize-html";
describe("sanitizeEmailHtml", () => {
it("keeps a data-URI screenshot as a real image, not text", () => {
const html = '<p>See screenshot:</p><img src="data:image/png;base64,iVBORw0KGgo=" alt="shot">';
const out = sanitizeEmailHtml(html);
expect(out).toContain('src="data:image/png;base64,iVBORw0KGgo="');
expect(out).not.toContain("[data:image");
});
it("preserves inline font/color formatting", () => {
const out = sanitizeEmailHtml('<p style="color:red;font-family:Arial;font-size:14px;">Hi</p>');
expect(out).toContain("color:red");
expect(out).toContain("font-family:Arial");
expect(out).toContain("font-size:14px");
});
it("strips <script> tags and inline event handlers", () => {
const out = sanitizeEmailHtml('<p onclick="alert(1)">click</p><script>alert(2)</script>');
expect(out).not.toContain("<script");
expect(out).not.toContain("onclick");
expect(out).not.toContain("alert");
});
it("strips javascript: hrefs but keeps real links with noopener", () => {
const out = sanitizeEmailHtml('<a href="javascript:alert(1)">bad</a><a href="https://example.com">good</a>');
expect(out).not.toContain("javascript:");
expect(out).toContain('href="https://example.com"');
expect(out).toContain("noopener");
});
it("drops unresolved cid: image references instead of leaking the token as text", () => {
const out = sanitizeEmailHtml('<img src="cid:image001.png@01D9" alt="x">');
expect(out).not.toContain("cid:image001.png");
});
it("drops layout-breaking styles like position:fixed", () => {
const out = sanitizeEmailHtml('<div style="position:fixed;top:0;left:0;width:9999px;">x</div>');
expect(out).not.toContain("position");
expect(out).not.toContain("9999px");
});
it("unwraps Outlook VML/mso markup without leaking its CSS as visible text", () => {
const html = `<html><head><style>v\\:* {behavior:url(#default#VML);}</style></head><body><p>Hi</p></body></html>`;
const out = sanitizeEmailHtml(html);
expect(out).not.toContain("behavior:url");
expect(out).toContain("Hi");
});
});
+58
View File
@@ -0,0 +1,58 @@
import sanitizeHtml from "sanitize-html";
// 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 {
return 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" }),
},
});
}
+13 -1
View File
@@ -22,7 +22,14 @@ import type {
AttachmentDTO, AttachmentDTO,
} from "@/lib/tickets/types"; } from "@/lib/tickets/types";
type AttachmentInput = { filename: string; mimeType: string; sizeBytes: number; storageKey: string }; type AttachmentInput = {
id?: string;
filename: string;
mimeType: string;
sizeBytes: number;
storageKey: string;
contentId?: string | null;
};
function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string, tags: TagDTO[]): TicketDTO { function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string, tags: TagDTO[]): TicketDTO {
return { return {
@@ -48,6 +55,7 @@ function toMessageDTO(message: typeof messages.$inferSelect, attachments: Attach
authorType: message.authorType, authorType: message.authorType,
authorName: message.authorName, authorName: message.authorName,
body: message.body, body: message.body,
bodyHtml: message.bodyHtml,
direction: message.direction, direction: message.direction,
visibility: message.visibility, visibility: message.visibility,
attachments, attachments,
@@ -115,6 +123,7 @@ async function appendMessage(params: {
authorId: string | null; authorId: string | null;
authorName: string; authorName: string;
body: string; body: string;
bodyHtml?: string | null;
direction: "in" | "out"; direction: "in" | "out";
visibility?: "public" | "internal"; visibility?: "public" | "internal";
emailMessageId?: string; emailMessageId?: string;
@@ -128,6 +137,7 @@ async function appendMessage(params: {
authorId: params.authorId, authorId: params.authorId,
authorName: params.authorName, authorName: params.authorName,
body: params.body, body: params.body,
bodyHtml: params.bodyHtml ?? null,
direction: params.direction, direction: params.direction,
visibility: params.visibility ?? "public", visibility: params.visibility ?? "public",
emailMessageId: params.emailMessageId, emailMessageId: params.emailMessageId,
@@ -288,6 +298,7 @@ export async function recordEmailInboundMessage(params: {
fromName: string; fromName: string;
subject: string; subject: string;
body: string; body: string;
bodyHtml?: string | null;
messageId: string | null; messageId: string | null;
referencedMessageIds: string[]; referencedMessageIds: string[];
attachments?: AttachmentInput[]; attachments?: AttachmentInput[];
@@ -321,6 +332,7 @@ export async function recordEmailInboundMessage(params: {
authorId: customer.id, authorId: customer.id,
authorName: params.fromName, authorName: params.fromName,
body: params.body, body: params.body,
bodyHtml: params.bodyHtml,
direction: "in", direction: "in",
emailMessageId: params.messageId ?? undefined, emailMessageId: params.messageId ?? undefined,
attachments: params.attachments, attachments: params.attachments,
+1
View File
@@ -40,6 +40,7 @@ export interface MessageDTO {
authorType: MessageAuthorType; authorType: MessageAuthorType;
authorName: string; authorName: string;
body: string; body: string;
bodyHtml: string | null;
direction: MessageDirection; direction: MessageDirection;
visibility: MessageVisibility; visibility: MessageVisibility;
attachments: AttachmentDTO[]; attachments: AttachmentDTO[];