Editable from the settings screen like every other template. reviewUrl is the only required placeholder: a notification with no link in it still sends, still looks fine in the log, and is useless to whoever receives it, which is what the required-placeholder validation exists to catch. The two signed links are deliberately optional. They are absent whenever INTAKE_ACTION_SECRET is unset, and a template demanding them would leave an unconfigured environment unable to send this at all. A test asserts the template offers no way to publish. That the email cannot publish is what bounds the risk taken by pricing items on arrival, and it is a property of the copy as much as of the routes — a publish link in the body would be one nobody reviewed. Where the notification goes is an admin setting rather than an environment variable, for the same reason drafting_model is one: it is changed by whoever runs the shop, not by whoever deploys it. Empty is the default and means do not notify, which is a working configuration. INTAKE_ACTION_SECRET warns rather than fails at boot, like the drafting key. Being told an item arrived matters far more than being able to discard it in one click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 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';
|
|
|
|
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}})'
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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'
|
|
};
|
|
|
|
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 };
|
|
}
|