From 0e801edcf2f5763d9f5be1ae3eea23f72fb21c3b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:06:02 +0100 Subject: [PATCH] fix: save a rich text draft as an HTML template and edit it in the rich editor #1506 --- .../composer-template-reply.test.tsx | 58 ++++++++++++++++- components/email/email-composer.tsx | 12 ++-- .../__tests__/template-form-html.test.tsx | 64 +++++++++++++++++++ components/templates/template-form.tsx | 64 +++++++++++++++---- 4 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 components/templates/__tests__/template-form-html.test.tsx diff --git a/components/email/__tests__/composer-template-reply.test.tsx b/components/email/__tests__/composer-template-reply.test.tsx index 44ee3ff12..db74b7ee8 100644 --- a/components/email/__tests__/composer-template-reply.test.tsx +++ b/components/email/__tests__/composer-template-reply.test.tsx @@ -29,7 +29,14 @@ vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record) => void }) => React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'), })); -vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +// Save as template: what the composer hands the form. +const savedForm = vi.hoisted(() => ({ initialData: null as null | { body?: string; isHTML?: boolean } })); +vi.mock('@/components/templates/template-form', () => ({ + TemplateForm: (props: { initialData?: { body?: string; isHTML?: boolean } }) => { + savedForm.initialData = props.initialData ?? null; + return null; + }, +})); vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }), @@ -274,4 +281,53 @@ describe('applying a template to a reply (#911)', () => { expect(textarea.value).toBe('Hello Mr. XDear reader\n\nCheers, Me\n\nreply_line\n> Original message'); }); + + // An HTML template goes into a plain text draft as its text (#1506). + it('puts an HTML template into a plain text reply as text, not markup', async () => { + useSettingsStore.setState({ plainTextMode: true }); + Object.assign(TEMPLATE, { body: '

Dear reader

