Implement token expiration warnings (P3.9)

Add email warnings for Garmin token expiration at 14-day and 7-day thresholds.
When the garmin-sync cron job runs, it now checks each user's token expiry and
sends a warning email at exactly 14 days and 7 days before expiration.

Changes:
- Add sendTokenExpirationWarning() to email.ts with differentiated subject
  lines and urgency levels for 14-day vs 7-day warnings
- Integrate warning logic into garmin-sync cron route using daysUntilExpiry()
- Track warnings sent in sync response with new warningsSent counter
- Add 20 new tests (10 for email function, 10 for sync integration)

Test count: 517 → 537

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 08:24:19 +00:00
parent 6c3dd34412
commit 2ffee63a59
5 changed files with 304 additions and 19 deletions

View File

@@ -4,14 +4,14 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
## Current State Summary
### Overall Status: 517 tests passing across 30 test files
### Overall Status: 537 tests passing across 30 test files
### Library Implementation
| File | Status | Gap Analysis |
|------|--------|--------------|
| `cycle.ts` | **COMPLETE** | 9 tests covering all functions, production-ready |
| `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance |
| `email.ts` | **COMPLETE** | 14 tests covering sendDailyEmail, sendPeriodConfirmationEmail, email formatting, subject lines |
| `email.ts` | **COMPLETE** | 24 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines |
| `ics.ts` | **COMPLETE** | 23 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling |
| `encryption.ts` | **COMPLETE** | 14 tests covering AES-256-GCM encrypt/decrypt round-trip, error handling, key validation |
| `decision-engine.ts` | **COMPLETE** | 8 priority rules + override handling with `getDecisionWithOverrides()`, 24 tests |
@@ -29,7 +29,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| Prometheus Metrics | specs/observability.md | P2.16 | Medium |
| Structured Logging (pino) | specs/observability.md | P2.17 | Medium |
| OIDC Authentication | specs/authentication.md | P2.18 | Medium |
| Token Expiration Warnings | specs/email.md | P3.9 | Medium |
| Token Expiration Warnings | specs/email.md | P3.9 | **COMPLETE** |
### API Routes (17 total)
| Route | Status | Notes |
@@ -46,7 +46,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| GET /api/garmin/status | **COMPLETE** | Returns connection status, expiry, warning level (11 tests) |
| GET /api/calendar/[userId]/[token].ics | **COMPLETE** | Token validation, ICS generation, caching headers (10 tests) |
| POST /api/calendar/regenerate-token | **COMPLETE** | Generates 32-char token, returns URL (9 tests) |
| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs (22 tests) |
| POST /api/cron/garmin-sync | **COMPLETE** | Syncs Garmin data for all users, creates DailyLogs, sends token expiration warnings (32 tests) |
| POST /api/cron/notifications | **COMPLETE** | Sends daily emails with timezone matching, DailyLog handling (20 tests) |
| GET /api/history | **COMPLETE** | Paginated historical daily logs with date filtering (19 tests) |
| GET /api/health | **COMPLETE** | Health check for deployment monitoring (14 tests) |
@@ -90,13 +90,13 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `src/app/login/page.test.tsx` | **EXISTS** - 14 tests (form rendering, auth flow, error handling, validation) |
| `src/app/page.test.tsx` | **EXISTS** - 23 tests (data fetching, component rendering, override toggles, error handling) |
| `src/lib/nutrition.test.ts` | **EXISTS** - 17 tests (seed cycling, carb ranges, keto guidance by phase) |
| `src/lib/email.test.ts` | **EXISTS** - 14 tests (email content, subject lines, formatting) |
| `src/lib/email.test.ts` | **EXISTS** - 24 tests (email content, subject lines, formatting, token expiration warnings) |
| `src/lib/ics.test.ts` | **EXISTS** - 23 tests (ICS format validation, 90-day event generation, timezone handling) |
| `src/lib/encryption.test.ts` | **EXISTS** - 14 tests (encrypt/decrypt round-trip, error handling, key validation) |
| `src/lib/garmin.test.ts` | **EXISTS** - 33 tests (fetchGarminData, fetchHrvStatus, fetchBodyBattery, fetchIntensityMinutes, token expiry, error handling) |
| `src/app/api/garmin/tokens/route.test.ts` | **EXISTS** - 15 tests (POST/DELETE tokens, encryption, validation, auth) |
| `src/app/api/garmin/status/route.test.ts` | **EXISTS** - 11 tests (connection status, expiry, warning levels) |
| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 22 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling) |
| `src/app/api/cron/garmin-sync/route.test.ts` | **EXISTS** - 32 tests (auth, user iteration, token handling, Garmin data fetching, DailyLog creation, token expiration warnings, error handling) |
| `src/app/api/cron/notifications/route.test.ts` | **EXISTS** - 20 tests (timezone matching, DailyLog handling, email sending) |
| `src/app/api/calendar/[userId]/[token].ics/route.test.ts` | **EXISTS** - 10 tests (token validation, ICS generation, caching, error handling) |
| `src/app/api/calendar/regenerate-token/route.test.ts` | **EXISTS** - 9 tests (token generation, URL formatting, auth) |
@@ -119,7 +119,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
1. **Override Priority:** flare > stress > sleep > pms (must be enforced in order)
2. **HRV Unbalanced:** ALWAYS forces REST (highest algorithmic priority, non-overridable)
3. **Phase Limits:** Strictly enforced per phase configuration
4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry (NOT IMPLEMENTED - P3.9)
4. **Token Expiration Warnings:** Must send email at 14 days and 7 days before expiry (IMPLEMENTED - P3.9 COMPLETE)
5. **ICS Feed:** Generates 90 days of phase events for calendar subscription
---
@@ -324,7 +324,7 @@ Full feature set for production use.
- **Files:**
- `src/app/api/cron/garmin-sync/route.ts` - Iterates users, fetches data, stores DailyLog
- **Tests:**
- `src/app/api/cron/garmin-sync/route.test.ts` - 22 tests covering auth, user iteration, token handling, Garmin data fetching, DailyLog creation, error handling
- `src/app/api/cron/garmin-sync/route.test.ts` - 32 tests covering auth, user iteration, token handling, Garmin data fetching, DailyLog creation, token expiration warnings, error handling
- **Features Implemented:**
- Fetches all users with garminConnected=true
- Skips users with expired tokens
@@ -332,6 +332,7 @@ Full feature set for production use.
- Calculates cycle day, phase, phase limit, remaining minutes
- Computes training decision using decision engine
- Creates DailyLog entries for each user
- Sends token expiration warning emails at 14 and 7 days before expiry
- Returns sync summary (usersProcessed, errors, skippedExpired, timestamp)
- **Why:** Automated data sync is required for morning notifications
- **Depends On:** P2.1, P2.2
@@ -623,14 +624,19 @@ Testing, error handling, and refinements.
- All page files - Add loading.tsx or Suspense boundaries
- **Why:** Better perceived performance
### P3.9: Token Expiration Warnings
- [ ] Email warnings at 14 and 7 days before Garmin token expiry
- **Current State:** `sendTokenExpirationWarning()` function does not exist in email.ts
### P3.9: Token Expiration Warnings ✅ COMPLETE
- [x] Email warnings at 14 and 7 days before Garmin token expiry
- **Files:**
- `src/lib/email.ts` - Add `sendTokenExpirationWarning()`
- `src/app/api/cron/garmin-sync/route.ts` - Check expiry, trigger warnings
- `src/lib/email.ts` - Added `sendTokenExpirationWarning()` function
- `src/app/api/cron/garmin-sync/route.ts` - Added token expiry checking and warning logic
- **Tests:**
- Test warning triggers at exactly 14 days and 7 days
- `src/lib/email.test.ts` - 10 new tests for warning email function (24 total)
- `src/app/api/cron/garmin-sync/route.test.ts` - 10 new tests for warning integration (32 total)
- **Features Implemented:**
- Sends warning email at exactly 14 days before token expiry
- Sends warning email at exactly 7 days before token expiry
- Warning logic integrated into garmin-sync cron job
- Email includes days until expiry and instructions for refreshing tokens
- **Why:** Users need time to refresh tokens (per spec requirement in specs/email.md)
### P3.10: E2E Test Suite (AUTHORIZED SKIP)
@@ -780,7 +786,6 @@ P4.* UX Polish ────────> After core functionality complete
| Priority | Task | Effort | Notes |
|----------|------|--------|-------|
| HIGH | P3.9 Token Warnings | Small | Spec requirement, security-related |
| Medium | P2.13 Plan Page | Medium | Placeholder exists, needs content |
| Medium | P2.14 MiniCalendar | Small | Can reuse DayCell, ~70% remaining |
| Medium | P2.16 Metrics | Medium | Production monitoring |
@@ -814,7 +819,7 @@ P4.* UX Polish ────────> After core functionality complete
- [x] **decision-engine.ts** - Complete with 24 tests (`getTrainingDecision` + `getDecisionWithOverrides`)
- [x] **pocketbase.ts** - Complete with 9 tests (`createPocketBaseClient`, `isAuthenticated`, `getCurrentUser`, `loadAuthFromCookies`)
- [x] **nutrition.ts** - Complete with 17 tests (`getNutritionGuidance`, `getSeedSwitchAlert`, phase-specific carb ranges, keto guidance) (P3.2)
- [x] **email.ts** - Complete with 14 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, email formatting) (P3.3)
- [x] **email.ts** - Complete with 24 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting) (P3.3, P3.9)
- [x] **ics.ts** - Complete with 23 tests (`generateIcsFeed`, ICS format validation, 90-day event generation) (P3.4)
- [x] **encryption.ts** - Complete with 14 tests (AES-256-GCM encrypt/decrypt, round-trip validation, error handling) (P3.5)
- [x] **garmin.ts** - Complete with 33 tests (`fetchGarminData`, `fetchHrvStatus`, `fetchBodyBattery`, `fetchIntensityMinutes`, `isTokenExpired`, `daysUntilExpiry`, error handling) (P2.1, P3.6)
@@ -840,7 +845,7 @@ P4.* UX Polish ────────> After core functionality complete
- [x] **POST /api/garmin/tokens** - Stores encrypted Garmin OAuth tokens, 15 tests (P2.2)
- [x] **DELETE /api/garmin/tokens** - Clears tokens and disconnects Garmin, 15 tests (P2.2)
- [x] **GET /api/garmin/status** - Returns connection status, expiry, warning level, 11 tests (P2.3)
- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, 22 tests (P2.4)
- [x] **POST /api/cron/garmin-sync** - Daily sync of Garmin data for all connected users, creates DailyLogs, sends token expiration warnings, 32 tests (P2.4, P3.9)
- [x] **POST /api/cron/notifications** - Sends daily email notifications with timezone matching, DailyLog handling, nutrition guidance, 20 tests (P2.5)
- [x] **GET /api/calendar/[userId]/[token].ics** - Returns ICS feed with 90-day phase events, token validation, caching headers, 10 tests (P2.6)
- [x] **POST /api/calendar/regenerate-token** - Generates new 32-char calendar token, returns URL, 9 tests (P2.7)
@@ -858,6 +863,15 @@ P4.* UX Polish ────────> After core functionality complete
### Test Infrastructure
- [x] **test-setup.ts** - Global test setup with @testing-library/jest-dom matchers and cleanup
### P3: Quality and Testing
- [x] **P3.1: Decision Engine Tests** - Complete with 24 tests covering all 8 priority rules and override combinations
- [x] **P3.2: Nutrition Tests** - Complete with 17 tests covering seed cycling, carb ranges, keto guidance by phase
- [x] **P3.3: Email Tests** - Complete with 24 tests covering daily emails, period confirmation, token expiration warnings
- [x] **P3.4: ICS Tests** - Complete with 23 tests covering ICS format validation, 90-day event generation, timezone handling
- [x] **P3.5: Encryption Tests** - Complete with 14 tests covering AES-256-GCM round-trip, error handling, key validation
- [x] **P3.6: Garmin Tests** - Complete with 33 tests covering API interactions, token expiry, error handling
- [x] **P3.9: Token Expiration Warnings** - Complete with 10 new tests in email.test.ts, 10 new tests in garmin-sync/route.test.ts; sends warnings at 14 and 7 days before expiry
---
## Discovered Issues
@@ -883,7 +897,7 @@ P4.* UX Polish ────────> After core functionality complete
7. **Component Reuse:** Dashboard components are complete and can be used directly in P1.7
8. **HRV Rule:** HRV Unbalanced status ALWAYS forces REST - this is the highest algorithmic priority and cannot be overridden by manual toggles
9. **Override Order:** When multiple overrides are active, apply in order: flare > stress > sleep > pms
10. **Token Warnings:** Per spec, warnings must be sent at exactly 14 days and 7 days before expiry (P3.9 NOT IMPLEMENTED)
10. **Token Warnings:** Per spec, warnings are sent at exactly 14 days and 7 days before expiry (P3.9 COMPLETE)
11. **Health Check Priority:** P2.15 (GET /api/health) should be implemented early - it's required for deployment monitoring and load balancer health probes
12. **Structured Logging:** P2.17 (pino logger) should be implemented before other P2 items if possible, so new code can use proper logging from the start
13. **OIDC vs Email/Password:** Current email/password login (P1.6) works for development. P2.18 upgrades to OIDC for production security per specs/authentication.md

View File

@@ -44,6 +44,7 @@ const mockFetchBodyBattery = vi
.mockResolvedValue({ current: 85, yesterdayLow: 45 });
const mockFetchIntensityMinutes = vi.fn().mockResolvedValue(60);
const mockIsTokenExpired = vi.fn().mockReturnValue(false);
const mockDaysUntilExpiry = vi.fn().mockReturnValue(30);
vi.mock("@/lib/garmin", () => ({
fetchHrvStatus: (...args: unknown[]) => mockFetchHrvStatus(...args),
@@ -51,6 +52,15 @@ vi.mock("@/lib/garmin", () => ({
fetchIntensityMinutes: (...args: unknown[]) =>
mockFetchIntensityMinutes(...args),
isTokenExpired: (...args: unknown[]) => mockIsTokenExpired(...args),
daysUntilExpiry: (...args: unknown[]) => mockDaysUntilExpiry(...args),
}));
// Mock email sending
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/email", () => ({
sendTokenExpirationWarning: (...args: unknown[]) =>
mockSendTokenExpirationWarning(...args),
}));
import { POST } from "./route";
@@ -93,6 +103,7 @@ describe("POST /api/cron/garmin-sync", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUsers = [];
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
process.env.CRON_SECRET = validSecret;
});
@@ -381,5 +392,128 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.timestamp).toBeDefined();
expect(new Date(body.timestamp)).toBeInstanceOf(Date);
});
it("includes warningsSent in response", async () => {
mockUsers = [];
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
const body = await response.json();
expect(body.warningsSent).toBeDefined();
expect(body.warningsSent).toBe(0);
});
});
describe("Token expiration warnings", () => {
it("sends warning email when token expires in exactly 14 days", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(14);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user@example.com",
14,
);
const body = await response.json();
expect(body.warningsSent).toBe(1);
});
it("sends warning email when token expires in exactly 7 days", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(7);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user@example.com",
7,
);
const body = await response.json();
expect(body.warningsSent).toBe(1);
});
it("does not send warning when token expires in 30 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(30);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 15 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(15);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 8 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(8);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("does not send warning when token expires in 6 days", async () => {
mockUsers = [createMockUser()];
mockDaysUntilExpiry.mockReturnValue(6);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
it("sends warnings for multiple users on different thresholds", async () => {
mockUsers = [
createMockUser({ id: "user1", email: "user1@example.com" }),
createMockUser({ id: "user2", email: "user2@example.com" }),
];
// First user at 14 days, second user at 7 days
mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).toHaveBeenCalledTimes(2);
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user1@example.com",
14,
);
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user2@example.com",
7,
);
const body = await response.json();
expect(body.warningsSent).toBe(2);
});
it("continues processing sync even if warning email fails", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })];
mockDaysUntilExpiry.mockReturnValue(14);
mockSendTokenExpirationWarning.mockRejectedValueOnce(
new Error("Email failed"),
);
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
expect(response.status).toBe(200);
const body = await response.json();
expect(body.usersProcessed).toBe(1);
});
it("does not send warning for expired tokens", async () => {
mockUsers = [createMockUser()];
mockIsTokenExpired.mockReturnValue(true);
mockDaysUntilExpiry.mockReturnValue(-1);
await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
});
});
});

View File

@@ -4,8 +4,10 @@ import { NextResponse } from "next/server";
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { sendTokenExpirationWarning } from "@/lib/email";
import { decrypt } from "@/lib/encryption";
import {
daysUntilExpiry,
fetchBodyBattery,
fetchHrvStatus,
fetchIntensityMinutes,
@@ -19,6 +21,7 @@ interface SyncResult {
usersProcessed: number;
errors: number;
skippedExpired: number;
warningsSent: number;
timestamp: string;
}
@@ -36,6 +39,7 @@ export async function POST(request: Request) {
usersProcessed: 0,
errors: 0,
skippedExpired: 0,
warningsSent: 0,
timestamp: new Date().toISOString(),
};
@@ -61,6 +65,17 @@ export async function POST(request: Request) {
continue;
}
// Check for token expiration warnings (exactly 14 or 7 days)
const daysRemaining = daysUntilExpiry(tokens);
if (daysRemaining === 14 || daysRemaining === 7) {
try {
await sendTokenExpirationWarning(user.email, daysRemaining);
result.warningsSent++;
} catch {
// Continue processing even if warning email fails
}
}
// Decrypt OAuth2 token
const oauth2Json = decrypt(user.garminOauth2Token);
const oauth2Data = JSON.parse(oauth2Json);

View File

@@ -14,7 +14,11 @@ vi.mock("resend", () => ({
}));
import type { DailyEmailData } from "./email";
import { sendDailyEmail, sendPeriodConfirmationEmail } from "./email";
import {
sendDailyEmail,
sendPeriodConfirmationEmail,
sendTokenExpirationWarning,
} from "./email";
describe("sendDailyEmail", () => {
afterEach(() => {
@@ -189,3 +193,87 @@ describe("sendPeriodConfirmationEmail", () => {
);
});
});
describe("sendTokenExpirationWarning", () => {
afterEach(() => {
vi.clearAllMocks();
});
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.objectContaining({
subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days",
}),
);
});
it("sends to correct recipient", async () => {
await sendTokenExpirationWarning("user@example.com", 14);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
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];
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];
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];
expect(call.text).toContain("Auto-generated by PhaseFlow");
});
});
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.objectContaining({
subject:
"🚨 PhaseFlow: Garmin tokens expire in 7 days - action required",
}),
);
});
it("sends to correct recipient", async () => {
await sendTokenExpirationWarning("user@example.com", 7);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
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];
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];
expect(call.text).toContain("urgent");
});
it("includes auto-generated footer", async () => {
await sendTokenExpirationWarning("user@example.com", 7);
const call = mockSend.mock.calls[0][0];
expect(call.text).toContain("Auto-generated by PhaseFlow");
});
});
});

View File

@@ -81,3 +81,37 @@ Auto-generated by PhaseFlow`;
text: body,
});
}
export async function sendTokenExpirationWarning(
to: string,
daysUntilExpiry: number,
): Promise<void> {
const isUrgent = daysUntilExpiry <= 7;
const subject = isUrgent
? `🚨 PhaseFlow: Garmin tokens expire in ${daysUntilExpiry} days - action required`
: `⚠️ PhaseFlow: Garmin tokens expire in ${daysUntilExpiry} days`;
const urgencyMessage = isUrgent
? `⚠️ This is urgent - your Garmin data sync will stop working in ${daysUntilExpiry} days if you don't refresh your tokens.`
: `Your Garmin OAuth tokens will expire in ${daysUntilExpiry} days.`;
const body = `${urgencyMessage}
📋 HOW TO REFRESH YOUR TOKENS:
1. Go to Settings > Garmin in PhaseFlow
2. Follow the instructions to reconnect your Garmin account
3. Paste the new tokens from the bootstrap script
This will ensure your training recommendations continue to use fresh Garmin data.
---
Auto-generated by PhaseFlow`;
await resend.emails.send({
from: EMAIL_FROM,
to,
subject,
text: body,
});
}