From ccbc86016d8f523201c0ef4df8d4cae979365da2 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Tue, 20 Jan 2026 19:37:24 +0000 Subject: [PATCH] Switch email provider from Resend to Mailgun and add cron scheduler - 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 --- .env.example | 5 +- package.json | 3 +- pnpm-lock.yaml | 259 +++++++++++++++++++++++++++++++++-------- src/instrumentation.ts | 62 ++++++++++ src/lib/email.test.ts | 111 ++++++++++-------- src/lib/email.ts | 33 ++++-- 6 files changed, 369 insertions(+), 104 deletions(-) create mode 100644 src/instrumentation.ts diff --git a/.env.example b/.env.example index 79fd822..f8477ac 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,9 @@ NODE_ENV=development POCKETBASE_URL=http://localhost:8090 NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090 -# Email (Resend) -RESEND_API_KEY=re_xxxxxxxxxxxx +# Email (Mailgun) +MAILGUN_API_KEY=key-xxxxxxxxxxxx +MAILGUN_DOMAIN=yourdomain.com EMAIL_FROM=phaseflow@yourdomain.com # Encryption (for Garmin tokens) diff --git a/package.json b/package.json index 7529882..6c0d362 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", + "form-data": "^4.0.1", "ics": "^3.8.1", "lucide-react": "^0.562.0", + "mailgun.js": "^11.1.0", "next": "16.1.1", "node-cron": "^4.2.1", "oauth-1.0a": "^2.2.6", @@ -29,7 +31,6 @@ "prom-client": "^15.1.3", "react": "19.2.3", "react-dom": "19.2.3", - "resend": "^6.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^4.3.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7da21aa..349d78c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,12 +17,18 @@ importers: drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@opentelemetry/api@1.9.0) + form-data: + specifier: ^4.0.1 + version: 4.0.5 ics: specifier: ^3.8.1 version: 3.8.1 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) + mailgun.js: + specifier: ^11.1.0 + version: 11.1.0 next: 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) @@ -47,9 +53,6 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) - resend: - specifier: ^6.7.0 - version: 6.7.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1119,9 +1122,6 @@ packages: cpu: [x64] os: [win32] - '@stablelib/base64@1.0.1': - resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1337,10 +1337,19 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} 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: resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} hasBin: true @@ -1359,6 +1368,10 @@ packages: buffer-from@1.1.2: 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: resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==} @@ -1376,6 +1389,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1409,6 +1426,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1519,6 +1540,10 @@ packages: sqlite3: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} @@ -1530,9 +1555,25 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} 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: 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: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -1564,9 +1605,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - fast-sha256@1.3.0: - resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1576,6 +1614,19 @@ packages: picomatch: 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: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} 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} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} 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: 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: 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: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1733,9 +1811,25 @@ packages: magic-string@0.30.21: 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: 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: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -1844,6 +1938,9 @@ packages: property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1879,15 +1976,6 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 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: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1953,9 +2041,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - standardwebhooks@1.0.0: - resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} - std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -1976,9 +2061,6 @@ packages: babel-plugin-macros: optional: true - svix@1.84.1: - resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} - symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -2059,9 +2141,8 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uuid@10.0.0: - resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - hasBin: true + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} @@ -2851,8 +2932,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.55.1': optional: true - '@stablelib/base64@1.0.1': {} - '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -3071,8 +3150,20 @@ snapshots: assertion-error@2.0.1: {} + asynckit@0.4.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: {} bidi-js@1.0.3: @@ -3091,6 +3182,11 @@ snapshots: 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: {} chai@6.2.2: {} @@ -3103,6 +3199,10 @@ snapshots: clsx@2.1.1: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + convert-source-map@2.0.0: {} css-tree@3.1.0: @@ -3132,6 +3232,8 @@ snapshots: decimal.js@10.6.0: {} + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -3153,6 +3255,12 @@ snapshots: optionalDependencies: '@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: {} enhanced-resolve@5.18.4: @@ -3162,8 +3270,23 @@ snapshots: entities@6.0.1: {} + es-define-property@1.0.1: {} + + es-errors@1.3.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): dependencies: debug: 4.4.3 @@ -3262,26 +3385,66 @@ snapshots: expect-type@1.3.0: {} - fast-sha256@1.3.0: {} - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: 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: optional: true fsevents@2.3.3: optional: true + function-bind@1.1.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: dependencies: resolve-pkg-maps: 1.0.0 + gopd@1.2.0: {} + 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: dependencies: '@exodus/bytes': 1.8.0 @@ -3413,8 +3576,24 @@ snapshots: dependencies: '@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: {} + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + min-indent@1.0.1: {} ms@2.1.3: {} @@ -3524,6 +3703,8 @@ snapshots: property-expr@2.0.6: {} + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} quick-format-unescaped@4.0.4: {} @@ -3548,10 +3729,6 @@ snapshots: require-from-string@2.0.2: {} - resend@6.7.0: - dependencies: - svix: 1.84.1 - resolve-pkg-maps@1.0.0: {} rollup@4.55.1: @@ -3656,11 +3833,6 @@ snapshots: stackback@0.0.2: {} - standardwebhooks@1.0.0: - dependencies: - '@stablelib/base64': 1.0.1 - fast-sha256: 1.3.0 - std-env@3.10.0: {} strip-indent@3.0.0: @@ -3674,11 +3846,6 @@ snapshots: optionalDependencies: '@babel/core': 7.28.5 - svix@1.84.1: - dependencies: - standardwebhooks: 1.0.0 - uuid: 10.0.0 - symbol-tree@3.2.4: {} tailwind-merge@3.4.0: {} @@ -3740,7 +3907,7 @@ snapshots: escalade: 3.2.0 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): dependencies: diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..40d4422 --- /dev/null +++ b/src/instrumentation.ts @@ -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", + ); + } +} diff --git a/src/lib/email.test.ts b/src/lib/email.test.ts index d596f59..bec20a1 100644 --- a/src/lib/email.test.ts +++ b/src/lib/email.test.ts @@ -1,20 +1,29 @@ // 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"; -const { mockSend, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({ - mockSend: vi.fn().mockResolvedValue({ id: "mock-email-id" }), +const { mockCreate, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({ + mockCreate: vi.fn().mockResolvedValue({ id: "mock-email-id" }), mockLoggerInfo: vi.fn(), mockLoggerError: vi.fn(), })); -// Mock the resend module before importing email utilities -vi.mock("resend", () => ({ - Resend: class MockResend { - emails = { send: mockSend }; +// Mock the mailgun.js module before importing email utilities +vi.mock("mailgun.js", () => ({ + default: class MockMailgun { + client() { + return { + messages: { create: mockCreate }, + }; + } }, })); +// Mock form-data (required by mailgun.js) +vi.mock("form-data", () => ({ + default: class MockFormData {}, +})); + // Mock the logger vi.mock("@/lib/logger", () => ({ logger: { @@ -57,7 +66,9 @@ describe("sendDailyEmail", () => { it("sends email with correct subject line per spec", async () => { await sendDailyEmail(sampleData); - expect(mockSend).toHaveBeenCalledWith( + // Mailgun create takes (domain, messageData) - check second param + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ subject: "PhaseFlow: 💪 TRAIN - Day 15 (OVULATION)", }), @@ -66,13 +77,13 @@ describe("sendDailyEmail", () => { it("includes cycle day and phase in email body", async () => { 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)"); }); it("includes decision icon and reason", async () => { await sendDailyEmail(sampleData); - const call = mockSend.mock.calls[0][0]; + const call = mockCreate.mock.calls[0][1]; expect(call.text).toContain( "💪 Body battery high, HRV balanced - great day for training!", ); @@ -80,7 +91,7 @@ describe("sendDailyEmail", () => { it("includes biometric data in email body", async () => { 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("Yesterday's Low: 45"); expect(call.text).toContain("HRV Status: Balanced"); @@ -90,7 +101,7 @@ describe("sendDailyEmail", () => { it("includes nutrition guidance in email body", async () => { await sendDailyEmail(sampleData); - const call = mockSend.mock.calls[0][0]; + const call = mockCreate.mock.calls[0][1]; expect(call.text).toContain( "🌱 SEEDS: Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)", ); @@ -107,30 +118,32 @@ describe("sendDailyEmail", () => { bodyBatteryYesterdayLow: null, }; 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("Yesterday's Low: N/A"); }); it("sends email to correct recipient", async () => { await sendDailyEmail(sampleData); - expect(mockSend).toHaveBeenCalledWith( + // Mailgun uses an array for recipients + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ - to: "user@example.com", + to: ["user@example.com"], }), ); }); it("includes auto-generated footer", async () => { 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"); }); it("includes seed switch alert on day 15", async () => { // sampleData already has cycleDay: 15 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"); }); @@ -140,7 +153,7 @@ describe("sendDailyEmail", () => { cycleDay: 10, }; await sendDailyEmail(day10Data); - const call = mockSend.mock.calls[0][0]; + const call = mockCreate.mock.calls[0][1]; expect(call.text).not.toContain("SWITCH TODAY"); }); }); @@ -156,7 +169,8 @@ describe("sendPeriodConfirmationEmail", () => { new Date("2025-01-15"), 31, ); - expect(mockSend).toHaveBeenCalledWith( + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ subject: "🔵 Period Tracking Updated", }), @@ -169,7 +183,7 @@ describe("sendPeriodConfirmationEmail", () => { new Date("2025-01-15"), 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 expect(call.text).toContain("Your cycle has been reset"); expect(call.text).toContain("Last period:"); @@ -181,7 +195,7 @@ describe("sendPeriodConfirmationEmail", () => { new Date("2025-01-15"), 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"); }); @@ -191,9 +205,10 @@ describe("sendPeriodConfirmationEmail", () => { new Date("2025-01-15"), 31, ); - expect(mockSend).toHaveBeenCalledWith( + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ - to: "test@example.com", + to: ["test@example.com"], }), ); }); @@ -204,7 +219,7 @@ describe("sendPeriodConfirmationEmail", () => { new Date("2025-01-15"), 31, ); - const call = mockSend.mock.calls[0][0]; + const call = mockCreate.mock.calls[0][1]; expect(call.text).toContain("Auto-generated by PhaseFlow"); }); @@ -214,7 +229,7 @@ describe("sendPeriodConfirmationEmail", () => { new Date("2025-01-15"), 31, ); - const call = mockSend.mock.calls[0][0]; + const call = mockCreate.mock.calls[0][1]; expect(call.text).toContain( "Your calendar will update automatically within 24 hours", ); @@ -229,7 +244,8 @@ describe("sendTokenExpirationWarning", () => { describe("14-day warning", () => { it("sends email with correct subject for 14-day warning", async () => { await sendTokenExpirationWarning("user@example.com", 14); - expect(mockSend).toHaveBeenCalledWith( + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days", }), @@ -238,29 +254,30 @@ describe("sendTokenExpirationWarning", () => { it("sends to correct recipient", async () => { await sendTokenExpirationWarning("user@example.com", 14); - expect(mockSend).toHaveBeenCalledWith( + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ - to: "user@example.com", + to: ["user@example.com"], }), ); }); it("includes days until expiry in body", async () => { 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"); }); it("includes instructions to refresh tokens", async () => { 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("Garmin"); }); it("includes auto-generated footer", async () => { 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"); }); }); @@ -268,7 +285,8 @@ describe("sendTokenExpirationWarning", () => { describe("7-day warning", () => { it("sends email with urgent subject for 7-day warning", async () => { await sendTokenExpirationWarning("user@example.com", 7); - expect(mockSend).toHaveBeenCalledWith( + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ subject: "🚨 PhaseFlow: Garmin tokens expire in 7 days - action required", @@ -278,28 +296,29 @@ describe("sendTokenExpirationWarning", () => { it("sends to correct recipient", async () => { await sendTokenExpirationWarning("user@example.com", 7); - expect(mockSend).toHaveBeenCalledWith( + expect(mockCreate).toHaveBeenCalledWith( + expect.any(String), expect.objectContaining({ - to: "user@example.com", + to: ["user@example.com"], }), ); }); it("includes days until expiry in body", async () => { 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"); }); it("uses more urgent tone than 14-day warning", async () => { 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"); }); it("includes auto-generated footer", async () => { 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"); }); }); @@ -344,12 +363,12 @@ describe("email structured logging", () => { }); it("logs email failed with error level on failure", async () => { - const error = new Error("Resend API failed"); - mockSend.mockRejectedValueOnce(error); + const error = new Error("Mailgun API failed"); + mockCreate.mockRejectedValueOnce(error); await expect( sendDailyEmail(sampleDailyEmailData, "user-123"), - ).rejects.toThrow("Resend API failed"); + ).rejects.toThrow("Mailgun API failed"); expect(mockLoggerError).toHaveBeenCalledWith( expect.objectContaining({ @@ -381,8 +400,8 @@ describe("email structured logging", () => { }); it("logs email failed with error level on failure", async () => { - const error = new Error("Resend API failed"); - mockSend.mockRejectedValueOnce(error); + const error = new Error("Mailgun API failed"); + mockCreate.mockRejectedValueOnce(error); await expect( sendPeriodConfirmationEmail( @@ -391,7 +410,7 @@ describe("email structured logging", () => { 31, "user-456", ), - ).rejects.toThrow("Resend API failed"); + ).rejects.toThrow("Mailgun API failed"); expect(mockLoggerError).toHaveBeenCalledWith( expect.objectContaining({ @@ -418,12 +437,12 @@ describe("email structured logging", () => { }); it("logs email failed with error level on failure", async () => { - const error = new Error("Resend API failed"); - mockSend.mockRejectedValueOnce(error); + const error = new Error("Mailgun API failed"); + mockCreate.mockRejectedValueOnce(error); await expect( sendTokenExpirationWarning("user@example.com", 14, "user-789"), - ).rejects.toThrow("Resend API failed"); + ).rejects.toThrow("Mailgun API failed"); expect(mockLoggerError).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/email.ts b/src/lib/email.ts index 4a7295f..f1a4ba5 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -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. -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 { emailSentTotal } from "@/lib/metrics"; 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"; export interface DailyEmailData { @@ -64,9 +79,9 @@ ${data.decision.icon} ${data.decision.reason} Auto-generated by PhaseFlow`; try { - await resend.emails.send({ + await getMailgunClient().messages.create(MAILGUN_DOMAIN, { from: EMAIL_FROM, - to: data.to, + to: [data.to], subject, text: body, }); @@ -96,9 +111,9 @@ Your calendar will update automatically within 24 hours. Auto-generated by PhaseFlow`; try { - await resend.emails.send({ + await getMailgunClient().messages.create(MAILGUN_DOMAIN, { from: EMAIL_FROM, - to, + to: [to], subject, text: body, }); @@ -141,9 +156,9 @@ This will ensure your training recommendations continue to use fresh Garmin data Auto-generated by PhaseFlow`; try { - await resend.emails.send({ + await getMailgunClient().messages.create(MAILGUN_DOMAIN, { from: EMAIL_FROM, - to, + to: [to], subject, text: body, });