From a6464676a3ec13a9283d7aa376c1f639c7400bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 04:11:27 +0000 Subject: [PATCH 1/4] Restore Resend email sending with a full transactional template set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds src/lib/email.ts (previously a no-op stub from the PayMongo/Resend strip) into a real Resend-backed sender, with templates as React Email components under src/emails/ styled to the Field Manual design system. Wired into live flows: account-claim invite (admin-enroll), welcome (signup), live-class registration confirmation, and certificate-issued. Payment receipt, refund status, payment-failed, and password-reset ship as templates + send helpers, ready to wire in once those flows exist (no PayMongo checkout or reset-token flow currently emits the data they need). All sends stay best-effort — errors are logged, never thrown — matching the existing no-op fallback when RESEND_API_KEY is unset. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RKvzDvyd3X1jXTSyQ1ELVt --- .env.example | 8 +- eslint-rules/no-tailwind.js | 2 +- package.json | 4 +- pnpm-lock.yaml | 630 +++++++++++++++++++++++++ src/app/actions/admin-enroll.ts | 14 +- src/app/actions/auth.ts | 6 + src/app/actions/certificates.ts | 10 + src/emails/account-invite.tsx | 30 ++ src/emails/certificate-issued.tsx | 33 ++ src/emails/live-class-confirmation.tsx | 57 +++ src/emails/password-reset.tsx | 30 ++ src/emails/payment-failed.tsx | 27 ++ src/emails/payment-receipt.tsx | 51 ++ src/emails/refund-status.tsx | 78 +++ src/emails/shared.tsx | 176 +++++++ src/emails/welcome.tsx | 21 + src/lib/__tests__/email.test.ts | 207 ++++++++ src/lib/email.ts | 317 ++++++++++++- 18 files changed, 1681 insertions(+), 20 deletions(-) create mode 100644 src/emails/account-invite.tsx create mode 100644 src/emails/certificate-issued.tsx create mode 100644 src/emails/live-class-confirmation.tsx create mode 100644 src/emails/password-reset.tsx create mode 100644 src/emails/payment-failed.tsx create mode 100644 src/emails/payment-receipt.tsx create mode 100644 src/emails/refund-status.tsx create mode 100644 src/emails/shared.tsx create mode 100644 src/emails/welcome.tsx create mode 100644 src/lib/__tests__/email.test.ts diff --git a/.env.example b/.env.example index ab7ddff..dbb0851 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,10 @@ CSP_ENFORCE="false" NODE_ENV="development" LOG_LEVEL="debug" -# NOTE (stripped launch build): PayMongo and Resend were removed — payments -# are collected manually (GCash/bank transfer) and enrollments granted via +# NOTE (stripped launch build): PayMongo was removed — payments are +# collected manually (GCash/bank transfer) and enrollments granted via # /admin/enroll. See docs/LAUNCH-DEPLOY.md. + +# Email (ADR-007). Sends no-op (logs only) when RESEND_API_KEY is unset. +RESEND_API_KEY="" +RESEND_FROM_EMAIL="noreply@projectamazonph.online" diff --git a/eslint-rules/no-tailwind.js b/eslint-rules/no-tailwind.js index f2bda37..7d2b4de 100644 --- a/eslint-rules/no-tailwind.js +++ b/eslint-rules/no-tailwind.js @@ -10,7 +10,7 @@ const TAILWIND_UTILITIES = /\b(bg-|text-|flex|grid|gap-|w-|h-|p-|m-|rounded-|border-|shadow-|font-|leading-|tracking-|overflow-|position-|z-|opacity-|cursor-|select-|sr-|transition-|animate-|from-|to-|via-|dark:|hover:|focus:|active:|disabled:)/; const PDF_GENERATOR_FILES = /cert-pdf|receipt-pdf/; -const EMAIL_TEMPLATE_FILES = /email\.tsx$/; +const EMAIL_TEMPLATE_FILES = /email\.tsx$|[\\/]emails[\\/]/; function isTokensFile(filename) { return filename && filename.includes('src/styles/tokens.css'); diff --git a/package.json b/package.json index e60c440..86831b4 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "pino": "^10.3.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-email": "^6.9.1", + "resend": "^6.18.1", "server-only": "^0.0.1", "zod": "^4.4.3" }, @@ -55,11 +57,11 @@ "@vitejs/plugin-react": "^6.0.3", "@vitest/coverage-v8": "^4.1.10", "dotenv": "^17.4.2", - "pino-pretty": "^13.0.0", "eslint": "^9.39.5", "eslint-config-next": "^16.0.0", "husky": "^9.1.6", "lint-staged": "^17.0.8", + "pino-pretty": "^13.0.0", "prettier": "^3.9.5", "prisma": "^7.8.0", "tsx": "^4.23.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1134106..ca2b4e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,12 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) + react-email: + specifier: ^6.9.1 + version: 6.9.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + resend: + specifier: ^6.18.1 + version: 6.18.1(@react-email/render@2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) server-only: specifier: ^0.0.1 version: 0.0.1 @@ -187,6 +193,11 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -200,6 +211,10 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} @@ -927,6 +942,13 @@ packages: '@types/react': optional: true + '@react-email/render@2.1.0': + resolution: {integrity: sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA==} + 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-pdf/fns@3.1.3': resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==} @@ -1227,6 +1249,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@sentry/babel-plugin-component-annotate@5.3.0': resolution: {integrity: sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==} engines: {node: '>= 18'} @@ -1446,6 +1471,12 @@ packages: peerDependencies: webpack: '>=5.0.0' + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1484,6 +1515,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1510,6 +1544,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.64.0': resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1794,6 +1831,10 @@ packages: abs-svg-path@0.1.1: resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -1822,6 +1863,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: @@ -1921,6 +1970,9 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + atomically@2.1.1: + resolution: {integrity: sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1951,6 +2003,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.10.42: resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} engines: {node: '>=6.0.0'} @@ -2026,6 +2082,10 @@ packages: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -2034,6 +2094,9 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} @@ -2074,6 +2137,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@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2083,16 +2150,32 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@15.1.0: + resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} + engines: {node: '>=20'} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: 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} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -2117,6 +2200,14 @@ 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@2.2.0: + resolution: {integrity: sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==} + engines: {node: '>=18'} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2141,6 +2232,10 @@ packages: resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} engines: {node: '>=16.0.0'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2180,6 +2275,23 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@10.2.0: + resolution: {integrity: sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw==} + engines: {node: '>=20'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -2214,10 +2326,22 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.9: + resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.24.2: resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2449,6 +2573,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.3: resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} @@ -2640,6 +2767,16 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + html5parser@3.0.0: + resolution: {integrity: sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-status-codes@2.3.0: resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} @@ -2795,6 +2932,10 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-url@1.2.4: resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} @@ -2839,6 +2980,10 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2877,6 +3022,9 @@ packages: 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==} @@ -2896,6 +3044,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -2903,6 +3055,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3004,6 +3159,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -3040,6 +3199,11 @@ 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 + marked@18.0.6: resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==} engines: {node: '>= 20'} @@ -3049,6 +3213,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-engine@1.0.3: resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==} @@ -3067,10 +3234,22 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + 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'} @@ -3163,6 +3342,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -3204,9 +3387,18 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + normalize-svg-path@1.1.0: resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==} + 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'} @@ -3286,6 +3478,9 @@ packages: parse-svg-path@0.1.2: resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3304,6 +3499,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -3352,6 +3550,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picospinner@3.0.0: + resolution: {integrity: sha512-lGA1TNsmy2bxvRsTI2cV01kfTwKzZjnZSDmF9llYNyMHMrU4sP87lQ5taiIKm88L3cbswjl008nwyGc3WpNvzg==} + engines: {node: '>=18.0.0'} + pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -3386,6 +3588,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.5: + resolution: {integrity: sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -3447,6 +3652,10 @@ packages: typescript: optional: true + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -3454,6 +3663,10 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3490,6 +3703,14 @@ packages: peerDependencies: react: ^19.2.7 + react-email@6.9.1: + resolution: {integrity: sha512-uUDRgFukMUXRlrsCNGlA0PZuUlQ44faI9hT/D7uMjozdumLBdHjfQttQswRYLjyLW8fFtg2HBrxAESbFU4ZKKA==} + 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-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -3500,6 +3721,10 @@ packages: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -3534,6 +3759,15 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resend@6.18.1: + resolution: {integrity: sha512-XN8XIaDdKF+ziSQ3K23ndUcyhP7U3ze2gky6SPgYkuAOq54mH4Wdhwm7QylEQ3zlz0NzdX7/l1AgmJUZbdPI/Q==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3612,6 +3846,9 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semifies@1.0.0: resolution: {integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==} @@ -3680,6 +3917,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -3688,6 +3928,17 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -3720,6 +3971,9 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -3788,6 +4042,12 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + 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'} @@ -3816,6 +4076,13 @@ packages: svg-arc-to-cubic-bezier@3.2.0: resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -3863,6 +4130,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@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3879,6 +4150,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3907,6 +4182,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + 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'} @@ -3947,6 +4226,10 @@ packages: typescript: optional: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-compatible-readable-stream@3.6.1: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} @@ -4059,6 +4342,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'} @@ -4100,6 +4386,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -4116,6 +4414,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -4232,6 +4534,10 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.7 + '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -4244,6 +4550,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4850,6 +5168,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@react-email/render@2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + entities: 4.5.0 + html-to-text: 9.0.5 + html5parser: 3.0.0 + prettier: 3.9.5 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@react-pdf/fns@3.1.3': {} '@react-pdf/font@4.0.8': @@ -5102,6 +5429,11 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + '@sentry/babel-plugin-component-annotate@5.3.0': {} '@sentry/browser-utils@10.66.0': @@ -5350,6 +5682,10 @@ snapshots: - rollup - supports-color + '@socket.io/component-emitter@3.1.2': {} + + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -5398,6 +5734,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 26.1.1 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -5424,6 +5764,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.1.1 + '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5727,6 +6071,11 @@ snapshots: abs-svg-path@0.1.1: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-import-phases@1.0.4(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -5747,6 +6096,10 @@ snapshots: optionalDependencies: ajv: 8.20.0 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-keywords@5.1.0(ajv@8.20.0): dependencies: ajv: 8.20.0 @@ -5873,6 +6226,11 @@ snapshots: atomic-sleep@1.0.0: {} + atomically@2.1.1: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -5891,6 +6249,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + baseline-browser-mapping@2.10.42: {} better-result@2.9.2: {} @@ -5979,12 +6339,18 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chokidar@5.0.0: dependencies: readdirp: 5.0.0 chrome-trace-event@1.0.4: {} + citty@0.2.2: {} + cjs-module-lexer@2.2.0: {} cli-cursor@5.0.0: @@ -6016,22 +6382,48 @@ snapshots: colorette@2.0.20: {} + commander@13.1.0: {} + commander@2.20.3: {} commondir@1.0.1: {} 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.2.0 + env-paths: 3.0.0 + json-schema-typed: 8.0.2 + semver: 7.8.5 + uint8array-extras: 1.5.0 + confbox@0.2.4: {} convert-source-map@2.0.0: {} + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 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 + css.escape@1.5.1: {} csstype@3.2.3: {} @@ -6058,6 +6450,12 @@ snapshots: dateformat@4.6.3: {} + debounce-fn@6.0.0: + dependencies: + mimic-function: 5.0.1 + + debounce@2.2.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6070,6 +6468,8 @@ snapshots: deepmerge-ts@7.1.5: {} + deepmerge@4.3.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -6102,6 +6502,28 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@10.2.0: + dependencies: + type-fest: 5.8.0 + dotenv@16.6.1: {} dotenv@17.4.2: {} @@ -6131,11 +6553,32 @@ snapshots: dependencies: once: 1.4.0 + engine.io-parser@5.2.3: {} + + engine.io@6.6.9: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 26.1.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.24.2: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@4.5.0: {} + env-paths@3.0.0: {} environment@1.1.0: {} @@ -6534,6 +6977,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.3: {} fastq@1.20.1: @@ -6722,6 +7167,23 @@ snapshots: html-escaper@2.0.2: {} + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + + html5parser@3.0.0: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + http-status-codes@2.3.0: {} https-proxy-agent@5.0.1: @@ -6881,6 +7343,8 @@ snapshots: dependencies: which-typed-array: 1.1.22 + is-unicode-supported@2.1.0: {} + is-url@1.2.4: {} is-weakmap@2.0.2: {} @@ -6930,6 +7394,8 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@2.6.1: {} + jiti@2.7.0: {} jose@6.2.3: {} @@ -6954,6 +7420,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -6973,12 +7441,16 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@3.0.3: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: dependencies: language-subtag-registry: 0.3.23 + leac@0.6.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -7063,6 +7535,11 @@ snapshots: lodash.merge@4.6.2: {} + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.2.0 + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -7101,10 +7578,14 @@ snapshots: dependencies: semver: 7.8.5 + marked@15.0.12: {} + marked@18.0.6: {} math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-engine@1.0.3: {} merge-stream@2.0.0: {} @@ -7118,8 +7599,18 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + 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: {} min-indent@1.0.1: {} @@ -7172,6 +7663,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + neo-async@2.6.2: {} next@16.2.10(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): @@ -7213,10 +7706,18 @@ snapshots: node-releases@2.0.50: {} + normalize-path@3.0.0: {} + normalize-svg-path@1.1.0: dependencies: svg-arc-to-cubic-bezier: 3.2.0 + nypm@0.6.6: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.2.4 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -7306,6 +7807,11 @@ snapshots: parse-svg-path@0.1.2: {} + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -7319,6 +7825,8 @@ snapshots: pathe@2.0.3: {} + peberminta@0.9.0: {} + perfect-debounce@2.1.0: {} pg-cloudflare@1.4.0: @@ -7362,6 +7870,8 @@ snapshots: picomatch@4.0.5: {} + picospinner@3.0.0: {} + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -7418,6 +7928,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.5: {} + postcss-value-parser@4.2.0: {} postcss@8.4.31: @@ -7473,10 +7985,17 @@ snapshots: - react - react-dom + prismjs@1.30.0: {} + process-warning@5.0.0: {} progress@2.0.3: {} + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7518,12 +8037,45 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-email@6.9.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/parser': 7.29.2 + '@babel/traverse': 7.29.0 + '@react-email/render': 2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + chokidar: 4.0.3 + commander: 13.1.0 + conf: 15.1.0 + css-tree: 3.2.1 + debounce: 2.2.0 + esbuild: 0.28.1 + glob: 13.0.6 + jiti: 2.6.1 + 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.7 + react-dom: 19.2.7(react@19.2.7) + socket.io: 4.8.3 + tailwindcss: 4.3.3 + tsconfig-paths: 4.2.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + react-is@16.13.1: {} react-is@17.0.2: {} react@19.2.7: {} + readdirp@4.1.2: {} + readdirp@5.0.0: {} real-require@0.2.0: {} @@ -7566,6 +8118,13 @@ snapshots: transitivePeerDependencies: - supports-color + resend@6.18.1(@react-email/render@2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)): + dependencies: + postal-mime: 2.7.5 + standardwebhooks: 1.0.0 + optionalDependencies: + '@react-email/render': 2.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -7686,6 +8245,10 @@ snapshots: secure-json-parse@4.1.0: {} + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + semifies@1.0.0: {} semver@6.3.1: {} @@ -7790,6 +8353,8 @@ snapshots: signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -7800,6 +8365,36 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.9 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -7825,6 +8420,11 @@ snapshots: dependencies: type-fest: 0.7.1 + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@3.10.0: {} std-env@4.2.0: {} @@ -7916,6 +8516,12 @@ snapshots: strip-json-comments@5.0.3: {} + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.7): dependencies: client-only: 0.0.1 @@ -7935,6 +8541,10 @@ snapshots: svg-arc-to-cubic-bezier@3.2.0: {} + tagged-tag@1.0.0: {} + + tailwindcss@4.3.3: {} + tapable@2.3.3: {} terser@5.49.0: @@ -7978,6 +8588,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@2.8.1: {} tsx@4.23.1: @@ -7992,6 +8608,10 @@ snapshots: type-fest@0.7.1: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -8038,6 +8658,8 @@ snapshots: typescript@6.0.3: {} + uint8array-extras@1.5.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -8102,6 +8724,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + vary@1.1.2: {} + vite-compatible-readable-stream@3.6.1: dependencies: inherits: 2.0.4 @@ -8204,6 +8828,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 @@ -8270,6 +8896,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.1: {} + xtend@4.0.2: {} yallist@3.1.1: {} @@ -8279,6 +8907,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.2.0: {} + yoga-layout@3.2.1: {} zeptomatch@2.1.0: diff --git a/src/app/actions/admin-enroll.ts b/src/app/actions/admin-enroll.ts index 6930b66..d7485dd 100644 --- a/src/app/actions/admin-enroll.ts +++ b/src/app/actions/admin-enroll.ts @@ -5,8 +5,9 @@ * * Admin enters a student email + pricing tier; we create/find the user and * enroll them in every course on the tier. For brand-new students the - * one-time claim link is returned so the admin can send it to the student - * over Messenger/email themselves (no automated email in this build). + * one-time claim link is emailed automatically (best-effort) and also + * returned so the admin can send it themselves over Messenger as a backup + * if the email doesn't land (e.g. no Resend domain verified yet). */ import { z } from 'zod'; @@ -14,6 +15,7 @@ import { revalidatePath } from 'next/cache'; import { requireAdmin } from '@/lib/auth'; import { auditLog } from '@/lib/admin-audit'; import { grantManualEnrollment } from '@/lib/enrollment'; +import { sendAccountInviteEmail } from '@/lib/email'; import type { ActionResult } from '@/lib/validation'; const manualEnrollSchema = z.object({ @@ -67,6 +69,14 @@ export async function manualEnrollAction( url.searchParams.set('email', parsed.data.email); url.searchParams.set('next', '/dashboard'); claimUrl = url.toString(); + + // Best-effort — errors are logged, never thrown. The claimUrl above is + // shown to the admin regardless, as a manual-send backup. + sendAccountInviteEmail({ + to: parsed.data.email, + tierName: result.tierName, + claimUrl, + }).catch(() => {}); } return { diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index 2f10075..6041015 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -15,6 +15,7 @@ import { getSession, } from '@/lib/auth'; import { logger } from '@/lib/logger'; +import { sendWelcomeEmail } from '@/lib/email'; import { rateLimit } from '@/lib/rate-limit'; import { hashClaimToken, @@ -93,6 +94,9 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => { name: data.name ?? existing.name, }); await setAuthCookie(token); + + sendWelcomeEmail({ to: existing.email, studentName: data.name ?? existing.name ?? 'there' }).catch(() => {}); + return { userId: existing.id }; } @@ -115,6 +119,8 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => { }); await setAuthCookie(token); + sendWelcomeEmail({ to: user.email, studentName: user.name ?? 'there' }).catch(() => {}); + return { userId: user.id }; }); diff --git a/src/app/actions/certificates.ts b/src/app/actions/certificates.ts index f2f9dda..f2edbe3 100644 --- a/src/app/actions/certificates.ts +++ b/src/app/actions/certificates.ts @@ -18,6 +18,7 @@ import { db } from '@/lib/db'; import { requireAuth } from '@/lib/auth'; import { createSafeAction } from '@/lib/validation'; import { evaluateCourseAccess } from '@/lib/tier-gate'; +import { sendCertificateIssuedEmail } from '@/lib/email'; import { issueCertificate, getCertificateByVerificationHash, @@ -61,6 +62,15 @@ export const issueCertificateAction = createSafeAction< ); } + if (!issued.alreadyExisted) { + sendCertificateIssuedEmail({ + to: user.email, + studentName: user.name ?? 'there', + courseTitle: course.title, + verificationHash: issued.verificationHash, + }).catch(() => {}); + } + return { certificateId: issued.id, verificationHash: issued.verificationHash, diff --git a/src/emails/account-invite.tsx b/src/emails/account-invite.tsx new file mode 100644 index 0000000..2ea0d6f --- /dev/null +++ b/src/emails/account-invite.tsx @@ -0,0 +1,30 @@ +import { EmailButton, EmailFootnote, EmailHeading, EmailParagraph, EmailShell } from './shared'; + +export interface AccountInviteEmailProps { + tierName: string; + claimUrl: string; +} + +/** + * Sent when the admin manually enrolls a brand-new student (/admin/enroll). + * Delivers the single-use link the student uses to set a password and claim + * their account. This is the only place the raw claim token should be sent — + * never log it. The link expires after CLAIM_TOKEN_TTL_MS (7 days). + */ +export default function AccountInviteEmail({ tierName, claimUrl }: AccountInviteEmailProps) { + return ( + + Set your password to finish + + You've been enrolled in {tierName}. To access your + account, set a password using the secure link below. It expires in 7 + days. + + Claim your account → + + If you weren't expecting this, you can ignore this email — no + account can be accessed without this link. + + + ); +} diff --git a/src/emails/certificate-issued.tsx b/src/emails/certificate-issued.tsx new file mode 100644 index 0000000..0145cb9 --- /dev/null +++ b/src/emails/certificate-issued.tsx @@ -0,0 +1,33 @@ +import { EmailButton, EmailFootnote, EmailHeading, EmailParagraph, EmailShell } from './shared'; + +export interface CertificateIssuedEmailProps { + studentName: string; + courseTitle: string; + certificateUrl: string; + verifyUrl: string; +} + +/** Sent when a student earns a course-completion certificate. */ +export default function CertificateIssuedEmail({ + studentName, + courseTitle, + certificateUrl, + verifyUrl, +}: CertificateIssuedEmailProps) { + return ( + + Congratulations, {studentName}! + + You've completed {courseTitle} and earned your + certificate. + + View your certificate → + + Anyone can verify this certificate at{' '} + + {verifyUrl} + + + + ); +} diff --git a/src/emails/live-class-confirmation.tsx b/src/emails/live-class-confirmation.tsx new file mode 100644 index 0000000..e5194ff --- /dev/null +++ b/src/emails/live-class-confirmation.tsx @@ -0,0 +1,57 @@ +import { + EmailButton, + EmailDetailBox, + EmailDetailRow, + EmailFootnote, + EmailHeading, + EmailParagraph, + EmailShell, +} from './shared'; + +export interface LiveClassConfirmationEmailProps { + studentName: string; + classTitle: string; + instructorName: string; + date: string; + time: string; + durationMinutes: number; + meetingUrl?: string | null; + classesUrl: string; +} + +/** Sent immediately when a student registers for a live class. */ +export default function LiveClassConfirmationEmail({ + studentName, + classTitle, + instructorName, + date, + time, + durationMinutes, + meetingUrl, + classesUrl, +}: LiveClassConfirmationEmailProps) { + return ( + + {classTitle} + + with {instructorName} + + + Hi {studentName}, you're registered for this live class. + + + + + + + {meetingUrl ? 'Join the class →' : 'View class details →'} + + + A recording will be available afterward if you can't make it live. + + + ); +} diff --git a/src/emails/password-reset.tsx b/src/emails/password-reset.tsx new file mode 100644 index 0000000..26c8284 --- /dev/null +++ b/src/emails/password-reset.tsx @@ -0,0 +1,30 @@ +import { EmailButton, EmailFootnote, EmailHeading, EmailParagraph, EmailShell } from './shared'; + +export interface PasswordResetEmailProps { + resetUrl: string; + expiresInMinutes: number; +} + +/** + * Password-reset link. Template only — no reset-token flow exists yet + * (there is no forgot-password action, token model, or reset page in this + * build; see the sign-in note in src/app/actions/auth.ts about the prior + * emailVerified lockout incident). Wire this up alongside that flow, not + * before it — don't gate anything on delivery until send + verify both work. + */ +export default function PasswordResetEmail({ resetUrl, expiresInMinutes }: PasswordResetEmailProps) { + return ( + + Reset your password + + We received a request to reset your password. Click below to choose a + new one. This link expires in {expiresInMinutes} minutes. + + Reset password → + + If you didn't request this, you can safely ignore this email — + your password won't change. + + + ); +} diff --git a/src/emails/payment-failed.tsx b/src/emails/payment-failed.tsx new file mode 100644 index 0000000..c090eef --- /dev/null +++ b/src/emails/payment-failed.tsx @@ -0,0 +1,27 @@ +import { EmailButton, EmailHeading, EmailParagraph, EmailShell } from './shared'; + +export interface PaymentFailedEmailProps { + studentName: string; + tierName: string; + retryUrl: string; +} + +/** + * Notifies a buyer their payment didn't go through. Not wired to a live + * trigger in this build — depends on the PayMongo checkout flow that was + * stripped for the manual-enrollment launch. Ready to call from a payment + * webhook handler when that flow returns. + */ +export default function PaymentFailedEmail({ studentName, tierName, retryUrl }: PaymentFailedEmailProps) { + return ( + + Your payment didn't go through + + Hi {studentName}, we couldn't complete your payment for{' '} + {tierName}. No charge was made. You can try again below — if a + payment method was declined, use a different one. + + Try again → + + ); +} diff --git a/src/emails/payment-receipt.tsx b/src/emails/payment-receipt.tsx new file mode 100644 index 0000000..6d7ed30 --- /dev/null +++ b/src/emails/payment-receipt.tsx @@ -0,0 +1,51 @@ +import { + EmailButton, + EmailDetailBox, + EmailDetailRow, + EmailHeading, + EmailParagraph, + EmailShell, +} from './shared'; + +export interface PaymentReceiptEmailProps { + studentName: string; + tierName: string; + amount: string; + method: string; + paidAt: string; + paymentsUrl: string; + receiptUrl?: string | null; +} + +/** + * Receipt for a completed payment. Not wired to a live trigger in this build + * — the launch stripped PayMongo, so no code currently creates `Payment` + * rows. Ready to call from wherever payment confirmation lands next. + */ +export default function PaymentReceiptEmail({ + studentName, + tierName, + amount, + method, + paidAt, + paymentsUrl, + receiptUrl, +}: PaymentReceiptEmailProps) { + return ( + + Payment received + + Hi {studentName}, thanks for your payment. Here's your receipt. + + + + + + + + + {receiptUrl ? 'Download receipt →' : 'View payment history →'} + + + ); +} diff --git a/src/emails/refund-status.tsx b/src/emails/refund-status.tsx new file mode 100644 index 0000000..ba3105b --- /dev/null +++ b/src/emails/refund-status.tsx @@ -0,0 +1,78 @@ +import { EmailButton, EmailHeading, EmailParagraph, EmailShell, colors } from './shared'; + +export type RefundStatusKind = 'requested' | 'approved' | 'rejected'; + +export interface RefundStatusEmailProps { + studentName: string; + tierName: string; + amount: string; + status: RefundStatusKind; + reviewerNotes?: string | null; + paymentsUrl: string; +} + +/** + * Refund lifecycle notification (requested / approved / rejected). Not wired + * to a live trigger in this build — refunds depend on the PayMongo flow that + * was stripped for the manual-enrollment launch. Ready to call from + * `RefundRequest` status transitions when that flow returns. + */ +export default function RefundStatusEmail({ + studentName, + tierName, + amount, + status, + reviewerNotes, + paymentsUrl, +}: RefundStatusEmailProps) { + const heading = + status === 'requested' + ? 'Refund request received' + : status === 'approved' + ? 'Refund approved' + : 'Refund not approved'; + + const headingColor = + status === 'approved' ? colors.success : status === 'rejected' ? colors.error : colors.accent; + + return ( + + {heading} + Hi {studentName}, + {status === 'requested' && ( + + We received your refund request for your {tierName} enrollment ( + {amount}). Our team reviews every request personally — you'll + hear back within one business day. + + )} + {status === 'approved' && ( + + Your refund of {amount} for {tierName} has been approved and is + being processed. The amount will appear on your original payment + method within 5–10 business days. + + )} + {status === 'rejected' && ( + <> + + Your refund request for {tierName} ({amount}) was not approved. + + {reviewerNotes && ( + + Reason from our team: {reviewerNotes} + + )} + + )} + View payment history → + + ); +} diff --git a/src/emails/shared.tsx b/src/emails/shared.tsx new file mode 100644 index 0000000..96065f8 --- /dev/null +++ b/src/emails/shared.tsx @@ -0,0 +1,176 @@ +/** + * Shared building blocks for transactional email templates. + * + * Styled to match the "Field Manual" design system (docs/stitch-prompts.md): + * warm off-white background, white card, orange accent, Space Grotesk + * headings. Email clients strip most custom fonts, so headings declare + * Space Grotesk via (progressive enhancement) with a system-font + * fallback that still looks correct everywhere. + */ + +import { + Body, + Button, + Container, + Font, + Head, + Hr, + Html, + Preview, + Section, + Text, +} from 'react-email'; +import type { CSSProperties, ReactNode } from 'react'; +import { BRAND_NAME } from '@/lib/brand'; + +export const colors = { + bg: '#FAFAF7', + card: '#FFFFFF', + border: '#E5E5E0', + text: '#171717', + textSecondary: '#404040', + textTertiary: '#737373', + accent: '#FF6B35', + accentSoft: '#FFE5D9', + success: '#0E7C3A', + error: '#B91C1C', +} as const; + +const headingFont = "'Space Grotesk', Helvetica, Arial, sans-serif"; +const bodyFont = 'Helvetica, Arial, sans-serif'; + +/** Full HTML document shell: off-white body, bordered white card, footer. */ +export function EmailShell({ + previewText, + eyebrow, + children, +}: { + previewText: string; + eyebrow?: string; + children: ReactNode; +}) { + return ( + + + + + {previewText} + + + + {eyebrow ?? BRAND_NAME} + +
+ {children} +
+ + {`${BRAND_NAME} · projectamazonph.com`} + +
+ + + ); +} + +export function EmailHeading({ children, color = colors.text }: { children: ReactNode; color?: string }) { + return ( + + {children} + + ); +} + +export function EmailParagraph({ children, style }: { children: ReactNode; style?: CSSProperties }) { + return ( + + {children} + + ); +} + +export function EmailButton({ href, children }: { href: string; children: ReactNode }) { + return ( + + ); +} + +/** Boxed key/value detail rows, e.g. class schedule or payment line items. */ +export function EmailDetailBox({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function EmailDetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + {label}: + {value} + + ); +} + +export function EmailDivider() { + return
; +} + +export function EmailFootnote({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/emails/welcome.tsx b/src/emails/welcome.tsx new file mode 100644 index 0000000..0d0d744 --- /dev/null +++ b/src/emails/welcome.tsx @@ -0,0 +1,21 @@ +import { EmailButton, EmailHeading, EmailParagraph, EmailShell } from './shared'; + +export interface WelcomeEmailProps { + studentName: string; + dashboardUrl: string; +} + +/** Sent right after a student's account becomes active (fresh signup or claimed guest account). */ +export default function WelcomeEmail({ studentName, dashboardUrl }: WelcomeEmailProps) { + return ( + + Welcome, {studentName}! + + Your account is active. Head to your dashboard to start your first + module, track your XP, and unlock the practice tools included in your + tier. + + Go to your dashboard → + + ); +} diff --git a/src/lib/__tests__/email.test.ts b/src/lib/__tests__/email.test.ts new file mode 100644 index 0000000..2130c5b --- /dev/null +++ b/src/lib/__tests__/email.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ReactElement } from 'react'; + +const { mockSend, MockResend } = vi.hoisted(() => { + const mockSend = vi.fn(); + // Must be a plain function, not an arrow function — `new Resend(...)` in + // email.ts invokes this as a constructor, and arrow functions can't be. + const MockResend = vi.fn().mockImplementation(function () { + return { emails: { send: mockSend } }; + }); + return { mockSend, MockResend }; +}); + +vi.mock('resend', () => ({ Resend: MockResend })); + +const { mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({ + mockLoggerInfo: vi.fn(), + mockLoggerError: vi.fn(), +})); + +vi.mock('@/lib/logger', () => ({ + logger: { info: mockLoggerInfo, error: mockLoggerError }, +})); + +const ORIGINAL_ENV = { ...process.env }; + +describe('email.ts', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + process.env = { ...ORIGINAL_ENV }; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + describe('when RESEND_API_KEY is unset', () => { + beforeEach(() => { + delete process.env.RESEND_API_KEY; + }); + + it('no-ops and logs instead of sending', async () => { + const { sendWelcomeEmail } = await import('@/lib/email'); + await sendWelcomeEmail({ to: 'student@example.com', studentName: 'Ana' }); + + expect(MockResend).not.toHaveBeenCalled(); + expect(mockSend).not.toHaveBeenCalled(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.objectContaining({ to: 'student@example.com' }), + expect.stringContaining('skipped'), + ); + }); + }); + + describe('when RESEND_API_KEY is set', () => { + beforeEach(() => { + process.env.RESEND_API_KEY = 're_test_key'; + mockSend.mockResolvedValue({ data: { id: 'email_123' }, error: null }); + }); + + it('sendAccountInviteEmail sends the claim link with the right subject', async () => { + const { sendAccountInviteEmail } = await import('@/lib/email'); + await sendAccountInviteEmail({ + to: 'guest@example.com', + tierName: 'PPC Foundations', + claimUrl: 'https://amph.test/auth/signup?claim=abc', + }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const call = mockSend.mock.calls[0][0]; + expect(call.to).toBe('guest@example.com'); + expect(call.subject).toContain('Claim your'); + expect((call.react as ReactElement).props).toMatchObject({ + tierName: 'PPC Foundations', + claimUrl: 'https://amph.test/auth/signup?claim=abc', + }); + }); + + it('sendWelcomeEmail includes the dashboard URL', async () => { + const { sendWelcomeEmail } = await import('@/lib/email'); + await sendWelcomeEmail({ to: 'student@example.com', studentName: 'Ana' }); + + const call = mockSend.mock.calls[0][0]; + expect(call.subject).toContain('Ana'); + expect((call.react as ReactElement).props).toMatchObject({ + studentName: 'Ana', + dashboardUrl: expect.stringContaining('/dashboard'), + }); + }); + + it('sendLiveClassReminderEmail formats the scheduled date/time', async () => { + const { sendLiveClassReminderEmail } = await import('@/lib/email'); + await sendLiveClassReminderEmail({ + to: 'student@example.com', + studentName: 'Ana', + classTitle: 'Bid Optimization Deep Dive', + instructorName: 'Ryan Dabao', + scheduledAt: new Date('2026-08-15T06:00:00Z'), + durationMinutes: 60, + meetingUrl: 'https://meet.example.com/xyz', + }); + + const call = mockSend.mock.calls[0][0]; + expect(call.subject).toBe("You're registered: Bid Optimization Deep Dive"); + expect((call.react as ReactElement).props).toMatchObject({ + classTitle: 'Bid Optimization Deep Dive', + instructorName: 'Ryan Dabao', + durationMinutes: 60, + meetingUrl: 'https://meet.example.com/xyz', + }); + }); + + it('sendCertificateIssuedEmail builds a verification URL from the hash', async () => { + const { sendCertificateIssuedEmail } = await import('@/lib/email'); + await sendCertificateIssuedEmail({ + to: 'student@example.com', + studentName: 'Ana', + courseTitle: 'PPC Foundations', + verificationHash: 'hash-123', + }); + + const call = mockSend.mock.calls[0][0]; + expect((call.react as ReactElement).props.verifyUrl).toContain('/verify/hash-123'); + }); + + it('sendPaymentReceiptEmail formats centavos into pesos', async () => { + const { sendPaymentReceiptEmail } = await import('@/lib/email'); + await sendPaymentReceiptEmail({ + to: 'student@example.com', + studentName: 'Ana', + tierName: 'PPC Foundations', + amountPhp: 299900, + method: 'GCash', + paidAt: new Date('2026-07-15T00:00:00Z'), + receiptUrl: null, + }); + + const call = mockSend.mock.calls[0][0]; + expect((call.react as ReactElement).props.amount).toContain('2,999.00'); + }); + + it.each([ + ['requested', 'Refund request received'], + ['approved', 'is being processed'], + ['rejected', 'not approved'], + ] as const)('sendRefundStatusEmail — %s status has the right subject', async (status, expectedSubjectPart) => { + const { sendRefundStatusEmail } = await import('@/lib/email'); + await sendRefundStatusEmail({ + to: 'student@example.com', + studentName: 'Ana', + tierName: 'PPC Foundations', + amountPhp: 299900, + status, + }); + + const call = mockSend.mock.calls[0][0]; + expect(call.subject).toContain(expectedSubjectPart); + }); + + it('sendPaymentFailedEmail defaults retryUrl to the pricing page', async () => { + const { sendPaymentFailedEmail } = await import('@/lib/email'); + await sendPaymentFailedEmail({ + to: 'student@example.com', + studentName: 'Ana', + tierName: 'PPC Foundations', + }); + + const call = mockSend.mock.calls[0][0]; + expect((call.react as ReactElement).props.retryUrl).toContain('/pricing'); + }); + + it('sendPasswordResetEmail embeds the raw token in the reset URL', async () => { + const { sendPasswordResetEmail } = await import('@/lib/email'); + await sendPasswordResetEmail({ to: 'student@example.com', resetToken: 'raw-token-abc' }); + + const call = mockSend.mock.calls[0][0]; + expect((call.react as ReactElement).props.resetUrl).toContain('token=raw-token-abc'); + }); + + it('logs and swallows the error when Resend returns an error', async () => { + mockSend.mockResolvedValue({ data: null, error: { message: 'invalid domain' } }); + const { sendWelcomeEmail } = await import('@/lib/email'); + + await expect( + sendWelcomeEmail({ to: 'student@example.com', studentName: 'Ana' }), + ).resolves.toBeUndefined(); + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ to: 'student@example.com' }), + expect.stringContaining('send failed'), + ); + }); + + it('logs and swallows unexpected exceptions from the Resend client', async () => { + mockSend.mockRejectedValue(new Error('network down')); + const { sendWelcomeEmail } = await import('@/lib/email'); + + await expect( + sendWelcomeEmail({ to: 'student@example.com', studentName: 'Ana' }), + ).resolves.toBeUndefined(); + expect(mockLoggerError).toHaveBeenCalledWith( + expect.objectContaining({ to: 'student@example.com' }), + expect.stringContaining('unexpected error'), + ); + }); + }); +}); diff --git a/src/lib/email.ts b/src/lib/email.ts index 94e0f21..df9d734 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,20 +1,114 @@ /** - * Email — disabled in the stripped launch build. + * Email — transactional sending via Resend (ADR-007). * - * Resend was removed for the manual-enrollment launch (see - * docs/LAUNCH-DEPLOY.md). These stubs keep the call sites compiling and log - * what WOULD have been sent, so callers stay best-effort no-ops exactly like - * the old Resend-backed versions when RESEND_API_KEY was unset. + * Best-effort: every send function logs and resolves on failure instead of + * throwing, so a broken inbox or missing API key never breaks the calling + * flow (enrollment, registration, certificate issuance, ...). When + * RESEND_API_KEY is unset (e.g. local dev), sends no-op and log what would + * have been sent — the same fallback behavior the stripped launch build used. * - * To restore real sending, revert to the pre-strip `email.tsx` from the main - * repo history and re-add the `resend` dependency. + * Templates live in src/emails/*.tsx (React Email components, styled to the + * "Field Manual" design system). This file only wires data → template → send. */ import 'server-only'; +import { createElement } from 'react'; +import { Resend } from 'resend'; import { logger } from './logger'; +import { formatDateTime, formatPhp } from './format'; +import { BRAND_NAME } from './brand'; -interface LiveClassReminderEmailProps { +import AccountInviteEmail from '@/emails/account-invite'; +import WelcomeEmail from '@/emails/welcome'; +import LiveClassConfirmationEmail from '@/emails/live-class-confirmation'; +import CertificateIssuedEmail from '@/emails/certificate-issued'; +import PaymentReceiptEmail from '@/emails/payment-receipt'; +import RefundStatusEmail, { type RefundStatusKind } from '@/emails/refund-status'; +import PaymentFailedEmail from '@/emails/payment-failed'; +import PasswordResetEmail from '@/emails/password-reset'; + +// Lazy singleton: the Resend constructor throws when the API key is unset, +// so constructing at module scope would break `next build` (this module is +// imported from several server actions collected at build time). +let resend: Resend | null = null; +function getResend(): Resend { + resend ??= new Resend(process.env.RESEND_API_KEY); + return resend; +} + +const FROM = process.env.RESEND_FROM_EMAIL ?? 'noreply@projectamazonph.online'; +const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'; + +async function sendEmail({ + to, + subject, + react, +}: { + to: string; + subject: string; + react: React.ReactElement; +}): Promise { + if (!process.env.RESEND_API_KEY) { + logger.info({ to, subject }, '[email disabled] RESEND_API_KEY not set — skipped'); + return; + } + try { + const { error } = await getResend().emails.send({ from: FROM, to, subject, react }); + if (error) { + logger.error({ to, subject, err: error }, '[email] send failed'); + } + } catch (err) { + logger.error({ to, subject, err }, '[email] unexpected error'); + } +} + +// --------------------------------------------------------------------------- +// Account-claim email (guest checkout / manual enrollment) +// --------------------------------------------------------------------------- + +interface AccountInviteEmailArgs { + to: string; + tierName: string; + claimUrl: string; +} + +/** + * Deliver the single-use link a newly-enrolled student uses to set a + * password and claim their account. This is the only place the raw claim + * token should be sent — never log it. + */ +export async function sendAccountInviteEmail({ to, tierName, claimUrl }: AccountInviteEmailArgs): Promise { + await sendEmail({ + to, + subject: `Claim your ${BRAND_NAME} account`, + react: createElement(AccountInviteEmail, { tierName, claimUrl }), + }); +} + +// --------------------------------------------------------------------------- +// Welcome email +// --------------------------------------------------------------------------- + +interface WelcomeEmailArgs { + to: string; + studentName: string; +} + +/** Sent right after a student's account becomes active (fresh signup or claimed guest account). */ +export async function sendWelcomeEmail({ to, studentName }: WelcomeEmailArgs): Promise { + await sendEmail({ + to, + subject: `Welcome to ${BRAND_NAME}, ${studentName}!`, + react: createElement(WelcomeEmail, { studentName, dashboardUrl: `${APP_URL}/dashboard` }), + }); +} + +// --------------------------------------------------------------------------- +// Live class registration confirmation +// --------------------------------------------------------------------------- + +interface LiveClassReminderEmailArgs { to: string; studentName: string; classTitle: string; @@ -24,14 +118,209 @@ interface LiveClassReminderEmailProps { meetingUrl?: string | null; } -/** No-op: email sending is disabled in this build. Logs and resolves. */ +/** Sent immediately when a student registers for a live class. */ export async function sendLiveClassReminderEmail({ to, + studentName, classTitle, + instructorName, scheduledAt, -}: LiveClassReminderEmailProps): Promise { - logger.info( - { to, classTitle, scheduledAt }, - '[email disabled] skipped live-class reminder', - ); + durationMinutes, + meetingUrl, +}: LiveClassReminderEmailArgs): Promise { + const date = new Intl.DateTimeFormat('en-PH', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(scheduledAt); + const time = new Intl.DateTimeFormat('en-PH', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }).format(scheduledAt); + + await sendEmail({ + to, + subject: `You're registered: ${classTitle}`, + react: createElement(LiveClassConfirmationEmail, { + studentName, + classTitle, + instructorName, + date, + time, + durationMinutes, + meetingUrl, + classesUrl: `${APP_URL}/live-classes`, + }), + }); +} + +// --------------------------------------------------------------------------- +// Certificate issued +// --------------------------------------------------------------------------- + +interface CertificateIssuedEmailArgs { + to: string; + studentName: string; + courseTitle: string; + verificationHash: string; +} + +/** Sent when a student earns a course-completion certificate. */ +export async function sendCertificateIssuedEmail({ + to, + studentName, + courseTitle, + verificationHash, +}: CertificateIssuedEmailArgs): Promise { + await sendEmail({ + to, + subject: `Certificate earned — ${courseTitle}`, + react: createElement(CertificateIssuedEmail, { + studentName, + courseTitle, + certificateUrl: `${APP_URL}/certificates`, + verifyUrl: `${APP_URL}/verify/${verificationHash}`, + }), + }); +} + +// --------------------------------------------------------------------------- +// Payment receipt — not wired to a live trigger (see src/emails/payment-receipt.tsx) +// --------------------------------------------------------------------------- + +interface PaymentReceiptEmailArgs { + to: string; + studentName: string; + tierName: string; + amountPhp: number; + method: string; + paidAt: Date; + receiptUrl?: string | null; +} + +export async function sendPaymentReceiptEmail({ + to, + studentName, + tierName, + amountPhp, + method, + paidAt, + receiptUrl, +}: PaymentReceiptEmailArgs): Promise { + const amount = formatPhp(amountPhp); + await sendEmail({ + to, + subject: `Receipt for your ${tierName} payment — ${amount}`, + react: createElement(PaymentReceiptEmail, { + studentName, + tierName, + amount, + method, + paidAt: formatDateTime(paidAt), + paymentsUrl: `${APP_URL}/payments`, + receiptUrl, + }), + }); +} + +// --------------------------------------------------------------------------- +// Refund status — not wired to a live trigger (see src/emails/refund-status.tsx) +// --------------------------------------------------------------------------- + +interface RefundStatusEmailArgs { + to: string; + studentName: string; + tierName: string; + amountPhp: number; + status: RefundStatusKind; + reviewerNotes?: string | null; +} + +export async function sendRefundStatusEmail({ + to, + studentName, + tierName, + amountPhp, + status, + reviewerNotes, +}: RefundStatusEmailArgs): Promise { + const amount = formatPhp(amountPhp); + const subject = + status === 'requested' + ? `Refund request received — ${tierName}` + : status === 'approved' + ? `Your refund of ${amount} is being processed` + : `Your refund request was not approved`; + + await sendEmail({ + to, + subject, + react: createElement(RefundStatusEmail, { + studentName, + tierName, + amount, + status, + reviewerNotes, + paymentsUrl: `${APP_URL}/payments`, + }), + }); +} + +// --------------------------------------------------------------------------- +// Payment failed — not wired to a live trigger (see src/emails/payment-failed.tsx) +// --------------------------------------------------------------------------- + +interface PaymentFailedEmailArgs { + to: string; + studentName: string; + tierName: string; + retryUrl?: string; +} + +export async function sendPaymentFailedEmail({ + to, + studentName, + tierName, + retryUrl, +}: PaymentFailedEmailArgs): Promise { + await sendEmail({ + to, + subject: `Your payment didn't go through — ${tierName}`, + react: createElement(PaymentFailedEmail, { + studentName, + tierName, + retryUrl: retryUrl ?? `${APP_URL}/pricing`, + }), + }); +} + +// --------------------------------------------------------------------------- +// Password reset — template only, no reset-token flow exists yet (see +// src/emails/password-reset.tsx for why this isn't wired up). +// --------------------------------------------------------------------------- + +interface PasswordResetEmailArgs { + to: string; + resetToken: string; + expiresInMinutes?: number; +} + +export async function sendPasswordResetEmail({ + to, + resetToken, + expiresInMinutes = 30, +}: PasswordResetEmailArgs): Promise { + const resetUrl = new URL('/auth/reset-password', APP_URL); + resetUrl.searchParams.set('token', resetToken); + + await sendEmail({ + to, + subject: `Reset your ${BRAND_NAME} password`, + react: createElement(PasswordResetEmail, { + resetUrl: resetUrl.toString(), + expiresInMinutes, + }), + }); } From 47e90af64c30a58c35135d314dac7471bc207993 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 05:57:14 +0000 Subject: [PATCH 2/4] Point email footer at the verified projectamazonph.online domain --- src/emails/shared.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emails/shared.tsx b/src/emails/shared.tsx index 96065f8..55d2906 100644 --- a/src/emails/shared.tsx +++ b/src/emails/shared.tsx @@ -90,7 +90,7 @@ export function EmailShell({ {children} - {`${BRAND_NAME} · projectamazonph.com`} + {`${BRAND_NAME} · projectamazonph.online`} From c7f1be6216f9ca75ff6693107d9b18263d320be1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:53:15 +0000 Subject: [PATCH 3/4] Fix CI typecheck failure and address review feedback on PR #96 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix TS2532 (noUncheckedIndexedAccess): mockSend.mock.calls[0][0] was possibly undefined, and React 19's ReactElement.props is typed unknown. Added a typed firstSendArg() helper and a props() cast helper instead of indexing/casting inline at each call site. - Set an explicit Asia/Manila timeZone on both Intl.DateTimeFormat calls in the live-class reminder email — without it, the displayed class date/time follows the server's runtime timezone (UTC on Vercel), not the Philippines. - Remove em dashes from code comments and email copy per AGENTS.md ("Don't use em-dashes. Use periods, commas, parentheses"), across all files touched in this PR. Co-Authored-By: Claude Sonnet 5 --- .env.example | 2 +- src/app/actions/admin-enroll.ts | 6 +-- src/emails/account-invite.tsx | 4 +- src/emails/certificate-issued.tsx | 2 +- src/emails/live-class-confirmation.tsx | 2 +- src/emails/password-reset.tsx | 8 ++-- src/emails/payment-failed.tsx | 6 +-- src/emails/payment-receipt.tsx | 6 +-- src/emails/refund-status.tsx | 6 +-- src/emails/welcome.tsx | 2 +- src/lib/__tests__/email.test.ts | 51 +++++++++++++++++--------- src/lib/email.ts | 26 +++++++------ 12 files changed, 70 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index dbb0851..f339ec7 100644 --- a/.env.example +++ b/.env.example @@ -38,7 +38,7 @@ CSP_ENFORCE="false" NODE_ENV="development" LOG_LEVEL="debug" -# NOTE (stripped launch build): PayMongo was removed — payments are +# NOTE (stripped launch build): PayMongo was removed, payments are # collected manually (GCash/bank transfer) and enrollments granted via # /admin/enroll. See docs/LAUNCH-DEPLOY.md. diff --git a/src/app/actions/admin-enroll.ts b/src/app/actions/admin-enroll.ts index d7485dd..b24bca6 100644 --- a/src/app/actions/admin-enroll.ts +++ b/src/app/actions/admin-enroll.ts @@ -1,7 +1,7 @@ 'use server'; /** - * Manual enrollment action — stripped launch build. + * Manual enrollment action (stripped launch build). * * Admin enters a student email + pricing tier; we create/find the user and * enroll them in every course on the tier. For brand-new students the @@ -26,7 +26,7 @@ const manualEnrollSchema = z.object({ export interface ManualEnrollActionData { isNewUser: boolean; - /** Full signup link for new accounts — show once, admin sends it manually. */ + /** Full signup link for new accounts, shown once, admin sends it manually. */ claimUrl?: string; tierName: string; enrolledCount: number; @@ -70,7 +70,7 @@ export async function manualEnrollAction( url.searchParams.set('next', '/dashboard'); claimUrl = url.toString(); - // Best-effort — errors are logged, never thrown. The claimUrl above is + // Best-effort: errors are logged, never thrown. The claimUrl above is // shown to the admin regardless, as a manual-send backup. sendAccountInviteEmail({ to: parsed.data.email, diff --git a/src/emails/account-invite.tsx b/src/emails/account-invite.tsx index 2ea0d6f..8c04fc0 100644 --- a/src/emails/account-invite.tsx +++ b/src/emails/account-invite.tsx @@ -8,7 +8,7 @@ export interface AccountInviteEmailProps { /** * Sent when the admin manually enrolls a brand-new student (/admin/enroll). * Delivers the single-use link the student uses to set a password and claim - * their account. This is the only place the raw claim token should be sent — + * their account. This is the only place the raw claim token should be sent, * never log it. The link expires after CLAIM_TOKEN_TTL_MS (7 days). */ export default function AccountInviteEmail({ tierName, claimUrl }: AccountInviteEmailProps) { @@ -22,7 +22,7 @@ export default function AccountInviteEmail({ tierName, claimUrl }: AccountInvite Claim your account → - If you weren't expecting this, you can ignore this email — no + If you weren't expecting this, you can ignore this email. No account can be accessed without this link. diff --git a/src/emails/certificate-issued.tsx b/src/emails/certificate-issued.tsx index 0145cb9..3ad75c3 100644 --- a/src/emails/certificate-issued.tsx +++ b/src/emails/certificate-issued.tsx @@ -15,7 +15,7 @@ export default function CertificateIssuedEmail({ verifyUrl, }: CertificateIssuedEmailProps) { return ( - + Congratulations, {studentName}! You've completed {courseTitle} and earned your diff --git a/src/emails/live-class-confirmation.tsx b/src/emails/live-class-confirmation.tsx index e5194ff..4be2f79 100644 --- a/src/emails/live-class-confirmation.tsx +++ b/src/emails/live-class-confirmation.tsx @@ -32,7 +32,7 @@ export default function LiveClassConfirmationEmail({ }: LiveClassConfirmationEmailProps) { return ( {classTitle} diff --git a/src/emails/password-reset.tsx b/src/emails/password-reset.tsx index 26c8284..193e577 100644 --- a/src/emails/password-reset.tsx +++ b/src/emails/password-reset.tsx @@ -6,11 +6,11 @@ export interface PasswordResetEmailProps { } /** - * Password-reset link. Template only — no reset-token flow exists yet + * Password-reset link. Template only, no reset-token flow exists yet * (there is no forgot-password action, token model, or reset page in this * build; see the sign-in note in src/app/actions/auth.ts about the prior * emailVerified lockout incident). Wire this up alongside that flow, not - * before it — don't gate anything on delivery until send + verify both work. + * before it. Don't gate anything on delivery until send and verify both work. */ export default function PasswordResetEmail({ resetUrl, expiresInMinutes }: PasswordResetEmailProps) { return ( @@ -22,8 +22,8 @@ export default function PasswordResetEmail({ resetUrl, expiresInMinutes }: Passw Reset password → - If you didn't request this, you can safely ignore this email — - your password won't change. + If you didn't request this, you can safely ignore this email. + Your password won't change. ); diff --git a/src/emails/payment-failed.tsx b/src/emails/payment-failed.tsx index c090eef..4b2aa0f 100644 --- a/src/emails/payment-failed.tsx +++ b/src/emails/payment-failed.tsx @@ -8,8 +8,8 @@ export interface PaymentFailedEmailProps { /** * Notifies a buyer their payment didn't go through. Not wired to a live - * trigger in this build — depends on the PayMongo checkout flow that was - * stripped for the manual-enrollment launch. Ready to call from a payment + * trigger in this build (depends on the PayMongo checkout flow that was + * stripped for the manual-enrollment launch). Ready to call from a payment * webhook handler when that flow returns. */ export default function PaymentFailedEmail({ studentName, tierName, retryUrl }: PaymentFailedEmailProps) { @@ -18,7 +18,7 @@ export default function PaymentFailedEmail({ studentName, tierName, retryUrl }: Your payment didn't go through Hi {studentName}, we couldn't complete your payment for{' '} - {tierName}. No charge was made. You can try again below — if a + {tierName}. No charge was made. You can try again below. If a payment method was declined, use a different one. Try again → diff --git a/src/emails/payment-receipt.tsx b/src/emails/payment-receipt.tsx index 6d7ed30..d9d9e3f 100644 --- a/src/emails/payment-receipt.tsx +++ b/src/emails/payment-receipt.tsx @@ -19,8 +19,8 @@ export interface PaymentReceiptEmailProps { /** * Receipt for a completed payment. Not wired to a live trigger in this build - * — the launch stripped PayMongo, so no code currently creates `Payment` - * rows. Ready to call from wherever payment confirmation lands next. + * (the launch stripped PayMongo, so no code currently creates `Payment` + * rows). Ready to call from wherever payment confirmation lands next. */ export default function PaymentReceiptEmail({ studentName, @@ -32,7 +32,7 @@ export default function PaymentReceiptEmail({ receiptUrl, }: PaymentReceiptEmailProps) { return ( - + Payment received Hi {studentName}, thanks for your payment. Here's your receipt. diff --git a/src/emails/refund-status.tsx b/src/emails/refund-status.tsx index ba3105b..9ca6589 100644 --- a/src/emails/refund-status.tsx +++ b/src/emails/refund-status.tsx @@ -13,7 +13,7 @@ export interface RefundStatusEmailProps { /** * Refund lifecycle notification (requested / approved / rejected). Not wired - * to a live trigger in this build — refunds depend on the PayMongo flow that + * to a live trigger in this build, refunds depend on the PayMongo flow that * was stripped for the manual-enrollment launch. Ready to call from * `RefundRequest` status transitions when that flow returns. */ @@ -36,13 +36,13 @@ export default function RefundStatusEmail({ status === 'approved' ? colors.success : status === 'rejected' ? colors.error : colors.accent; return ( - + {heading} Hi {studentName}, {status === 'requested' && ( We received your refund request for your {tierName} enrollment ( - {amount}). Our team reviews every request personally — you'll + {amount}). Our team reviews every request personally, you'll hear back within one business day. )} diff --git a/src/emails/welcome.tsx b/src/emails/welcome.tsx index 0d0d744..6bf9844 100644 --- a/src/emails/welcome.tsx +++ b/src/emails/welcome.tsx @@ -8,7 +8,7 @@ export interface WelcomeEmailProps { /** Sent right after a student's account becomes active (fresh signup or claimed guest account). */ export default function WelcomeEmail({ studentName, dashboardUrl }: WelcomeEmailProps) { return ( - + Welcome, {studentName}! Your account is active. Head to your dashboard to start your first diff --git a/src/lib/__tests__/email.test.ts b/src/lib/__tests__/email.test.ts index 2130c5b..fcbda3b 100644 --- a/src/lib/__tests__/email.test.ts +++ b/src/lib/__tests__/email.test.ts @@ -1,9 +1,26 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { ReactElement } from 'react'; +interface SentMessage { + to: string; + subject: string; + react: ReactElement; +} + +/** The first argument of the first call to mockSend, typed and null-checked. */ +function firstSendArg(): SentMessage { + const call = mockSend.mock.calls[0]; + if (!call) throw new Error('mockSend was not called'); + return call[0] as SentMessage; +} + +function props(element: ReactElement): Record { + return element.props as Record; +} + const { mockSend, MockResend } = vi.hoisted(() => { const mockSend = vi.fn(); - // Must be a plain function, not an arrow function — `new Resend(...)` in + // Must be a plain function, not an arrow function: `new Resend(...)` in // email.ts invokes this as a constructor, and arrow functions can't be. const MockResend = vi.fn().mockImplementation(function () { return { emails: { send: mockSend } }; @@ -68,10 +85,10 @@ describe('email.ts', () => { }); expect(mockSend).toHaveBeenCalledTimes(1); - const call = mockSend.mock.calls[0][0]; + const call = firstSendArg(); expect(call.to).toBe('guest@example.com'); expect(call.subject).toContain('Claim your'); - expect((call.react as ReactElement).props).toMatchObject({ + expect(props(call.react)).toMatchObject({ tierName: 'PPC Foundations', claimUrl: 'https://amph.test/auth/signup?claim=abc', }); @@ -81,9 +98,9 @@ describe('email.ts', () => { const { sendWelcomeEmail } = await import('@/lib/email'); await sendWelcomeEmail({ to: 'student@example.com', studentName: 'Ana' }); - const call = mockSend.mock.calls[0][0]; + const call = firstSendArg(); expect(call.subject).toContain('Ana'); - expect((call.react as ReactElement).props).toMatchObject({ + expect(props(call.react)).toMatchObject({ studentName: 'Ana', dashboardUrl: expect.stringContaining('/dashboard'), }); @@ -101,9 +118,9 @@ describe('email.ts', () => { meetingUrl: 'https://meet.example.com/xyz', }); - const call = mockSend.mock.calls[0][0]; + const call = firstSendArg(); expect(call.subject).toBe("You're registered: Bid Optimization Deep Dive"); - expect((call.react as ReactElement).props).toMatchObject({ + expect(props(call.react)).toMatchObject({ classTitle: 'Bid Optimization Deep Dive', instructorName: 'Ryan Dabao', durationMinutes: 60, @@ -120,8 +137,8 @@ describe('email.ts', () => { verificationHash: 'hash-123', }); - const call = mockSend.mock.calls[0][0]; - expect((call.react as ReactElement).props.verifyUrl).toContain('/verify/hash-123'); + const call = firstSendArg(); + expect(props(call.react).verifyUrl).toContain('/verify/hash-123'); }); it('sendPaymentReceiptEmail formats centavos into pesos', async () => { @@ -136,15 +153,15 @@ describe('email.ts', () => { receiptUrl: null, }); - const call = mockSend.mock.calls[0][0]; - expect((call.react as ReactElement).props.amount).toContain('2,999.00'); + const call = firstSendArg(); + expect(props(call.react).amount).toContain('2,999.00'); }); it.each([ ['requested', 'Refund request received'], ['approved', 'is being processed'], ['rejected', 'not approved'], - ] as const)('sendRefundStatusEmail — %s status has the right subject', async (status, expectedSubjectPart) => { + ] as const)('sendRefundStatusEmail (%s status) has the right subject', async (status, expectedSubjectPart) => { const { sendRefundStatusEmail } = await import('@/lib/email'); await sendRefundStatusEmail({ to: 'student@example.com', @@ -154,7 +171,7 @@ describe('email.ts', () => { status, }); - const call = mockSend.mock.calls[0][0]; + const call = firstSendArg(); expect(call.subject).toContain(expectedSubjectPart); }); @@ -166,16 +183,16 @@ describe('email.ts', () => { tierName: 'PPC Foundations', }); - const call = mockSend.mock.calls[0][0]; - expect((call.react as ReactElement).props.retryUrl).toContain('/pricing'); + const call = firstSendArg(); + expect(props(call.react).retryUrl).toContain('/pricing'); }); it('sendPasswordResetEmail embeds the raw token in the reset URL', async () => { const { sendPasswordResetEmail } = await import('@/lib/email'); await sendPasswordResetEmail({ to: 'student@example.com', resetToken: 'raw-token-abc' }); - const call = mockSend.mock.calls[0][0]; - expect((call.react as ReactElement).props.resetUrl).toContain('token=raw-token-abc'); + const call = firstSendArg(); + expect(props(call.react).resetUrl).toContain('token=raw-token-abc'); }); it('logs and swallows the error when Resend returns an error', async () => { diff --git a/src/lib/email.ts b/src/lib/email.ts index df9d734..7539696 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,11 +1,11 @@ /** - * Email — transactional sending via Resend (ADR-007). + * Email: transactional sending via Resend (ADR-007). * * Best-effort: every send function logs and resolves on failure instead of * throwing, so a broken inbox or missing API key never breaks the calling * flow (enrollment, registration, certificate issuance, ...). When * RESEND_API_KEY is unset (e.g. local dev), sends no-op and log what would - * have been sent — the same fallback behavior the stripped launch build used. + * have been sent. The same fallback behavior the stripped launch build used. * * Templates live in src/emails/*.tsx (React Email components, styled to the * "Field Manual" design system). This file only wires data → template → send. @@ -50,7 +50,7 @@ async function sendEmail({ react: React.ReactElement; }): Promise { if (!process.env.RESEND_API_KEY) { - logger.info({ to, subject }, '[email disabled] RESEND_API_KEY not set — skipped'); + logger.info({ to, subject }, '[email disabled] RESEND_API_KEY not set, skipped'); return; } try { @@ -76,7 +76,7 @@ interface AccountInviteEmailArgs { /** * Deliver the single-use link a newly-enrolled student uses to set a * password and claim their account. This is the only place the raw claim - * token should be sent — never log it. + * token should be sent. Never log it. */ export async function sendAccountInviteEmail({ to, tierName, claimUrl }: AccountInviteEmailArgs): Promise { await sendEmail({ @@ -133,11 +133,13 @@ export async function sendLiveClassReminderEmail({ year: 'numeric', month: 'long', day: 'numeric', + timeZone: 'Asia/Manila', }).format(scheduledAt); const time = new Intl.DateTimeFormat('en-PH', { hour: 'numeric', minute: '2-digit', hour12: true, + timeZone: 'Asia/Manila', }).format(scheduledAt); await sendEmail({ @@ -176,7 +178,7 @@ export async function sendCertificateIssuedEmail({ }: CertificateIssuedEmailArgs): Promise { await sendEmail({ to, - subject: `Certificate earned — ${courseTitle}`, + subject: `Certificate earned: ${courseTitle}`, react: createElement(CertificateIssuedEmail, { studentName, courseTitle, @@ -187,7 +189,7 @@ export async function sendCertificateIssuedEmail({ } // --------------------------------------------------------------------------- -// Payment receipt — not wired to a live trigger (see src/emails/payment-receipt.tsx) +// Payment receipt (not wired to a live trigger, see src/emails/payment-receipt.tsx) // --------------------------------------------------------------------------- interface PaymentReceiptEmailArgs { @@ -212,7 +214,7 @@ export async function sendPaymentReceiptEmail({ const amount = formatPhp(amountPhp); await sendEmail({ to, - subject: `Receipt for your ${tierName} payment — ${amount}`, + subject: `Receipt for your ${tierName} payment (${amount})`, react: createElement(PaymentReceiptEmail, { studentName, tierName, @@ -226,7 +228,7 @@ export async function sendPaymentReceiptEmail({ } // --------------------------------------------------------------------------- -// Refund status — not wired to a live trigger (see src/emails/refund-status.tsx) +// Refund status (not wired to a live trigger, see src/emails/refund-status.tsx) // --------------------------------------------------------------------------- interface RefundStatusEmailArgs { @@ -249,7 +251,7 @@ export async function sendRefundStatusEmail({ const amount = formatPhp(amountPhp); const subject = status === 'requested' - ? `Refund request received — ${tierName}` + ? `Refund request received: ${tierName}` : status === 'approved' ? `Your refund of ${amount} is being processed` : `Your refund request was not approved`; @@ -269,7 +271,7 @@ export async function sendRefundStatusEmail({ } // --------------------------------------------------------------------------- -// Payment failed — not wired to a live trigger (see src/emails/payment-failed.tsx) +// Payment failed (not wired to a live trigger, see src/emails/payment-failed.tsx) // --------------------------------------------------------------------------- interface PaymentFailedEmailArgs { @@ -287,7 +289,7 @@ export async function sendPaymentFailedEmail({ }: PaymentFailedEmailArgs): Promise { await sendEmail({ to, - subject: `Your payment didn't go through — ${tierName}`, + subject: `Your payment didn't go through: ${tierName}`, react: createElement(PaymentFailedEmail, { studentName, tierName, @@ -297,7 +299,7 @@ export async function sendPaymentFailedEmail({ } // --------------------------------------------------------------------------- -// Password reset — template only, no reset-token flow exists yet (see +// Password reset (template only, no reset-token flow exists yet, see // src/emails/password-reset.tsx for why this isn't wired up). // --------------------------------------------------------------------------- From bc678e67c8ef34ff63a1e7e50028580b4826c484 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 12:02:27 +0000 Subject: [PATCH 4/4] Rename email templates to PascalCase and stop logging raw addresses - Rename src/emails/*.tsx from kebab-case to PascalCase, matching this repo's actual component-file convention (every other .tsx component under src/components/ is PascalCase already). - Stop logging raw recipient email addresses in src/lib/email.ts. Nothing else in the codebase logs a raw `email` field (userId is used instead), and the pino redact config exists for exactly this reason. Added a maskEmail() helper (e.g. "st***@example.com") used across the disabled, send-failed, and unexpected-error log lines. Co-Authored-By: Claude Sonnet 5 --- ...ount-invite.tsx => AccountInviteEmail.tsx} | 0 ...-issued.tsx => CertificateIssuedEmail.tsx} | 0 ...ion.tsx => LiveClassConfirmationEmail.tsx} | 0 ...sword-reset.tsx => PasswordResetEmail.tsx} | 0 ...ment-failed.tsx => PaymentFailedEmail.tsx} | 0 ...nt-receipt.tsx => PaymentReceiptEmail.tsx} | 0 ...efund-status.tsx => RefundStatusEmail.tsx} | 0 src/emails/{welcome.tsx => WelcomeEmail.tsx} | 0 src/lib/__tests__/email.test.ts | 15 ++++++-- src/lib/email.ts | 38 +++++++++++-------- 10 files changed, 35 insertions(+), 18 deletions(-) rename src/emails/{account-invite.tsx => AccountInviteEmail.tsx} (100%) rename src/emails/{certificate-issued.tsx => CertificateIssuedEmail.tsx} (100%) rename src/emails/{live-class-confirmation.tsx => LiveClassConfirmationEmail.tsx} (100%) rename src/emails/{password-reset.tsx => PasswordResetEmail.tsx} (100%) rename src/emails/{payment-failed.tsx => PaymentFailedEmail.tsx} (100%) rename src/emails/{payment-receipt.tsx => PaymentReceiptEmail.tsx} (100%) rename src/emails/{refund-status.tsx => RefundStatusEmail.tsx} (100%) rename src/emails/{welcome.tsx => WelcomeEmail.tsx} (100%) diff --git a/src/emails/account-invite.tsx b/src/emails/AccountInviteEmail.tsx similarity index 100% rename from src/emails/account-invite.tsx rename to src/emails/AccountInviteEmail.tsx diff --git a/src/emails/certificate-issued.tsx b/src/emails/CertificateIssuedEmail.tsx similarity index 100% rename from src/emails/certificate-issued.tsx rename to src/emails/CertificateIssuedEmail.tsx diff --git a/src/emails/live-class-confirmation.tsx b/src/emails/LiveClassConfirmationEmail.tsx similarity index 100% rename from src/emails/live-class-confirmation.tsx rename to src/emails/LiveClassConfirmationEmail.tsx diff --git a/src/emails/password-reset.tsx b/src/emails/PasswordResetEmail.tsx similarity index 100% rename from src/emails/password-reset.tsx rename to src/emails/PasswordResetEmail.tsx diff --git a/src/emails/payment-failed.tsx b/src/emails/PaymentFailedEmail.tsx similarity index 100% rename from src/emails/payment-failed.tsx rename to src/emails/PaymentFailedEmail.tsx diff --git a/src/emails/payment-receipt.tsx b/src/emails/PaymentReceiptEmail.tsx similarity index 100% rename from src/emails/payment-receipt.tsx rename to src/emails/PaymentReceiptEmail.tsx diff --git a/src/emails/refund-status.tsx b/src/emails/RefundStatusEmail.tsx similarity index 100% rename from src/emails/refund-status.tsx rename to src/emails/RefundStatusEmail.tsx diff --git a/src/emails/welcome.tsx b/src/emails/WelcomeEmail.tsx similarity index 100% rename from src/emails/welcome.tsx rename to src/emails/WelcomeEmail.tsx diff --git a/src/lib/__tests__/email.test.ts b/src/lib/__tests__/email.test.ts index fcbda3b..fcd0db3 100644 --- a/src/lib/__tests__/email.test.ts +++ b/src/lib/__tests__/email.test.ts @@ -64,10 +64,19 @@ describe('email.ts', () => { expect(MockResend).not.toHaveBeenCalled(); expect(mockSend).not.toHaveBeenCalled(); expect(mockLoggerInfo).toHaveBeenCalledWith( - expect.objectContaining({ to: 'student@example.com' }), + expect.objectContaining({ to: 'st***@example.com' }), expect.stringContaining('skipped'), ); }); + + it('never logs the raw recipient address', async () => { + const { sendWelcomeEmail } = await import('@/lib/email'); + await sendWelcomeEmail({ to: 'student@example.com', studentName: 'Ana' }); + + const [logCtx] = mockLoggerInfo.mock.calls[0] ?? []; + expect(logCtx.to).not.toBe('student@example.com'); + expect(logCtx.to).toBe('st***@example.com'); + }); }); describe('when RESEND_API_KEY is set', () => { @@ -203,7 +212,7 @@ describe('email.ts', () => { sendWelcomeEmail({ to: 'student@example.com', studentName: 'Ana' }), ).resolves.toBeUndefined(); expect(mockLoggerError).toHaveBeenCalledWith( - expect.objectContaining({ to: 'student@example.com' }), + expect.objectContaining({ to: 'st***@example.com' }), expect.stringContaining('send failed'), ); }); @@ -216,7 +225,7 @@ describe('email.ts', () => { sendWelcomeEmail({ to: 'student@example.com', studentName: 'Ana' }), ).resolves.toBeUndefined(); expect(mockLoggerError).toHaveBeenCalledWith( - expect.objectContaining({ to: 'student@example.com' }), + expect.objectContaining({ to: 'st***@example.com' }), expect.stringContaining('unexpected error'), ); }); diff --git a/src/lib/email.ts b/src/lib/email.ts index 7539696..7a22c6b 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -19,14 +19,14 @@ import { logger } from './logger'; import { formatDateTime, formatPhp } from './format'; import { BRAND_NAME } from './brand'; -import AccountInviteEmail from '@/emails/account-invite'; -import WelcomeEmail from '@/emails/welcome'; -import LiveClassConfirmationEmail from '@/emails/live-class-confirmation'; -import CertificateIssuedEmail from '@/emails/certificate-issued'; -import PaymentReceiptEmail from '@/emails/payment-receipt'; -import RefundStatusEmail, { type RefundStatusKind } from '@/emails/refund-status'; -import PaymentFailedEmail from '@/emails/payment-failed'; -import PasswordResetEmail from '@/emails/password-reset'; +import AccountInviteEmail from '@/emails/AccountInviteEmail'; +import WelcomeEmail from '@/emails/WelcomeEmail'; +import LiveClassConfirmationEmail from '@/emails/LiveClassConfirmationEmail'; +import CertificateIssuedEmail from '@/emails/CertificateIssuedEmail'; +import PaymentReceiptEmail from '@/emails/PaymentReceiptEmail'; +import RefundStatusEmail, { type RefundStatusKind } from '@/emails/RefundStatusEmail'; +import PaymentFailedEmail from '@/emails/PaymentFailedEmail'; +import PasswordResetEmail from '@/emails/PasswordResetEmail'; // Lazy singleton: the Resend constructor throws when the API key is unset, // so constructing at module scope would break `next build` (this module is @@ -40,6 +40,13 @@ function getResend(): Resend { const FROM = process.env.RESEND_FROM_EMAIL ?? 'noreply@projectamazonph.online'; const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'; +/** Masks a recipient address for logs, e.g. "st***@example.com". Never log the raw address. */ +function maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!local || !domain) return '[redacted]'; + return `${local.slice(0, 2)}***@${domain}`; +} + async function sendEmail({ to, subject, @@ -49,17 +56,18 @@ async function sendEmail({ subject: string; react: React.ReactElement; }): Promise { + const logCtx = { to: maskEmail(to), subject }; if (!process.env.RESEND_API_KEY) { - logger.info({ to, subject }, '[email disabled] RESEND_API_KEY not set, skipped'); + logger.info(logCtx, '[email disabled] RESEND_API_KEY not set, skipped'); return; } try { const { error } = await getResend().emails.send({ from: FROM, to, subject, react }); if (error) { - logger.error({ to, subject, err: error }, '[email] send failed'); + logger.error({ ...logCtx, err: error }, '[email] send failed'); } } catch (err) { - logger.error({ to, subject, err }, '[email] unexpected error'); + logger.error({ ...logCtx, err }, '[email] unexpected error'); } } @@ -189,7 +197,7 @@ export async function sendCertificateIssuedEmail({ } // --------------------------------------------------------------------------- -// Payment receipt (not wired to a live trigger, see src/emails/payment-receipt.tsx) +// Payment receipt (not wired to a live trigger, see src/emails/PaymentReceiptEmail.tsx) // --------------------------------------------------------------------------- interface PaymentReceiptEmailArgs { @@ -228,7 +236,7 @@ export async function sendPaymentReceiptEmail({ } // --------------------------------------------------------------------------- -// Refund status (not wired to a live trigger, see src/emails/refund-status.tsx) +// Refund status (not wired to a live trigger, see src/emails/RefundStatusEmail.tsx) // --------------------------------------------------------------------------- interface RefundStatusEmailArgs { @@ -271,7 +279,7 @@ export async function sendRefundStatusEmail({ } // --------------------------------------------------------------------------- -// Payment failed (not wired to a live trigger, see src/emails/payment-failed.tsx) +// Payment failed (not wired to a live trigger, see src/emails/PaymentFailedEmail.tsx) // --------------------------------------------------------------------------- interface PaymentFailedEmailArgs { @@ -300,7 +308,7 @@ export async function sendPaymentFailedEmail({ // --------------------------------------------------------------------------- // Password reset (template only, no reset-token flow exists yet, see -// src/emails/password-reset.tsx for why this isn't wired up). +// src/emails/PasswordResetEmail.tsx for why this isn't wired up). // --------------------------------------------------------------------------- interface PasswordResetEmailArgs {