Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hqbase",
"version": "0.1.24",
"version": "0.1.25",
"private": true,
"type": "module",
"packageManager": "pnpm@11.7.0",
Expand Down
51 changes: 45 additions & 6 deletions test/unit/worker/features/messages/html-sanitizer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ describe("email HTML sanitizer", () => {
html: `<table style="width: 100%; color: #222; position: fixed">
<tr><td><strong>Hello</strong></td></tr>
</table><img src="cid:signature-logo%40example.com" onerror="alert(1)">`,
messageId: "msg-1"
messageId: "msg-1",
subject: "Hello"
});

expect(result.hasRemoteImages).toBe(false);
Expand All @@ -48,7 +49,8 @@ describe("email HTML sanitizer", () => {
<a href="javascript:alert(1)" onclick="alert(1)">unsafe</a>
<a href="https://example.com/path">safe</a>
<p style="background-image:url(https://evil.example/pixel); color: red">Text</p>`,
messageId: "msg-1"
messageId: "msg-1",
subject: "Hello"
});

expect(result.hasRemoteImages).toBe(true);
Expand All @@ -67,14 +69,16 @@ describe("email HTML sanitizer", () => {
attachments: [],
origin: "https://mail.example.com",
html: '<img src="https://images.example.com/open.gif" srcset="https://images.example.com/2x.png 2x">',
messageId: "msg-1"
messageId: "msg-1",
subject: "Hello"
});
const loaded = sanitizeMessageHtml({
allowRemoteImages: true,
attachments: [],
origin: "https://mail.example.com",
html: '<img src="//images.example.com/open.gif">',
messageId: "msg-1"
messageId: "msg-1",
subject: "Hello"
});

expect(blocked.hasRemoteImages).toBe(true);
Expand All @@ -90,7 +94,8 @@ describe("email HTML sanitizer", () => {
attachments: [],
origin: "https://mail.example.com",
html: '<img srcset="https://images.example.com/open.gif 1x">',
messageId: "msg-1"
messageId: "msg-1",
subject: "Hello"
});

expect(result.hasRemoteImages).toBe(true);
Expand All @@ -103,14 +108,48 @@ describe("email HTML sanitizer", () => {
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"
messageId: "msg-1",
subject: "Re: Hello"
});

expect(result.html).toBe("<p>New reply</p>");
expect(result.quotedHtml).toContain("<strong>Earlier reply</strong>");
expect(result.quotedHtml).not.toContain("gmail_quote");
});

it.each([
["without an authored introduction", ""],
["after an authored introduction", "<p>For your information</p>"]
])("keeps a Gmail forward visible %s", (_description, introduction) => {
const result = sanitizeMessageHtml({
allowRemoteImages: false,
attachments: [],
origin: "https://mail.example.com",
html: `${introduction}<div class="gmail_quote gmail_quote_container"><div class="gmail_attr">---------- Forwarded message ---------<br>From: The Google Workspace Team &lt;workspace-noreply@google.com&gt;<br>Subject: Promotional access</div><table><tbody><tr><td><strong>Forwarded promotion</strong></td></tr></tbody></table></div>`,
messageId: "msg-1",
subject: "Fwd: Promotional access"
});

expect(result.html).toContain("---------- Forwarded message ---------");
expect(result.html).toContain("<strong>Forwarded promotion</strong>");
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: `<p>New reply</p><div class="gmail_quote"><div class="gmail_attr">---------- Forwarded message ---------</div><strong>Earlier forward</strong></div>`,
messageId: "msg-1",
subject: "Re: Promotional access"
});

expect(result.html).toBe("<p>New reply</p>");
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],
Expand Down
17 changes: 3 additions & 14 deletions worker/features/messages/html-sanitizer.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -284,19 +286,6 @@ function sanitizerOptions(): sanitizeHtml.IOptions {
};
}

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;
Expand Down
43 changes: 43 additions & 0 deletions worker/features/messages/quote-classifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import sanitizeHtml from "sanitize-html";

export function splitQuotedHtml(
html: string,
subject: 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 (
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();
}
3 changes: 2 additions & 1 deletion worker/features/messages/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
Expand Down