diff --git a/.env.example b/.env.example
index ab7ddff..f339ec7 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..b24bca6 100644
--- a/src/app/actions/admin-enroll.ts
+++ b/src/app/actions/admin-enroll.ts
@@ -1,12 +1,13 @@
'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
- * 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({
@@ -24,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;
@@ -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/AccountInviteEmail.tsx b/src/emails/AccountInviteEmail.tsx
new file mode 100644
index 0000000..8c04fc0
--- /dev/null
+++ b/src/emails/AccountInviteEmail.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/CertificateIssuedEmail.tsx b/src/emails/CertificateIssuedEmail.tsx
new file mode 100644
index 0000000..3ad75c3
--- /dev/null
+++ b/src/emails/CertificateIssuedEmail.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/LiveClassConfirmationEmail.tsx b/src/emails/LiveClassConfirmationEmail.tsx
new file mode 100644
index 0000000..4be2f79
--- /dev/null
+++ b/src/emails/LiveClassConfirmationEmail.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/PasswordResetEmail.tsx b/src/emails/PasswordResetEmail.tsx
new file mode 100644
index 0000000..193e577
--- /dev/null
+++ b/src/emails/PasswordResetEmail.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 and 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/PaymentFailedEmail.tsx b/src/emails/PaymentFailedEmail.tsx
new file mode 100644
index 0000000..4b2aa0f
--- /dev/null
+++ b/src/emails/PaymentFailedEmail.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/PaymentReceiptEmail.tsx b/src/emails/PaymentReceiptEmail.tsx
new file mode 100644
index 0000000..d9d9e3f
--- /dev/null
+++ b/src/emails/PaymentReceiptEmail.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/RefundStatusEmail.tsx b/src/emails/RefundStatusEmail.tsx
new file mode 100644
index 0000000..9ca6589
--- /dev/null
+++ b/src/emails/RefundStatusEmail.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/WelcomeEmail.tsx b/src/emails/WelcomeEmail.tsx
new file mode 100644
index 0000000..6bf9844
--- /dev/null
+++ b/src/emails/WelcomeEmail.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/emails/shared.tsx b/src/emails/shared.tsx
new file mode 100644
index 0000000..55d2906
--- /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}
+
+
+
+ {`${BRAND_NAME} · projectamazonph.online`}
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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/lib/__tests__/email.test.ts b/src/lib/__tests__/email.test.ts
new file mode 100644
index 0000000..fcd0db3
--- /dev/null
+++ b/src/lib/__tests__/email.test.ts
@@ -0,0 +1,233 @@
+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
+ // 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: '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', () => {
+ 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 = firstSendArg();
+ expect(call.to).toBe('guest@example.com');
+ expect(call.subject).toContain('Claim your');
+ expect(props(call.react)).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 = firstSendArg();
+ expect(call.subject).toContain('Ana');
+ expect(props(call.react)).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 = firstSendArg();
+ expect(call.subject).toBe("You're registered: Bid Optimization Deep Dive");
+ expect(props(call.react)).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 = firstSendArg();
+ expect(props(call.react).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 = 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) => {
+ const { sendRefundStatusEmail } = await import('@/lib/email');
+ await sendRefundStatusEmail({
+ to: 'student@example.com',
+ studentName: 'Ana',
+ tierName: 'PPC Foundations',
+ amountPhp: 299900,
+ status,
+ });
+
+ const call = firstSendArg();
+ 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 = 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 = firstSendArg();
+ expect(props(call.react).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: 'st***@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: 'st***@example.com' }),
+ expect.stringContaining('unexpected error'),
+ );
+ });
+ });
+});
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 94e0f21..7a22c6b 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -1,20 +1,122 @@
/**
- * 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/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
+// 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';
+
+/** 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,
+ react,
+}: {
+ to: string;
+ subject: string;
+ react: React.ReactElement;
+}): Promise {
+ const logCtx = { to: maskEmail(to), subject };
+ if (!process.env.RESEND_API_KEY) {
+ 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({ ...logCtx, err: error }, '[email] send failed');
+ }
+ } catch (err) {
+ logger.error({ ...logCtx, 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 +126,211 @@ 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',
+ 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({
+ 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/PaymentReceiptEmail.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/RefundStatusEmail.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/PaymentFailedEmail.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/PasswordResetEmail.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,
+ }),
+ });
}