fix: preserve HTML signature when sending a quick reply

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. <strong>…) 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) <noreply@anthropic.com>
This commit is contained in:
Phongsaton Untan
2026-05-30 15:12:30 +02:00
committed by Linus Rath
co-authored by Claude Opus 4.8
parent 0879030dc8
commit 196e51e91b
3 changed files with 68 additions and 5 deletions
+17 -5
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
? appendHtmlSignature(`<div>${escapedBody}</div>`, 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,
+22
View File
@@ -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('<div>Hello</div>', { htmlSignature: '<strong>Alice</strong>' }))
.toBe('<div>Hello</div><br><br>-- <br><strong>Alice</strong>');
});
it('escapes and appends a text signature when no html signature exists', () => {
expect(appendHtmlSignature('<div>Hello</div>', { textSignature: 'Alice\nEng' }))
.toBe('<div>Hello</div><br><br>-- <br>Alice<br>Eng');
});
it('omits the separator marker when disabled', () => {
expect(appendHtmlSignature('<div>Hi</div>', { htmlSignature: '<strong>A</strong>' }, { separator: false }))
.toBe('<div>Hi</div><br><br><strong>A</strong>');
});
it('leaves the body untouched when no signature exists', () => {
expect(appendHtmlSignature('<div>Hi</div>', {})).toBe('<div>Hi</div>');
});
});
describe('hasMeaningfulHtmlBody', () => {
it('prefers html bodies that preserve signature formatting', () => {
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
+29
View File
@@ -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 ? '<br><br>' : '<br><br>-- <br>';
if (signature?.htmlSignature?.trim()) {
return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;
}
if (signature?.textSignature?.trim()) {
const escaped = signature.textSignature
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return `${htmlBody}${sep}${escaped}`;
}
return htmlBody;
}
export function hasMeaningfulHtmlBody(html: string): boolean {
if (!html.trim()) return false;