', isHTML: true }); + try { + render(); + const textarea = await waitFor(() => { + const el = document.querySelector('textarea'); + expect(el).toBeTruthy(); + return el as HTMLTextAreaElement; + }); + + await applyTemplate(); + + expect(textarea.value).toContain('Dear reader'); + expect(textarea.value).not.toContain('<'); + } finally { + Object.assign(TEMPLATE, { body: 'Dear reader', isHTML: false }); + } + }); + + describe('saving the draft as a template (#1506)', () => { + afterEach(() => { + savedForm.initialData = null; + }); + + async function openSaveAsTemplate() { + fireEvent.click(await screen.findByTestId('composer-templates-menu')); + fireEvent.click(await screen.findByRole('menuitem', { name: 'save_as_template' })); + await waitFor(() => expect(savedForm.initialData).not.toBeNull()); + return savedForm.initialData!; + } + + it('marks a rich text draft as HTML', async () => { + await openReply(); + const data = await openSaveAsTemplate(); + expect(data.body).toContain('<'); + expect(data.isHTML).toBe(true); + }); + + it('leaves a plain text draft as plain text', async () => { + useSettingsStore.setState({ plainTextMode: true }); + render(); + await waitFor(() => expect(document.querySelector('textarea')).toBeTruthy()); + const data = await openSaveAsTemplate(); + expect(data.isHTML).toBe(false); + }); + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0e18405ba..60077f6e6 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2168,10 +2168,13 @@ export function EmailComposer({ ? substitutePlaceholders(template.body, filledValues) : template.body; - // In plain text mode, use template body as-is; otherwise convert to HTML - let bodyContent = plainTextMode || template.isHTML - ? filledBody - : `

${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

`; + // Match the template to the draft's format: an HTML template goes into a + // plain-text draft as its text, not its markup (#1506). + let bodyContent = plainTextMode + ? (template.isHTML ? htmlToPlainText(filledBody) : filledBody) + : template.isHTML + ? filledBody + : `

${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}

`; // Plugins may rewrite the template before it goes in (onTemplateRender). const rendered = await renderTemplateWithPlugins(template.id, { subject: filledSubject, @@ -5299,6 +5302,7 @@ export function EmailComposer({ initialData={{ subject, body, + isHTML: !plainTextMode, to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)), cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)), bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)), diff --git a/components/templates/__tests__/template-form-html.test.tsx b/components/templates/__tests__/template-form-html.test.tsx new file mode 100644 index 000000000..01fa81bed --- /dev/null +++ b/components/templates/__tests__/template-form-html.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { TemplateForm } from '../template-form'; + +// A template saved from a rich text draft is HTML: the form shows it in the +// composer's editor with HTML ticked, never as source in a text box (#1506). + +const draftHtml = '

Hello team

See you

'; + +function editingArea() { + return document.querySelector('[data-testid="template-body-editor"] .ProseMirror') as HTMLElement | null; +} + +describe('TemplateForm body format (#1506)', () => { + it('opens a rich text draft in the rich editor, with HTML ticked', async () => { + const onSave = vi.fn(); + render(); + + await waitFor(() => expect(editingArea()).toBeTruthy()); + expect(editingArea()!.textContent).toContain('Hello team'); + expect(document.querySelector('textarea')).toBeNull(); + expect(screen.getByRole('button', { name: 'HTML' }).getAttribute('aria-pressed')).toBe('true'); + + fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'Weekly' } }); + fireEvent.click(screen.getByRole('button', { name: 'create' })); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ isHTML: true, body: expect.stringContaining('team') })); + }); + + it('keeps a plain text draft in the text box', () => { + render(); + expect((document.querySelector('textarea') as HTMLTextAreaElement).value).toBe('Hello team'); + expect(editingArea()).toBeNull(); + }); + + it('converts the body when HTML is switched on and off', async () => { + const onSave = vi.fn(); + render(); + fireEvent.change(document.querySelector('textarea')!, { target: { value: 'Hi \n\nBye' } }); + + fireEvent.click(screen.getByRole('button', { name: 'HTML' })); + await waitFor(() => expect(editingArea()).toBeTruthy()); + // The text is shown as text: the angle brackets were escaped, not parsed. + expect(editingArea()!.textContent).toContain('Hi '); + expect(editingArea()!.querySelectorAll('p').length).toBe(2); + + fireEvent.click(screen.getByRole('button', { name: 'HTML' })); + const textarea = document.querySelector('textarea') as HTMLTextAreaElement; + expect(textarea.value).not.toContain('

'); + expect(textarea.value).toContain('Hi '); + expect(textarea.value).toContain('Bye'); + }); + + it('keeps an existing HTML template in the rich editor when edited', async () => { + render( + , + ); + await waitFor(() => expect(editingArea()).toBeTruthy()); + expect(document.querySelector('textarea')).toBeNull(); + }); +}); diff --git a/components/templates/template-form.tsx b/components/templates/template-form.tsx index ef56cd4ab..5109d4a2e 100644 --- a/components/templates/template-form.tsx +++ b/components/templates/template-form.tsx @@ -13,6 +13,10 @@ import { BUILT_IN_PLACEHOLDERS } from '@/lib/template-types'; import type { EmailTemplate } from '@/lib/template-types'; import { useTemplateStore } from '@/stores/template-store'; import { useAuthStore } from '@/stores/auth-store'; +import { RichTextEditor } from '@/components/email/rich-text-editor'; +import { plainTextToComposerBody } from '@/lib/email-composer-utils'; +import { htmlToPlainText } from '@/lib/html-to-text'; +import type { Editor } from '@tiptap/react'; interface TemplateFormProps { @@ -20,6 +24,8 @@ interface TemplateFormProps { initialData?: { subject?: string; body?: string; + /** The body is the composer's HTML, not plain text (#1506). */ + isHTML?: boolean; to?: string[]; cc?: string[]; bcc?: string[]; @@ -55,7 +61,9 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared = const [category, setCategory] = useState(template?.category || ''); const [subject, setSubject] = useState(template?.subject || initialData?.subject || ''); const [body, setBody] = useState(template?.body || initialData?.body || ''); - const [isHTML, setIsHTML] = useState(template?.isHTML || false); + const [isHTML, setIsHTML] = useState(template ? !!template.isHTML : !!initialData?.isHTML); + // The rich editor, while the body is HTML: placeholders go in at its caret. + const editorRef = useRef(null); const [toRecipients, setToRecipients] = useState( template?.defaultRecipients?.to?.join(', ') || initialData?.to?.join(', ') || '' ); @@ -120,9 +128,26 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared = const tag = `{{${placeholder}}}`; if (field === 'subject') { setSubject((prev) => prev + tag); - } else { - setBody((prev) => prev + tag); + return; } + const editor = editorRef.current; + if (isHTML && editor && !editor.isDestroyed) { + editor.chain().focus().insertContent(tag).run(); + return; + } + setBody((prev) => prev + tag); + }; + + // Switching the body between plain text and HTML converts what is there, + // so neither editor ever shows the other format's source (#1506). + const toggleHTML = () => { + if (isHTML) { + editorRef.current = null; + setBody((prev) => htmlToPlainText(prev, { paragraphSpacing: true })); + } else { + setBody((prev) => plainTextToComposerBody(prev)); + } + setIsHTML(!isHTML); }; const placeholderButton = (field: 'subject' | 'body') => ( @@ -195,20 +220,33 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =

- + {placeholderButton('body')}
-