import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; import { TEMPLATES, TemplateKey, StoredTemplate, missingPlaceholders, renderTemplate, formatDuration, greeting, SAMPLE_VALUES } from '../emailTemplates'; import { getSettings } from '../adminSettings'; /** A row of the admin_settings key/value store. */ interface SettingRow { key: string; value: string; } const router = Router(); const KEYS = Object.keys(TEMPLATES) as TemplateKey[]; // Stored in admin_settings rather than a table of their own: it is already a // key/value store with a settled read/write shape, and five templates is not a // schema. const settingKey = (key: TemplateKey, part: 'subject' | 'body') => `email_${key}_${part}`; function isTemplateKey(value: unknown): value is TemplateKey { return typeof value === 'string' && (KEYS as string[]).includes(value); } export async function loadStoredTemplate(key: TemplateKey): Promise { const { rows } = await pool.query(`SELECT key, value FROM admin_settings WHERE key = ANY($1)`, [ [settingKey(key, 'subject'), settingKey(key, 'body')] ]); const stored: StoredTemplate = {}; for (const row of rows) { if (row.key === settingKey(key, 'subject')) stored.subject = row.value; if (row.key === settingKey(key, 'body')) stored.body = row.value; } return stored; } // Returns the definitions alongside whatever is stored, so the admin screen can // show the placeholders a template accepts and which of them it must keep, // rather than the editor having to know. router.get('/', asyncRoute(async (_req: Request, res: Response) => { const { rows } = await pool.query( `SELECT key, value FROM admin_settings WHERE key LIKE 'email\\_%'` ); const stored = new Map(rows.map((r) => [r.key, r.value])); res.json( KEYS.map((key) => ({ key, label: TEMPLATES[key].label, required: TEMPLATES[key].required, available: TEMPLATES[key].available, defaultSubject: TEMPLATES[key].defaultSubject, defaultBody: TEMPLATES[key].defaultBody, // Null rather than the default, so the admin can tell "not customised" // from "customised to exactly the default text". subject: stored.get(settingKey(key, 'subject')) ?? null, body: stored.get(settingKey(key, 'body')) ?? null })) ); })); // 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. /** * The sample values, with the three duration placeholders replaced by what the * settings actually hold. * * The preview exists so an admin sees the email that will be sent. A duration * drawn from a static sample would show "one hour" while the setting said two, * which is the precise failure this placeholder was added to remove. */ async function previewValues(key: TemplateKey): Promise> { const { cartExpiryHours, verifyTokenHours, passwordResetHours, greetingFormat, greetingFallback } = await getSettings(); // `expiresIn` names one placeholder but two different lifetimes, so the value // depends on which template is being previewed. The route knows the key. const expiresIn = key === 'passwordReset' ? passwordResetHours : verifyTokenHours; return { ...SAMPLE_VALUES, holdDuration: formatDuration(cartExpiryHours), expiresIn: formatDuration(expiresIn), // Built from the configured format for the same reason as the durations: // the preview is meant to show the email that will be sent. greeting: greeting(SAMPLE_VALUES.firstName, greetingFormat, greetingFallback, SAMPLE_VALUES.lastName) }; } 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 }, await previewValues(key) ); res.json(rendered); })); router.put('/:key', asyncRoute(async (req: Request, res: Response) => { const key = req.params.key; if (!isTemplateKey(key)) { return res.status(404).json({ error: 'unknown template' }); } const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : ''; const body = typeof req.body?.body === 'string' ? req.body.body.trim() : ''; if (!subject) { return res.status(400).json({ error: 'a subject is required' }); } if (!body) { return res.status(400).json({ error: 'a body is required' }); } // The rule that makes this feature safe rather than a way to break password // resets from a settings screen. A body without its link still sends, still // looks correct in the log, and is useless to everyone who receives it — so // the save is refused rather than warned about. const missing = missingPlaceholders(key, body); if (missing.length) { const named = missing.map((name) => '{{' + name + '}}').join(' and '); return res.status(400).json({ error: `the body must keep ${named}` }); } for (const [part, value] of [ ['subject', subject], ['body', body] ] as const) { await pool.query( `INSERT INTO admin_settings (key, value, updated_at) VALUES ($1, $2, now()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`, [settingKey(key, part), value] ); } res.json({ key, subject, body }); })); // Restores the built-in copy by removing the stored rows, rather than by // writing the default into them — so "not customised" stays distinguishable // from "customised back to the original wording". router.delete('/:key', asyncRoute(async (req: Request, res: Response) => { const key = req.params.key; if (!isTemplateKey(key)) { return res.status(404).json({ error: 'unknown template' }); } await pool.query(`DELETE FROM admin_settings WHERE key = ANY($1)`, [ [settingKey(key, 'subject'), settingKey(key, 'body')] ]); res.json({ key, subject: null, body: null }); })); export default router;