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:
1 parent
019f4a1b53
commit
955fdeee41
14 files changed
+1332
-8
No files matched your search
Generated
+63
-2
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
<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) => (
|
{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"}`}>
|
||||||
<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) => (
|
{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"}`}>
|
||||||
<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) => (
|
{message.attachments.map((a) => (
|
||||||
<AttachmentChip key={a.id} attachment={a} token={token} />
|
<AttachmentChip key={a.id} attachment={a} token={token} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in new issue
Block a user