submitUrl, label and submissionsAllowed were added to the uploadLink template but SAMPLE_VALUES had no entries for any of them, so an admin opening Email templates, Upload link for a contributor, Preview saw literal {{submitUrl}} in the body — the preview being the only way to check an edit before saving.
The unit test meant to catch exactly this, in emailTemplates.test.ts, iterated a hardcoded KEYS array that predated intakeDraft and uploadLink, so it never checked either template's samples. KEYS is now Object.keys(TEMPLATES) as TemplateKey[], so the guard covers every template automatically and cannot go stale the same way again. intakeDraft already had samples for all its placeholders and passes once included, as expected.
One other test in the same file, "every template can address the customer", asserts that available contains greeting/firstName/lastName — a real invariant of the six customer-facing templates, but not of intakeDraft or uploadLink, which notify the shop and a contributor rather than a customer with a name on file. Switching that test to the new all-templates KEYS would have made it fail for both, so it now uses its own explicit CUSTOMER_FACING_KEYS list instead. That is a deliberate, commented exception: a hardcoded list is correct there because the claim itself does not extend to every template, whereas the SAMPLE_VALUES guard's claim does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
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'
|
|
| 'emailChanged'
|
|
| 'intakeDraft'
|
|
| 'uploadLink';
|
|
|
|
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', 'firstName', 'lastName', 'verifyUrl', 'expiresIn'],
|
|
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 {{expiresIn}}.'
|
|
},
|
|
|
|
passwordReset: {
|
|
label: 'Password reset',
|
|
required: ['resetUrl'],
|
|
available: ['greeting', 'firstName', 'lastName', 'resetUrl', 'expiresIn'],
|
|
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 {{expiresIn}}.\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: ['greeting', 'firstName', 'lastName', '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: ['greeting', 'firstName', 'lastName', '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
|
|
},
|
|
|
|
emailChanged: {
|
|
label: 'Email address changed',
|
|
// Naming the new address is the point: a notice that does not say what
|
|
// the address was changed *to* is nearly useless to someone checking
|
|
// whether it was them. This is the mail that catches an account
|
|
// takeover, so it goes to the address being replaced.
|
|
required: ['newEmail'],
|
|
available: ['greeting', 'firstName', 'lastName', 'newEmail'],
|
|
defaultSubject: 'Your Redefined Designs email address was changed',
|
|
defaultBody:
|
|
'{{greeting}}\n\n' +
|
|
'The email address on your account was changed to **{{newEmail}}**.\n\n' +
|
|
'If you made this change, nothing more is needed. This message is only a\n' +
|
|
'record of it.\n\n' +
|
|
'If you did not, contact us straight away: whoever made the change can now\n' +
|
|
'receive password reset links for your account.'
|
|
},
|
|
cartReminder: {
|
|
label: 'Cart reminder',
|
|
required: ['itemList', 'cartUrl'],
|
|
available: ['greeting', 'firstName', 'lastName', 'itemList', 'cartUrl', 'holdDuration'],
|
|
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' +
|
|
'Items are held for {{holdDuration}} from when they were added.\n\n' +
|
|
'[View your cart]({{cartUrl}}) before your reservation expires.'
|
|
},
|
|
|
|
intakeDraft: {
|
|
label: 'Item submitted for review',
|
|
// Only the review link. The signed shortcuts are absent whenever
|
|
// INTAKE_ACTION_SECRET is unset, and requiring them would make an
|
|
// unconfigured environment unable to send this at all.
|
|
required: ['reviewUrl'],
|
|
available: [
|
|
'itemName',
|
|
'draftName',
|
|
'draftDescription',
|
|
'price',
|
|
'submitterNote',
|
|
'linkLabel',
|
|
'reviewUrl',
|
|
'regenerateUrl',
|
|
'discardUrl'
|
|
],
|
|
defaultSubject: 'An item was submitted: {{draftName}}',
|
|
defaultBody:
|
|
'Someone sent in an item through {{linkLabel}}.\n\n' +
|
|
'**{{draftName}}**\n\n' +
|
|
'{{draftDescription}}\n\n' +
|
|
'Suggested price: {{price}}\n\n' +
|
|
"The sender's note: {{submitterNote}}\n\n" +
|
|
'[Review and publish it]({{reviewUrl}})\n\n' +
|
|
'Nothing is listed until you publish it from that screen, and the price ' +
|
|
'above is a suggestion rather than a decision.\n\n' +
|
|
'[Ask for another draft]({{regenerateUrl}}) - [Discard it]({{discardUrl}})'
|
|
},
|
|
|
|
uploadLink: {
|
|
label: 'Upload link for a contributor',
|
|
// The link itself, for the same reason verification requires verifyUrl: an
|
|
// email inviting somebody to send in photos, with no way to do it, sends
|
|
// perfectly happily and wastes everyone's time.
|
|
required: ['submitUrl'],
|
|
available: ['submitUrl', 'label', 'submissionsAllowed'],
|
|
defaultSubject: 'Send us your items',
|
|
defaultBody:
|
|
'You can send us photos of items you would like us to sell.\n\n' +
|
|
'[Send in an item]({{submitUrl}})\n\n' +
|
|
'You can send {{submissionsAllowed}}. Photograph one item at a time, and tell us anything you know about it — where it came from, what it is made of, any damage. A photo cannot show any of that.\n\n' +
|
|
'Keep this link to yourself: anyone who has it can send us items in your name.'
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Renders a configured lifetime, in hours, as the words an email should use.
|
|
*
|
|
* All three duration placeholders go through this, so the reset email and the
|
|
* cart reminder say "one hour" the same way rather than in two authors'
|
|
* phrasing. A fractional hour drops to minutes: "0.5 hours" reads badly, and
|
|
* "1.5 hours" reads worse in a sentence a customer is meant to act on.
|
|
*/
|
|
export function formatDuration(hours: number): string {
|
|
if (!Number.isInteger(hours)) {
|
|
return `${Math.round(hours * 60)} minutes`;
|
|
}
|
|
return hours === 1 ? 'one hour' : `${hours} hours`;
|
|
}
|
|
|
|
/**
|
|
* Builds the `{{greeting}}` value from the admin-configured format.
|
|
*
|
|
* 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).
|
|
* `{{firstName}}` and `{{lastName}}` are still offered for a template that
|
|
* genuinely wants the name inline, but the greeting is the safe default.
|
|
*
|
|
* The fallback is a separate setting rather than the format with the name
|
|
* removed. Editing a name out of a sentence is the kind of thing that has to be
|
|
* right every time and cannot be, so an admin writes both and neither is
|
|
* guessed.
|
|
*/
|
|
export function greeting(
|
|
firstName: string | null | undefined,
|
|
format: string,
|
|
fallback: string,
|
|
lastName?: string | null
|
|
): string {
|
|
const first = (firstName ?? '').trim();
|
|
if (!first) return fallback;
|
|
return format
|
|
.replace(/\{\{\s*firstName\s*\}\}/g, first)
|
|
.replace(/\{\{\s*lastName\s*\}\}/g, (lastName ?? '').trim());
|
|
}
|
|
|
|
/** 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)) {
|
|
// PLACEHOLDER has exactly one capture group, so a match always has [1] —
|
|
// but a RegExpMatchArray cannot say so, hence the guard rather than an
|
|
// assertion. A match without it would be a change to the pattern.
|
|
const name = match[1];
|
|
if (name) present.add(name);
|
|
}
|
|
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) => {
|
|
// hasOwnProperty does not narrow an index signature, so the lookup is done
|
|
// once and tested. Checking the value also treats an explicitly-undefined
|
|
// entry the same as a missing one, which is what the caller means.
|
|
const value = values[name];
|
|
return value === undefined ? whole : value;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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<string, string> = {
|
|
greeting: 'Hi Ada,',
|
|
firstName: 'Ada',
|
|
lastName: 'Lovelace',
|
|
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',
|
|
// Fallbacks only. The admin preview overrides both from the live settings,
|
|
// so the pane shows the duration that would actually be sent rather than a
|
|
// plausible-looking number that disagrees with it.
|
|
draftName: 'Blue stoneware vase',
|
|
draftDescription: 'A hand-thrown vase with a chipped base.',
|
|
price: '$80.00',
|
|
submitterNote: 'Found in a loft clearance.',
|
|
linkLabel: 'Autumn drop-off',
|
|
reviewUrl: 'https://example.com/admin',
|
|
regenerateUrl: 'https://example.com/api/intake-actions/1/regenerate?expires=0&sig=sample',
|
|
discardUrl: 'https://example.com/api/intake-actions/1/discard?expires=0&sig=sample',
|
|
expiresIn: 'one hour',
|
|
holdDuration: '24 hours',
|
|
submitUrl: 'https://example.com/submit/sample-token',
|
|
label: 'Autumn drop-off',
|
|
submissionsAllowed: '25 items'
|
|
};
|
|
|
|
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 };
|
|
}
|