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: `
`,
- 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
`,
+ 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 });
});