Merge pull request 'Feature/92 editable email templates' (#112) from feature/92-editable-email-templates into main
Reviewed-on: #112
This commit was merged in pull request #112.
This commit is contained in:
Generated
+119
@@ -8,10 +8,12 @@
|
|||||||
"name": "redefined-designs-backend",
|
"name": "redefined-designs-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.2.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^8.6.2",
|
"express-rate-limit": "^8.6.2",
|
||||||
|
"markdown-it": "^15.0.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"node-pg-migrate": "^7.6.1",
|
"node-pg-migrate": "^7.6.1",
|
||||||
@@ -1944,6 +1946,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/linkify-it": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/markdown-it": {
|
||||||
|
"version": "14.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz",
|
||||||
|
"integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/linkify-it": "^5",
|
||||||
|
"@types/mdurl": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/mdurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/methods": {
|
"node_modules/@types/methods": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||||
@@ -3401,6 +3425,18 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/error-ex": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
@@ -5562,6 +5598,25 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
@@ -5645,6 +5700,49 @@
|
|||||||
"tmpl": "1.0.5"
|
"tmpl": "1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/markdown-it": {
|
||||||
|
"version": "15.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz",
|
||||||
|
"integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^3.0.0",
|
||||||
|
"entities": "^8.0.0",
|
||||||
|
"linkify-it": "^6.0.0",
|
||||||
|
"mdurl": "^2.1.0",
|
||||||
|
"punycode.js": "^2.3.1",
|
||||||
|
"uc.micro": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"markdown-it": "bin/markdown-it.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/markdown-it/node_modules/argparse": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -5654,6 +5752,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdurl": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/media-typer": {
|
"node_modules/media-typer": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
@@ -6472,6 +6576,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pure-rand": {
|
"node_modules/pure-rand": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||||
@@ -7428,6 +7541,12 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uglify-js": {
|
"node_modules/uglify-js": {
|
||||||
"version": "3.19.3",
|
"version": "3.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
|
|||||||
@@ -22,10 +22,12 @@
|
|||||||
"migrate:create": "node-pg-migrate create --migration-file-language js"
|
"migrate:create": "node-pg-migrate create --migration-file-language js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/markdown-it": "^14.2.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^8.6.2",
|
"express-rate-limit": "^8.6.2",
|
||||||
|
"markdown-it": "^15.0.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"node-pg-migrate": "^7.6.1",
|
"node-pg-migrate": "^7.6.1",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRoute
|
|||||||
import adminRouter from './routes/admin';
|
import adminRouter from './routes/admin';
|
||||||
import adminCustomersRouter from './routes/adminCustomers';
|
import adminCustomersRouter from './routes/adminCustomers';
|
||||||
import adminSettingsRouter from './routes/adminSettings';
|
import adminSettingsRouter from './routes/adminSettings';
|
||||||
|
import adminEmailTemplatesRouter from './routes/adminEmailTemplates';
|
||||||
import adminCategoriesRouter from './routes/adminCategories';
|
import adminCategoriesRouter from './routes/adminCategories';
|
||||||
import adminTagsRouter from './routes/adminTags';
|
import adminTagsRouter from './routes/adminTags';
|
||||||
import filtersRouter from './routes/filters';
|
import filtersRouter from './routes/filters';
|
||||||
@@ -54,6 +55,7 @@ app.use('/api/checkout/cart', cartCheckoutRouter);
|
|||||||
// instead of being quietly public. See middleware/adminGate.ts and #63.
|
// instead of being quietly public. See middleware/adminGate.ts and #63.
|
||||||
app.use('/api/admin/customers', requireAdminGate, adminCustomersRouter);
|
app.use('/api/admin/customers', requireAdminGate, adminCustomersRouter);
|
||||||
app.use('/api/admin/settings', requireAdminGate, adminSettingsRouter);
|
app.use('/api/admin/settings', requireAdminGate, adminSettingsRouter);
|
||||||
|
app.use('/api/admin/email-templates', requireAdminGate, adminEmailTemplatesRouter);
|
||||||
app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter);
|
app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter);
|
||||||
app.use('/api/admin/tags', requireAdminGate, adminTagsRouter);
|
app.use('/api/admin/tags', requireAdminGate, adminTagsRouter);
|
||||||
app.use('/api/admin', requireAdminGate, adminRouter);
|
app.use('/api/admin', requireAdminGate, adminRouter);
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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', 'verifyUrl'],
|
||||||
|
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 24 hours.'
|
||||||
|
},
|
||||||
|
|
||||||
|
passwordReset: {
|
||||||
|
label: 'Password reset',
|
||||||
|
required: ['resetUrl'],
|
||||||
|
available: ['greeting', 'resetUrl'],
|
||||||
|
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 one hour.\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: ['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: ['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
|
||||||
|
},
|
||||||
|
|
||||||
|
cartReminder: {
|
||||||
|
label: 'Cart reminder',
|
||||||
|
required: ['itemList', 'cartUrl'],
|
||||||
|
available: ['greeting', 'itemList', 'cartUrl'],
|
||||||
|
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' +
|
||||||
|
'[View your cart]({{cartUrl}}) before your reservation expires.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `{{greeting}}` value: "Hi Thom," when a first name is known, "Hi,"
|
||||||
|
* otherwise.
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
export function greeting(firstName: string | null | undefined): string {
|
||||||
|
const name = (firstName ?? '').trim();
|
||||||
|
return name ? `Hi ${name},` : 'Hi,';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)) {
|
||||||
|
present.add(match[1]);
|
||||||
|
}
|
||||||
|
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) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { pool } from './db';
|
import { pool } from './db';
|
||||||
import { sendMail } from './mailer';
|
import { sendMail } from './mailer';
|
||||||
|
import { renderTemplate, TemplateKey } from './emailTemplates';
|
||||||
|
import { loadStoredTemplate } from './routes/adminEmailTemplates';
|
||||||
|
|
||||||
// Shown to the customer when they opt in, and stored verbatim against their
|
// Shown to the customer when they opt in, and stored verbatim against their
|
||||||
// consent so the record says what they actually agreed to — the same pattern
|
// consent so the record says what they actually agreed to — the same pattern
|
||||||
@@ -42,15 +44,24 @@ export async function collectFavoriteRecipients(
|
|||||||
// thing the customer asked to hear about. Sent independently so one bad
|
// thing the customer asked to hear about. Sent independently so one bad
|
||||||
// address cannot stop the rest — and whatever prompted this has already
|
// address cannot stop the rest — and whatever prompted this has already
|
||||||
// happened regardless of whether the mail goes out.
|
// happened regardless of whether the mail goes out.
|
||||||
function send(recipients: FavoriteRecipient[], subject: (name: string) => string, body: (name: string) => string): void {
|
async function send(recipients: FavoriteRecipient[], key: TemplateKey): Promise<void> {
|
||||||
|
if (!recipients.length) return;
|
||||||
|
|
||||||
|
// Loaded once for the batch rather than per recipient: the copy is the same
|
||||||
|
// for everyone, only the item name differs.
|
||||||
|
const stored = await loadStoredTemplate(key);
|
||||||
|
const siteUrl = process.env.PUBLIC_URL ?? '';
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
sendMail(recipient.email, subject(recipient.item_name), body(recipient.item_name))
|
const { subject, html } = renderTemplate(key, stored, {
|
||||||
|
itemName: recipient.item_name,
|
||||||
|
siteUrl
|
||||||
|
});
|
||||||
|
sendMail(recipient.email, subject, html)
|
||||||
.catch(err => console.error('favorite alert failed', err));
|
.catch(err => console.error('favorite alert failed', err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const 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>`;
|
|
||||||
|
|
||||||
// Called *after* the sale has been committed, never inside the transaction.
|
// Called *after* the sale has been committed, never inside the transaction.
|
||||||
// Emailing about a sale that then rolled back would be worse than a late
|
// Emailing about a sale that then rolled back would be worse than a late
|
||||||
@@ -60,27 +71,16 @@ const FOOTER = `<p>You are receiving this because you asked to be told when a fa
|
|||||||
// longer available reads as a bug.
|
// longer available reads as a bug.
|
||||||
export async function notifyFavoritersOfSale(itemIds: number[], buyerId: number | null): Promise<void> {
|
export async function notifyFavoritersOfSale(itemIds: number[], buyerId: number | null): Promise<void> {
|
||||||
const recipients = await collectFavoriteRecipients(itemIds, buyerId);
|
const recipients = await collectFavoriteRecipients(itemIds, buyerId);
|
||||||
send(
|
await send(recipients, 'favoriteSold');
|
||||||
recipients,
|
|
||||||
name => `"${name}" has been sold`,
|
|
||||||
name => `<p>An item you favorited has been sold to another customer, so it is no longer available.</p>
|
|
||||||
<p><b>${name}</b></p>
|
|
||||||
<p>Every piece is one of a kind, so this one will not be restocked. You can browse what is
|
|
||||||
still available at <a href="${process.env.PUBLIC_URL}">Redefined Designs</a>.</p>
|
|
||||||
${FOOTER}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sent when an item is withdrawn from sale rather than sold. Recipients must be
|
// Sent when an item is withdrawn from sale rather than sold. Recipients must be
|
||||||
// collected before the delete, since the favorites rows cascade with the item.
|
// collected before the delete, since the favorites rows cascade with the item.
|
||||||
export function notifyFavoritersOfRemoval(recipients: FavoriteRecipient[]): void {
|
// Async now that the copy is loaded from the database before rendering. It was
|
||||||
send(
|
// previously synchronous in dispatch — the sends were fire-and-forget, but they
|
||||||
recipients,
|
// were *started* before the caller returned. Leaving it fire-and-forget would
|
||||||
name => `"${name}" is no longer available`,
|
// mean the response can beat the mail out of the door, which is a behaviour
|
||||||
name => `<p>An item you favorited has been withdrawn and is no longer available.</p>
|
// change nobody asked for and which the withdrawal test caught.
|
||||||
<p><b>${name}</b></p>
|
export async function notifyFavoritersOfRemoval(recipients: FavoriteRecipient[]): Promise<void> {
|
||||||
<p>You can browse what is still available at
|
await send(recipients, 'favoriteWithdrawn');
|
||||||
<a href="${process.env.PUBLIC_URL}">Redefined Designs</a>.</p>
|
|
||||||
${FOOTER}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ router.delete('/items/:id', asyncRoute(async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
// Sent only once the delete has succeeded, so nobody hears about a withdrawal
|
// Sent only once the delete has succeeded, so nobody hears about a withdrawal
|
||||||
// that did not happen.
|
// that did not happen.
|
||||||
notifyFavoritersOfRemoval(recipients);
|
await notifyFavoritersOfRemoval(recipients);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { pool } from '../db';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
import { TEMPLATES, TemplateKey, StoredTemplate, missingPlaceholders } from '../emailTemplates';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const KEYS = Object.keys(TEMPLATES) as TemplateKey[];
|
||||||
|
|
||||||
|
// Stored in admin_settings rather than a table of their own: it is already a
|
||||||
|
// key/value store with a settled read/write shape, and five templates is not a
|
||||||
|
// schema.
|
||||||
|
const settingKey = (key: TemplateKey, part: 'subject' | 'body') => `email_${key}_${part}`;
|
||||||
|
|
||||||
|
function isTemplateKey(value: unknown): value is TemplateKey {
|
||||||
|
return typeof value === 'string' && (KEYS as string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadStoredTemplate(key: TemplateKey): Promise<StoredTemplate> {
|
||||||
|
const { rows } = await pool.query(`SELECT key, value FROM admin_settings WHERE key = ANY($1)`, [
|
||||||
|
[settingKey(key, 'subject'), settingKey(key, 'body')]
|
||||||
|
]);
|
||||||
|
const stored: StoredTemplate = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.key === settingKey(key, 'subject')) stored.subject = row.value;
|
||||||
|
if (row.key === settingKey(key, 'body')) stored.body = row.value;
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the definitions alongside whatever is stored, so the admin screen can
|
||||||
|
// show the placeholders a template accepts and which of them it must keep,
|
||||||
|
// rather than the editor having to know.
|
||||||
|
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT key, value FROM admin_settings WHERE key LIKE 'email\\_%'`
|
||||||
|
);
|
||||||
|
const stored = new Map<string, string>(rows.map((r) => [r.key, r.value]));
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
KEYS.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: TEMPLATES[key].label,
|
||||||
|
required: TEMPLATES[key].required,
|
||||||
|
available: TEMPLATES[key].available,
|
||||||
|
defaultSubject: TEMPLATES[key].defaultSubject,
|
||||||
|
defaultBody: TEMPLATES[key].defaultBody,
|
||||||
|
// Null rather than the default, so the admin can tell "not customised"
|
||||||
|
// from "customised to exactly the default text".
|
||||||
|
subject: stored.get(settingKey(key, 'subject')) ?? null,
|
||||||
|
body: stored.get(settingKey(key, 'body')) ?? null
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.put('/:key', asyncRoute(async (req: Request, res: Response) => {
|
||||||
|
const key = req.params.key;
|
||||||
|
if (!isTemplateKey(key)) {
|
||||||
|
return res.status(404).json({ error: 'unknown template' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : '';
|
||||||
|
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
return res.status(400).json({ error: 'a subject is required' });
|
||||||
|
}
|
||||||
|
if (!body) {
|
||||||
|
return res.status(400).json({ error: 'a body is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule that makes this feature safe rather than a way to break password
|
||||||
|
// resets from a settings screen. A body without its link still sends, still
|
||||||
|
// looks correct in the log, and is useless to everyone who receives it — so
|
||||||
|
// the save is refused rather than warned about.
|
||||||
|
const missing = missingPlaceholders(key, body);
|
||||||
|
if (missing.length) {
|
||||||
|
const named = missing.map((name) => '{{' + name + '}}').join(' and ');
|
||||||
|
return res.status(400).json({ error: `the body must keep ${named}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [part, value] of [
|
||||||
|
['subject', subject],
|
||||||
|
['body', body]
|
||||||
|
] as const) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO admin_settings (key, value, updated_at) VALUES ($1, $2, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
|
||||||
|
[settingKey(key, part), value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ key, subject, body });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Restores the built-in copy by removing the stored rows, rather than by
|
||||||
|
// writing the default into them — so "not customised" stays distinguishable
|
||||||
|
// from "customised back to the original wording".
|
||||||
|
router.delete('/:key', asyncRoute(async (req: Request, res: Response) => {
|
||||||
|
const key = req.params.key;
|
||||||
|
if (!isTemplateKey(key)) {
|
||||||
|
return res.status(404).json({ error: 'unknown template' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(`DELETE FROM admin_settings WHERE key = ANY($1)`, [
|
||||||
|
[settingKey(key, 'subject'), settingKey(key, 'body')]
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ key, subject: null, body: null });
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -4,6 +4,8 @@ import crypto from 'node:crypto';
|
|||||||
import { pool } from '../db';
|
import { pool } from '../db';
|
||||||
import { requireCustomer } from '../middleware/customerAuth';
|
import { requireCustomer } from '../middleware/customerAuth';
|
||||||
import { sendMail } from '../mailer';
|
import { sendMail } from '../mailer';
|
||||||
|
import { renderTemplate, greeting } from '../emailTemplates';
|
||||||
|
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||||
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
import { asyncRoute } from '../asyncRoute';
|
||||||
@@ -103,11 +105,12 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
|||||||
[verifyToken, customer.id, new Date(Date.now() + 24 * 60 * 60 * 1000)]
|
[verifyToken, customer.id, new Date(Date.now() + 24 * 60 * 60 * 1000)]
|
||||||
);
|
);
|
||||||
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${verifyToken}`;
|
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${verifyToken}`;
|
||||||
sendMail(
|
const verifyTemplate = renderTemplate('verification', await loadStoredTemplate('verification'), {
|
||||||
customer.email,
|
greeting: greeting(customer.first_name),
|
||||||
'Verify your Redefined Designs account',
|
verifyUrl
|
||||||
`<p>Welcome! Please <a href="${verifyUrl}">verify your email</a> to finish setting up your account.</p>`
|
});
|
||||||
).catch(err => console.error('verify email send failed', err));
|
sendMail(customer.email, verifyTemplate.subject, verifyTemplate.html)
|
||||||
|
.catch(err => console.error('verify email send failed', err));
|
||||||
|
|
||||||
const sessionToken = await createSession(customer.id);
|
const sessionToken = await createSession(customer.id);
|
||||||
setSessionCookie(res, sessionToken);
|
setSessionCookie(res, sessionToken);
|
||||||
@@ -154,13 +157,12 @@ router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(a
|
|||||||
);
|
);
|
||||||
|
|
||||||
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
|
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
|
||||||
sendMail(
|
const resetTemplate = renderTemplate('passwordReset', await loadStoredTemplate('passwordReset'), {
|
||||||
customer.email,
|
greeting: greeting(customer.first_name),
|
||||||
'Reset your Redefined Designs password',
|
resetUrl
|
||||||
`<p>Someone asked to reset the password for this account.</p>
|
});
|
||||||
<p><a href="${resetUrl}">Choose a new password</a>. This link expires in one hour.</p>
|
sendMail(customer.email, resetTemplate.subject, resetTemplate.html)
|
||||||
<p>If this wasn't you, you can ignore this email — your password has not changed.</p>`
|
.catch(err => console.error('password reset email send failed', err));
|
||||||
).catch(err => console.error('password reset email send failed', err));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ status: 'sent' });
|
res.json({ status: 'sent' });
|
||||||
|
|||||||
+19
-9
@@ -2,6 +2,8 @@ import cron from 'node-cron';
|
|||||||
import app from './app';
|
import app from './app';
|
||||||
import { pool } from './db';
|
import { pool } from './db';
|
||||||
import { sendMail } from './mailer';
|
import { sendMail } from './mailer';
|
||||||
|
import { renderTemplate, greeting } from './emailTemplates';
|
||||||
|
import { loadStoredTemplate } from './routes/adminEmailTemplates';
|
||||||
import { validateEnv } from './envValidation';
|
import { validateEnv } from './envValidation';
|
||||||
|
|
||||||
// Release cart holds whose expiry has passed.
|
// Release cart holds whose expiry has passed.
|
||||||
@@ -39,16 +41,24 @@ async function sendCartReminders(): Promise<void> {
|
|||||||
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
|
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Loaded once rather than per recipient: the copy is shared, only the
|
||||||
|
// greeting and the item list differ.
|
||||||
|
const stored = await loadStoredTemplate('cartReminder');
|
||||||
|
|
||||||
for (const [email, data] of byEmail) {
|
for (const [email, data] of byEmail) {
|
||||||
const itemList = data.items.map(i => `<li>${i.name} — reserved until ${i.expiresAt.toLocaleString()}</li>`).join('');
|
// Markdown, not HTML. Values are substituted into the template source
|
||||||
await sendMail(
|
// before it is rendered, and the renderer escapes raw HTML — so an <li>
|
||||||
email,
|
// here would reach the customer as literal angle brackets.
|
||||||
'Items waiting in your cart',
|
const itemList = data.items
|
||||||
`<p>Hi${data.firstName ? ' ' + data.firstName : ''},</p>
|
.map(i => `- ${i.name} — reserved until ${i.expiresAt.toLocaleString()}`)
|
||||||
<p>You still have items in your cart at Redefined Designs:</p>
|
.join('\n');
|
||||||
<ul>${itemList}</ul>
|
|
||||||
<p><a href="${process.env.PUBLIC_URL}/cart">View your cart</a> before your reservation expires.</p>`
|
const { subject, html } = renderTemplate('cartReminder', stored, {
|
||||||
);
|
greeting: greeting(data.firstName),
|
||||||
|
itemList,
|
||||||
|
cartUrl: `${process.env.PUBLIC_URL}/cart`
|
||||||
|
});
|
||||||
|
await sendMail(email, subject, html);
|
||||||
const ids = data.items.map(i => i.cartItemId);
|
const ids = data.items.map(i => i.cartItemId);
|
||||||
await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]);
|
await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import app from '../../src/app';
|
||||||
|
import { pool } from '../../src/db';
|
||||||
|
import { resetDb, closeDb } from './setup/testDb';
|
||||||
|
import { renderTemplate } from '../../src/emailTemplates';
|
||||||
|
import { loadStoredTemplate } from '../../src/routes/adminEmailTemplates';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// resetDb clears stored email templates as well, so a template saved by one
|
||||||
|
// test cannot change the mail another asserts on.
|
||||||
|
await resetDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await pool.end();
|
||||||
|
await closeDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
const VALID_RESET_BODY = 'New copy. [Choose a new password]({{resetUrl}}) within the hour.';
|
||||||
|
|
||||||
|
describe('GET /api/admin/email-templates', () => {
|
||||||
|
it('lists every template with its defaults and placeholder rules', async () => {
|
||||||
|
const res = await request(app).get('/api/admin/email-templates');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.map((t: { key: string }) => t.key).sort()).toEqual([
|
||||||
|
'cartReminder',
|
||||||
|
'favoriteSold',
|
||||||
|
'favoriteWithdrawn',
|
||||||
|
'passwordReset',
|
||||||
|
'verification'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reset = res.body.find((t: { key: string }) => t.key === 'passwordReset');
|
||||||
|
expect(reset.required).toEqual(['resetUrl']);
|
||||||
|
expect(reset.defaultBody).toContain('{{resetUrl}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Null rather than the default text, so the screen can tell "never edited"
|
||||||
|
// from "edited to something identical to the default".
|
||||||
|
it('reports an uncustomised template as null rather than as its default', async () => {
|
||||||
|
const res = await request(app).get('/api/admin/email-templates');
|
||||||
|
const reset = res.body.find((t: { key: string }) => t.key === 'passwordReset');
|
||||||
|
|
||||||
|
expect(reset.subject).toBeNull();
|
||||||
|
expect(reset.body).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/admin/email-templates/:key', () => {
|
||||||
|
it('stores a replacement subject and body', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/email-templates/passwordReset')
|
||||||
|
.send({ subject: 'Your reset link', body: VALID_RESET_BODY });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const listed = await request(app).get('/api/admin/email-templates');
|
||||||
|
const reset = listed.body.find((t: { key: string }) => t.key === 'passwordReset');
|
||||||
|
expect(reset.subject).toBe('Your reset link');
|
||||||
|
expect(reset.body).toBe(VALID_RESET_BODY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The rule that keeps this from being a way to break password resets from a
|
||||||
|
// settings screen.
|
||||||
|
it('refuses a body that has dropped the required placeholder', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/email-templates/passwordReset')
|
||||||
|
.send({ subject: 'Your reset link', body: 'Just click the thing.' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('{{resetUrl}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every missing placeholder, not just the first', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/email-templates/cartReminder')
|
||||||
|
.send({ subject: 'Your cart', body: 'You left things behind.' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('{{itemList}}');
|
||||||
|
expect(res.body.error).toContain('{{cartUrl}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an empty subject or body', async () => {
|
||||||
|
const noSubject = await request(app)
|
||||||
|
.put('/api/admin/email-templates/passwordReset')
|
||||||
|
.send({ subject: ' ', body: VALID_RESET_BODY });
|
||||||
|
expect(noSubject.status).toBe(400);
|
||||||
|
|
||||||
|
const noBody = await request(app)
|
||||||
|
.put('/api/admin/email-templates/passwordReset')
|
||||||
|
.send({ subject: 'Something', body: ' ' });
|
||||||
|
expect(noBody.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a template key it does not recognise', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.put('/api/admin/email-templates/invoiceOverdue')
|
||||||
|
.send({ subject: 'x', body: 'y' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing is stored when a save is refused, or a half-applied edit would sit
|
||||||
|
// there looking accepted.
|
||||||
|
it('stores nothing when it refuses', async () => {
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/email-templates/passwordReset')
|
||||||
|
.send({ subject: 'Your reset link', body: 'No link here.' });
|
||||||
|
|
||||||
|
const { rows } = await pool.query(`SELECT key FROM admin_settings WHERE key LIKE 'email%'`);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/admin/email-templates/:key', () => {
|
||||||
|
it('restores the built-in copy by forgetting the stored rows', async () => {
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/email-templates/passwordReset')
|
||||||
|
.send({ subject: 'Custom', body: VALID_RESET_BODY });
|
||||||
|
|
||||||
|
const res = await request(app).delete('/api/admin/email-templates/passwordReset');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const listed = await request(app).get('/api/admin/email-templates');
|
||||||
|
const reset = listed.body.find((t: { key: string }) => t.key === 'passwordReset');
|
||||||
|
expect(reset.subject).toBeNull();
|
||||||
|
expect(reset.body).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The point of the whole feature: what is stored is what customers receive.
|
||||||
|
describe('what a stored template does to the email that gets sent', () => {
|
||||||
|
it('is used in place of the default once saved', async () => {
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/email-templates/passwordReset')
|
||||||
|
.send({ subject: 'Reset requested', body: VALID_RESET_BODY });
|
||||||
|
|
||||||
|
const { subject, html } = renderTemplate(
|
||||||
|
'passwordReset',
|
||||||
|
await loadStoredTemplate('passwordReset'),
|
||||||
|
{ greeting: 'Hi Thom,', resetUrl: 'https://shop.test/r?token=abc' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(subject).toBe('Reset requested');
|
||||||
|
expect(html).toContain('New copy.');
|
||||||
|
expect(html).toContain('https://shop.test/r?token=abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the built-in copy when nothing is stored', async () => {
|
||||||
|
const { subject, html } = renderTemplate(
|
||||||
|
'passwordReset',
|
||||||
|
await loadStoredTemplate('passwordReset'),
|
||||||
|
{ greeting: 'Hi,', resetUrl: 'https://shop.test/r?token=abc' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(subject).toBe('Reset your Redefined Designs password');
|
||||||
|
expect(html).toContain('Choose a new password');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Editing the copy must not be able to remove the sentence that explains why
|
||||||
|
// the email is lawful to send.
|
||||||
|
it('keeps the consent footer on a favorite alert whose body was replaced', async () => {
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/email-templates/favoriteSold')
|
||||||
|
.send({ subject: 'Gone', body: 'Sorry, {{itemName}} sold.' });
|
||||||
|
|
||||||
|
const { html } = renderTemplate('favoriteSold', await loadStoredTemplate('favoriteSold'), {
|
||||||
|
itemName: 'Oak table',
|
||||||
|
siteUrl: 'https://shop.test'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain('Sorry, Oak table sold.');
|
||||||
|
expect(html).toContain('account page');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,14 @@ export async function resetDb(): Promise<void> {
|
|||||||
tags, categories
|
tags, categories
|
||||||
RESTART IDENTITY CASCADE
|
RESTART IDENTITY CASCADE
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// admin_settings is not truncated — it holds the seeded cart_expiry_hours
|
||||||
|
// default that other suites read. But the email template rows in it are test
|
||||||
|
// data like any other, and a stored template outliving the suite that wrote
|
||||||
|
// it silently changes the mail every later suite asserts on. That is not
|
||||||
|
// hypothetical: a subject of "Gone" written by the template tests reached the
|
||||||
|
// favorite-alert tests and made five of them fail somewhere else entirely.
|
||||||
|
await testPool.query(`DELETE FROM admin_settings WHERE key LIKE 'email\_%'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeDb(): Promise<void> {
|
export async function closeDb(): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
TEMPLATES,
|
||||||
|
TemplateKey,
|
||||||
|
missingPlaceholders,
|
||||||
|
renderTemplate
|
||||||
|
} from '../../src/emailTemplates';
|
||||||
|
|
||||||
|
const KEYS: TemplateKey[] = [
|
||||||
|
'verification',
|
||||||
|
'passwordReset',
|
||||||
|
'favoriteSold',
|
||||||
|
'favoriteWithdrawn',
|
||||||
|
'cartReminder'
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('the built-in templates', () => {
|
||||||
|
it.each(KEYS)('%s has a default subject and body', (key) => {
|
||||||
|
expect(TEMPLATES[key].defaultSubject.trim()).not.toBe('');
|
||||||
|
expect(TEMPLATES[key].defaultBody.trim()).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// A default that would be refused on save is a default nobody can edit and
|
||||||
|
// put back.
|
||||||
|
it.each(KEYS)('%s default body satisfies its own required placeholders', (key) => {
|
||||||
|
expect(missingPlaceholders(key, TEMPLATES[key].defaultBody)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('missingPlaceholders', () => {
|
||||||
|
it('names the placeholder a body has dropped', () => {
|
||||||
|
expect(missingPlaceholders('passwordReset', 'Hello, no link here.')).toEqual(['resetUrl']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is satisfied once the placeholder is present', () => {
|
||||||
|
expect(missingPlaceholders('passwordReset', 'Reset it [here]({{resetUrl}}).')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports every missing placeholder rather than the first', () => {
|
||||||
|
const missing = missingPlaceholders('cartReminder', 'You have things.');
|
||||||
|
expect(missing).toContain('itemList');
|
||||||
|
expect(missing).toContain('cartUrl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates whitespace inside the braces', () => {
|
||||||
|
expect(missingPlaceholders('passwordReset', 'Go [here]({{ resetUrl }}).')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderTemplate', () => {
|
||||||
|
const resetValues = { resetUrl: 'https://shop.test/reset-password?token=abc' };
|
||||||
|
|
||||||
|
it('substitutes a placeholder into the rendered body', () => {
|
||||||
|
const { html } = renderTemplate('passwordReset', {}, resetValues);
|
||||||
|
expect(html).toContain('https://shop.test/reset-password?token=abc');
|
||||||
|
expect(html).not.toContain('{{resetUrl}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders markdown as HTML', () => {
|
||||||
|
const { html } = renderTemplate(
|
||||||
|
'passwordReset',
|
||||||
|
{ body: 'Use **this** [link]({{resetUrl}}).' },
|
||||||
|
resetValues
|
||||||
|
);
|
||||||
|
expect(html).toContain('<strong>this</strong>');
|
||||||
|
expect(html).toContain('<a href="https://shop.test/reset-password?token=abc"');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reason markdown-it runs with html disabled. An admin editing copy must
|
||||||
|
// not be able to put script into a customer's inbox, and sanitising after the
|
||||||
|
// fact is a weaker guarantee than never emitting it.
|
||||||
|
it('escapes raw HTML in a stored body rather than passing it through', () => {
|
||||||
|
const { html } = renderTemplate(
|
||||||
|
'passwordReset',
|
||||||
|
{ body: '<script>alert(1)</script> [link]({{resetUrl}})' },
|
||||||
|
resetValues
|
||||||
|
);
|
||||||
|
expect(html).not.toContain('<script>');
|
||||||
|
expect(html).toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the built-in body when nothing is stored', () => {
|
||||||
|
const stored = renderTemplate('passwordReset', {}, resetValues);
|
||||||
|
const explicit = renderTemplate(
|
||||||
|
'passwordReset',
|
||||||
|
{ body: TEMPLATES.passwordReset.defaultBody },
|
||||||
|
resetValues
|
||||||
|
);
|
||||||
|
expect(stored.html).toBe(explicit.html);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a stored subject over the default, and substitutes into it', () => {
|
||||||
|
const { subject } = renderTemplate(
|
||||||
|
'favoriteSold',
|
||||||
|
{ subject: '{{itemName}} is gone' },
|
||||||
|
{ itemName: 'Oak table', siteUrl: 'https://shop.test' }
|
||||||
|
);
|
||||||
|
expect(subject).toBe('Oak table is gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Values are substituted into the markdown source, so a value that needs to
|
||||||
|
// become a list has to arrive as markdown. Emitting HTML here would be
|
||||||
|
// escaped and shown to the customer as literal tags.
|
||||||
|
it('renders a markdown list supplied as a placeholder value', () => {
|
||||||
|
const { html } = renderTemplate(
|
||||||
|
'cartReminder',
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
greeting: 'Hi Thom,',
|
||||||
|
itemList: '- Oak table\n- Brass lamp',
|
||||||
|
cartUrl: 'https://shop.test/cart'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(html).toContain('<ul>');
|
||||||
|
expect(html).toContain('<li>Oak table</li>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The consent sentence explains why the email is lawful to send. It is
|
||||||
|
// appended by the server precisely so that editing the copy cannot remove it.
|
||||||
|
it.each(['favoriteSold', 'favoriteWithdrawn'] as TemplateKey[])(
|
||||||
|
'appends the unremovable consent footer to %s',
|
||||||
|
(key) => {
|
||||||
|
const { html } = renderTemplate(
|
||||||
|
key,
|
||||||
|
{ body: 'Short replacement copy about {{itemName}}.' },
|
||||||
|
{ itemName: 'Oak table', siteUrl: 'https://shop.test' }
|
||||||
|
);
|
||||||
|
expect(html).toContain('favorited');
|
||||||
|
expect(html).toContain('account page');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not append that footer to templates it does not belong to', () => {
|
||||||
|
const { html } = renderTemplate('passwordReset', {}, resetValues);
|
||||||
|
expect(html).not.toContain('account page');
|
||||||
|
});
|
||||||
|
|
||||||
|
// An unsubstituted placeholder in the output means a caller forgot a value,
|
||||||
|
// and shipping "{{resetUrl}}" to a customer is worse than failing.
|
||||||
|
it('leaves no unsubstituted placeholders when every value is supplied', () => {
|
||||||
|
const { html, subject } = renderTemplate(
|
||||||
|
'cartReminder',
|
||||||
|
{},
|
||||||
|
{ greeting: 'Hi Thom,', itemList: '- One thing', cartUrl: 'https://shop.test/cart' }
|
||||||
|
);
|
||||||
|
expect(html).not.toMatch(/\{\{\s*\w+\s*\}\}/);
|
||||||
|
expect(subject).not.toMatch(/\{\{\s*\w+\s*\}\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Card from 'antd/es/card';
|
||||||
|
import Input from 'antd/es/input';
|
||||||
|
import Button from 'antd/es/button';
|
||||||
|
import Space from 'antd/es/space';
|
||||||
|
import Tag from 'antd/es/tag';
|
||||||
|
import Typography from 'antd/es/typography';
|
||||||
|
import message from 'antd/es/message';
|
||||||
|
import { EmailTemplate, saveEmailTemplate, resetEmailTemplate } from './emailTemplatesApi';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
type Props = Readonly<{
|
||||||
|
template: EmailTemplate;
|
||||||
|
onChanged: (updated: EmailTemplate) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default function EmailTemplateCard({ template, onChanged }: Props) {
|
||||||
|
// Falls back to the default so the editor starts from the real copy rather
|
||||||
|
// than an empty box. `subject`/`body` being null means "never customised",
|
||||||
|
// which is why the badge below can say so.
|
||||||
|
const [subject, setSubject] = useState(template.subject ?? template.defaultSubject);
|
||||||
|
const [body, setBody] = useState(template.body ?? template.defaultBody);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const customised = template.subject !== null || template.body !== null;
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await saveEmailTemplate(template.key, subject, body);
|
||||||
|
onChanged(updated);
|
||||||
|
message.success(`${template.label} saved`);
|
||||||
|
} catch (err) {
|
||||||
|
// The server's refusal names the placeholder that is missing, which is
|
||||||
|
// the only useful thing to say here — so it is shown rather than replaced
|
||||||
|
// with something generic.
|
||||||
|
message.error((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const restored = await resetEmailTemplate(template.key);
|
||||||
|
setSubject(template.defaultSubject);
|
||||||
|
setBody(template.defaultBody);
|
||||||
|
onChanged(restored);
|
||||||
|
message.success(`${template.label} restored to the default`);
|
||||||
|
} catch (err) {
|
||||||
|
message.error((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
{template.label}
|
||||||
|
{customised ? <Tag color="blue">Customised</Tag> : <Tag>Default</Tag>}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Paragraph type="secondary" style={{ marginBottom: 8 }}>
|
||||||
|
Markdown. Placeholders are replaced when the email is sent
|
||||||
|
{template.required.length > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}— <Text strong>{template.required.map((n) => `{{${n}}}`).join(' and ')}</Text>{' '}
|
||||||
|
{template.required.length === 1 ? 'is' : 'are'} required and cannot be removed
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Space wrap size={[4, 4]} style={{ marginBottom: 12 }}>
|
||||||
|
{template.available.map((name) => (
|
||||||
|
<Tag key={name} style={{ fontFamily: 'monospace' }}>{`{{${name}}}`}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
aria-label={`${template.label} subject`}
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder="Subject"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Input.TextArea
|
||||||
|
aria-label={`${template.label} body`}
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
autoSize={{ minRows: 6, maxRows: 16 }}
|
||||||
|
style={{ fontFamily: 'monospace', marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" loading={saving} onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
{/* Only offered when there is something to restore, so the button is
|
||||||
|
not a no-op that looks like it did something. */}
|
||||||
|
{customised && (
|
||||||
|
<Button loading={saving} onClick={handleReset}>
|
||||||
|
Restore default
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,12 +6,15 @@ import Typography from 'antd/es/typography';
|
|||||||
import message from 'antd/es/message';
|
import message from 'antd/es/message';
|
||||||
import Card from 'antd/es/card';
|
import Card from 'antd/es/card';
|
||||||
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
|
import { fetchAdminSettings, updateAdminSettings } from './adminSettingsApi';
|
||||||
|
import { EmailTemplate, fetchEmailTemplates } from './emailTemplatesApi';
|
||||||
|
import EmailTemplateCard from './EmailTemplateCard';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAdminSettings()
|
fetchAdminSettings()
|
||||||
@@ -23,6 +26,22 @@ export default function Settings() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmailTemplates()
|
||||||
|
.then(setTemplates)
|
||||||
|
// Reported rather than swallowed: an empty list would otherwise read as
|
||||||
|
// "there are no templates" instead of "they could not be loaded".
|
||||||
|
.catch(() => message.error('Could not load the email templates'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Replaces the one that changed so the Customised badge and the Restore
|
||||||
|
// button reflect what the server now holds, without refetching the rest.
|
||||||
|
function handleTemplateChanged(updated: EmailTemplate) {
|
||||||
|
setTemplates(current =>
|
||||||
|
current.map(t => (t.key === updated.key ? { ...t, subject: updated.subject, body: updated.body } : t))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
await updateAdminSettings(values);
|
await updateAdminSettings(values);
|
||||||
@@ -30,6 +49,7 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div style={{ maxWidth: 720 }}>
|
||||||
<Card style={{ maxWidth: 480 }}>
|
<Card style={{ maxWidth: 480 }}>
|
||||||
<Title level={4}>Cart Settings</Title>
|
<Title level={4}>Cart Settings</Title>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
@@ -42,5 +62,16 @@ export default function Settings() {
|
|||||||
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
<Button type="primary" onClick={handleSave} loading={loading}>Save</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Title level={4} style={{ marginTop: 32 }}>Customer emails</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
The wording customers receive. Leave one alone and it sends the built-in copy.
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
{templates.map(template => (
|
||||||
|
<EmailTemplateCard key={template.key} template={template} onChanged={handleTemplateChanged} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export interface EmailTemplate {
|
||||||
|
key: string;
|
||||||
|
/** Human name for the card, so it is identifiable without reading the body. */
|
||||||
|
label: string;
|
||||||
|
/** Placeholders the body must keep. Saving without one is refused. */
|
||||||
|
required: string[];
|
||||||
|
/** Every placeholder this template understands. */
|
||||||
|
available: string[];
|
||||||
|
defaultSubject: string;
|
||||||
|
defaultBody: string;
|
||||||
|
/** Null when never customised, which is distinct from "same as the default". */
|
||||||
|
subject: string | null;
|
||||||
|
body: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json<T>(res: Response): Promise<T> {
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's refusals say which placeholder is missing, and that message is
|
||||||
|
// the whole point of the validation — so it is surfaced rather than replaced
|
||||||
|
// with something generic.
|
||||||
|
async function expectOk(res: Response, action: string): Promise<Response> {
|
||||||
|
if (res.ok) return res;
|
||||||
|
const detail = await res.json().catch(() => null);
|
||||||
|
throw new Error(detail?.error ? String(detail.error) : action);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEmailTemplates(): Promise<EmailTemplate[]> {
|
||||||
|
const res = await expectOk(await fetch('/api/admin/email-templates'), 'could not load templates');
|
||||||
|
return json<EmailTemplate[]>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEmailTemplate(
|
||||||
|
key: string,
|
||||||
|
subject: string,
|
||||||
|
body: string
|
||||||
|
): Promise<EmailTemplate> {
|
||||||
|
const res = await expectOk(
|
||||||
|
await fetch(`/api/admin/email-templates/${key}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subject, body })
|
||||||
|
}),
|
||||||
|
'could not save the template'
|
||||||
|
);
|
||||||
|
return json<EmailTemplate>(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetEmailTemplate(key: string): Promise<EmailTemplate> {
|
||||||
|
const res = await expectOk(
|
||||||
|
await fetch(`/api/admin/email-templates/${key}`, { method: 'DELETE' }),
|
||||||
|
'could not restore the default'
|
||||||
|
);
|
||||||
|
return json<EmailTemplate>(res);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
|
||||||
|
const suffix = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
|
||||||
|
// Each test leaves the templates as it found them, because they are stored in
|
||||||
|
// admin_settings and would otherwise change the copy a later test reads.
|
||||||
|
async function restore(page: import('@playwright/test').Page, key: string) {
|
||||||
|
await page.request.delete(`/api/admin/email-templates/${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings(page: import('@playwright/test').Page) {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Customer emails' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serial: these edit one shared stored template, and the suite runs fully
|
||||||
|
// parallel by default — so run concurrently they would race, one asserting a
|
||||||
|
// template is unset while another has just saved it.
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Editing the customer emails', () => {
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await restore(page, 'passwordReset');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows every template, marked default until it is edited', async ({ page }) => {
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
for (const label of [
|
||||||
|
'Email verification',
|
||||||
|
'Password reset',
|
||||||
|
'Favorited item sold',
|
||||||
|
'Favorited item withdrawn',
|
||||||
|
'Cart reminder'
|
||||||
|
]) {
|
||||||
|
await expect(page.getByText(label, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves a replacement subject and body', async ({ page }) => {
|
||||||
|
const subject = `Reset ${suffix()}`;
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
await page.getByLabel('Password reset subject').fill(subject);
|
||||||
|
await page
|
||||||
|
.getByLabel('Password reset body')
|
||||||
|
.fill('Fresh wording. [Choose a new password]({{resetUrl}}).');
|
||||||
|
|
||||||
|
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
|
||||||
|
await card.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Password reset saved')).toBeVisible();
|
||||||
|
|
||||||
|
// Persisted, not merely accepted by the form.
|
||||||
|
const stored = await (await page.request.get('/api/admin/email-templates')).json();
|
||||||
|
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
|
||||||
|
expect(reset.subject).toBe(subject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The assertion that matters. A body without its link still sends and still
|
||||||
|
// looks fine in the log, so the save has to be refused rather than warned
|
||||||
|
// about — and the admin has to be told which placeholder is missing.
|
||||||
|
test('refuses a body that drops the required placeholder, and says which', async ({ page }) => {
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
await page.getByLabel('Password reset body').fill('Just click the thing in your email.');
|
||||||
|
|
||||||
|
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
|
||||||
|
await card.getByRole('button', { name: 'Save', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('the body must keep {{resetUrl}}')).toBeVisible();
|
||||||
|
|
||||||
|
// And nothing was stored.
|
||||||
|
const stored = await (await page.request.get('/api/admin/email-templates')).json();
|
||||||
|
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
|
||||||
|
expect(reset.body).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restores the built-in copy', async ({ page }) => {
|
||||||
|
await page.request.put('/api/admin/email-templates/passwordReset', {
|
||||||
|
data: { subject: 'Temporary', body: 'Temporary [link]({{resetUrl}}).' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await openSettings(page);
|
||||||
|
const card = page.locator('.ant-card').filter({ hasText: 'Password reset' });
|
||||||
|
await card.getByRole('button', { name: 'Restore default' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Password reset restored to the default')).toBeVisible();
|
||||||
|
|
||||||
|
const stored = await (await page.request.get('/api/admin/email-templates')).json();
|
||||||
|
const reset = stored.find((t: { key: string }) => t.key === 'passwordReset');
|
||||||
|
expect(reset.subject).toBeNull();
|
||||||
|
expect(reset.body).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user