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
+1332 -8

No files matched your search

+13 -1
View File
@@ -22,7 +22,14 @@ import type {
AttachmentDTO,
} 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 {
return {
@@ -48,6 +55,7 @@ function toMessageDTO(message: typeof messages.$inferSelect, attachments: Attach
authorType: message.authorType,
authorName: message.authorName,
body: message.body,
bodyHtml: message.bodyHtml,
direction: message.direction,
visibility: message.visibility,
attachments,
@@ -115,6 +123,7 @@ async function appendMessage(params: {
authorId: string | null;
authorName: string;
body: string;
bodyHtml?: string | null;
direction: "in" | "out";
visibility?: "public" | "internal";
emailMessageId?: string;
@@ -128,6 +137,7 @@ async function appendMessage(params: {
authorId: params.authorId,
authorName: params.authorName,
body: params.body,
bodyHtml: params.bodyHtml ?? null,
direction: params.direction,
visibility: params.visibility ?? "public",
emailMessageId: params.emailMessageId,
@@ -288,6 +298,7 @@ export async function recordEmailInboundMessage(params: {
fromName: string;
subject: string;
body: string;
bodyHtml?: string | null;
messageId: string | null;
referencedMessageIds: string[];
attachments?: AttachmentInput[];
@@ -321,6 +332,7 @@ export async function recordEmailInboundMessage(params: {
authorId: customer.id,
authorName: params.fromName,
body: params.body,
bodyHtml: params.bodyHtml,
direction: "in",
emailMessageId: params.messageId ?? undefined,
attachments: params.attachments,