diff --git a/backend/package-lock.json b/backend/package-lock.json index 4854063..4653eda 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,10 +8,12 @@ "name": "redefined-designs-backend", "version": "1.0.0", "dependencies": { + "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", "express": "^4.19.2", "express-rate-limit": "^8.6.2", + "markdown-it": "^15.0.0", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", "node-pg-migrate": "^7.6.1", @@ -1944,6 +1946,28 @@ "dev": true, "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": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -3401,6 +3425,18 @@ "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": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5562,6 +5598,25 @@ "dev": true, "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -5645,6 +5700,49 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5654,6 +5752,12 @@ "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": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6472,6 +6576,15 @@ "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": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -7428,6 +7541,12 @@ "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": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", diff --git a/backend/package.json b/backend/package.json index fe3da80..64bc930 100755 --- a/backend/package.json +++ b/backend/package.json @@ -22,10 +22,12 @@ "migrate:create": "node-pg-migrate create --migration-file-language js" }, "dependencies": { + "@types/markdown-it": "^14.2.0", "bcryptjs": "^2.4.3", "cookie-parser": "^1.4.6", "express": "^4.19.2", "express-rate-limit": "^8.6.2", + "markdown-it": "^15.0.0", "multer": "^1.4.5-lts.1", "node-cron": "^3.0.3", "node-pg-migrate": "^7.6.1", diff --git a/backend/src/app.ts b/backend/src/app.ts index f674ee1..e55c9ce 100755 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,7 @@ import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRoute import adminRouter from './routes/admin'; import adminCustomersRouter from './routes/adminCustomers'; import adminSettingsRouter from './routes/adminSettings'; +import adminEmailTemplatesRouter from './routes/adminEmailTemplates'; import adminCategoriesRouter from './routes/adminCategories'; import adminTagsRouter from './routes/adminTags'; 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. app.use('/api/admin/customers', requireAdminGate, adminCustomersRouter); 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/tags', requireAdminGate, adminTagsRouter); app.use('/api/admin', requireAdminGate, adminRouter); diff --git a/backend/src/emailTemplates.ts b/backend/src/emailTemplates.ts new file mode 100644 index 0000000..136a44f --- /dev/null +++ b/backend/src/emailTemplates.ts @@ -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 = + '
You are receiving this because you asked to be told when a favorited item becomes ' + + 'unavailable. You can turn these off on your account page.
'; + +export const TEMPLATES: RecordYou are receiving this because you asked to be told when a favorited item becomes - unavailable. You can turn these off on your account page.
`; // 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 @@ -60,27 +71,16 @@ const FOOTER = `You are receiving this because you asked to be told when a fa
// longer available reads as a bug.
export async function notifyFavoritersOfSale(itemIds: number[], buyerId: number | null): Promise An item you favorited has been sold to another customer, so it is no longer available. ${name} Every piece is one of a kind, so this one will not be restocked. You can browse what is
- still available at Redefined Designs. An item you favorited has been withdrawn and is no longer available. ${name} You can browse what is still available at
- Redefined Designs. Welcome! Please verify your email to finish setting up your account. Someone asked to reset the password for this account. Choose a new password. This link expires in one hour. If this wasn't you, you can ignore this email — your password has not changed. Hi${data.firstName ? ' ' + data.firstName : ''}, You still have items in your cart at Redefined Designs: View your cart before your reservation expires.${itemList}
-