From 0d2fdc44f417da14902369ed19b1cff47e8b81f4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:25:25 +0100 Subject: [PATCH] fix: keep angle brackets and ampersands in plain-text templates and placeholder values --- components/email/email-composer.tsx | 2 +- .../templates/placeholder-fill-modal.tsx | 4 +- lib/__tests__/template-utils.test.ts | 44 +++++++++++++++---- lib/template-utils.ts | 18 +++++--- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 60077f6e6..e2aaabb1b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2165,7 +2165,7 @@ export function EmailComposer({ ? substitutePlaceholders(template.subject, filledValues) : template.subject; const filledBody = Object.keys(filledValues).length > 0 - ? substitutePlaceholders(template.body, filledValues) + ? substitutePlaceholders(template.body, filledValues, { html: !!template.isHTML }) : template.body; // Match the template to the draft's format: an HTML template goes into a diff --git a/components/templates/placeholder-fill-modal.tsx b/components/templates/placeholder-fill-modal.tsx index 3dd5e8d2e..8cae3b2b8 100644 --- a/components/templates/placeholder-fill-modal.tsx +++ b/components/templates/placeholder-fill-modal.tsx @@ -42,8 +42,8 @@ export function PlaceholderFillModal({ const dirty = placeholders.some((p) => values[p] !== initialValues[p]); const preview = useMemo(() => { - return substitutePlaceholders(template.body, values); - }, [template.body, values]); + return substitutePlaceholders(template.body, values, { html: !!template.isHTML }); + }, [template.body, template.isHTML, values]); const requestClose = async () => { if (await confirmDiscard(dirty)) onClose(); diff --git a/lib/__tests__/template-utils.test.ts b/lib/__tests__/template-utils.test.ts index 05e677966..94149da89 100644 --- a/lib/__tests__/template-utils.test.ts +++ b/lib/__tests__/template-utils.test.ts @@ -10,6 +10,7 @@ import { filterTemplates, exportTemplates, importTemplates, + parseSyncedTemplates, spliceTemplateAboveSignature, composeBodyHasUserContent, embedTemplateImages, @@ -79,9 +80,18 @@ describe('substitutePlaceholders', () => { expect(substitutePlaceholders('{{known}} {{unknown}}', { known: 'yes' })).toBe('yes {{unknown}}'); }); - it('sanitizes XSS in values', () => { - const result = substitutePlaceholders('{{name}}', { name: '' }); - expect(result).not.toContain('' }, { html: true }); + expect(result).toBe('
<script>alert(1)</script>
'); + }); + + // Values are text: an address in angle brackets or an ampersand is kept. + it('keeps values as typed in a plain template', () => { + expect(substitutePlaceholders('To {{name}}', { name: 'Smith & Co{{name}}
', { name: 'SmithSmith <j@x.com>
'); }); it('handles empty values', () => { @@ -284,15 +294,33 @@ describe('importTemplates', () => { expect(result.errors).toContain('missing_template_name'); }); - it('sanitizes imported values against XSS', () => { + // Name, subject, category and a plain body are text, shown as text: markup + // in them is harmless and is kept as typed, not cut out or entity-encoded. + it('keeps angle brackets and ampersands in plain-text fields', () => { const json = JSON.stringify({ type: 'webmail-templates', version: 1, - templates: [{ name: '