Files
phaseflow/src/lib/email.ts
Petru Paler d613417e47
Some checks failed
CI / quality (push) Failing after 28s
Deploy / deploy (push) Successful in 2m39s
Fix spec compliance gaps in email and dashboard
- Email subject now follows spec format: PhaseFlow: [STATUS] - Day [cycleDay] ([phase])
- Daily email includes seed switch alert on day 15 (using getSeedSwitchAlert)
- Data panel HRV status now color-coded: green=Balanced, red=Unbalanced, gray=Unknown
- Data panel shows progress bar for week intensity vs phase limit with color thresholds

Adds 13 new tests (990 total).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 23:20:18 +00:00

158 lines
4.2 KiB
TypeScript

// ABOUTME: Email sending utilities using Resend.
// ABOUTME: Sends daily training notifications and period confirmation emails.
import { Resend } from "resend";
import { logger } from "@/lib/logger";
import { emailSentTotal } from "@/lib/metrics";
import { getSeedSwitchAlert } from "@/lib/nutrition";
const resend = new Resend(process.env.RESEND_API_KEY);
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
export interface DailyEmailData {
to: string;
cycleDay: number;
phase: string;
decision: {
status: string;
reason: string;
icon: string;
};
bodyBatteryCurrent: number | null;
bodyBatteryYesterdayLow: number | null;
hrvStatus: string;
weekIntensity: number;
phaseLimit: number;
remainingMinutes: number;
seeds: string;
carbRange: string;
ketoGuidance: string;
}
export async function sendDailyEmail(
data: DailyEmailData,
userId?: string,
): Promise<void> {
// Subject format per spec: PhaseFlow: [STATUS] - Day [cycleDay] ([phase])
const subject = `PhaseFlow: ${data.decision.icon} ${data.decision.status} - Day ${data.cycleDay} (${data.phase})`;
// Check for seed switch alert on day 15
const seedSwitchAlert = getSeedSwitchAlert(data.cycleDay);
const seedSwitchSection = seedSwitchAlert ? `\n\n${seedSwitchAlert}` : "";
const body = `Good morning!
📅 CYCLE DAY: ${data.cycleDay} (${data.phase})
💪 TODAY'S PLAN:
${data.decision.icon} ${data.decision.reason}
📊 YOUR DATA:
• Body Battery Now: ${data.bodyBatteryCurrent ?? "N/A"}
• Yesterday's Low: ${data.bodyBatteryYesterdayLow ?? "N/A"}
• HRV Status: ${data.hrvStatus}
• Week Intensity: ${data.weekIntensity} / ${data.phaseLimit} minutes
• Remaining: ${data.remainingMinutes} minutes
🌱 SEEDS: ${data.seeds}
🍽️ MACROS: ${data.carbRange}
🥑 KETO: ${data.ketoGuidance}${seedSwitchSection}
---
Auto-generated by PhaseFlow`;
try {
await resend.emails.send({
from: EMAIL_FROM,
to: data.to,
subject,
text: body,
});
logger.info({ userId, type: "daily", recipient: data.to }, "Email sent");
emailSentTotal.inc({ type: "daily" });
} catch (err) {
logger.error({ userId, type: "daily", err }, "Email failed");
throw err;
}
}
export async function sendPeriodConfirmationEmail(
to: string,
lastPeriodDate: Date,
cycleLength: number,
userId?: string,
): Promise<void> {
const subject = "🔵 Period Tracking Updated";
const body = `Your cycle has been reset. Last period: ${lastPeriodDate.toLocaleDateString()}
Phase calendar updated for next ${cycleLength} days.
Your calendar will update automatically within 24 hours.
---
Auto-generated by PhaseFlow`;
try {
await resend.emails.send({
from: EMAIL_FROM,
to,
subject,
text: body,
});
logger.info(
{ userId, type: "period_confirmation", recipient: to },
"Email sent",
);
} catch (err) {
logger.error({ userId, type: "period_confirmation", err }, "Email failed");
throw err;
}
}
export async function sendTokenExpirationWarning(
to: string,
daysUntilExpiry: number,
userId?: string,
): 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`;
try {
await resend.emails.send({
from: EMAIL_FROM,
to,
subject,
text: body,
});
logger.info({ userId, type: "warning", recipient: to }, "Email sent");
emailSentTotal.inc({ type: "warning" });
} catch (err) {
logger.error({ userId, type: "warning", err }, "Email failed");
throw err;
}
}