From 83026fe621ddc1f80e8f2ff0a8850d1ec3416d46 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:20:49 +0200 Subject: [PATCH] fix: keep already written text when applying a template in compose #540 --- components/email/email-composer.tsx | 40 ++++++++++++++++++++++----- lib/__tests__/signature-utils.test.ts | 31 +++++++++++++++++++++ lib/__tests__/template-utils.test.ts | 37 +++++++++++++++++++++++++ lib/signature-utils.ts | 23 +++++++++++++++ lib/template-utils.ts | 38 +++++++++++++++++++++++-- 5 files changed, 160 insertions(+), 9 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 3396e106e..d808aff6d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -31,11 +31,11 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from " import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { generateSubAddress } from "@/lib/sub-addressing"; -import { substitutePlaceholders, spliceTemplateAboveSignature } from "@/lib/template-utils"; +import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils"; import { TemplatePicker } from "@/components/templates/template-picker"; import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; -import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature } from "@/lib/signature-utils"; +import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { findComposeIdentityId, findDraftIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; @@ -1199,18 +1199,44 @@ export function EmailComposer({ : `
${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
--
Hello Mr.
${signature}`)).toBe(true); + }); + + it('is true for a body without a signature block', () => { + expect(composeBodyHasUserContent('Hello Mr.
')).toBe(true); + }); + + it('ignores signature text so a signature-only body stays untouched', () => { + expect(composeBodyHasUserContent(`${signature}`)).toBe(false); + expect(composeBodyHasUserContent(`Hello Mr.
${openEnded}`)).toBe(true); + }); +}); diff --git a/lib/signature-utils.ts b/lib/signature-utils.ts index 330fe45f7..180ae7854 100644 --- a/lib/signature-utils.ts +++ b/lib/signature-utils.ts @@ -61,6 +61,29 @@ export function plainTextBodyHasSignature( return normalizeSignatureLineBreaks(body).endsWith(plainTextSignature); } +/** + * The plain-text body with a trailing signature - and the `-- ` separator + * line in front of it - removed. Returns the body unchanged when it does not + * end with the identity's signature. Used to tell an untouched compose body + * (signature only) from one the user has already written into (#540). + */ +export function plainTextBodyWithoutSignature( + body: string, + signature?: SignatureSource | null, +): string { + const plainTextSignature = getPlainTextSignature(signature); + if (!plainTextSignature) { + return body; + } + const normalized = normalizeSignatureLineBreaks(body); + if (!normalized.endsWith(plainTextSignature)) { + return body; + } + return normalized + .slice(0, normalized.length - plainTextSignature.length) + .replace(/\n*(?:-- ?)?\n*$/, ''); +} + /** * 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 diff --git a/lib/template-utils.ts b/lib/template-utils.ts index a4ca6f32d..114fa84b2 100644 --- a/lib/template-utils.ts +++ b/lib/template-utils.ts @@ -80,6 +80,9 @@ export function filterTemplates(templates: EmailTemplate[], query: string): Emai ); } +const SIGNATURE_START_SELECTOR = '[data-signature-block="separator"], [data-signature-block="start"]'; +const SIGNATURE_END_SELECTOR = '[data-signature-block="end"]'; + // Compose bodies carry the embedded signature bracketed by // data-signature-block markers (see email-composer's // buildEmbeddedSignatureHtml). Applying a template must replace only the @@ -87,9 +90,9 @@ export function filterTemplates(templates: EmailTemplate[], query: string): Emai // of overwriting the whole body. export function spliceTemplateAboveSignature(prevHtml: string, templateHtml: string): string { const doc = new DOMParser().parseFromString(prevHtml, 'text/html'); - const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]'); + const startEl = doc.querySelector(SIGNATURE_START_SELECTOR); if (!startEl) return templateHtml; - const endEl = doc.querySelector('[data-signature-block="end"]'); + const endEl = doc.querySelector(SIGNATURE_END_SELECTOR); const host = doc.createElement('div'); let cursor: Node | null = startEl; while (cursor) { @@ -100,6 +103,37 @@ export function spliceTemplateAboveSignature(prevHtml: string, templateHtml: str return templateHtml + host.innerHTML; } +/** + * Whether a compose body holds anything the user wrote above the embedded + * signature. A fresh compose body is just an empty paragraph plus the + * signature range, so this stays false until the user types something - + * the composer then inserts the template at the caret rather than splicing + * it over the draft (#540). + */ +export function composeBodyHasUserContent(prevHtml: string): boolean { + if (!prevHtml.trim()) return false; + + const doc = new DOMParser().parseFromString(prevHtml, 'text/html'); + const startEl = doc.querySelector(SIGNATURE_START_SELECTOR); + if (startEl) { + // Drop the signature range [start … end] inclusive, mirroring the + // traversal spliceTemplateAboveSignature uses to keep it. + const endEl = doc.querySelector(SIGNATURE_END_SELECTOR); + let cursor: Node | null = startEl; + while (cursor) { + const next: Node | null = cursor.nextSibling; + const isEnd = cursor === endEl; + cursor.parentNode?.removeChild(cursor); + if (isEnd) break; + cursor = next; + } + } + + if (doc.body.textContent?.trim()) return true; + // Text-free content still counts as a draft worth keeping. + return doc.body.querySelector('img, table, hr, [data-quoted-html]') !== null; +} + function sanitizeText(value: unknown): string { return DOMPurify.sanitize(String(value || ''), STRIP_HTML_CONFIG); }