- 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>
158 lines
4.2 KiB
TypeScript
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;
|
|
}
|
|
}
|