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 =
'
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.
';
export const TEMPLATES: Record = {
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();
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 {
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 = {
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
): { 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 };
}