Switch email provider from Resend to Mailgun and add cron scheduler
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:
2026-01-20 19:37:24 +00:00
parent 6543c79a04
commit ccbc86016d
6 changed files with 369 additions and 104 deletions

View File

@@ -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)

View File

@@ -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
View File

@@ -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
View 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",
);
}
}

View File

@@ -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({

View File

@@ -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,
}); });