Switch email provider from Resend to Mailgun and add cron scheduler
Some checks failed
Deploy / deploy (push) Failing after 1m43s
Some checks failed
Deploy / deploy (push) Failing after 1m43s
- Replace resend package with mailgun.js and form-data - Update email.ts to use Mailgun API with lazy client initialization - Add instrumentation.ts to schedule cron jobs (notifications :00, Garmin :30) - Update tests for Mailgun mock structure - Update .env.example with MAILGUN_API_KEY and MAILGUN_DOMAIN Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,9 @@ NODE_ENV=development
|
|||||||
POCKETBASE_URL=http://localhost:8090
|
POCKETBASE_URL=http://localhost:8090
|
||||||
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
|
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
|
||||||
|
|
||||||
# Email (Resend)
|
# Email (Mailgun)
|
||||||
RESEND_API_KEY=re_xxxxxxxxxxxx
|
MAILGUN_API_KEY=key-xxxxxxxxxxxx
|
||||||
|
MAILGUN_DOMAIN=yourdomain.com
|
||||||
EMAIL_FROM=phaseflow@yourdomain.com
|
EMAIL_FROM=phaseflow@yourdomain.com
|
||||||
|
|
||||||
# Encryption (for Garmin tokens)
|
# Encryption (for Garmin tokens)
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"form-data": "^4.0.1",
|
||||||
"ics": "^3.8.1",
|
"ics": "^3.8.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"mailgun.js": "^11.1.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"oauth-1.0a": "^2.2.6",
|
"oauth-1.0a": "^2.2.6",
|
||||||
@@ -29,7 +31,6 @@
|
|||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"resend": "^6.7.0",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
|
|||||||
259
pnpm-lock.yaml
generated
259
pnpm-lock.yaml
generated
@@ -17,12 +17,18 @@ importers:
|
|||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.1
|
specifier: ^0.45.1
|
||||||
version: 0.45.1(@opentelemetry/api@1.9.0)
|
version: 0.45.1(@opentelemetry/api@1.9.0)
|
||||||
|
form-data:
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.5
|
||||||
ics:
|
ics:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
|
mailgun.js:
|
||||||
|
specifier: ^11.1.0
|
||||||
|
version: 11.1.0
|
||||||
next:
|
next:
|
||||||
specifier: 16.1.1
|
specifier: 16.1.1
|
||||||
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -47,9 +53,6 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3(react@19.2.3)
|
version: 19.2.3(react@19.2.3)
|
||||||
resend:
|
|
||||||
specifier: ^6.7.0
|
|
||||||
version: 6.7.0
|
|
||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -1119,9 +1122,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@stablelib/base64@1.0.1':
|
|
||||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -1337,10 +1337,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
asynckit@0.4.0:
|
||||||
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
atomic-sleep@1.0.0:
|
atomic-sleep@1.0.0:
|
||||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
axios@1.13.2:
|
||||||
|
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||||
|
|
||||||
|
base-64@1.0.0:
|
||||||
|
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.14:
|
baseline-browser-mapping@2.9.14:
|
||||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1359,6 +1368,10 @@ packages:
|
|||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
call-bind-apply-helpers@1.0.2:
|
||||||
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001763:
|
caniuse-lite@1.0.30001763:
|
||||||
resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==}
|
resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==}
|
||||||
|
|
||||||
@@ -1376,6 +1389,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
@@ -1409,6 +1426,10 @@ packages:
|
|||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
dequal@2.0.3:
|
dequal@2.0.3:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1519,6 +1540,10 @@ packages:
|
|||||||
sqlite3:
|
sqlite3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
electron-to-chromium@1.5.267:
|
electron-to-chromium@1.5.267:
|
||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
|
|
||||||
@@ -1530,9 +1555,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
es-define-property@1.0.1:
|
||||||
|
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-errors@1.3.0:
|
||||||
|
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
es-module-lexer@1.7.0:
|
es-module-lexer@1.7.0:
|
||||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||||
|
|
||||||
|
es-object-atoms@1.1.1:
|
||||||
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
esbuild-register@3.6.0:
|
esbuild-register@3.6.0:
|
||||||
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1564,9 +1605,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
fast-sha256@1.3.0:
|
|
||||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1576,6 +1614,19 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
follow-redirects@1.15.11:
|
||||||
|
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1586,16 +1637,43 @@ packages:
|
|||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
function-bind@1.1.2:
|
||||||
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2:
|
gensync@1.0.0-beta.2:
|
||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
get-intrinsic@1.3.0:
|
||||||
|
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
get-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
get-tsconfig@4.13.0:
|
get-tsconfig@4.13.0:
|
||||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||||
|
|
||||||
|
gopd@1.2.0:
|
||||||
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
graceful-fs@4.2.11:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
|
has-symbols@1.1.0:
|
||||||
|
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
hasown@2.0.2:
|
||||||
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
html-encoding-sniffer@6.0.0:
|
html-encoding-sniffer@6.0.0:
|
||||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
@@ -1733,9 +1811,25 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
mailgun.js@11.1.0:
|
||||||
|
resolution: {integrity: sha512-pXYcQT3nU32gMjUjZpl2FdQN4Vv2iobqYiXqyyevk0vXTKQj8Or0ifLXLNAGqMHnymTjV0OphBpurkchvHsRAg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0:
|
||||||
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
mdn-data@2.12.2:
|
mdn-data@2.12.2:
|
||||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||||
|
|
||||||
|
mime-db@1.52.0:
|
||||||
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
min-indent@1.0.1:
|
min-indent@1.0.1:
|
||||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1844,6 +1938,9 @@ packages:
|
|||||||
property-expr@2.0.6:
|
property-expr@2.0.6:
|
||||||
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1879,15 +1976,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
resend@6.7.0:
|
|
||||||
resolution: {integrity: sha512-2ZV0NDZsh4Gh+Nd1hvluZIitmGJ59O4+OxMufymG6Y8uz1Jgt2uS1seSENnkIUlmwg7/dwmfIJC9rAufByz7wA==}
|
|
||||||
engines: {node: '>=20'}
|
|
||||||
peerDependencies:
|
|
||||||
'@react-email/render': '*'
|
|
||||||
peerDependenciesMeta:
|
|
||||||
'@react-email/render':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
@@ -1953,9 +2041,6 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
standardwebhooks@1.0.0:
|
|
||||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
|
||||||
|
|
||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
@@ -1976,9 +2061,6 @@ packages:
|
|||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
svix@1.84.1:
|
|
||||||
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
|
|
||||||
|
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
@@ -2059,9 +2141,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
uuid@10.0.0:
|
url-join@4.0.1:
|
||||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
vite@7.3.1:
|
vite@7.3.1:
|
||||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||||
@@ -2851,8 +2932,6 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@stablelib/base64@1.0.1': {}
|
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
@@ -3071,8 +3150,20 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
|
axios@1.13.2:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11
|
||||||
|
form-data: 4.0.5
|
||||||
|
proxy-from-env: 1.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
|
base-64@1.0.0: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.14: {}
|
baseline-browser-mapping@2.9.14: {}
|
||||||
|
|
||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
@@ -3091,6 +3182,11 @@ snapshots:
|
|||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
|
call-bind-apply-helpers@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
caniuse-lite@1.0.30001763: {}
|
caniuse-lite@1.0.30001763: {}
|
||||||
|
|
||||||
chai@6.2.2: {}
|
chai@6.2.2: {}
|
||||||
@@ -3103,6 +3199,10 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
css-tree@3.1.0:
|
css-tree@3.1.0:
|
||||||
@@ -3132,6 +3232,8 @@ snapshots:
|
|||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
@@ -3153,6 +3255,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-errors: 1.3.0
|
||||||
|
gopd: 1.2.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.267: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.4:
|
enhanced-resolve@5.18.4:
|
||||||
@@ -3162,8 +3270,23 @@ snapshots:
|
|||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
|
es-define-property@1.0.1: {}
|
||||||
|
|
||||||
|
es-errors@1.3.0: {}
|
||||||
|
|
||||||
es-module-lexer@1.7.0: {}
|
es-module-lexer@1.7.0: {}
|
||||||
|
|
||||||
|
es-object-atoms@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
has-tostringtag: 1.0.2
|
||||||
|
hasown: 2.0.2
|
||||||
|
|
||||||
esbuild-register@3.6.0(esbuild@0.25.12):
|
esbuild-register@3.6.0(esbuild@0.25.12):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -3262,26 +3385,66 @@ snapshots:
|
|||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
fast-sha256@1.3.0: {}
|
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.3):
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
|
form-data@4.0.5:
|
||||||
|
dependencies:
|
||||||
|
asynckit: 0.4.0
|
||||||
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
|
get-intrinsic@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
es-errors: 1.3.0
|
||||||
|
es-object-atoms: 1.1.1
|
||||||
|
function-bind: 1.1.2
|
||||||
|
get-proto: 1.0.1
|
||||||
|
gopd: 1.2.0
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
math-intrinsics: 1.1.0
|
||||||
|
|
||||||
|
get-proto@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
dunder-proto: 1.0.1
|
||||||
|
es-object-atoms: 1.1.1
|
||||||
|
|
||||||
get-tsconfig@4.13.0:
|
get-tsconfig@4.13.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
|
has-tostringtag@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
|
||||||
|
hasown@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
html-encoding-sniffer@6.0.0:
|
html-encoding-sniffer@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@exodus/bytes': 1.8.0
|
'@exodus/bytes': 1.8.0
|
||||||
@@ -3413,8 +3576,24 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
mailgun.js@11.1.0:
|
||||||
|
dependencies:
|
||||||
|
axios: 1.13.2
|
||||||
|
base-64: 1.0.0
|
||||||
|
url-join: 4.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
mdn-data@2.12.2: {}
|
mdn-data@2.12.2: {}
|
||||||
|
|
||||||
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.52.0
|
||||||
|
|
||||||
min-indent@1.0.1: {}
|
min-indent@1.0.1: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
@@ -3524,6 +3703,8 @@ snapshots:
|
|||||||
|
|
||||||
property-expr@2.0.6: {}
|
property-expr@2.0.6: {}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
quick-format-unescaped@4.0.4: {}
|
quick-format-unescaped@4.0.4: {}
|
||||||
@@ -3548,10 +3729,6 @@ snapshots:
|
|||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
resend@6.7.0:
|
|
||||||
dependencies:
|
|
||||||
svix: 1.84.1
|
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
rollup@4.55.1:
|
rollup@4.55.1:
|
||||||
@@ -3656,11 +3833,6 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
standardwebhooks@1.0.0:
|
|
||||||
dependencies:
|
|
||||||
'@stablelib/base64': 1.0.1
|
|
||||||
fast-sha256: 1.3.0
|
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
strip-indent@3.0.0:
|
strip-indent@3.0.0:
|
||||||
@@ -3674,11 +3846,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
|
|
||||||
svix@1.84.1:
|
|
||||||
dependencies:
|
|
||||||
standardwebhooks: 1.0.0
|
|
||||||
uuid: 10.0.0
|
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
tailwind-merge@3.4.0: {}
|
tailwind-merge@3.4.0: {}
|
||||||
@@ -3740,7 +3907,7 @@ snapshots:
|
|||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
uuid@10.0.0: {}
|
url-join@4.0.1: {}
|
||||||
|
|
||||||
vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2):
|
vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
62
src/instrumentation.ts
Normal file
62
src/instrumentation.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// ABOUTME: Next.js instrumentation file for server startup initialization.
|
||||||
|
// ABOUTME: Schedules cron jobs for notifications and Garmin sync using node-cron.
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
// Only run on the server side
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
const cron = await import("node-cron");
|
||||||
|
|
||||||
|
const APP_URL = process.env.APP_URL || "http://localhost:3000";
|
||||||
|
const CRON_SECRET = process.env.CRON_SECRET;
|
||||||
|
|
||||||
|
// Log startup
|
||||||
|
console.log("[cron] Scheduler starting...");
|
||||||
|
|
||||||
|
if (!CRON_SECRET) {
|
||||||
|
console.warn(
|
||||||
|
"[cron] CRON_SECRET not set - cron jobs will fail authentication",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to call cron endpoints
|
||||||
|
async function triggerCronEndpoint(endpoint: string, name: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${APP_URL}/api/cron/${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${CRON_SECRET}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(
|
||||||
|
`[cron] ${name} failed: ${response.status} ${errorText}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log(`[cron] ${name} completed:`, result);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[cron] ${name} error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule notifications at the top of every hour
|
||||||
|
cron.default.schedule("0 * * * *", () => {
|
||||||
|
console.log("[cron] Triggering notifications...");
|
||||||
|
triggerCronEndpoint("notifications", "Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule Garmin sync at 30 minutes past every hour
|
||||||
|
cron.default.schedule("30 * * * *", () => {
|
||||||
|
console.log("[cron] Triggering Garmin sync...");
|
||||||
|
triggerCronEndpoint("garmin-sync", "Garmin sync");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[cron] Scheduler started - notifications at :00, Garmin sync at :30",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
// ABOUTME: Unit tests for email sending utilities.
|
// ABOUTME: Unit tests for email sending utilities.
|
||||||
// ABOUTME: Tests email composition, subject lines, and Resend integration.
|
// ABOUTME: Tests email composition, subject lines, and Mailgun integration.
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { mockSend, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
|
const { mockCreate, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
|
||||||
mockSend: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
mockCreate: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
||||||
mockLoggerInfo: vi.fn(),
|
mockLoggerInfo: vi.fn(),
|
||||||
mockLoggerError: vi.fn(),
|
mockLoggerError: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the resend module before importing email utilities
|
// Mock the mailgun.js module before importing email utilities
|
||||||
vi.mock("resend", () => ({
|
vi.mock("mailgun.js", () => ({
|
||||||
Resend: class MockResend {
|
default: class MockMailgun {
|
||||||
emails = { send: mockSend };
|
client() {
|
||||||
|
return {
|
||||||
|
messages: { create: mockCreate },
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock form-data (required by mailgun.js)
|
||||||
|
vi.mock("form-data", () => ({
|
||||||
|
default: class MockFormData {},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock("@/lib/logger", () => ({
|
vi.mock("@/lib/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -57,7 +66,9 @@ describe("sendDailyEmail", () => {
|
|||||||
|
|
||||||
it("sends email with correct subject line per spec", async () => {
|
it("sends email with correct subject line per spec", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
// Mailgun create takes (domain, messageData) - check second param
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject: "PhaseFlow: 💪 TRAIN - Day 15 (OVULATION)",
|
subject: "PhaseFlow: 💪 TRAIN - Day 15 (OVULATION)",
|
||||||
}),
|
}),
|
||||||
@@ -66,13 +77,13 @@ describe("sendDailyEmail", () => {
|
|||||||
|
|
||||||
it("includes cycle day and phase in email body", async () => {
|
it("includes cycle day and phase in email body", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("📅 CYCLE DAY: 15 (OVULATION)");
|
expect(call.text).toContain("📅 CYCLE DAY: 15 (OVULATION)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes decision icon and reason", async () => {
|
it("includes decision icon and reason", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain(
|
expect(call.text).toContain(
|
||||||
"💪 Body battery high, HRV balanced - great day for training!",
|
"💪 Body battery high, HRV balanced - great day for training!",
|
||||||
);
|
);
|
||||||
@@ -80,7 +91,7 @@ describe("sendDailyEmail", () => {
|
|||||||
|
|
||||||
it("includes biometric data in email body", async () => {
|
it("includes biometric data in email body", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Body Battery Now: 85");
|
expect(call.text).toContain("Body Battery Now: 85");
|
||||||
expect(call.text).toContain("Yesterday's Low: 45");
|
expect(call.text).toContain("Yesterday's Low: 45");
|
||||||
expect(call.text).toContain("HRV Status: Balanced");
|
expect(call.text).toContain("HRV Status: Balanced");
|
||||||
@@ -90,7 +101,7 @@ describe("sendDailyEmail", () => {
|
|||||||
|
|
||||||
it("includes nutrition guidance in email body", async () => {
|
it("includes nutrition guidance in email body", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain(
|
expect(call.text).toContain(
|
||||||
"🌱 SEEDS: Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
"🌱 SEEDS: Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
||||||
);
|
);
|
||||||
@@ -107,30 +118,32 @@ describe("sendDailyEmail", () => {
|
|||||||
bodyBatteryYesterdayLow: null,
|
bodyBatteryYesterdayLow: null,
|
||||||
};
|
};
|
||||||
await sendDailyEmail(dataWithNulls);
|
await sendDailyEmail(dataWithNulls);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Body Battery Now: N/A");
|
expect(call.text).toContain("Body Battery Now: N/A");
|
||||||
expect(call.text).toContain("Yesterday's Low: N/A");
|
expect(call.text).toContain("Yesterday's Low: N/A");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends email to correct recipient", async () => {
|
it("sends email to correct recipient", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
// Mailgun uses an array for recipients
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "user@example.com",
|
to: ["user@example.com"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes auto-generated footer", async () => {
|
it("includes auto-generated footer", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes seed switch alert on day 15", async () => {
|
it("includes seed switch alert on day 15", async () => {
|
||||||
// sampleData already has cycleDay: 15
|
// sampleData already has cycleDay: 15
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("🌱 SWITCH TODAY! Start Sesame + Sunflower");
|
expect(call.text).toContain("🌱 SWITCH TODAY! Start Sesame + Sunflower");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,7 +153,7 @@ describe("sendDailyEmail", () => {
|
|||||||
cycleDay: 10,
|
cycleDay: 10,
|
||||||
};
|
};
|
||||||
await sendDailyEmail(day10Data);
|
await sendDailyEmail(day10Data);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).not.toContain("SWITCH TODAY");
|
expect(call.text).not.toContain("SWITCH TODAY");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -156,7 +169,8 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject: "🔵 Period Tracking Updated",
|
subject: "🔵 Period Tracking Updated",
|
||||||
}),
|
}),
|
||||||
@@ -169,7 +183,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
// Date formatting depends on locale, so check for key parts
|
// Date formatting depends on locale, so check for key parts
|
||||||
expect(call.text).toContain("Your cycle has been reset");
|
expect(call.text).toContain("Your cycle has been reset");
|
||||||
expect(call.text).toContain("Last period:");
|
expect(call.text).toContain("Last period:");
|
||||||
@@ -181,7 +195,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
28,
|
28,
|
||||||
);
|
);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Phase calendar updated for next 28 days");
|
expect(call.text).toContain("Phase calendar updated for next 28 days");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,9 +205,10 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "test@example.com",
|
to: ["test@example.com"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -204,7 +219,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,7 +229,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain(
|
expect(call.text).toContain(
|
||||||
"Your calendar will update automatically within 24 hours",
|
"Your calendar will update automatically within 24 hours",
|
||||||
);
|
);
|
||||||
@@ -229,7 +244,8 @@ describe("sendTokenExpirationWarning", () => {
|
|||||||
describe("14-day warning", () => {
|
describe("14-day warning", () => {
|
||||||
it("sends email with correct subject for 14-day warning", async () => {
|
it("sends email with correct subject for 14-day warning", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days",
|
subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days",
|
||||||
}),
|
}),
|
||||||
@@ -238,29 +254,30 @@ describe("sendTokenExpirationWarning", () => {
|
|||||||
|
|
||||||
it("sends to correct recipient", async () => {
|
it("sends to correct recipient", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "user@example.com",
|
to: ["user@example.com"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes days until expiry in body", async () => {
|
it("includes days until expiry in body", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("14 days");
|
expect(call.text).toContain("14 days");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes instructions to refresh tokens", async () => {
|
it("includes instructions to refresh tokens", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Settings");
|
expect(call.text).toContain("Settings");
|
||||||
expect(call.text).toContain("Garmin");
|
expect(call.text).toContain("Garmin");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes auto-generated footer", async () => {
|
it("includes auto-generated footer", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -268,7 +285,8 @@ describe("sendTokenExpirationWarning", () => {
|
|||||||
describe("7-day warning", () => {
|
describe("7-day warning", () => {
|
||||||
it("sends email with urgent subject for 7-day warning", async () => {
|
it("sends email with urgent subject for 7-day warning", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject:
|
subject:
|
||||||
"🚨 PhaseFlow: Garmin tokens expire in 7 days - action required",
|
"🚨 PhaseFlow: Garmin tokens expire in 7 days - action required",
|
||||||
@@ -278,28 +296,29 @@ describe("sendTokenExpirationWarning", () => {
|
|||||||
|
|
||||||
it("sends to correct recipient", async () => {
|
it("sends to correct recipient", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "user@example.com",
|
to: ["user@example.com"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes days until expiry in body", async () => {
|
it("includes days until expiry in body", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("7 days");
|
expect(call.text).toContain("7 days");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses more urgent tone than 14-day warning", async () => {
|
it("uses more urgent tone than 14-day warning", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("urgent");
|
expect(call.text).toContain("urgent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes auto-generated footer", async () => {
|
it("includes auto-generated footer", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -344,12 +363,12 @@ describe("email structured logging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs email failed with error level on failure", async () => {
|
it("logs email failed with error level on failure", async () => {
|
||||||
const error = new Error("Resend API failed");
|
const error = new Error("Mailgun API failed");
|
||||||
mockSend.mockRejectedValueOnce(error);
|
mockCreate.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendDailyEmail(sampleDailyEmailData, "user-123"),
|
sendDailyEmail(sampleDailyEmailData, "user-123"),
|
||||||
).rejects.toThrow("Resend API failed");
|
).rejects.toThrow("Mailgun API failed");
|
||||||
|
|
||||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -381,8 +400,8 @@ describe("email structured logging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs email failed with error level on failure", async () => {
|
it("logs email failed with error level on failure", async () => {
|
||||||
const error = new Error("Resend API failed");
|
const error = new Error("Mailgun API failed");
|
||||||
mockSend.mockRejectedValueOnce(error);
|
mockCreate.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendPeriodConfirmationEmail(
|
sendPeriodConfirmationEmail(
|
||||||
@@ -391,7 +410,7 @@ describe("email structured logging", () => {
|
|||||||
31,
|
31,
|
||||||
"user-456",
|
"user-456",
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Resend API failed");
|
).rejects.toThrow("Mailgun API failed");
|
||||||
|
|
||||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -418,12 +437,12 @@ describe("email structured logging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs email failed with error level on failure", async () => {
|
it("logs email failed with error level on failure", async () => {
|
||||||
const error = new Error("Resend API failed");
|
const error = new Error("Mailgun API failed");
|
||||||
mockSend.mockRejectedValueOnce(error);
|
mockCreate.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendTokenExpirationWarning("user@example.com", 14, "user-789"),
|
sendTokenExpirationWarning("user@example.com", 14, "user-789"),
|
||||||
).rejects.toThrow("Resend API failed");
|
).rejects.toThrow("Mailgun API failed");
|
||||||
|
|
||||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
// ABOUTME: Email sending utilities using Resend.
|
// ABOUTME: Email sending utilities using Mailgun.
|
||||||
// ABOUTME: Sends daily training notifications and period confirmation emails.
|
// ABOUTME: Sends daily training notifications and period confirmation emails.
|
||||||
import { Resend } from "resend";
|
import FormData from "form-data";
|
||||||
|
import Mailgun from "mailgun.js";
|
||||||
|
import type { IMailgunClient } from "mailgun.js/Interfaces";
|
||||||
|
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { emailSentTotal } from "@/lib/metrics";
|
import { emailSentTotal } from "@/lib/metrics";
|
||||||
import { getSeedSwitchAlert } from "@/lib/nutrition";
|
import { getSeedSwitchAlert } from "@/lib/nutrition";
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
// Lazy-initialize Mailgun client to avoid build-time errors when env vars aren't set
|
||||||
|
let mg: IMailgunClient | null = null;
|
||||||
|
|
||||||
|
function getMailgunClient(): IMailgunClient {
|
||||||
|
if (!mg) {
|
||||||
|
const mailgun = new Mailgun(FormData);
|
||||||
|
mg = mailgun.client({
|
||||||
|
username: "api",
|
||||||
|
key: process.env.MAILGUN_API_KEY || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN || "paler.net";
|
||||||
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
|
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
|
||||||
|
|
||||||
export interface DailyEmailData {
|
export interface DailyEmailData {
|
||||||
@@ -64,9 +79,9 @@ ${data.decision.icon} ${data.decision.reason}
|
|||||||
Auto-generated by PhaseFlow`;
|
Auto-generated by PhaseFlow`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||||
from: EMAIL_FROM,
|
from: EMAIL_FROM,
|
||||||
to: data.to,
|
to: [data.to],
|
||||||
subject,
|
subject,
|
||||||
text: body,
|
text: body,
|
||||||
});
|
});
|
||||||
@@ -96,9 +111,9 @@ Your calendar will update automatically within 24 hours.
|
|||||||
Auto-generated by PhaseFlow`;
|
Auto-generated by PhaseFlow`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||||
from: EMAIL_FROM,
|
from: EMAIL_FROM,
|
||||||
to,
|
to: [to],
|
||||||
subject,
|
subject,
|
||||||
text: body,
|
text: body,
|
||||||
});
|
});
|
||||||
@@ -141,9 +156,9 @@ This will ensure your training recommendations continue to use fresh Garmin data
|
|||||||
Auto-generated by PhaseFlow`;
|
Auto-generated by PhaseFlow`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||||
from: EMAIL_FROM,
|
from: EMAIL_FROM,
|
||||||
to,
|
to: [to],
|
||||||
subject,
|
subject,
|
||||||
text: body,
|
text: body,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user