From 196e51e91be2eee2520cfb02ee802926f673b96f Mon Sep 17 00:00:00 2001 From: Phongsaton Untan Date: Sat, 30 May 2026 07:19:38 +0700 Subject: [PATCH] fix: preserve HTML signature when sending a quick reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick-reply box built its body with appendPlainTextSignature, which runs the identity's HTML signature through htmlToPlainText, and sent a text-only message (htmlBody was undefined). A formatted signature (e.g. …) was therefore flattened to plain text in the sent mail, even though it previewed correctly in the identity editor. The full composer already builds an HTML signature block; quick reply did not. Add an appendHtmlSignature helper (mirrors the composer's send-time block) and, when the sending identity has an HTML signature, send a matching HTML body from handleQuickReply so the markup is preserved. Text-only identities keep the plain-text-only behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(main)/[locale]/page.tsx | 22 +++++++++++++++----- lib/__tests__/signature-utils.test.ts | 22 ++++++++++++++++++++ lib/signature-utils.ts | 29 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index ae57e8ec..dfaa9a20 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -55,7 +55,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; -import { appendPlainTextSignature } from "@/lib/signature-utils"; +import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { resolveReplyFrom } from "@/lib/reply-identity"; @@ -2090,9 +2090,21 @@ export default function Home() { // Append signature from the sending identity (fall back to primary // when the reply-from lives on the same identity but a different alias). - const finalBody = appendPlainTextSignature(body, sendingIdentity, { - separator: useSettingsStore.getState().signatureSeparatorEnabled, - }); + const separator = useSettingsStore.getState().signatureSeparatorEnabled; + const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator }); + + // When the identity has an HTML signature, send a matching HTML body so the + // signature keeps its formatting; appendPlainTextSignature would otherwise + // flatten it to plain text. Text-only identities keep the plain-text-only + // behavior (htmlBody stays undefined). + const escapedBody = body + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + const finalHtmlBody = sendingIdentity?.htmlSignature?.trim() + ? appendHtmlSignature(`
${escapedBody}
`, sendingIdentity, { separator }) + : undefined; const originalEmailId = selectedEmail.id; const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; @@ -2124,7 +2136,7 @@ export default function Home() { headerFromEmail, undefined, headerFromName, - undefined, + finalHtmlBody, undefined, threading?.inReplyTo, threading?.references, diff --git a/lib/__tests__/signature-utils.test.ts b/lib/__tests__/signature-utils.test.ts index d2a95d61..27cb2d52 100644 --- a/lib/__tests__/signature-utils.test.ts +++ b/lib/__tests__/signature-utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + appendHtmlSignature, appendPlainTextSignature, getPlainTextSignature, hasMeaningfulHtmlBody, @@ -27,6 +28,27 @@ describe('signature-utils', () => { }); }); + describe('appendHtmlSignature', () => { + it('appends a sanitized html signature, preserving formatting', () => { + expect(appendHtmlSignature('
Hello
', { htmlSignature: 'Alice' })) + .toBe('
Hello


--
Alice'); + }); + + it('escapes and appends a text signature when no html signature exists', () => { + expect(appendHtmlSignature('
Hello
', { textSignature: 'Alice\nEng' })) + .toBe('
Hello


--
Alice
Eng'); + }); + + it('omits the separator marker when disabled', () => { + expect(appendHtmlSignature('
Hi
', { htmlSignature: 'A' }, { separator: false })) + .toBe('
Hi


A'); + }); + + it('leaves the body untouched when no signature exists', () => { + expect(appendHtmlSignature('
Hi
', {})).toBe('
Hi
'); + }); + }); + describe('hasMeaningfulHtmlBody', () => { it('prefers html bodies that preserve signature formatting', () => { expect(hasMeaningfulHtmlBody('
Hello

Alice

')).toBe(true); diff --git a/lib/signature-utils.ts b/lib/signature-utils.ts index f348124e..678fa461 100644 --- a/lib/signature-utils.ts +++ b/lib/signature-utils.ts @@ -124,6 +124,35 @@ export function appendPlainTextSignature( return `${body}${sep}${plainTextSignature}`; } +/** + * Append a signature to an HTML body, preserving rich formatting. Used by the + * quick-reply path so an HTML signature keeps its markup instead of being + * flattened to plain text. Mirrors the composer's send-time signature block + * (`buildSignatureHtml` in email-composer.tsx). + */ +export function appendHtmlSignature( + htmlBody: string, + signature?: SignatureSource | null, + options: { separator?: boolean } = {}, +): string { + const sep = options.separator === false ? '

' : '

--
'; + + if (signature?.htmlSignature?.trim()) { + return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`; + } + + if (signature?.textSignature?.trim()) { + const escaped = signature.textSignature + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + return `${htmlBody}${sep}${escaped}`; + } + + return htmlBody; +} + export function hasMeaningfulHtmlBody(html: string): boolean { if (!html.trim()) return false;