diff --git a/package-lock.json b/package-lock.json index 79e347532a6..425d87fcc8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -144,6 +144,7 @@ "chai-jest-snapshot": "^2.0.0", "chai-sorted": "^0.2.0", "chai-subset": "^1.6.0", + "chokidar": "^4.0.1", "commitizen": "^4.3.0", "cz-conventional-changelog": "^3.3.0", "eslint": "^9.14.0", @@ -173,7 +174,8 @@ "supertest": "^7.0.0", "ts-unused-exports": "^10.1.0", "typescript": "5.7.2", - "typescript-eslint": "^8.13.0" + "typescript-eslint": "^8.13.0", + "ws": "^8.18.0" }, "engines": { "node": "20.x", @@ -2030,6 +2032,32 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/cli/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/@babel/cli/node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2039,6 +2067,20 @@ "node": ">= 6" } }, + "node_modules/@babel/cli/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -11756,6 +11798,7 @@ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, + "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -12321,12 +12364,16 @@ } }, "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/bl": { @@ -13009,30 +13056,19 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], + "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/chownr": { @@ -16477,6 +16513,21 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -17830,6 +17881,7 @@ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, + "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" }, @@ -20186,6 +20238,34 @@ "node": ">=6" } }, + "node_modules/mocha/node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/mocha/node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -20375,6 +20455,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mocha/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -20880,6 +20973,31 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/nodemon/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -20892,6 +21010,19 @@ "node": ">=10" } }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/nodemon/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -20926,6 +21057,7 @@ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -22892,15 +23024,17 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, + "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/redis": { diff --git a/package.json b/package.json index 0d98f51be51..76b6f766b87 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "chai-jest-snapshot": "^2.0.0", "chai-sorted": "^0.2.0", "chai-subset": "^1.6.0", + "chokidar": "^4.0.1", "commitizen": "^4.3.0", "cz-conventional-changelog": "^3.3.0", "eslint": "^9.14.0", @@ -194,7 +195,8 @@ "supertest": "^7.0.0", "ts-unused-exports": "^10.1.0", "typescript": "5.7.2", - "typescript-eslint": "^8.13.0" + "typescript-eslint": "^8.13.0", + "ws": "^8.18.0" }, "scripts": { "build": "npm run build:clean && npm run build:updates && npm run build:server", @@ -240,6 +242,7 @@ "lint:check": "npm run lint -- --quiet", "lint:fix": "npm run lint -- --fix", "mailpit": "./scripts/dev/run-docker.sh compose -f docker-compose.dev.yml --profile mail up", + "mail:preview": "npm run script scripts/dev/dev-email-preview.ts", "postinstall": "./scripts/postinstall.sh", "prettier": "prettier \"**/*.@(js|ts|json|md)\"", "prettier:check": "npm run prettier -- --check", diff --git a/scripts/dev/dev-email-preview.ts b/scripts/dev/dev-email-preview.ts new file mode 100644 index 00000000000..8e4d7d3fdee --- /dev/null +++ b/scripts/dev/dev-email-preview.ts @@ -0,0 +1,218 @@ +import http from 'http'; +import path from 'path'; + +import chokidar from 'chokidar'; +import express from 'express'; +import { Server as WebSocketServer, WebSocket } from 'ws'; + +import EmailLib, { getTemplateAttributes } from '../../server/lib/email'; +import templates, { isValidTemplate, recompileAllTemplates } from '../../server/lib/emailTemplates'; +import { stripHTML } from '../../server/lib/sanitize-html'; +import MOCKS from '../../test/mocks/data'; + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocketServer({ server }); + +const port = 2999; +const templatesDir = path.join(__dirname, '../..', 'templates', 'emails'); + +const mockData = { + host: MOCKS.host1, + collective: MOCKS.collective1, + fromCollective: MOCKS.collective2, + user: MOCKS.user1, + order: MOCKS.order1, + remoteUser: MOCKS.user1, + recipient: MOCKS.user2, + event: MOCKS.event1, + expense: MOCKS.expense1, + items: MOCKS.expense1.items, + email: 'test@opencollective.com', +}; + +// WebSocket connection handler +wss.on('connection', ws => { + console.log('Client connected'); + ws.on('close', () => console.log('Client disconnected')); +}); + +// File watcher +const watcher = chokidar.watch(templatesDir, { + ignored: /(^|[\/\\])\../, // ignore dotfiles + persistent: true, +}); + +watcher.on('change', path => { + console.log(`File ${path} has been changed`); + wss.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + recompileAllTemplates(); + client.send('reload'); + } + }); +}); + +app.get('/', async (req, res) => { + try { + const templateList = Object.keys(templates).sort(); + + const html = ` + + +
+Template not found: ${safeTemplateName}
${safeTemplateName}
${renderEmail(safeTemplateName).text}+ + + `); +}); + +app.get('/render/:template', async (req, res) => { + const templateName = req.params.template; + try { + if (!isValidTemplate(templateName)) { + throw new Error(`Template not found: ${templateName}`); + } + + const renderResult = renderEmail(templateName); + const attributes = getTemplateAttributes(renderResult.html); + res.send(attributes.body); + } catch (error) { + res.status(400).send(` + + + +
${stripHTML(error.message)}. Details:
+${stripHTML(error.stack)}+ + + `); + } +}); +app.get('/render/:template/title', async (req, res) => { + const templateName = req.params.template; + try { + const renderResult = renderEmail(templateName); + const attributes = getTemplateAttributes(renderResult.html); + res.send(attributes.subject); + } catch (error) { + console.error(error); + res.status(400).send(`Error while generating title`); + } +}); + +server.listen(port, () => { + console.log(`Email preview server running at http://localhost:${port}`); +}); diff --git a/server/lib/email.ts b/server/lib/email.ts index bd8852334f2..10905730b67 100644 --- a/server/lib/email.ts +++ b/server/lib/email.ts @@ -90,7 +90,7 @@ const isValidUnsubscribeToken = (token, email, collectiveSlug, type) => { /* * Gets the body from a string (usually a template) */ -const getTemplateAttributes = (str: string) => { +export const getTemplateAttributes = (str: string) => { let index = 0; const lines = str.split('\n'); const attributes = { body: '', subject: '' }; @@ -273,7 +273,7 @@ const generateEmailFromTemplate = ( recipient, data: SendMessageData = {}, options: SendMessageOptions = {}, -) => { +): ReturnType