From ff0667ce5226895312699aa72e83fe88f6ae0442 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 18:43:14 +0200 Subject: [PATCH] feat(deps): adopt react-email for portal-auth template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the activation + reset email templates from hand-strung HTML strings to React components rendered via @react-email/components. Concrete wins this lands: - React auto-escapes interpolation — drops the hand-rolled escapeHtml() helper. Eliminates the entire class of "I forgot to escape" XSS bugs. - @react-email primitives (Button, Hr, Link, Text) render to Outlook/Gmail/AppleMail-safe inline-styled HTML. - JSX over template strings makes the templates editable / reviewable. - Sets the pattern for the remaining 7 templates (crm-invite, document-signing, inquiry-*, notification-digest, admin-email-change, residential-inquiry). Migrate opportunistically when those files are next touched. The shell (logo, blurred background, table-based wrapper) stays via renderShell so this is a strictly inner-body migration — visual parity preserved. Vitest config: added @vitejs/plugin-react so .tsx files imported by tests (transitively via the service that uses the template) transform correctly under Next's tsconfig `jsx: 'preserve'` setting. Verified: tsc clean, vitest 1293/1293 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 4 + pnpm-lock.yaml | 704 +++++++++++++++++++++++- src/lib/email/templates/portal-auth.ts | 144 ----- src/lib/email/templates/portal-auth.tsx | 223 ++++++++ src/lib/services/portal-auth.service.ts | 4 +- vitest.config.ts | 7 + 6 files changed, 938 insertions(+), 148 deletions(-) delete mode 100644 src/lib/email/templates/portal-auth.ts create mode 100644 src/lib/email/templates/portal-auth.tsx diff --git a/package.json b/package.json index 91ebacc8..06b6e5a8 100644 --- a/package.json +++ b/package.json @@ -56,11 +56,13 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@react-email/components": "^1.0.12", "@socket.io/redis-adapter": "^8.3.0", "@tanstack/query-broadcast-client-experimental": "^5.100.10", "@tanstack/react-query": "^5.100.10", "@tanstack/react-query-devtools": "^5.100.10", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.24", "@types/pdfkit": "^0.17.6", "archiver": "^7.0.1", "better-auth": "^1.6.10", @@ -92,6 +94,7 @@ "react-day-picker": "^10.0.0", "react-dom": "^19.2.6", "react-easy-crop": "^5.5.7", + "react-email": "^6.1.3", "react-hook-form": "^7.75.0", "recharts": "^3.8.1", "sharp": "^0.34.5", @@ -120,6 +123,7 @@ "@types/nodemailer": "^8.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "^4.1.6", "autoprefixer": "^10.5.0", "dotenv": "^17.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5806093..146ae6ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-email/components': + specifier: ^1.0.12 + version: 1.0.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.6) @@ -112,6 +115,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-virtual': + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@types/pdfkit': specifier: ^0.17.6 version: 0.17.6 @@ -205,6 +211,9 @@ importers: react-easy-crop: specifier: ^5.5.7 version: 5.5.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-email: + specifier: ^6.1.3 + version: 6.1.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-hook-form: specifier: ^7.75.0 version: 7.75.0(react@19.2.6) @@ -284,6 +293,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4)) '@vitest/coverage-v8': specifier: ^4.1.6 version: 4.1.6(vitest@4.1.6) @@ -369,6 +381,11 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} @@ -386,6 +403,10 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.27.0': + resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} @@ -1752,6 +1773,192 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-email/body@0.3.0': + resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/button@0.2.1': + resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-block@0.2.1': + resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-inline@0.0.6': + resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/column@0.0.14': + resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/components@1.0.12': + resolution: {integrity: sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/container@0.0.16': + resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/font@0.0.10': + resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/head@0.0.13': + resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/heading@0.0.16': + resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/hr@0.0.12': + resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/html@0.0.12': + resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/img@0.0.12': + resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/link@0.0.13': + resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/markdown@0.0.18': + resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/preview@0.0.14': + resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/render@2.0.6': + resolution: {integrity: sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/render@2.0.8': + resolution: {integrity: sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/row@0.0.13': + resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/section@0.0.17': + resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/tailwind@2.0.7': + resolution: {integrity: sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + '@react-email/body': '>=0' + '@react-email/button': '>=0' + '@react-email/code-block': '>=0' + '@react-email/code-inline': '>=0' + '@react-email/container': '>=0' + '@react-email/heading': '>=0' + '@react-email/hr': '>=0' + '@react-email/img': '>=0' + '@react-email/link': '>=0' + '@react-email/preview': '>=0' + '@react-email/text': '>=0' + react: ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@react-email/body': + optional: true + '@react-email/button': + optional: true + '@react-email/code-block': + optional: true + '@react-email/code-inline': + optional: true + '@react-email/container': + optional: true + '@react-email/heading': + optional: true + '@react-email/hr': + optional: true + '@react-email/img': + optional: true + '@react-email/link': + optional: true + '@react-email/preview': + optional: true + + '@react-email/text@0.1.6': + resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==} + engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@react-grab/cli@0.1.34': resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==} hasBin: true @@ -1865,6 +2072,9 @@ packages: '@rolldown/pluginutils@1.0.0-rc.12': resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==} + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1922,10 +2132,19 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@total-typescript/ts-reset@0.6.1': resolution: {integrity: sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==} @@ -2189,6 +2408,19 @@ packages: cpu: [x64] os: [win32] + '@vitejs/plugin-react@6.0.1': + resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: 8.0.5 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + '@vitest/coverage-v8@4.1.6': resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} peerDependencies: @@ -2258,9 +2490,20 @@ packages: air-datepicker@3.6.0: resolution: {integrity: sha512-+txUkqa949rXBJDmkQAIb/GehZECJYF4rm9XJxVYtEX22C9WvBpE/XwCUQZBopKIkpg4ycAySJ9lH3JOg9qQTw==} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} @@ -2363,6 +2606,9 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -2635,6 +2881,13 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2697,6 +2950,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -2716,6 +2973,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@15.1.0: + resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} + engines: {node: '>=20'} + convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -2754,6 +3015,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2827,9 +3092,17 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debounce-fn@6.0.0: + resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} + engines: {node: '>=18'} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + debounce@2.2.0: + resolution: {integrity: sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==} + engines: {node: '>=18'} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2925,6 +3198,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -3313,6 +3590,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -3437,6 +3717,14 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3749,6 +4037,10 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -3783,6 +4075,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3790,6 +4088,11 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -3973,6 +4276,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + lucide-react@1.14.0: resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} peerDependencies: @@ -3998,10 +4305,18 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} @@ -4017,10 +4332,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -4191,6 +4514,11 @@ packages: notepack.io@3.0.1: resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + nypm@0.6.6: + resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==} + engines: {node: '>=18'} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -4346,6 +4674,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -4373,6 +4705,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picospinner@3.0.0: + resolution: {integrity: sha512-lGA1TNsmy2bxvRsTI2cV01kfTwKzZjnZSDmF9llYNyMHMrU4sP87lQ5taiIKm88L3cbswjl008nwyGc3WpNvzg==} + engines: {node: '>=18.0.0'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -4472,6 +4808,10 @@ packages: engines: {node: '>=14'} hasBin: true + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -4527,6 +4867,14 @@ packages: react: '>=16.4.0' react-dom: '>=16.4.0' + react-email@6.1.3: + resolution: {integrity: sha512-S+zukMTZGGH9xc8lPynXmPN5plRZ8MdVkTNfqkgcavAyPeUXB5nuHhCGpJAcdt9hdtsN7vjvzqgQvXT9SkEE8A==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + react-grab@0.1.34: resolution: {integrity: sha512-jtdOdv0kb90oqL+pMszSh9DOLgVRaX4ZE6XN4GkDEpNNUqveQfZT014+EeJraljpqQfuWKW+96NrrRqUD93D2g==} hasBin: true @@ -4620,6 +4968,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -4659,6 +5011,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -4995,6 +5351,12 @@ packages: strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -5024,6 +5386,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -5037,6 +5403,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -5121,6 +5490,10 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} @@ -5136,6 +5509,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -5164,6 +5541,10 @@ packages: resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} engines: {node: '>= 4.0.0'} + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -5353,6 +5734,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5527,6 +5911,10 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/parser@7.27.0': + dependencies: + '@babel/types': 7.29.0 + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -5541,6 +5929,18 @@ snapshots: '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + '@babel/traverse@7.27.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -6809,6 +7209,137 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-email/body@0.3.0(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/button@0.2.1(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/code-block@0.2.1(react@19.2.6)': + dependencies: + prismjs: 1.30.0 + react: 19.2.6 + + '@react-email/code-inline@0.0.6(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/column@0.0.14(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/components@1.0.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@react-email/body': 0.3.0(react@19.2.6) + '@react-email/button': 0.2.1(react@19.2.6) + '@react-email/code-block': 0.2.1(react@19.2.6) + '@react-email/code-inline': 0.0.6(react@19.2.6) + '@react-email/column': 0.0.14(react@19.2.6) + '@react-email/container': 0.0.16(react@19.2.6) + '@react-email/font': 0.0.10(react@19.2.6) + '@react-email/head': 0.0.13(react@19.2.6) + '@react-email/heading': 0.0.16(react@19.2.6) + '@react-email/hr': 0.0.12(react@19.2.6) + '@react-email/html': 0.0.12(react@19.2.6) + '@react-email/img': 0.0.12(react@19.2.6) + '@react-email/link': 0.0.13(react@19.2.6) + '@react-email/markdown': 0.0.18(react@19.2.6) + '@react-email/preview': 0.0.14(react@19.2.6) + '@react-email/render': 2.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-email/row': 0.0.13(react@19.2.6) + '@react-email/section': 0.0.17(react@19.2.6) + '@react-email/tailwind': 2.0.7(@react-email/body@0.3.0(react@19.2.6))(@react-email/button@0.2.1(react@19.2.6))(@react-email/code-block@0.2.1(react@19.2.6))(@react-email/code-inline@0.0.6(react@19.2.6))(@react-email/container@0.0.16(react@19.2.6))(@react-email/heading@0.0.16(react@19.2.6))(@react-email/hr@0.0.12(react@19.2.6))(@react-email/img@0.0.12(react@19.2.6))(@react-email/link@0.0.13(react@19.2.6))(@react-email/preview@0.0.14(react@19.2.6))(@react-email/text@0.1.6(react@19.2.6))(react@19.2.6) + '@react-email/text': 0.1.6(react@19.2.6) + react: 19.2.6 + transitivePeerDependencies: + - react-dom + + '@react-email/container@0.0.16(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/font@0.0.10(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/head@0.0.13(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/heading@0.0.16(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/hr@0.0.12(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/html@0.0.12(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/img@0.0.12(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/link@0.0.13(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/markdown@0.0.18(react@19.2.6)': + dependencies: + marked: 15.0.12 + react: 19.2.6 + + '@react-email/preview@0.0.14(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/render@2.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@react-email/render@2.0.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.8.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@react-email/row@0.0.13(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/section@0.0.17(react@19.2.6)': + dependencies: + react: 19.2.6 + + '@react-email/tailwind@2.0.7(@react-email/body@0.3.0(react@19.2.6))(@react-email/button@0.2.1(react@19.2.6))(@react-email/code-block@0.2.1(react@19.2.6))(@react-email/code-inline@0.0.6(react@19.2.6))(@react-email/container@0.0.16(react@19.2.6))(@react-email/heading@0.0.16(react@19.2.6))(@react-email/hr@0.0.12(react@19.2.6))(@react-email/img@0.0.12(react@19.2.6))(@react-email/link@0.0.13(react@19.2.6))(@react-email/preview@0.0.14(react@19.2.6))(@react-email/text@0.1.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@react-email/text': 0.1.6(react@19.2.6) + react: 19.2.6 + tailwindcss: 4.3.0 + optionalDependencies: + '@react-email/body': 0.3.0(react@19.2.6) + '@react-email/button': 0.2.1(react@19.2.6) + '@react-email/code-block': 0.2.1(react@19.2.6) + '@react-email/code-inline': 0.0.6(react@19.2.6) + '@react-email/container': 0.0.16(react@19.2.6) + '@react-email/heading': 0.0.16(react@19.2.6) + '@react-email/hr': 0.0.12(react@19.2.6) + '@react-email/img': 0.0.12(react@19.2.6) + '@react-email/link': 0.0.13(react@19.2.6) + '@react-email/preview': 0.0.14(react@19.2.6) + + '@react-email/text@0.1.6(react@19.2.6)': + dependencies: + react: 19.2.6 + '@react-grab/cli@0.1.34': dependencies: commander: 14.0.3 @@ -6885,6 +7416,8 @@ snapshots: '@rolldown/pluginutils@1.0.0-rc.12': {} + '@rolldown/pluginutils@1.0.0-rc.7': {} + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.16.1': {} @@ -6943,8 +7476,16 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + '@tanstack/react-virtual@3.13.24(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/virtual-core': 3.14.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.14.0': {} + '@total-typescript/ts-reset@0.6.1': {} '@tybys/wasm-util@0.10.2': @@ -7199,6 +7740,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vitejs/plugin-react@6.0.1(vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: 8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4) + '@vitest/coverage-v8@4.1.6(vitest@4.1.6)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -7287,6 +7833,10 @@ snapshots: air-datepicker@3.6.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -7294,6 +7844,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 @@ -7432,6 +7989,11 @@ snapshots: atomic-sleep@1.0.0: {} + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + autoprefixer@10.5.0(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -7664,6 +8226,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.2.2: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -7722,6 +8290,8 @@ snapshots: colorette@2.0.20: {} + commander@13.1.0: {} + commander@14.0.3: {} commander@4.1.1: {} @@ -7738,6 +8308,18 @@ snapshots: concat-map@0.0.1: {} + conf@15.1.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + atomically: 2.1.1 + debounce-fn: 6.0.0 + dot-prop: 10.1.0 + env-paths: 3.0.0 + json-schema-typed: 8.0.2 + semver: 7.8.0 + uint8array-extras: 1.5.0 + convert-source-map@1.9.0: {} convert-source-map@2.0.0: {} @@ -7776,6 +8358,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -7842,8 +8429,14 @@ snapshots: dateformat@4.6.3: {} + debounce-fn@6.0.0: + dependencies: + mimic-function: 5.0.1 + debounce@1.2.1: {} + debounce@2.2.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -7918,6 +8511,10 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dot-prop@10.1.0: + dependencies: + type-fest: 5.6.0 + dotenv@17.4.2: {} drizzle-kit@0.31.10: @@ -7995,8 +8592,7 @@ snapshots: entities@4.5.0: {} - env-paths@3.0.0: - optional: true + env-paths@3.0.0: {} environment@1.1.0: {} @@ -8392,6 +8988,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-uri@3.1.2: {} + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -8541,6 +9139,14 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@11.12.0: {} + globals@14.0.0: {} globalthis@1.0.4: @@ -8849,6 +9455,8 @@ snapshots: jiti@1.21.7: {} + jiti@2.4.2: {} + jose@6.2.3: {} joycon@3.1.1: {} @@ -8871,12 +9479,18 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: dependencies: minimist: 1.2.8 + json5@2.2.3: {} + jsonc-parser@3.3.1: {} jsx-ast-utils@3.3.5: @@ -9045,6 +9659,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.3.6: {} + lucide-react@1.14.0(react@19.2.6): dependencies: react: 19.2.6 @@ -9080,8 +9696,12 @@ snapshots: dependencies: semver: 7.8.0 + marked@15.0.12: {} + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + memory-pager@1.5.0: optional: true @@ -9094,10 +9714,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-function@5.0.1: {} minimatch@10.2.5: @@ -9250,6 +9876,12 @@ snapshots: notepack.io@3.0.1: {} + nypm@0.6.6: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.1.2 + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -9405,6 +10037,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.3.6 + minipass: 7.1.3 + path-type@4.0.0: {} pathe@2.0.3: {} @@ -9433,6 +10070,8 @@ snapshots: picomatch@4.0.4: {} + picospinner@3.0.0: {} + pify@2.3.0: {} pino-abstract-transport@3.0.0: @@ -9532,6 +10171,8 @@ snapshots: prettier@3.8.3: {} + prismjs@1.30.0: {} + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} @@ -9587,6 +10228,37 @@ snapshots: react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 + react-email@6.1.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@babel/parser': 7.27.0 + '@babel/traverse': 7.27.0 + '@react-email/render': 2.0.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + chokidar: 4.0.3 + commander: 13.1.0 + conf: 15.1.0 + css-tree: 3.2.1 + debounce: 2.2.0 + esbuild: 0.28.0 + glob: 13.0.6 + jiti: 2.4.2 + log-symbols: 7.0.1 + marked: 15.0.12 + mime-types: 3.0.2 + normalize-path: 3.0.0 + nypm: 0.6.6 + picospinner: 3.0.0 + prismjs: 1.30.0 + prompts: 2.4.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + socket.io: 4.8.3 + tailwindcss: 4.3.0 + tsconfig-paths: 4.2.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + react-grab@0.1.34(react@19.2.6): dependencies: '@react-grab/cli': 0.1.34 @@ -9680,6 +10352,8 @@ snapshots: dependencies: picomatch: 2.3.2 + readdirp@4.1.2: {} + real-require@0.2.0: {} recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1): @@ -9736,6 +10410,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} @@ -10168,6 +10844,12 @@ snapshots: strnum@2.3.0: {} + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + styled-jsx@5.1.6(react@19.2.6): dependencies: client-only: 0.0.1 @@ -10191,6 +10873,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.4)): @@ -10225,6 +10909,8 @@ snapshots: - tsx - yaml + tailwindcss@4.3.0: {} + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -10326,6 +11012,12 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + tslib@1.14.1: {} tslib@2.8.1: {} @@ -10341,6 +11033,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -10380,6 +11076,8 @@ snapshots: uid2@1.0.0: {} + uint8array-extras@1.5.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -10577,6 +11275,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + when-exit@2.1.5: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/src/lib/email/templates/portal-auth.ts b/src/lib/email/templates/portal-auth.ts deleted file mode 100644 index 6868afda..00000000 --- a/src/lib/email/templates/portal-auth.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; - -interface ActivationData { - portName: string; - link: string; - ttlHours: number; - recipientName?: string; -} - -interface ResetData { - portName: string; - link: string; - ttlMinutes: number; - recipientName?: string; -} - -interface RenderOpts { - subject?: string | null; - branding?: BrandingShell | null; -} - -export function activationEmail( - data: ActivationData, - overrides?: RenderOpts, -): { - subject: string; - html: string; - text: string; -} { - const subject = overrides?.subject - ? overrides.subject - .replace(/\{\{portName\}\}/g, data.portName) - .replace(/\{\{recipientName\}\}/g, data.recipientName ?? '') - .replace(/\{\{ttlHours\}\}/g, String(data.ttlHours)) - : `Activate your ${data.portName} client portal account`; - const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Welcome,'; - const accent = brandingPrimaryColor(overrides?.branding); - - const body = ` -

- Welcome to ${escapeHtml(data.portName)} -

-

${greeting}

-

- You've been invited to access the ${escapeHtml(data.portName)} client portal. - Click the button below to set your password and activate your account. - The link expires in ${data.ttlHours} hours. -

-

- - Activate account - -

-

- If the button doesn't work, paste this link into your browser:
- ${data.link} -

-

- Thank you,
- ${escapeHtml(data.portName)} CRM -

`; - - const text = [ - `Welcome to ${data.portName}`, - '', - `You've been invited to access the ${data.portName} client portal.`, - `Activate your account by visiting: ${data.link}`, - '', - `The link expires in ${data.ttlHours} hours.`, - '', - `Thank you,`, - `${data.portName} CRM`, - ].join('\n'); - - return { - subject, - html: renderShell({ title: subject, body, branding: overrides?.branding }), - text, - }; -} - -export function resetEmail( - data: ResetData, - overrides?: RenderOpts, -): { subject: string; html: string; text: string } { - const subject = overrides?.subject - ? overrides.subject - .replace(/\{\{portName\}\}/g, data.portName) - .replace(/\{\{recipientName\}\}/g, data.recipientName ?? '') - .replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes)) - : `Reset your ${data.portName} client portal password`; - const greeting = data.recipientName ? `Dear ${escapeHtml(data.recipientName)},` : 'Hello,'; - const accent = brandingPrimaryColor(overrides?.branding); - - const body = ` -

- Password reset -

-

${greeting}

-

- We received a request to reset the password on your ${escapeHtml(data.portName)} - client portal account. Click the button below to choose a new one. - The link expires in ${data.ttlMinutes} minutes. -

-

- - Reset password - -

-

- If you didn't request this, you can safely ignore this email - your password will remain unchanged. -

-

- Thank you,
- ${escapeHtml(data.portName)} CRM -

`; - - const text = [ - `Password reset for ${data.portName}`, - '', - `Reset your password by visiting: ${data.link}`, - `The link expires in ${data.ttlMinutes} minutes.`, - '', - `If you didn't request this, you can safely ignore this email.`, - '', - `Thank you,`, - `${data.portName} CRM`, - ].join('\n'); - - return { - subject, - html: renderShell({ title: subject, body, branding: overrides?.branding }), - text, - }; -} - -function escapeHtml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} diff --git a/src/lib/email/templates/portal-auth.tsx b/src/lib/email/templates/portal-auth.tsx new file mode 100644 index 00000000..02248375 --- /dev/null +++ b/src/lib/email/templates/portal-auth.tsx @@ -0,0 +1,223 @@ +import { render } from '@react-email/components'; +import { Button, Hr, Link, Text } from '@react-email/components'; +import * as React from 'react'; + +import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell'; + +interface ActivationData { + portName: string; + link: string; + ttlHours: number; + recipientName?: string; +} + +interface ResetData { + portName: string; + link: string; + ttlMinutes: number; + recipientName?: string; +} + +interface RenderOpts { + subject?: string | null; + branding?: BrandingShell | null; +} + +// ─── React-email body components ────────────────────────────────────────────── + +// react-email's `render()` auto-escapes string interpolation, so we don't +// need our hand-rolled escapeHtml() on these bodies. Inline styles use +// camelCase per CSSProperties — react-email serialises them to +// email-client-safe inline `style="..."` attributes on output. + +function ActivationBody({ + portName, + link, + ttlHours, + recipientName, + accent, +}: ActivationData & { accent: string }) { + const greeting = recipientName ? `Dear ${recipientName},` : 'Welcome,'; + + return ( + <> + + Welcome to {portName} + + {greeting} + + You've been invited to access the {portName} client portal. Click the button below to + set your password and activate your account. The link expires in {ttlHours} hours. + +
+ +
+
+ + If the button doesn't work, paste this link into your browser: +
+ + {link} + +
+ + Thank you, +
+ {portName} CRM +
+ + ); +} + +function ResetBody({ + portName, + link, + ttlMinutes, + recipientName, + accent, +}: ResetData & { accent: string }) { + const greeting = recipientName ? `Dear ${recipientName},` : 'Hello,'; + + return ( + <> + + Password reset + + {greeting} + + We received a request to reset the password on your {portName} client portal account. Click + the button below to choose a new one. The link expires in {ttlMinutes} minutes. + +
+ +
+
+ + If you didn't request this, you can safely ignore this email — your password will + remain unchanged. + + + Thank you, +
+ {portName} CRM +
+ + ); +} + +// ─── Public surface ─────────────────────────────────────────────────────────── + +export async function activationEmail( + data: ActivationData, + overrides?: RenderOpts, +): Promise<{ subject: string; html: string; text: string }> { + const subject = overrides?.subject + ? overrides.subject + .replace(/\{\{portName\}\}/g, data.portName) + .replace(/\{\{recipientName\}\}/g, data.recipientName ?? '') + .replace(/\{\{ttlHours\}\}/g, String(data.ttlHours)) + : `Activate your ${data.portName} client portal account`; + const accent = brandingPrimaryColor(overrides?.branding); + + const body = await render(, { + pretty: false, + }); + + const text = [ + `Welcome to ${data.portName}`, + '', + `You've been invited to access the ${data.portName} client portal.`, + `Activate your account by visiting: ${data.link}`, + '', + `The link expires in ${data.ttlHours} hours.`, + '', + `Thank you,`, + `${data.portName} CRM`, + ].join('\n'); + + return { + subject, + html: renderShell({ title: subject, body, branding: overrides?.branding }), + text, + }; +} + +export async function resetEmail( + data: ResetData, + overrides?: RenderOpts, +): Promise<{ subject: string; html: string; text: string }> { + const subject = overrides?.subject + ? overrides.subject + .replace(/\{\{portName\}\}/g, data.portName) + .replace(/\{\{recipientName\}\}/g, data.recipientName ?? '') + .replace(/\{\{ttlMinutes\}\}/g, String(data.ttlMinutes)) + : `Reset your ${data.portName} client portal password`; + const accent = brandingPrimaryColor(overrides?.branding); + + const body = await render(, { + pretty: false, + }); + + const text = [ + `Password reset for ${data.portName}`, + '', + `Reset your password by visiting: ${data.link}`, + `The link expires in ${data.ttlMinutes} minutes.`, + '', + `If you didn't request this, you can safely ignore this email.`, + '', + `Thank you,`, + `${data.portName} CRM`, + ].join('\n'); + + return { + subject, + html: renderShell({ title: subject, body, branding: overrides?.branding }), + text, + }; +} diff --git a/src/lib/services/portal-auth.service.ts b/src/lib/services/portal-auth.service.ts index 07a56f6f..8116fad9 100644 --- a/src/lib/services/portal-auth.service.ts +++ b/src/lib/services/portal-auth.service.ts @@ -147,7 +147,7 @@ async function issueActivationToken( const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`; const subjectOverride = await loadSubjectOverride(portId, 'portal_activation'); const branding = await getBrandingShell(portId); - const { subject, html, text } = activationEmail( + const { subject, html, text } = await activationEmail( { portName, link, @@ -408,7 +408,7 @@ export async function requestPasswordReset(email: string): Promise { const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`; const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset'); const branding = await getBrandingShell(user.portId); - const { subject, html, text } = resetEmail( + const { subject, html, text } = await resetEmail( { portName, link, diff --git a/vitest.config.ts b/vitest.config.ts index 2b7a5885..f4f8f351 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,9 +1,16 @@ import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; import path from 'path'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { loadEnv } = require('vite'); export default defineConfig({ + // Next.js tsconfig sets jsx: 'preserve' so .tsx files imported by + // tests (e.g. react-email templates) aren't transformed by vite's + // default loader. The official react plugin handles the JSX + // transform in test-time only — Next's runtime keeps its preserve + // setting for the prod build. + plugins: [react()], test: { globals: true, environment: 'node',