From 1fe36e4d7cd97924e884fbc9d19f964755130b7e Mon Sep 17 00:00:00 2001 From: olegberman Date: Wed, 29 Jul 2026 18:47:16 -0400 Subject: [PATCH] Show forwarded email content --- CHANGELOG.md | 5 ++ package.json | 2 +- .../features/messages/html-sanitizer.test.ts | 51 ++++++++++++++++--- worker/features/messages/html-sanitizer.ts | 17 ++----- worker/features/messages/quote-classifier.ts | 43 ++++++++++++++++ worker/features/messages/routes.ts | 3 +- 6 files changed, 99 insertions(+), 22 deletions(-) create mode 100644 worker/features/messages/quote-classifier.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f9956..fbc5a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.1.25 + +- Keep manually forwarded Gmail messages visible in the reader while continuing to collapse genuine + quoted reply history. + ## 0.1.24 - Consolidate conversation, unread-count, and incoming-mail refreshes into one coordinated path. diff --git a/package.json b/package.json index 30476bb..901e406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqbase", - "version": "0.1.24", + "version": "0.1.25", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", diff --git a/test/unit/worker/features/messages/html-sanitizer.test.ts b/test/unit/worker/features/messages/html-sanitizer.test.ts index a2da6c5..b831983 100644 --- a/test/unit/worker/features/messages/html-sanitizer.test.ts +++ b/test/unit/worker/features/messages/html-sanitizer.test.ts @@ -24,7 +24,8 @@ describe("email HTML sanitizer", () => { html: `
Hello
`, - messageId: "msg-1" + messageId: "msg-1", + subject: "Hello" }); expect(result.hasRemoteImages).toBe(false); @@ -48,7 +49,8 @@ describe("email HTML sanitizer", () => { unsafe safe

Text

`, - messageId: "msg-1" + messageId: "msg-1", + subject: "Hello" }); expect(result.hasRemoteImages).toBe(true); @@ -67,14 +69,16 @@ describe("email HTML sanitizer", () => { attachments: [], origin: "https://mail.example.com", html: '', - messageId: "msg-1" + messageId: "msg-1", + subject: "Hello" }); const loaded = sanitizeMessageHtml({ allowRemoteImages: true, attachments: [], origin: "https://mail.example.com", html: '', - messageId: "msg-1" + messageId: "msg-1", + subject: "Hello" }); expect(blocked.hasRemoteImages).toBe(true); @@ -90,7 +94,8 @@ describe("email HTML sanitizer", () => { attachments: [], origin: "https://mail.example.com", html: '', - messageId: "msg-1" + messageId: "msg-1", + subject: "Hello" }); expect(result.hasRemoteImages).toBe(true); @@ -103,7 +108,8 @@ describe("email HTML sanitizer", () => { attachments: [], origin: "https://mail.example.com", html: `

New reply

On Tuesday, Pat wrote:
Earlier reply
`, - messageId: "msg-1" + messageId: "msg-1", + subject: "Re: Hello" }); expect(result.html).toBe("

New reply

"); @@ -111,6 +117,39 @@ describe("email HTML sanitizer", () => { expect(result.quotedHtml).not.toContain("gmail_quote"); }); + it.each([ + ["without an authored introduction", ""], + ["after an authored introduction", "

For your information

"] + ])("keeps a Gmail forward visible %s", (_description, introduction) => { + const result = sanitizeMessageHtml({ + allowRemoteImages: false, + attachments: [], + origin: "https://mail.example.com", + html: `${introduction}
---------- Forwarded message ---------
From: The Google Workspace Team <workspace-noreply@google.com>
Subject: Promotional access
Forwarded promotion
`, + messageId: "msg-1", + subject: "Fwd: Promotional access" + }); + + expect(result.html).toContain("---------- Forwarded message ---------"); + expect(result.html).toContain("Forwarded promotion"); + if (introduction) expect(result.html).toContain("For your information"); + expect(result.quotedHtml).toBeNull(); + }); + + it("still collapses forwarded content when it is quoted by a reply", () => { + const result = sanitizeMessageHtml({ + allowRemoteImages: false, + attachments: [], + origin: "https://mail.example.com", + html: `

New reply

---------- Forwarded message ---------
Earlier forward
`, + messageId: "msg-1", + subject: "Re: Promotional access" + }); + + expect(result.html).toBe("

New reply

"); + expect(result.quotedHtml).toContain("Earlier forward"); + }); + it("preserves safe rich HTML, remote images, and referenced CID images for outbound quotes", () => { const result = sanitizeQuotedMessageHtml({ attachments: [attachment], diff --git a/worker/features/messages/html-sanitizer.ts b/worker/features/messages/html-sanitizer.ts index 8a8019a..58739dd 100644 --- a/worker/features/messages/html-sanitizer.ts +++ b/worker/features/messages/html-sanitizer.ts @@ -1,6 +1,7 @@ import sanitizeHtml from "sanitize-html"; import { isSafeInlineImage } from "./inline-media"; +import { splitQuotedHtml } from "./quote-classifier"; import type { StoredAttachment } from "./types"; const allowedTags = [ @@ -142,8 +143,9 @@ export function sanitizeMessageHtml(input: { html: string; messageId: string; origin: string; + subject: string; }): SanitizedMessageHtml { - const parts = splitQuotedHtml(input.html); + const parts = splitQuotedHtml(input.html, input.subject); const body = sanitizeDisplayHtml({ ...input, html: parts.body }); const quote = parts.quote ? sanitizeDisplayHtml({ ...input, html: parts.quote }) : null; return { @@ -284,19 +286,6 @@ function sanitizerOptions(): sanitizeHtml.IOptions { }; } -function splitQuotedHtml(html: string): { body: string; quote: string | null } { - const match = - /]*\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; diff --git a/worker/features/messages/quote-classifier.ts b/worker/features/messages/quote-classifier.ts new file mode 100644 index 0000000..af80afd --- /dev/null +++ b/worker/features/messages/quote-classifier.ts @@ -0,0 +1,43 @@ +import sanitizeHtml from "sanitize-html"; + +export function splitQuotedHtml( + html: string, + subject: string +): { body: string; quote: string | null } { + const match = + /]*\bclass\s*=\s*(?:"[^"]*\bgmail_quote(?:_container)?\b[^"]*"|'[^']*\bgmail_quote(?:_container)?\b[^']*')[^>]*>/i.exec( + html + ); + if (!match) return { body: html, quote: null }; + if ( + beginsWithForwardedMessage(html.slice(match.index)) && + (isForwardSubject(subject) || !visibleText(html.slice(0, match.index))) + ) { + 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 beginsWithForwardedMessage(html: string): boolean { + return /^-{2,}\s*Forwarded message\s*-{2,}/i.test(visibleText(html.slice(0, 20_000))); +} + +function isForwardSubject(subject: string): boolean { + return /^\s*fw(?:d)?\s*:/i.test(subject); +} + +function visibleText(html: string): string { + return sanitizeHtml(html, { + allowedAttributes: {}, + allowedTags: [], + disallowedTagsMode: "discard", + nonTextTags: ["script", "style", "textarea", "option", "noscript"] + }) + .replace(/\u00a0/g, " ") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/worker/features/messages/routes.ts b/worker/features/messages/routes.ts index c870b11..c3b528c 100644 --- a/worker/features/messages/routes.ts +++ b/worker/features/messages/routes.ts @@ -94,7 +94,8 @@ messageRoutes.get("/:id/html", async (c) => { attachments: message.attachments, html: await object.text(), messageId: message.id, - origin: new URL(c.req.url).origin + origin: new URL(c.req.url).origin, + subject: message.subject }); return c.json({ ...rendered, remoteMediaTrusted: trusted }); });