diff --git a/CHANGELOG.md b/CHANGELOG.md index a84f79e..27c290a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.17 + +- Preserve sanitized formatting, links, tables, remote image references, and matching inline images + when quoting an HTML message in a reply. +- Collapse quoted history behind an ellipsis in the HQBase reader for both HTML and plain-text + messages. +- Show the first and final two messages in longer conversations and place intervening messages + behind a counted divider. + ## 0.1.16 - Include the selected message as quoted HTML and plain text in replies so email clients can diff --git a/app/features/messages/conversation-messages.tsx b/app/features/messages/conversation-messages.tsx index 8e1df32..00ad299 100644 --- a/app/features/messages/conversation-messages.tsx +++ b/app/features/messages/conversation-messages.tsx @@ -1,11 +1,12 @@ -import { Download } from "lucide-react"; -import type * as React from "react"; +import { Download, MessagesSquare } from "lucide-react"; +import * as React from "react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/cn"; import { formatDateTime } from "@/lib/format"; -import { MessageHtml } from "./message-html"; +import { MessageHtml, PlainTextMessage } from "./message-html"; import type { MessageDetail } from "./types"; export function ConversationMessages({ @@ -15,64 +16,115 @@ export function ConversationMessages({ compact?: boolean; messages: MessageDetail[]; }): React.ReactElement { + const hiddenCount = Math.max(0, messages.length - 3); + const threadFingerprint = messages.map((message) => message.id).join(":"); + const [expandedThread, setExpandedThread] = React.useState(null); + const showMiddle = expandedThread === threadFingerprint; + + if (hiddenCount === 0) { + return
{messages.map(renderMessage)}
; + } + + const first = messages[0]; + const middle = messages.slice(1, -2); + const final = messages.slice(-2); return (
- {messages.map((message) => { - const timestamp = message.receivedAt ?? message.sentAt ?? message.createdAt; - return ( -
-
-
-
{message.fromAddress}
-
- to {message.to.join(", ")} - {message.cc.length > 0 ? ` · cc ${message.cc.join(", ")}` : ""} -
-
-
- {message.direction === "outbound" ? ( - - Sent - - ) : null} - -
-
-
- {message.htmlAvailable ? ( - - ) : ( -
-                  {message.textBody || message.snippet}
-                
- )} - {message.attachments.length > 0 ? ( - <> - -
-
Attachments
- {message.attachments.map((attachment) => ( - - - {attachment.filename} - - ))} -
- - ) : null} + {first ? renderMessage(first) : null} + + setExpandedThread((current) => (current === threadFingerprint ? null : threadFingerprint)) + } + /> + {showMiddle ? middle.map(renderMessage) : null} + {final.map(renderMessage)} +
+ ); + + function renderMessage(message: MessageDetail): React.ReactElement { + const timestamp = message.receivedAt ?? message.sentAt ?? message.createdAt; + return ( +
+
+
+
{message.fromAddress}
+
+ to {message.to.join(", ")} + {message.cc.length > 0 ? ` · cc ${message.cc.join(", ")}` : ""}
-
- ); - })} +
+
+ {message.direction === "outbound" ? ( + + Sent + + ) : null} + +
+ +
+ {message.htmlAvailable ? ( + + ) : ( + + )} + {message.attachments.length > 0 ? ( + <> + +
+
Attachments
+ {message.attachments.map((attachment) => ( + + + {attachment.filename} + + ))} +
+ + ) : null} +
+ + ); + } +} + +function ThreadMessagesDivider({ + count, + expanded, + onToggle +}: { + count: number; + expanded: boolean; + onToggle: () => void; +}): React.ReactElement { + const noun = count === 1 ? "message" : "messages"; + return ( +
+ + +
); } diff --git a/app/features/messages/message-html.tsx b/app/features/messages/message-html.tsx index 151d131..d70f66a 100644 --- a/app/features/messages/message-html.tsx +++ b/app/features/messages/message-html.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { getMessageHtml, trustRemoteMediaSender } from "./api"; import { buildEmailHtmlDocument } from "./html-document"; @@ -19,12 +20,15 @@ export function MessageHtml({ message }: MessageHtmlProps): React.ReactElement { const [error, setError] = React.useState(null); const [savingTrust, setSavingTrust] = React.useState(false); const [frameHeight, setFrameHeight] = React.useState(320); + const [quoteExpanded, setQuoteExpanded] = React.useState(false); + const [quoteFrameHeight, setQuoteFrameHeight] = React.useState(240); React.useEffect(() => { let active = true; setHtml(null); setError(null); setLoadRemoteImages(false); + setQuoteExpanded(false); void getMessageHtml(message.id) .then((result) => { if (!active) return; @@ -50,6 +54,17 @@ export function MessageHtml({ message }: MessageHtmlProps): React.ReactElement { }), [html, loadRemoteImages] ); + const renderedQuote = React.useMemo( + () => + html?.quotedHtml + ? buildEmailHtmlDocument({ + allowRemoteImages: loadRemoteImages, + html: html.quotedHtml, + origin: window.location.origin + }) + : null, + [html, loadRemoteImages] + ); async function loadImages(): Promise { setLoadingImages(true); @@ -82,7 +97,7 @@ export function MessageHtml({ message }: MessageHtmlProps): React.ReactElement { if (!rendered) { return ( <> - + <PlainTextMessage message={message} /> {error && <p className="mt-3 text-xs text-destructive">{error}</p>} </> ); @@ -112,6 +127,53 @@ export function MessageHtml({ message }: MessageHtmlProps): React.ReactElement { srcDoc={rendered} title={`Message body: ${message.subject}`} /> + {renderedQuote ? ( + <> + <QuotedContentDivider + expanded={quoteExpanded} + onToggle={() => setQuoteExpanded((expanded) => !expanded)} + /> + {quoteExpanded ? ( + <iframe + className="w-full rounded-md border bg-white" + height={quoteFrameHeight} + onLoad={(event) => { + const height = event.currentTarget.contentDocument?.documentElement.scrollHeight; + if (height) setQuoteFrameHeight(Math.max(240, height)); + }} + sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin" + srcDoc={renderedQuote} + title={`Quoted message history: ${message.subject}`} + /> + ) : null} + </> + ) : null} + </div> + ); +} + +export function QuotedContentDivider({ + expanded, + onToggle +}: { + expanded: boolean; + onToggle: () => void; +}): React.ReactElement { + return ( + <div className="flex items-center gap-2" data-quoted-content-control> + <Separator className="flex-1" /> + <Button + aria-expanded={expanded} + aria-label={expanded ? "Hide quoted message history" : "Show quoted message history"} + className="h-6 min-w-10 rounded-full px-2 font-mono tracking-wider" + onClick={onToggle} + size="sm" + type="button" + variant="outline" + > + ... + </Button> + <Separator className="flex-1" /> </div> ); } @@ -159,10 +221,34 @@ export function RemoteImagesAlert({ ); } -function PlainText({ message }: MessageHtmlProps): React.ReactElement { +export function PlainTextMessage({ message }: MessageHtmlProps): React.ReactElement { + const [expanded, setExpanded] = React.useState(false); + const content = splitQuotedText(message.textBody || message.snippet); return ( - <pre className="whitespace-pre-wrap break-words font-sans text-sm leading-7 text-foreground/90"> - {message.textBody || message.snippet} - </pre> + <div className="flex flex-col gap-4"> + <pre className="whitespace-pre-wrap break-words font-sans text-sm leading-7 text-foreground/90"> + {content.body} + </pre> + {content.quote ? ( + <> + <QuotedContentDivider expanded={expanded} onToggle={() => setExpanded((open) => !open)} /> + {expanded ? ( + <pre className="whitespace-pre-wrap break-words font-sans text-sm leading-7 text-muted-foreground"> + {content.quote} + </pre> + ) : null} + </> + ) : null} + </div> ); } + +export function splitQuotedText(value: string): { body: string; quote: string | null } { + const match = /\n{1,2}On [^\n]+ wrote:\n>/i.exec(value.replace(/\r\n?/g, "\n")); + if (!match) return { body: value, quote: null }; + const normalized = value.replace(/\r\n?/g, "\n"); + return { + body: normalized.slice(0, match.index).trimEnd(), + quote: normalized.slice(match.index).trim() + }; +} diff --git a/app/features/messages/types.ts b/app/features/messages/types.ts index 58f4ed4..21d7342 100644 --- a/app/features/messages/types.ts +++ b/app/features/messages/types.ts @@ -39,5 +39,6 @@ export type MessageDetail = MessageSummary & { export type MessageHtml = { hasRemoteImages: boolean; html: string; + quotedHtml: string | null; remoteMediaTrusted: boolean; }; diff --git a/package.json b/package.json index 62eaa45..d86512f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqbase", - "version": "0.1.16", + "version": "0.1.17", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", diff --git a/test/unit/app/messages/conversation-reader.test.tsx b/test/unit/app/messages/conversation-reader.test.tsx index 904a432..fcabad4 100644 --- a/test/unit/app/messages/conversation-reader.test.tsx +++ b/test/unit/app/messages/conversation-reader.test.tsx @@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { InboxPage } from "@/features/inbox/inbox-page"; +import { ConversationMessages } from "@/features/messages/conversation-messages"; import { MessageDetail } from "@/features/messages/message-detail"; import { MessageListItem } from "@/features/messages/message-list-item"; import type { MessageDetail as MessageDetailType, MessageSummary } from "@/features/messages/types"; @@ -125,4 +126,23 @@ describe("conversation reader", () => { expect(unreadHtml).toContain('aria-label="Unread"'); expect(readHtml).not.toContain('aria-label="Unread"'); }); + + it("collapses messages between the first and final two behind a counted divider", () => { + const messages = Array.from({ length: 6 }, (_, index) => ({ + ...firstMessage, + id: `msg_${index + 1}`, + fromAddress: `sender-${index + 1}@example.com`, + textBody: `Message body ${index + 1}` + })); + const html = renderToStaticMarkup(<ConversationMessages messages={messages} />); + + expect(html).toContain("Message body 1"); + expect(html).not.toContain("Message body 2"); + expect(html).not.toContain("Message body 3"); + expect(html).not.toContain("Message body 4"); + expect(html).toContain("Message body 5"); + expect(html).toContain("Message body 6"); + expect(html).toContain("3 earlier messages"); + expect(html).toContain('aria-expanded="false"'); + }); }); diff --git a/test/unit/app/messages/message-html.test.tsx b/test/unit/app/messages/message-html.test.tsx index e1f69ff..66b2277 100644 --- a/test/unit/app/messages/message-html.test.tsx +++ b/test/unit/app/messages/message-html.test.tsx @@ -2,7 +2,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { buildEmailHtmlDocument } from "@/features/messages/html-document"; -import { RemoteImagesAlert } from "@/features/messages/message-html"; +import { + QuotedContentDivider, + RemoteImagesAlert, + splitQuotedText +} from "@/features/messages/message-html"; describe("message HTML view", () => { it("keeps remote origins out of the iframe policy until images are loaded", () => { @@ -42,4 +46,25 @@ describe("message HTML view", () => { expect(html).toContain("Load images"); expect(html).toContain("Always load from sender"); }); + + it("renders an accessible ellipsis disclosure for quoted history", () => { + const html = renderToStaticMarkup( + <QuotedContentDivider expanded={false} onToggle={() => undefined} /> + ); + + expect(html).toContain('aria-label="Show quoted message history"'); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain("..."); + }); + + it("separates conventional plain-text reply history", () => { + expect( + splitQuotedText( + "New reply\n\nOn 2026-07-28 at 15:29 UTC, owner@example.com wrote:\n> Earlier reply" + ) + ).toEqual({ + body: "New reply", + quote: "On 2026-07-28 at 15:29 UTC, owner@example.com wrote:\n> Earlier reply" + }); + }); }); diff --git a/test/unit/worker/features/messages/html-sanitizer.test.ts b/test/unit/worker/features/messages/html-sanitizer.test.ts index 38d86dc..a2da6c5 100644 --- a/test/unit/worker/features/messages/html-sanitizer.test.ts +++ b/test/unit/worker/features/messages/html-sanitizer.test.ts @@ -1,4 +1,7 @@ -import { sanitizeMessageHtml } from "@worker/features/messages/html-sanitizer"; +import { + sanitizeMessageHtml, + sanitizeQuotedMessageHtml +} from "@worker/features/messages/html-sanitizer"; import { describe, expect, it } from "vitest"; const attachment = { @@ -93,4 +96,37 @@ describe("email HTML sanitizer", () => { expect(result.hasRemoteImages).toBe(true); expect(result.html).not.toContain("images.example.com"); }); + + it("separates Gmail-compatible quoted history for reader disclosure", () => { + const result = sanitizeMessageHtml({ + allowRemoteImages: false, + attachments: [], + origin: "https://mail.example.com", + html: `<p>New reply</p><div class="gmail_quote gmail_quote_container"><div class="gmail_attr">On Tuesday, Pat wrote:</div><blockquote class="gmail_quote"><strong>Earlier reply</strong></blockquote></div>`, + messageId: "msg-1" + }); + + expect(result.html).toBe("<p>New reply</p>"); + expect(result.quotedHtml).toContain("<strong>Earlier reply</strong>"); + expect(result.quotedHtml).not.toContain("gmail_quote"); + }); + + it("preserves safe rich HTML, remote images, and referenced CID images for outbound quotes", () => { + const result = sanitizeQuotedMessageHtml({ + attachments: [attachment], + html: `<script>alert(1)</script><table style="width: 100%; position: fixed"><tbody><tr><td><strong>Rich reply</strong></td></tr></tbody></table> + <img src="cid:signature-logo@example.com" onerror="alert(1)"> + <img src="https://images.example.com/banner.png"> + <img src="cid:missing@example.com">` + }); + + expect(result.html).toContain("<table"); + expect(result.html).toContain("<strong>Rich reply</strong>"); + expect(result.html).toContain('src="cid:signature-logo@example.com"'); + expect(result.html).toContain('src="https://images.example.com/banner.png"'); + expect(result.html).not.toContain("position"); + expect(result.html).not.toContain("onerror"); + expect(result.html).not.toContain("missing@example.com"); + expect(result.inlineAttachmentIds).toEqual(["att-logo"]); + }); }); diff --git a/test/unit/worker/features/send/reply-body.test.ts b/test/unit/worker/features/send/reply-body.test.ts index 13c4e86..a3bad1f 100644 --- a/test/unit/worker/features/send/reply-body.test.ts +++ b/test/unit/worker/features/send/reply-body.test.ts @@ -12,7 +12,11 @@ const original = { describe("reply body", () => { it("appends conventional plain-text and Gmail-compatible HTML quotes", () => { - const body = buildReplyBody({ html: "<p>Thanks</p>", text: "Thanks" }, original); + const body = buildReplyBody( + { html: "<p>Thanks</p>", text: "Thanks" }, + original, + '<table><tbody><tr><td><strong>Rich reply</strong></td></tr></tbody></table><img src="cid:logo@example.com">' + ); expect(body.text).toBe( [ @@ -26,6 +30,14 @@ describe("reply body", () => { ); expect(body.html).toContain('class="gmail_quote gmail_quote_container"'); expect(body.html).toContain('class="gmail_attr"'); + expect(body.html).toContain("<strong>Rich reply</strong>"); + expect(body.html).toContain('src="cid:logo@example.com"'); + expect(body.html).not.toContain("Second &lt;line&gt;"); + }); + + it("falls back to escaped plain text when rich source HTML is unavailable", () => { + const body = buildReplyBody({ html: "<p>Thanks</p>", text: "Thanks" }, original); + expect(body.html).toContain("Second &lt;line&gt; &amp; more"); expect(body.html).not.toContain("Second <line>"); }); diff --git a/test/unit/worker/features/send/send-service.test.ts b/test/unit/worker/features/send/send-service.test.ts index af5e41f..9ba01d9 100644 --- a/test/unit/worker/features/send/send-service.test.ts +++ b/test/unit/worker/features/send/send-service.test.ts @@ -15,11 +15,19 @@ vi.mock("@worker/features/mailboxes/address-queries", () => ({ vi.mock("@worker/features/messages/queries", () => ({ ensureThread: vi.fn(), getMessageDetail: vi.fn(), + getMessageHtmlKey: vi.fn(), + insertAttachment: vi.fn(), insertMessage: vi.fn() })); import { findMailboxForSending } from "@worker/features/mailboxes/queries"; -import { ensureThread, getMessageDetail, insertMessage } from "@worker/features/messages/queries"; +import { + ensureThread, + getMessageDetail, + getMessageHtmlKey, + insertAttachment, + insertMessage +} from "@worker/features/messages/queries"; import { replyToMessage, sendNewMessage } from "@worker/features/send/service"; import type { WorkerEnv } from "@worker/lib/env"; @@ -53,6 +61,7 @@ const sentSummary = { describe("send service", () => { const send = vi.fn(); + const get = vi.fn(); const put = vi.fn(); const env = { ASSETS: {} as Fetcher, @@ -64,7 +73,7 @@ describe("send service", () => { HQBASE_RELEASE_MANIFEST_URL: "https://github.com/HQBase/hqbase/releases/latest/download/stable.json", HQBASE_WORKER_NAME: "hqbase", - MAIL_OBJECTS: { put } as unknown as R2Bucket, + MAIL_OBJECTS: { get, put } as unknown as R2Bucket, MAIL_SENDER: { send } as unknown as SendEmail, HQBASE_JOBS: {} as Queue } satisfies WorkerEnv; @@ -161,4 +170,79 @@ describe("send service", () => { httpMetadata: { contentType: "text/html; charset=utf-8" } }); }); + + it("quotes safe rich HTML and carries referenced CID images as inline attachments", async () => { + const inlineImage = { + id: "attachment-1", + messageId: "message-1", + filename: "logo.png", + contentType: "image/png", + sizeBytes: 3, + contentId: "<logo@example.com>", + r2Key: "mail/logo.png", + createdAt: "2026-07-10T00:00:00.000Z" + }; + vi.mocked(getMessageDetail).mockResolvedValue({ + ...sentSummary, + attachments: [inlineImage], + bcc: [], + cc: [], + deliveredToAddress: "support@example.com", + direction: "inbound", + folder: "inbox", + fromAddress: "owner@example.com", + htmlAvailable: true, + inReplyTo: null, + messageId: "<original@example.com>", + references: [], + textBody: "Original" + }); + vi.mocked(getMessageHtmlKey).mockResolvedValue("mail/original.html"); + get.mockImplementation(async (key: string) => { + if (key === "mail/original.html") { + return { + text: async () => + '<script>alert(1)</script><p><strong>Rich original</strong></p><img src="cid:logo@example.com"><img src="https://images.example.com/banner.png">' + }; + } + if (key === inlineImage.r2Key) { + return { arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer }; + } + return null; + }); + send.mockResolvedValue({ messageId: "<cloudflare-reply@example.com>" }); + + await replyToMessage(env, { + attachmentIds: [], + bcc: [], + cc: [], + from: mailbox.address, + html: "<p>Reply</p>", + messageId: "message-1", + text: "Reply", + to: ["owner@example.com"] + }); + + const payload = send.mock.calls[0]?.[0] as Parameters<SendEmail["send"]>[0]; + expect(payload.html).toContain("<strong>Rich original</strong>"); + expect(payload.html).toContain('src="cid:logo@example.com"'); + expect(payload.html).toContain('src="https://images.example.com/banner.png"'); + expect(payload.html).not.toContain("<script"); + expect(payload.attachments).toEqual([ + expect.objectContaining({ + contentId: "logo@example.com", + disposition: "inline", + filename: "logo.png", + type: "image/png" + }) + ]); + expect(insertAttachment).toHaveBeenCalledWith( + env.DB, + expect.objectContaining({ + contentId: "logo@example.com", + messageId: "message-1", + r2Key: "mail/logo.png" + }) + ); + }); }); diff --git a/worker/features/messages/html-sanitizer.ts b/worker/features/messages/html-sanitizer.ts index b04bd81..8a8019a 100644 --- a/worker/features/messages/html-sanitizer.ts +++ b/worker/features/messages/html-sanitizer.ts @@ -1,5 +1,6 @@ import sanitizeHtml from "sanitize-html"; +import { isSafeInlineImage } from "./inline-media"; import type { StoredAttachment } from "./types"; const allowedTags = [ @@ -132,6 +133,7 @@ const remoteCssResource = /(?:https?:)?\/\//i; export type SanitizedMessageHtml = { hasRemoteImages: boolean; html: string; + quotedHtml: string | null; }; export function sanitizeMessageHtml(input: { @@ -141,6 +143,72 @@ export function sanitizeMessageHtml(input: { messageId: string; origin: string; }): SanitizedMessageHtml { + const parts = splitQuotedHtml(input.html); + const body = sanitizeDisplayHtml({ ...input, html: parts.body }); + const quote = parts.quote ? sanitizeDisplayHtml({ ...input, html: parts.quote }) : null; + return { + hasRemoteImages: body.hasRemoteImages || Boolean(quote?.hasRemoteImages), + html: body.html, + quotedHtml: quote?.html || null + }; +} + +export function sanitizeQuotedMessageHtml(input: { + attachments: Array<Pick<StoredAttachment, "contentId" | "contentType" | "id">>; + html: string; +}): { html: string; inlineAttachmentIds: string[] } { + const attachmentsByContentId = new Map( + input.attachments.flatMap((attachment) => + attachment.contentId && isSafeInlineImage(attachment.contentType) + ? [[normalizeContentId(attachment.contentId), attachment] as const] + : [] + ) + ); + const inlineAttachmentIds = new Set<string>(); + const bounded = boundedHtml(input.html); + const html = sanitizeHtml(bounded, { + ...sanitizerOptions(), + allowedSchemesByTag: { + img: ["cid", "http", "https"] + }, + transformTags: { + a: (tagName, attributes) => ({ tagName, attribs: safeLinkAttributes(attributes) }), + img: (tagName, attributes) => { + const source = attributes.src?.trim() ?? ""; + const next: sanitizeHtml.Attributes = { ...attributes }; + delete next.srcset; + if (source.toLowerCase().startsWith("cid:")) { + const attachment = attachmentsByContentId.get(normalizeContentId(source.slice(4))); + if (attachment?.contentId) { + const contentId = stripContentIdBrackets(attachment.contentId); + inlineAttachmentIds.add(attachment.id); + next.src = `cid:${contentId}`; + } else { + delete next.src; + } + } else { + const remoteSource = absoluteRemoteUrl(source); + if (remoteSource) { + next.src = remoteSource; + next.referrerpolicy = "no-referrer"; + } else { + delete next.src; + } + } + return { tagName, attribs: next }; + } + } + }); + return { html, inlineAttachmentIds: [...inlineAttachmentIds] }; +} + +function sanitizeDisplayHtml(input: { + allowRemoteImages: boolean; + attachments: StoredAttachment[]; + html: string; + messageId: string; + origin: string; +}): Omit<SanitizedMessageHtml, "quotedHtml"> { const origin = safeOrigin(input.origin); const contentIds = new Map( input.attachments.flatMap((attachment) => @@ -152,27 +220,7 @@ export function sanitizeMessageHtml(input: { let hasRemoteImages = false; const html = sanitizeHtml(input.html, { - allowProtocolRelative: false, - allowedAttributes: { - "*": ["align", "dir", "lang", "style", "title", "valign"], - a: ["href", "rel", "target", "title"], - col: ["span", "width"], - img: ["alt", "height", "loading", "referrerpolicy", "src", "title", "width"], - li: ["value"], - ol: ["start", "type"], - table: ["align", "bgcolor", "border", "cellpadding", "cellspacing", "height", "width"], - td: ["align", "bgcolor", "colspan", "height", "rowspan", "valign", "width"], - th: ["align", "bgcolor", "colspan", "height", "rowspan", "scope", "valign", "width"] - }, - allowedSchemes: ["http", "https", "mailto", "tel"], - allowedStyles: { - "*": Object.fromEntries(styleProperties.map((property) => [property, [safeStyleValue]])) - }, - allowedTags, - disallowedTagsMode: "discard", - enforceHtmlBoundary: false, - nestingLimit: 80, - nonTextTags: ["script", "style", "textarea", "option", "noscript"], + ...sanitizerOptions(), onOpenTag(name, attributes) { if (hasRemoteReference(name, attributes, origin)) hasRemoteImages = true; }, @@ -210,6 +258,64 @@ export function sanitizeMessageHtml(input: { return { hasRemoteImages, html }; } +function sanitizerOptions(): sanitizeHtml.IOptions { + return { + allowProtocolRelative: false, + allowedAttributes: { + "*": ["align", "dir", "lang", "style", "title", "valign"], + a: ["href", "rel", "target", "title"], + col: ["span", "width"], + img: ["alt", "height", "loading", "referrerpolicy", "src", "title", "width"], + li: ["value"], + ol: ["start", "type"], + table: ["align", "bgcolor", "border", "cellpadding", "cellspacing", "height", "width"], + td: ["align", "bgcolor", "colspan", "height", "rowspan", "valign", "width"], + th: ["align", "bgcolor", "colspan", "height", "rowspan", "scope", "valign", "width"] + }, + allowedSchemes: ["http", "https", "mailto", "tel"], + allowedStyles: { + "*": Object.fromEntries(styleProperties.map((property) => [property, [safeStyleValue]])) + }, + allowedTags, + disallowedTagsMode: "discard", + enforceHtmlBoundary: false, + nestingLimit: 80, + nonTextTags: ["script", "style", "textarea", "option", "noscript"] + }; +} + +function splitQuotedHtml(html: string): { body: string; quote: string | null } { + const match = + /<div\b[^>]*\bclass\s*=\s*(?:"[^"]*\bgmail_quote(?:_container)?\b[^"]*"|'[^']*\bgmail_quote(?:_container)?\b[^']*')[^>]*>/i.exec( + html + ); + if (!match) return { body: html, quote: null }; + if (match.index === 0) return { body: "", quote: html }; + return { + body: html.slice(0, match.index).trimEnd(), + quote: html.slice(match.index) + }; +} + +function boundedHtml(html: string): string { + const maxCharacters = 200_000; + if (html.length <= maxCharacters) return html; + return `${html.slice(0, maxCharacters)}<p>[Previous message truncated by HQBase]</p>`; +} + +function absoluteRemoteUrl(value: string): string | null { + try { + const url = new URL(value); + return ["http:", "https:"].includes(url.protocol) ? url.href : null; + } catch { + return null; + } +} + +function stripContentIdBrackets(value: string): string { + return value.trim().replace(/^<|>$/g, ""); +} + function hasRemoteReference( tagName: string, attributes: sanitizeHtml.Attributes, diff --git a/worker/features/messages/inline-media.ts b/worker/features/messages/inline-media.ts new file mode 100644 index 0000000..55b98e3 --- /dev/null +++ b/worker/features/messages/inline-media.ts @@ -0,0 +1,9 @@ +export function isSafeInlineImage(contentType: string): boolean { + return ["image/avif", "image/gif", "image/jpeg", "image/png", "image/webp"].includes( + normalizedContentType(contentType) + ); +} + +export function normalizedContentType(contentType: string): string { + return contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "application/octet-stream"; +} diff --git a/worker/features/messages/routes.ts b/worker/features/messages/routes.ts index 1130266..c870b11 100644 --- a/worker/features/messages/routes.ts +++ b/worker/features/messages/routes.ts @@ -6,6 +6,7 @@ import { AppError } from "../../lib/errors"; import type { MessageAction } from "./actions"; import { sanitizeMessageHtml } from "./html-sanitizer"; +import { isSafeInlineImage, normalizedContentType } from "./inline-media"; import { findAttachment, getAttachmentMailboxId, @@ -18,6 +19,8 @@ import { } from "./queries"; import { isRemoteMediaTrusted, trustRemoteMediaSender } from "./remote-media"; +export { isSafeInlineImage } from "./inline-media"; + export const messageRoutes = new Hono<HonoApp>(); const actions: readonly MessageAction[] = ["read", "unread", "star", "unstar", "archive", "trash"]; @@ -185,13 +188,3 @@ attachmentRoutes.get("/:id", async (c) => { headers.set("content-disposition", `attachment; filename="${attachment.filename}"`); return new Response(object.body, { headers }); }); - -export function isSafeInlineImage(contentType: string): boolean { - return ["image/avif", "image/gif", "image/jpeg", "image/png", "image/webp"].includes( - normalizedContentType(contentType) - ); -} - -function normalizedContentType(contentType: string): string { - return contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "application/octet-stream"; -} diff --git a/worker/features/send/reply-body.ts b/worker/features/send/reply-body.ts index c7df2cd..860d7c2 100644 --- a/worker/features/send/reply-body.ts +++ b/worker/features/send/reply-body.ts @@ -10,7 +10,8 @@ type ReplySource = Pick< export function buildReplyBody( authored: { html?: string | undefined; text: string }, - original: ReplySource + original: ReplySource, + richQuoteHtml?: string ): { html?: string | undefined; text: string } { const attribution = `On ${formatTimestamp( original.receivedAt ?? original.sentAt ?? original.createdAt @@ -22,7 +23,7 @@ export function buildReplyBody( return { text, - html: `${authored.html.trimEnd()}${quoteHtml(attribution, quoted)}` + html: `${authored.html.trimEnd()}${quoteHtml(attribution, richQuoteHtml ?? plainTextHtml(quoted))}` }; } @@ -39,8 +40,11 @@ function quotePlainText(value: string): string { .join("\n"); } -function quoteHtml(attribution: string, value: string): string { - const quotedHtml = escapeHtml(value).replaceAll("\n", "<br>"); +function plainTextHtml(value: string): string { + return escapeHtml(value).replaceAll("\n", "<br>"); +} + +function quoteHtml(attribution: string, quotedHtml: string): string { return [ '<div class="gmail_quote gmail_quote_container">', `<div dir="ltr" class="gmail_attr"><br>${escapeHtml(attribution)}<br></div>`, diff --git a/worker/features/send/service.ts b/worker/features/send/service.ts index b97e9c2..34291a1 100644 --- a/worker/features/send/service.ts +++ b/worker/features/send/service.ts @@ -5,13 +5,16 @@ import { draftAttachmentObjects } from "../drafts/queries"; import { findAddressIdentity } from "../mailboxes/address-queries"; import { findMailboxForSending } from "../mailboxes/queries"; import { ensureReplySubject } from "../messages/headers"; +import { sanitizeQuotedMessageHtml } from "../messages/html-sanitizer"; +import { isSafeInlineImage } from "../messages/inline-media"; import { ensureThread, getMessageDetail, + getMessageHtmlKey, insertAttachment, insertMessage } from "../messages/queries"; -import type { MessageSummary } from "../messages/types"; +import type { MessageSummary, StoredAttachment } from "../messages/types"; import { buildReplyBody } from "./reply-body"; import type { ReplyMessageInput, SendMessageInput } from "./validation"; @@ -69,9 +72,18 @@ export async function replyToMessage( (value): value is string => value !== null ); const to = input.to?.length ? input.to : [original.fromAddress]; - const body = buildReplyBody(input, original); - const attachments = await loadAttachments(env, input.attachmentIds, userId); + const quoted = + input.html && original.htmlAvailable + ? await loadQuotedMessageHtml( + env, + original.id, + original.attachments, + maxAttachmentBytes - totalAttachmentBytes(attachments) + ) + : { html: undefined, inlineAttachments: [] }; + const body = buildReplyBody(input, original, quoted.html); + const outgoingAttachments = [...attachments, ...quoted.inlineAttachments]; const sendResult = await env.MAIL_SENDER.send({ from: input.from, to, @@ -84,7 +96,9 @@ export async function replyToMessage( References: references.join(" ") }, ...(body.html ? { html: body.html } : {}), - ...(attachments.length ? { attachments: attachments.map(asEmailAttachment) } : {}) + ...(outgoingAttachments.length + ? { attachments: outgoingAttachments.map(asEmailAttachment) } + : {}) }); return storeSentMessage(env, { @@ -99,7 +113,7 @@ export async function replyToMessage( messageId: sendResult.messageId, references, sentAt: timestamp, - storedAttachments: attachments, + storedAttachments: outgoingAttachments, draftId: input.draftId ?? null, userId: userId ?? null }); @@ -129,7 +143,7 @@ async function storeSentMessage( messageId: string; references: string[]; sentAt: string; - storedAttachments: StoredDraftAttachment[]; + storedAttachments: StoredOutgoingAttachment[]; draftId: string | null; userId: string | null; } @@ -178,7 +192,7 @@ async function storeSentMessage( filename: attachment.filename, contentType: attachment.contentType, sizeBytes: attachment.sizeBytes, - contentId: null, + contentId: attachment.contentId, r2Key: attachment.r2Key }); } @@ -190,14 +204,94 @@ async function storeSentMessage( return message; } +const maxAttachmentBytes = 25 * 1024 * 1024; + type StoredDraftAttachment = Awaited<ReturnType<typeof draftAttachmentObjects>>[number]; -async function loadAttachments(env: WorkerEnv, ids: string[], userId?: string) { +type StoredOutgoingAttachment = StoredDraftAttachment & { + contentId: string | null; + disposition: "attachment" | "inline"; +}; + +async function loadAttachments( + env: WorkerEnv, + ids: string[], + userId?: string +): Promise<StoredOutgoingAttachment[]> { if (ids.length === 0) return []; if (!userId) throw new AppError("ATTACHMENTS_FORBIDDEN", "Attachments require a user session.", 403); - return draftAttachmentObjects(env.DB, env.MAIL_OBJECTS, userId, ids); + return (await draftAttachmentObjects(env.DB, env.MAIL_OBJECTS, userId, ids)).map( + (attachment) => ({ + ...attachment, + contentId: null, + disposition: "attachment" + }) + ); } -function asEmailAttachment(attachment: StoredDraftAttachment): EmailAttachment { + +async function loadQuotedMessageHtml( + env: WorkerEnv, + messageId: string, + attachments: StoredAttachment[], + availableBytes: number +): Promise<{ html?: string; inlineAttachments: StoredOutgoingAttachment[] }> { + const htmlKey = await getMessageHtmlKey(env.DB, messageId); + if (!htmlKey) return { inlineAttachments: [] }; + const htmlObject = await env.MAIL_OBJECTS.get(htmlKey); + if (!htmlObject) return { inlineAttachments: [] }; + const sourceHtml = await htmlObject.text(); + const candidates = attachments.filter( + (attachment) => attachment.contentId && isSafeInlineImage(attachment.contentType) + ); + const referenced = new Set( + sanitizeQuotedMessageHtml({ attachments: candidates, html: sourceHtml }).inlineAttachmentIds + ); + let remainingBytes = Math.max(0, availableBytes); + const selected = candidates.filter((attachment) => { + if (!referenced.has(attachment.id) || attachment.sizeBytes > remainingBytes) return false; + remainingBytes -= attachment.sizeBytes; + return true; + }); + const hydrated = ( + await Promise.all( + selected.map(async (attachment): Promise<StoredOutgoingAttachment | null> => { + const object = await env.MAIL_OBJECTS.get(attachment.r2Key); + if (!object || !attachment.contentId) return null; + return { + id: attachment.id, + filename: attachment.filename, + contentType: attachment.contentType, + sizeBytes: attachment.sizeBytes, + r2Key: attachment.r2Key, + content: await object.arrayBuffer(), + contentId: stripContentIdBrackets(attachment.contentId), + disposition: "inline" + }; + }) + ) + ).filter((attachment): attachment is StoredOutgoingAttachment => attachment !== null); + const sanitized = sanitizeQuotedMessageHtml({ attachments: hydrated, html: sourceHtml }); + return { html: sanitized.html, inlineAttachments: hydrated }; +} + +function totalAttachmentBytes(attachments: StoredOutgoingAttachment[]): number { + return attachments.reduce((total, attachment) => total + attachment.sizeBytes, 0); +} + +function stripContentIdBrackets(value: string): string { + return value.trim().replace(/^<|>$/g, ""); +} + +function asEmailAttachment(attachment: StoredOutgoingAttachment): EmailAttachment { + if (attachment.disposition === "inline" && attachment.contentId) { + return { + disposition: "inline", + contentId: attachment.contentId, + filename: attachment.filename, + type: attachment.contentType, + content: attachment.content + }; + } return { disposition: "attachment", filename: attachment.filename,