From 1fa723bd1963c65a6de25a5f0b3919b4255915d5 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Sat, 22 Aug 2026 11:38:07 -0500 Subject: [PATCH 1/2] feat: tabs and a rendered preview for the email templates (#119) Follow-up to #92, which shipped the editable templates as a column of stacked cards. With six templates the cart reminder sat below five editors, so reaching it meant scrolling past all of them and which one you were editing was knowable only from a card title you had already scrolled past. They are tabs now, and the Default/Customised tag moves onto the tab label, so which templates have been changed is visible without opening each one. The larger gap was that there was no way to see what the email would look like. The editor is a markdown textarea; what gets sent is rendered HTML with placeholders substituted and, for the two favorite templates, a consent footer appended by the server. An admin editing copy could not tell whether the result read correctly. POST /api/admin/email-templates/:key/preview renders the draft in the editor rather than what is stored, so the effect of an edit is visible before committing to it. It renders on the server deliberately: renderTemplate is the only thing in the system that turns this markdown into HTML, and markdown-it is configured there with html: false, which is the control that stops an admin putting script into a customer's inbox. A renderer in the browser would be a second implementation of both, and a preview that disagreed with the mailer would be worse than none. It does not enforce required placeholders - saving refuses a body that dropped one, and previewing it is how the admin sees what they have done. The preview renders into a sandboxed iframe rather than through dangerouslySetInnerHTML. The markup is safe by construction, but an email is its own styling context: rendered inline, the admin theme's CSS would change how it looks and the preview would lie about the result. Sample values live beside the template definitions rather than in the route, so adding a placeholder puts the missing sample next to the change that needs it. A unit test asserts every available placeholder has one, because a missing sample renders a literal {{placeholder}} into the preview and teaches the admin their copy is broken when it is not. This also fixes a test that has been failing on main. email-templates.spec.ts located the Save button by filtering .ant-card for the template name, which matched an outer card containing every template's Save button - six of them - and died on a strict mode violation, taking two more tests with it as unrun. Only the active tab's editor is mounted now, so the labels are unambiguous and the filter is gone. Verification: eight end-to-end tests, four for editing and four for the preview, covering the draft being previewed rather than the stored copy, sample values replacing placeholders, raw HTML being escaped exactly as the mailer escapes it, and the consent footer appearing on a favorite template and not on a password reset. The full suite goes from 100 passed / 3 failed / 2 unrun to 112 passed / 2 failed / 0 unrun; the two that remain are the pre-existing password-reset failures that need a database on port 55432 and fail identically on main. 38 backend unit tests pass, tsc and ESLint are clean. Closes #119 Co-Authored-By: Claude Opus 5 --- backend/src/emailTemplates.ts | 24 +++ backend/src/routes/adminEmailTemplates.ts | 41 ++++- backend/tests/unit/emailTemplates.test.ts | 19 ++- frontend/src/admin/EmailTemplateCard.tsx | 115 -------------- frontend/src/admin/EmailTemplateEditor.tsx | 170 +++++++++++++++++++++ frontend/src/admin/Settings.tsx | 32 +++- frontend/src/admin/emailTemplatesApi.ts | 25 +++ frontend/tests/e2e/email-templates.spec.ts | 94 ++++++++++-- 8 files changed, 386 insertions(+), 134 deletions(-) delete mode 100644 frontend/src/admin/EmailTemplateCard.tsx create mode 100644 frontend/src/admin/EmailTemplateEditor.tsx diff --git a/backend/src/emailTemplates.ts b/backend/src/emailTemplates.ts index 60e1f12..59e6945 100644 --- a/backend/src/emailTemplates.ts +++ b/backend/src/emailTemplates.ts @@ -160,6 +160,30 @@ function substitute(text: string, values: Record): string { ); } +/** + * Representative values for every placeholder any template accepts, used to + * render a preview in the admin. + * + * Kept here beside the definitions rather than in the route, so that adding a + * placeholder to a template puts the missing sample right next to the change + * that needs it. A unit test asserts every `available` name has an entry, since + * a missing one would render the preview with a literal {{placeholder}} in it + * and quietly teach the admin that their copy is broken when it is not. + * + * itemList is markdown because values are substituted into the markdown source + * before rendering, which is the same reason the real caller supplies markdown. + */ +export const SAMPLE_VALUES: Record = { + greeting: 'Hi Ada,', + verifyUrl: 'https://example.com/verify-email?token=sample-token', + resetUrl: 'https://example.com/reset-password?token=sample-token', + itemName: 'Walnut sideboard', + siteUrl: 'https://example.com', + newEmail: 'new.address@example.com', + itemList: '- Walnut sideboard\n- Brass table lamp', + cartUrl: 'https://example.com/cart' +}; + export interface StoredTemplate { subject?: string | null; body?: string | null; diff --git a/backend/src/routes/adminEmailTemplates.ts b/backend/src/routes/adminEmailTemplates.ts index e239e84..4b06c3e 100644 --- a/backend/src/routes/adminEmailTemplates.ts +++ b/backend/src/routes/adminEmailTemplates.ts @@ -1,7 +1,14 @@ import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; -import { TEMPLATES, TemplateKey, StoredTemplate, missingPlaceholders } from '../emailTemplates'; +import { + TEMPLATES, + TemplateKey, + StoredTemplate, + missingPlaceholders, + renderTemplate, + SAMPLE_VALUES +} from '../emailTemplates'; const router = Router(); @@ -53,6 +60,38 @@ router.get('/', asyncRoute(async (_req: Request, res: Response) => { ); })); +// Renders what an email would look like, from the subject and body in the +// editor rather than from what is stored — so an admin sees the effect of an +// edit before committing to it. +// +// Rendered here rather than in the browser, deliberately. renderTemplate is the +// only thing that turns this markdown into HTML, and markdown-it is configured +// with html: false, which is what stops an admin putting script into a +// customer's inbox. A second renderer in the frontend would be a second place +// for that setting to be wrong, and a preview that differs from the mailer is +// worse than no preview. +// +// Deliberately does not enforce required placeholders. Saving refuses a body +// that dropped one; previewing it is how an admin sees what they have done. +router.post('/:key/preview', asyncRoute(async (req: Request, res: Response) => { + const key = req.params.key; + if (!isTemplateKey(key)) { + return res.status(404).json({ error: 'unknown template' }); + } + + const { subject, body } = req.body ?? {}; + const rendered = renderTemplate( + key, + { + subject: typeof subject === 'string' ? subject : null, + body: typeof body === 'string' ? body : null + }, + SAMPLE_VALUES + ); + + res.json(rendered); +})); + router.put('/:key', asyncRoute(async (req: Request, res: Response) => { const key = req.params.key; if (!isTemplateKey(key)) { diff --git a/backend/tests/unit/emailTemplates.test.ts b/backend/tests/unit/emailTemplates.test.ts index 92d5735..c108bf2 100644 --- a/backend/tests/unit/emailTemplates.test.ts +++ b/backend/tests/unit/emailTemplates.test.ts @@ -2,7 +2,8 @@ import { TEMPLATES, TemplateKey, missingPlaceholders, - renderTemplate + renderTemplate, + SAMPLE_VALUES } from '../../src/emailTemplates'; const KEYS: TemplateKey[] = [ @@ -147,3 +148,19 @@ describe('renderTemplate', () => { expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/); }); }); + +describe('SAMPLE_VALUES, which the admin preview renders with', () => { + // A missing sample renders the preview with a literal {{placeholder}} in it, + // which teaches the admin their copy is broken when it is not. Adding a + // placeholder to a template must mean adding a sample for it. + it.each(KEYS)('%s has a sample for every placeholder it accepts', (key) => { + const missing = TEMPLATES[key].available.filter((name) => !(name in SAMPLE_VALUES)); + expect(missing).toEqual([]); + }); + + it.each(KEYS)('%s previews with no placeholder left unsubstituted', (key) => { + const { html, subject } = renderTemplate(key, {}, SAMPLE_VALUES); + expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/); + expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/); + }); +}); diff --git a/frontend/src/admin/EmailTemplateCard.tsx b/frontend/src/admin/EmailTemplateCard.tsx deleted file mode 100644 index e1a1992..0000000 --- a/frontend/src/admin/EmailTemplateCard.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from 'react'; -import Card from 'antd/es/card'; -import Input from 'antd/es/input'; -import Button from 'antd/es/button'; -import Space from 'antd/es/space'; -import Tag from 'antd/es/tag'; -import Typography from 'antd/es/typography'; -import message from 'antd/es/message'; -import { EmailTemplate, saveEmailTemplate, resetEmailTemplate } from './emailTemplatesApi'; - -const { Text, Paragraph } = Typography; - -type Props = Readonly<{ - template: EmailTemplate; - onChanged: (updated: EmailTemplate) => void; -}>; - -export default function EmailTemplateCard({ template, onChanged }: Props) { - // Falls back to the default so the editor starts from the real copy rather - // than an empty box. `subject`/`body` being null means "never customised", - // which is why the badge below can say so. - const [subject, setSubject] = useState(template.subject ?? template.defaultSubject); - const [body, setBody] = useState(template.body ?? template.defaultBody); - const [saving, setSaving] = useState(false); - - const customised = template.subject !== null || template.body !== null; - - async function handleSave() { - setSaving(true); - try { - const updated = await saveEmailTemplate(template.key, subject, body); - onChanged(updated); - message.success(`${template.label} saved`); - } catch (err) { - // The server's refusal names the placeholder that is missing, which is - // the only useful thing to say here — so it is shown rather than replaced - // with something generic. - message.error((err as Error).message); - } finally { - setSaving(false); - } - } - - async function handleReset() { - setSaving(true); - try { - const restored = await resetEmailTemplate(template.key); - setSubject(template.defaultSubject); - setBody(template.defaultBody); - onChanged(restored); - message.success(`${template.label} restored to the default`); - } catch (err) { - message.error((err as Error).message); - } finally { - setSaving(false); - } - } - - return ( - - {template.label} - {customised ? Customised : Default} - - } - > - - Markdown. Placeholders are replaced when the email is sent - {template.required.length > 0 && ( - <> - {' '}— {template.required.map((n) => `{{${n}}}`).join(' and ')}{' '} - {template.required.length === 1 ? 'is' : 'are'} required and cannot be removed - - )} - . - - - - {template.available.map((name) => ( - {`{{${name}}}`} - ))} - - - setSubject(e.target.value)} - placeholder="Subject" - style={{ marginBottom: 8 }} - /> - setBody(e.target.value)} - autoSize={{ minRows: 6, maxRows: 16 }} - style={{ fontFamily: 'monospace', marginBottom: 12 }} - /> - - - - {/* Only offered when there is something to restore, so the button is - not a no-op that looks like it did something. */} - {customised && ( - - )} - - - ); -} diff --git a/frontend/src/admin/EmailTemplateEditor.tsx b/frontend/src/admin/EmailTemplateEditor.tsx new file mode 100644 index 0000000..8b25cda --- /dev/null +++ b/frontend/src/admin/EmailTemplateEditor.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; +import Input from 'antd/es/input'; +import Button from 'antd/es/button'; +import Space from 'antd/es/space'; +import Tag from 'antd/es/tag'; +import Row from 'antd/es/row'; +import Col from 'antd/es/col'; +import Alert from 'antd/es/alert'; +import Typography from 'antd/es/typography'; +import message from 'antd/es/message'; +import { + EmailTemplate, + saveEmailTemplate, + resetEmailTemplate, + previewEmailTemplate +} from './emailTemplatesApi'; + +const { Text, Paragraph } = Typography; + +type Props = Readonly<{ + template: EmailTemplate; + onChanged: (updated: EmailTemplate) => void; +}>; + +// Long enough that the preview is not re-rendered on every keystroke, short +// enough that it feels like it is following what you type. +const PREVIEW_DEBOUNCE_MS = 400; + +export default function EmailTemplateEditor({ template, onChanged }: Props) { + // Falls back to the default so the editor starts from the real copy rather + // than an empty box. `subject`/`body` being null means "never customised", + // which is why the badge on the tab can say so. + const [subject, setSubject] = useState(template.subject ?? template.defaultSubject); + const [body, setBody] = useState(template.body ?? template.defaultBody); + const [saving, setSaving] = useState(false); + const [preview, setPreview] = useState<{ subject: string; html: string } | null>(null); + const [previewError, setPreviewError] = useState(null); + + const customised = template.subject !== null || template.body !== null; + + // Previews the draft in the editor, not what is stored, so the effect of an + // edit is visible before committing to it. Debounced, and the timer is + // cleared on change so an abandoned keystroke never issues a request. + useEffect(() => { + const timer = setTimeout(() => { + previewEmailTemplate(template.key, subject, body) + .then((rendered) => { + setPreview(rendered); + setPreviewError(null); + }) + .catch((err: Error) => setPreviewError(err.message)); + }, PREVIEW_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [template.key, subject, body]); + + async function handleSave() { + setSaving(true); + try { + const updated = await saveEmailTemplate(template.key, subject, body); + onChanged(updated); + message.success(`${template.label} saved`); + } catch (err) { + // The server's refusal names the placeholder that is missing, which is + // the only useful thing to say here — so it is shown rather than replaced + // with something generic. + message.error((err as Error).message); + } finally { + setSaving(false); + } + } + + async function handleReset() { + setSaving(true); + try { + const restored = await resetEmailTemplate(template.key); + setSubject(template.defaultSubject); + setBody(template.defaultBody); + onChanged(restored); + message.success(`${template.label} restored to the default`); + } catch (err) { + message.error((err as Error).message); + } finally { + setSaving(false); + } + } + + return ( + + + + Markdown. Placeholders are replaced when the email is sent + {template.required.length > 0 && ( + <> + {' '}— {template.required.map((n) => `{{${n}}}`).join(' and ')}{' '} + {template.required.length === 1 ? 'is' : 'are'} required and cannot be removed + + )} + . + + + + {template.available.map((name) => ( + {`{{${name}}}`} + ))} + + + setSubject(e.target.value)} + placeholder="Subject" + style={{ marginBottom: 8 }} + /> + setBody(e.target.value)} + autoSize={{ minRows: 10, maxRows: 24 }} + style={{ fontFamily: 'monospace', marginBottom: 12 }} + /> + + + + {/* Only offered when there is something to restore, so the button is + not a no-op that looks like it did something. */} + {customised && ( + + )} + + + + + + Preview, with sample values in place of the placeholders. + + {previewError && ( + + )} +
+
+ Subject: + {preview?.subject ?? ''} +
+ {/* An iframe rather than dangerouslySetInnerHTML. The markup is safe + by construction — the server renders it with markdown-it's raw HTML + disabled — but an email is its own styling context, and rendering + it inline would let the admin theme's CSS change how it looks and + so make the preview lie. sandbox with no allow-* keeps script + inert even if that guarantee ever slipped. */} +