Files
redefined-designs/backend/src/emailTemplates.ts
T
bermudalamb f32913ef51
Linting / lint (pull_request) Successful in 1m57s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m3s
refactor: turn on noUncheckedIndexedAccess in both workspaces (#101)
Indexing an array now yields `T | undefined`, which is what it always did — the compiler simply says so. Enabled in backend/tsconfig.json and frontend/tsconfig.json, and in tsconfig.sonar.json alongside it so the drift guard stays satisfied.

The sequencing this issue insisted on was right, and the numbers show why. Before #159 typed the query results, `rows[0]` was `any` and `any` indexes to `any`: the flag would have found close to nothing and the count would have changed completely afterwards. With the rows typed it finds 57 — 50 in the backend, 4 in the frontend, 3 in the Playwright suite — and they fall into three kinds.

Thirty are `rows[0]` after a `rows.length` guard. TypeScript cannot connect the two, and rewriting them as `const [row] = rows; if (!row) …` makes the guard and the use the same check, which is better code independently of the flag.

Ten are rows a statement guarantees — `INSERT … RETURNING`, or a lookup for an id the session middleware has already matched. These get `requireRow(rows, what)`, a new helper in db.ts that throws naming the query. A thrown error rather than a non-null assertion: if the assumption is ever wrong, an assertion hands `undefined` to the next line and fails somewhere unrelated, whereas this fails at the query and says which. asyncRoute turns it into a 500, which is the right answer for "the database did not do what the statement says it does". It also states the assumption once instead of ten times.

The rest is ordinary indexing the compiler cannot prove: a regex capture group that the pattern guarantees, `split('+')[0]`, a modulo kept in range, `hasOwnProperty` failing to narrow an index signature, and Express typing route params as an index signature so `req.params.itemId` is `string | undefined` on a route that cannot match without it.

One correction to this issue's premise, which matters for what it was expected to find. The body says "in a handful it does not guard at all", and the unguarded-500 risk it describes was not found. Every `rows[0]` either sits behind a length check or behind a statement that guarantees a row. What the flag actually bought was the ten places where that guarantee was real but unstated, and those now say so.

Two changes worth calling out because they are not mechanical. TAG_COLORS is typed `[string, ...string[]]` in both copies rather than `as const` — the first attempt used `as const`, which narrowed the elements to literals and broke adminTags, so the annotation keeps `string` while telling the compiler index 0 exists. And the filter drawer's slider falls back to the bounds it was given rather than to null, because null there reads as "no filter" and would widen the results rather than leave them unchanged.

Test files needed changes too, since ts-jest compiles them against the same config: a regex destructure in the compose guard, and ten `mock.calls[0][0]` reads where the surrounding assertions already establish the call happened.

Verified: tsc clean over backend, frontend src and the Playwright suite; unit 254/254; integration 238/238; frontend build clean; lint unchanged in both workspaces.

Closes #101
2026-08-24 15:25:09 -05:00

260 lines
10 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';
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.'
}
};
/**
* 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.
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 };
}