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'; 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.' } }; /** * 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. expiresIn: 'one hour', holdDuration: '24 hours' }; 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 }; }