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.16

- Include the selected message as quoted HTML and plain text in replies so email clients can
collapse and expand the previous content.

## 0.1.15

- Send new messages, replies, and forwards with Command+Enter on macOS or Control+Enter elsewhere.
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.15",
"version": "0.1.16",
"private": true,
"type": "module",
"packageManager": "pnpm@11.7.0",
Expand Down
39 changes: 39 additions & 0 deletions test/unit/worker/features/send/reply-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { buildReplyBody } from "@worker/features/send/reply-body";
import { describe, expect, it } from "vitest";

const original = {
createdAt: "2026-07-28T15:30:00.000Z",
fromAddress: "owner@example.com",
receivedAt: "2026-07-28T15:29:00.000Z",
sentAt: null,
snippet: "Fallback",
textBody: "First line\r\n\r\nSecond <line> & more"
};

describe("reply body", () => {
it("appends conventional plain-text and Gmail-compatible HTML quotes", () => {
const body = buildReplyBody({ html: "<p>Thanks</p>", text: "Thanks" }, original);

expect(body.text).toBe(
[
"Thanks",
"",
"On 2026-07-28 at 15:29 UTC, owner@example.com wrote:",
"> First line",
">",
"> Second <line> & more"
].join("\n")
);
expect(body.html).toContain('class="gmail_quote gmail_quote_container"');
expect(body.html).toContain('class="gmail_attr"');
expect(body.html).toContain("Second &lt;line&gt; &amp; more");
expect(body.html).not.toContain("Second <line>");
});

it("bounds quoted message content", () => {
const body = buildReplyBody({ text: "Reply" }, { ...original, textBody: "a".repeat(100_001) });

expect(body.text).toContain("[Previous message truncated by HQBase]");
expect(body.html).toBeUndefined();
});
});
12 changes: 11 additions & 1 deletion test/unit/worker/features/send/send-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,15 @@ describe("send service", () => {
bcc: ["audit@example.com"],
cc: ["manager@example.com"],
from: mailbox.address,
html: "<p>Reply</p>",
messageId: "message-1",
text: "Reply",
to: ["alternate@example.com"]
});

const quotedText = "Reply\n\nOn 2026-07-10 at 00:00 UTC, owner@example.com wrote:\n> Original";
const quotedHtml =
'<p>Reply</p><div class="gmail_quote gmail_quote_container"><div dir="ltr" class="gmail_attr"><br>On 2026-07-10 at 00:00 UTC, owner@example.com wrote:<br></div><blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex">Original</blockquote></div>';
expect(send).toHaveBeenCalledWith({
from: mailbox.address,
bcc: ["audit@example.com"],
Expand All @@ -137,18 +141,24 @@ describe("send service", () => {
"In-Reply-To": "<original@example.com>",
References: "<earlier@example.com> <original@example.com>"
},
html: quotedHtml,
subject: "Re: Hello",
text: "Reply",
text: quotedText,
to: ["alternate@example.com"]
});
expect(insertMessage).toHaveBeenCalledWith(
env.DB,
expect.objectContaining({
bcc: ["audit@example.com"],
cc: ["manager@example.com"],
htmlR2Key: "sent/2026-07-10/html-1.html",
messageId: "<cloudflare-reply@example.com>",
textBody: quotedText,
to: ["alternate@example.com"]
})
);
expect(put).toHaveBeenCalledWith("sent/2026-07-10/html-1.html", quotedHtml, {
httpMetadata: { contentType: "text/html; charset=utf-8" }
});
});
});
68 changes: 68 additions & 0 deletions worker/features/send/reply-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { MessageDetail } from "../messages/types";

const maxQuotedCharacters = 100_000;
const truncationNotice = "[Previous message truncated by HQBase]";

type ReplySource = Pick<
MessageDetail,
"createdAt" | "fromAddress" | "receivedAt" | "sentAt" | "snippet" | "textBody"
>;

export function buildReplyBody(
authored: { html?: string | undefined; text: string },
original: ReplySource
): { html?: string | undefined; text: string } {
const attribution = `On ${formatTimestamp(
original.receivedAt ?? original.sentAt ?? original.createdAt
)}, ${original.fromAddress} wrote:`;
const quoted = boundedQuoteSource(original.textBody || original.snippet);
const text = `${authored.text.trimEnd()}\n\n${attribution}\n${quotePlainText(quoted)}`;

if (!authored.html) return { text };

return {
text,
html: `${authored.html.trimEnd()}${quoteHtml(attribution, quoted)}`
};
}

function boundedQuoteSource(value: string): string {
const normalized = value.replace(/\r\n?/g, "\n").trim();
if (normalized.length <= maxQuotedCharacters) return normalized;
return `${normalized.slice(0, maxQuotedCharacters).trimEnd()}\n\n${truncationNotice}`;
}

function quotePlainText(value: string): string {
return value
.split("\n")
.map((line) => (line ? `> ${line}` : ">"))
.join("\n");
}

function quoteHtml(attribution: string, value: string): string {
const quotedHtml = escapeHtml(value).replaceAll("\n", "<br>");
return [
'<div class="gmail_quote gmail_quote_container">',
`<div dir="ltr" class="gmail_attr"><br>${escapeHtml(attribution)}<br></div>`,
'<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex">',
quotedHtml,
"</blockquote>",
"</div>"
].join("");
}

function formatTimestamp(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const [day, time = ""] = date.toISOString().split("T");
return `${day} at ${time.slice(0, 5)} UTC`;
}

function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
10 changes: 6 additions & 4 deletions worker/features/send/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "../messages/queries";
import type { MessageSummary } from "../messages/types";

import { buildReplyBody } from "./reply-body";
import type { ReplyMessageInput, SendMessageInput } from "./validation";

export async function sendNewMessage(
Expand Down Expand Up @@ -68,6 +69,7 @@ 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 sendResult = await env.MAIL_SENDER.send({
Expand All @@ -76,12 +78,12 @@ export async function replyToMessage(
...(input.cc.length ? { cc: input.cc } : {}),
...(input.bcc.length ? { bcc: input.bcc } : {}),
subject: ensureReplySubject(original.subject),
text: input.text,
text: body.text,
headers: {
"In-Reply-To": original.messageId ?? original.id,
References: references.join(" ")
},
...(input.html ? { html: input.html } : {}),
...(body.html ? { html: body.html } : {}),
...(attachments.length ? { attachments: attachments.map(asEmailAttachment) } : {})
});

Expand All @@ -91,8 +93,8 @@ export async function replyToMessage(
cc: input.cc,
bcc: input.bcc,
subject: ensureReplySubject(original.subject),
text: input.text,
...(input.html ? { html: input.html } : {}),
text: body.text,
...(body.html ? { html: body.html } : {}),
inReplyTo: original.messageId ?? original.id,
messageId: sendResult.messageId,
references,
Expand Down