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:
2026-08-21 18:57:52 -05:00
co-authored by Claude Opus 5
parent 3b3888fd3c
commit 4ed9513ad2
12 changed files with 797 additions and 45 deletions
+172
View File
@@ -0,0 +1,172 @@
import MarkdownIt from 'markdown-it';
/**
* The five customer emails, their default copy, and the rules for editing it.
*
* Bodies are markdown rather than HTML. `html: false` is markdown-it's default
* and is the point of choosing it: raw HTML in a stored body is escaped, not
* passed through, so editing copy from the settings screen cannot put script
* into a customer's inbox. That is a stronger guarantee than sanitising output
* afterwards, because there is no output to sanitise.
*/
const md = new MarkdownIt({ html: false, linkify: true });
export type TemplateKey =
| 'verification'
| 'passwordReset'
| 'favoriteSold'
| 'favoriteWithdrawn'
| 'cartReminder';
export interface TemplateDefinition {
/** Shown in the admin so a card is identifiable without reading its body. */
label: string;
/**
* Placeholders a body must contain. Saving without one is refused: a reset
* email with no link still sends, still looks fine in the log, and is useless
* to everyone who receives it.
*/
required: readonly string[];
/** Every placeholder this template understands, for the admin to see. */
available: readonly string[];
defaultSubject: string;
defaultBody: string;
/**
* Appended after rendering and deliberately not editable. The favorite alerts
* carry a consent notice explaining why the customer is receiving them, which
* is a compliance artifact rather than copy — editing wording should not be
* able to delete the sentence that makes the email lawful to send.
*/
footer?: string;
}
const FAVORITE_CONSENT_FOOTER =
'<p>You are receiving this because you asked to be told when a favorited item becomes ' +
'unavailable. You can turn these off on your account page.</p>';
export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
verification: {
label: 'Email verification',
required: ['verifyUrl'],
available: ['greeting', 'verifyUrl'],
defaultSubject: 'Confirm your email address',
defaultBody:
'{{greeting}}\n\n' +
'Please confirm this address so we know we can reach you.\n\n' +
'[Confirm my email]({{verifyUrl}})\n\n' +
'This link expires in 24 hours.'
},
passwordReset: {
label: 'Password reset',
required: ['resetUrl'],
available: ['greeting', 'resetUrl'],
defaultSubject: 'Reset your Redefined Designs password',
defaultBody:
'Someone asked to reset the password for this account.\n\n' +
'[Choose a new password]({{resetUrl}}). This link expires in one hour.\n\n' +
"If this wasn't you, you can ignore this email — your password has not changed."
},
favoriteSold: {
label: 'Favorited item sold',
required: ['itemName'],
available: ['itemName', 'siteUrl'],
defaultSubject: '"{{itemName}}" has been sold',
defaultBody:
'An item you favorited has been sold to another customer, so it is no longer available.\n\n' +
'**{{itemName}}**\n\n' +
'Every piece is one of a kind, so this one will not be restocked. You can browse what is ' +
'still available at [Redefined Designs]({{siteUrl}}).',
footer: FAVORITE_CONSENT_FOOTER
},
favoriteWithdrawn: {
label: 'Favorited item withdrawn',
required: ['itemName'],
available: ['itemName', 'siteUrl'],
defaultSubject: '"{{itemName}}" is no longer available',
defaultBody:
'An item you favorited has been withdrawn and is no longer available.\n\n' +
'**{{itemName}}**\n\n' +
'You can browse what is still available at [Redefined Designs]({{siteUrl}}).',
footer: FAVORITE_CONSENT_FOOTER
},
cartReminder: {
label: 'Cart reminder',
required: ['itemList', 'cartUrl'],
available: ['greeting', 'itemList', 'cartUrl'],
defaultSubject: 'Items waiting in your cart',
defaultBody:
'{{greeting}}\n\n' +
'You still have items in your cart at Redefined Designs:\n\n' +
'{{itemList}}\n\n' +
'[View your cart]({{cartUrl}}) before your reservation expires.'
}
};
/**
* The `{{greeting}}` value: "Hi Thom," when a first name is known, "Hi,"
* otherwise.
*
* One placeholder rather than a bare name, so a template author writes
* `{{greeting}}` on its own line instead of `Hi {{firstName}},` — which reads
* as "Hi ," for anyone who registered before first names were required (#106).
*/
export function greeting(firstName: string | null | undefined): string {
const name = (firstName ?? '').trim();
return name ? `Hi ${name},` : 'Hi,';
}
/** Matches `{{name}}`, tolerating whitespace inside the braces. */
const PLACEHOLDER = /\{\{\s*(\w+)\s*\}\}/g;
/**
* Which of a template's required placeholders a candidate body is missing.
*
* Returns all of them rather than the first, so a save that dropped two says so
* once instead of over two attempts.
*/
export function missingPlaceholders(key: TemplateKey, body: string): string[] {
const present = new Set<string>();
for (const match of body.matchAll(PLACEHOLDER)) {
present.add(match[1]);
}
return TEMPLATES[key].required.filter((name) => !present.has(name));
}
function substitute(text: string, values: Record<string, string>): string {
return text.replace(PLACEHOLDER, (whole, name: string) =>
Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole
);
}
export interface StoredTemplate {
subject?: string | null;
body?: string | null;
}
/**
* Produces the subject and HTML for one email.
*
* Values are substituted into the markdown *before* rendering, which is why a
* value that should become a list has to arrive as markdown — emitting HTML
* here would be escaped and shown to the customer as literal tags.
*
* An absent or blank stored value falls back to the built-in default, so an
* unconfigured install behaves exactly as it did before any of this existed.
*/
export function renderTemplate(
key: TemplateKey,
stored: StoredTemplate,
values: Record<string, string>
): { subject: string; html: string } {
const definition = TEMPLATES[key];
const subjectSource = stored.subject?.trim() ? stored.subject : definition.defaultSubject;
const bodySource = stored.body?.trim() ? stored.body : definition.defaultBody;
const html = md.render(substitute(bodySource, values)) + (definition.footer ?? '');
return { subject: substitute(subjectSource, values), html };
}