diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a769c0..a84f79e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package.json b/package.json index 57b3b81..62eaa45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hqbase", - "version": "0.1.15", + "version": "0.1.16", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", diff --git a/test/unit/worker/features/send/reply-body.test.ts b/test/unit/worker/features/send/reply-body.test.ts new file mode 100644 index 0000000..13c4e86 --- /dev/null +++ b/test/unit/worker/features/send/reply-body.test.ts @@ -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 & more" +}; + +describe("reply body", () => { + it("appends conventional plain-text and Gmail-compatible HTML quotes", () => { + const body = buildReplyBody({ html: "

Thanks

", text: "Thanks" }, original); + + expect(body.text).toBe( + [ + "Thanks", + "", + "On 2026-07-28 at 15:29 UTC, owner@example.com wrote:", + "> First line", + ">", + "> Second & 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 <line> & more"); + expect(body.html).not.toContain("Second "); + }); + + 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(); + }); +}); diff --git a/test/unit/worker/features/send/send-service.test.ts b/test/unit/worker/features/send/send-service.test.ts index 422cddb..af5e41f 100644 --- a/test/unit/worker/features/send/send-service.test.ts +++ b/test/unit/worker/features/send/send-service.test.ts @@ -124,11 +124,15 @@ describe("send service", () => { bcc: ["audit@example.com"], cc: ["manager@example.com"], from: mailbox.address, + html: "

Reply

", 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 = + '

Reply


On 2026-07-10 at 00:00 UTC, owner@example.com wrote:
Original
'; expect(send).toHaveBeenCalledWith({ from: mailbox.address, bcc: ["audit@example.com"], @@ -137,8 +141,9 @@ describe("send service", () => { "In-Reply-To": "", References: " " }, + html: quotedHtml, subject: "Re: Hello", - text: "Reply", + text: quotedText, to: ["alternate@example.com"] }); expect(insertMessage).toHaveBeenCalledWith( @@ -146,9 +151,14 @@ describe("send service", () => { expect.objectContaining({ bcc: ["audit@example.com"], cc: ["manager@example.com"], + htmlR2Key: "sent/2026-07-10/html-1.html", messageId: "", + textBody: quotedText, to: ["alternate@example.com"] }) ); + expect(put).toHaveBeenCalledWith("sent/2026-07-10/html-1.html", quotedHtml, { + httpMetadata: { contentType: "text/html; charset=utf-8" } + }); }); }); diff --git a/worker/features/send/reply-body.ts b/worker/features/send/reply-body.ts new file mode 100644 index 0000000..c7df2cd --- /dev/null +++ b/worker/features/send/reply-body.ts @@ -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", "
"); + return [ + '
', + `

${escapeHtml(attribution)}
`, + '
', + quotedHtml, + "
", + "
" + ].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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/worker/features/send/service.ts b/worker/features/send/service.ts index 2687569..b97e9c2 100644 --- a/worker/features/send/service.ts +++ b/worker/features/send/service.ts @@ -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( @@ -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({ @@ -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) } : {}) }); @@ -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,