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

+24 -2
View File
@@ -8,6 +8,7 @@ import { useTicketEvents } from "@/lib/events/use-ticket-events";
import { formatRelativeTime } from "@/lib/format";
import { Avatar } from "@/components/avatar";
import { AttachmentChip } from "@/components/attachment-chip";
import { MessageBodyHtml } from "@/components/message-body-html";
import { TagPicker } from "./tag-picker";
import type { TicketDTO, MessageDTO, TicketStatus } from "@/lib/tickets/types";
@@ -234,6 +235,19 @@ export function TicketThread({
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}
placeholder={visibility === "internal" ? "Заметка — клиент её не увидит…" : "Ответить клиенту…"}
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} />
Внутренняя заметка
</p>
<p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<p className="mb-1 whitespace-pre-wrap text-sm">{message.body}</p>
)}
{message.attachments.map((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"}`}
>
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${isAgent ? "bg-accent text-white" : "bg-surface-hover"}`}>
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
{message.bodyHtml ? (
<MessageBodyHtml html={message.bodyHtml} className="mb-1" />
) : (
<p className="mb-1 whitespace-pre-wrap">{message.body}</p>
)}
{message.attachments.map((a) => (
<AttachmentChip key={a.id} attachment={a} />
))}