feat(backend): make the five customer emails editable copy (#92)
Every customer email was a template literal in the route that sent it, so changing a word meant a code change, a review and a deploy. All five now render from markdown that an admin can edit: verification, password reset, favorite sold, favorite withdrawn, and the cart reminder. Five, not the four the issue counted — the favorite alerts have separate copy for sold and withdrawn.
markdown-it runs with html disabled, which is its default and the reason for choosing it over marked. Raw HTML in a stored body is escaped rather than passed through, so editing copy cannot put script into a customer's inbox. That is a stronger guarantee than sanitising output, because there is no output to sanitise.
Values are substituted into the markdown before it renders, which means a value that should become a list has to arrive as markdown. The cart reminder previously built li elements by hand; those would now be escaped and shown to the customer as literal angle brackets, so it emits a markdown list instead. The greeting is one placeholder rather than a bare name, so a template author writes {{greeting}} instead of "Hi {{firstName}}," — which reads as "Hi ," for anyone who registered before first names were required.
Saving is refused when a body has dropped a placeholder it needs, naming all of them rather than the first. This is the rule that separates a convenience from a way to break password resets from a settings screen: a reset email with no link still sends, still looks correct in the log, and is useless to everyone who receives it.
The favorite alerts' consent sentence is appended by the server and is not editable. It explains why the customer is receiving the mail, which is a compliance artifact rather than copy, and editing wording should not be able to delete it.
Unset templates fall back to the built-in defaults, so an install that never touches the settings screen behaves exactly as it did. The API reports an uncustomised template as null rather than as its default text, so "never edited" stays distinguishable from "edited to something identical", and DELETE restores the default by forgetting the row rather than writing the default into it.
Two problems surfaced during verification, both worth recording.
Five favorite-alert tests failed with no error and no mail. The cause was not this code: resetDb does not truncate admin_settings, so a subject of "Gone" stored by the new template tests survived into a later suite and changed the mail it was asserting on. Cleaning up inside the template tests would have fixed only that pairing, so resetDb now clears stored templates for every suite — template rows are test data like any other, and one outliving the suite that wrote it makes a failure appear somewhere unrelated.
The withdrawal notification then failed on timing. Loading copy from the database made the sender async, and the removal path was fire-and-forget, so the response could beat the mail out of the door. Dispatch was previously synchronous even though the sends themselves were not awaited; that is now restored by awaiting it.
Verified: 197 unit and 195 integration passing, lint unchanged at 4 warnings. The admin screen for editing these follows in the next commit.
Refs #92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -346,7 +346,7 @@ router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
|
||||
// Sent only once the delete has succeeded, so nobody hears about a withdrawal
|
||||
// that did not happen.
|
||||
notifyFavoritersOfRemoval(recipients);
|
||||
await notifyFavoritersOfRemoval(recipients);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { TEMPLATES, TemplateKey, StoredTemplate, missingPlaceholders } from '../emailTemplates';
|
||||
|
||||
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<StoredTemplate> {
|
||||
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<string, string>(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
|
||||
}))
|
||||
);
|
||||
}));
|
||||
|
||||
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;
|
||||
@@ -4,6 +4,8 @@ import crypto from 'node:crypto';
|
||||
import { pool } from '../db';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting } from '../emailTemplates';
|
||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
@@ -103,11 +105,12 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
[verifyToken, customer.id, new Date(Date.now() + 24 * 60 * 60 * 1000)]
|
||||
);
|
||||
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${verifyToken}`;
|
||||
sendMail(
|
||||
customer.email,
|
||||
'Verify your Redefined Designs account',
|
||||
`<p>Welcome! Please <a href="${verifyUrl}">verify your email</a> to finish setting up your account.</p>`
|
||||
).catch(err => console.error('verify email send failed', err));
|
||||
const verifyTemplate = renderTemplate('verification', await loadStoredTemplate('verification'), {
|
||||
greeting: greeting(customer.first_name),
|
||||
verifyUrl
|
||||
});
|
||||
sendMail(customer.email, verifyTemplate.subject, verifyTemplate.html)
|
||||
.catch(err => console.error('verify email send failed', err));
|
||||
|
||||
const sessionToken = await createSession(customer.id);
|
||||
setSessionCookie(res, sessionToken);
|
||||
@@ -154,13 +157,12 @@ router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(a
|
||||
);
|
||||
|
||||
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
|
||||
sendMail(
|
||||
customer.email,
|
||||
'Reset your Redefined Designs password',
|
||||
`<p>Someone asked to reset the password for this account.</p>
|
||||
<p><a href="${resetUrl}">Choose a new password</a>. This link expires in one hour.</p>
|
||||
<p>If this wasn't you, you can ignore this email — your password has not changed.</p>`
|
||||
).catch(err => console.error('password reset email send failed', err));
|
||||
const resetTemplate = renderTemplate('passwordReset', await loadStoredTemplate('passwordReset'), {
|
||||
greeting: greeting(customer.first_name),
|
||||
resetUrl
|
||||
});
|
||||
sendMail(customer.email, resetTemplate.subject, resetTemplate.html)
|
||||
.catch(err => console.error('password reset email send failed', err));
|
||||
}
|
||||
|
||||
res.json({ status: 'sent' });
|
||||
|
||||
Reference in New Issue
Block a user