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:
committed by
Linus Rath
co-authored by
Claude Opus 4.8
parent
0879030dc8
commit
196e51e91b
@@ -55,7 +55,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { isFilePreviewable } from "@/lib/file-preview";
|
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 { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||||
import { resolveReplyFrom } from "@/lib/reply-identity";
|
import { resolveReplyFrom } from "@/lib/reply-identity";
|
||||||
@@ -2090,9 +2090,21 @@ export default function Home() {
|
|||||||
|
|
||||||
// Append signature from the sending identity (fall back to primary
|
// Append signature from the sending identity (fall back to primary
|
||||||
// when the reply-from lives on the same identity but a different alias).
|
// when the reply-from lives on the same identity but a different alias).
|
||||||
const finalBody = appendPlainTextSignature(body, sendingIdentity, {
|
const separator = useSettingsStore.getState().signatureSeparatorEnabled;
|
||||||
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(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
const finalHtmlBody = sendingIdentity?.htmlSignature?.trim()
|
||||||
|
? appendHtmlSignature(`<div>${escapedBody}</div>`, sendingIdentity, { separator })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const originalEmailId = selectedEmail.id;
|
const originalEmailId = selectedEmail.id;
|
||||||
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds;
|
||||||
@@ -2124,7 +2136,7 @@ export default function Home() {
|
|||||||
headerFromEmail,
|
headerFromEmail,
|
||||||
undefined,
|
undefined,
|
||||||
headerFromName,
|
headerFromName,
|
||||||
undefined,
|
finalHtmlBody,
|
||||||
undefined,
|
undefined,
|
||||||
threading?.inReplyTo,
|
threading?.inReplyTo,
|
||||||
threading?.references,
|
threading?.references,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
appendHtmlSignature,
|
||||||
appendPlainTextSignature,
|
appendPlainTextSignature,
|
||||||
getPlainTextSignature,
|
getPlainTextSignature,
|
||||||
hasMeaningfulHtmlBody,
|
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', () => {
|
describe('hasMeaningfulHtmlBody', () => {
|
||||||
it('prefers html bodies that preserve signature formatting', () => {
|
it('prefers html bodies that preserve signature formatting', () => {
|
||||||
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
|
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
|
||||||
|
|||||||
@@ -124,6 +124,35 @@ export function appendPlainTextSignature(
|
|||||||
return `${body}${sep}${plainTextSignature}`;
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
return `${htmlBody}${sep}${escaped}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlBody;
|
||||||
|
}
|
||||||
|
|
||||||
export function hasMeaningfulHtmlBody(html: string): boolean {
|
export function hasMeaningfulHtmlBody(html: string): boolean {
|
||||||
if (!html.trim()) return false;
|
if (!html.trim()) return false;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user