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. */} +