Add email sent/failed structured logging
All checks were successful
Deploy / deploy (push) Successful in 1m38s

Implement email logging per observability spec:
- Add structured logging for email sent (info level) and failed (error level)
- Include userId, type, and recipient fields in log events
- Add userId parameter to email functions (sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning)
- Update cron routes (notifications, garmin-sync) to pass userId

6 new tests added to email.test.ts (now 30 tests total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 23:06:19 +00:00
parent 13b58c3c32
commit 31932a88bf
6 changed files with 224 additions and 47 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: 835 tests passing across 44 test files
### Overall Status: 841 tests passing across 44 test files
### Library Implementation
| File | Status | Gap Analysis |
|------|--------|--------------|
| `cycle.ts` | **COMPLETE** | 22 tests covering all functions including dynamic phase boundaries for variable cycle lengths |
| `nutrition.ts` | **COMPLETE** | 17 tests covering getNutritionGuidance, getSeedSwitchAlert, phase-specific carb ranges, keto guidance |
| `email.ts` | **COMPLETE** | 24 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines |
| `email.ts` | **COMPLETE** | 30 tests covering sendDailyEmail, sendPeriodConfirmationEmail, sendTokenExpirationWarning, email formatting, subject lines, structured logging |
| `ics.ts` | **COMPLETE** | 33 tests covering generateIcsFeed (90 days of phase events), ICS format validation, timezone handling, period prediction feedback, CATEGORIES for calendar colors |
| `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 |
@@ -94,7 +94,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `src/app/login/page.test.tsx` | **EXISTS** - 32 tests (form rendering, auth flow, error handling, validation, accessibility, rate limiting) |
| `src/app/page.test.tsx` | **EXISTS** - 28 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** - 24 tests (email content, subject lines, formatting, token expiration warnings) |
| `src/lib/email.test.ts` | **EXISTS** - 30 tests (email content, subject lines, formatting, token expiration warnings, structured logging) |
| `src/lib/ics.test.ts` | **EXISTS** - 33 tests (ICS format validation, 90-day event generation, timezone handling, period prediction feedback, CATEGORIES for colors) |
| `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) |
@@ -892,7 +892,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 24 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting) (P3.3, P3.9)
- [x] **email.ts** - Complete with 30 tests (`sendDailyEmail`, `sendPeriodConfirmationEmail`, `sendTokenExpirationWarning`, email formatting, structured logging for sent/failed events) (P3.3, P3.9)
- [x] **ics.ts** - Complete with 33 tests (`generateIcsFeed`, ICS format validation, 90-day event generation, period prediction feedback, CATEGORIES for calendar colors) (P3.4, P4.5)
- [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)
@@ -945,7 +945,7 @@ P4.* UX Polish ────────> After core functionality complete
### 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.3: Email Tests** - Complete with 30 tests covering daily emails, period confirmation, token expiration warnings, structured logging
- [x] **P3.4: ICS Tests** - Complete with 28 tests covering ICS format validation, 90-day event generation, timezone handling, period prediction feedback
- [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
@@ -975,7 +975,7 @@ Analysis of all specs vs implementation revealed these gaps:
|-----|------|--------|-------|
| Logout functionality | authentication.md | **COMPLETE** | Added POST /api/auth/logout + settings button |
| Garmin sync structured logging | observability.md | **COMPLETE** | Added sync start/complete/failure logging |
| Email sent/failed logging | observability.md | **PENDING** | Email events should be logged |
| Email sent/failed logging | observability.md | **COMPLETE** | Email events now logged (info for success, error for failure) with structured data (userId, emailType, success) |
| Period history UI | cycle-tracking.md | **PENDING** | UI for viewing/editing past periods |
| Dashboard color-coded backgrounds | dashboard.md | **PENDING** | Phase-based background colors |
| Toast notifications | dashboard.md | **PENDING** | Success/error toasts for user actions |

View File

@@ -424,6 +424,7 @@ describe("POST /api/cron/garmin-sync", () => {
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user@example.com",
14,
"user123",
);
const body = await response.json();
expect(body.warningsSent).toBe(1);
@@ -438,6 +439,7 @@ describe("POST /api/cron/garmin-sync", () => {
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user@example.com",
7,
"user123",
);
const body = await response.json();
expect(body.warningsSent).toBe(1);
@@ -493,10 +495,12 @@ describe("POST /api/cron/garmin-sync", () => {
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user1@example.com",
14,
"user1",
);
expect(mockSendTokenExpirationWarning).toHaveBeenCalledWith(
"user2@example.com",
7,
"user2",
);
const body = await response.json();
expect(body.warningsSent).toBe(2);

View File

@@ -82,7 +82,7 @@ export async function POST(request: Request) {
const daysRemaining = daysUntilExpiry(tokens);
if (daysRemaining === 14 || daysRemaining === 7) {
try {
await sendTokenExpirationWarning(user.email, daysRemaining);
await sendTokenExpirationWarning(user.email, daysRemaining, user.id);
result.warningsSent++;
} catch {
// Continue processing even if warning email fails

View File

@@ -121,7 +121,8 @@ export async function POST(request: Request) {
const nutrition = getNutritionGuidance(dailyLog.cycleDay);
// Send email
await sendDailyEmail({
await sendDailyEmail(
{
to: user.email,
cycleDay: dailyLog.cycleDay,
phase: dailyLog.phase,
@@ -139,7 +140,9 @@ export async function POST(request: Request) {
seeds: nutrition.seeds,
carbRange: nutrition.carbRange,
ketoGuidance: nutrition.ketoGuidance,
});
},
user.id,
);
// Update notificationSentAt timestamp
await pb.collection("dailyLogs").update(dailyLog.id, {

View File

@@ -2,8 +2,10 @@
// ABOUTME: Tests email composition, subject lines, and Resend integration.
import { afterEach, describe, expect, it, vi } from "vitest";
const { mockSend } = vi.hoisted(() => ({
const { mockSend, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
mockSend: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
mockLoggerInfo: vi.fn(),
mockLoggerError: vi.fn(),
}));
// Mock the resend module before importing email utilities
@@ -13,6 +15,14 @@ vi.mock("resend", () => ({
},
}));
// Mock the logger
vi.mock("@/lib/logger", () => ({
logger: {
info: mockLoggerInfo,
error: mockLoggerError,
},
}));
import type { DailyEmailData } from "./email";
import {
sendDailyEmail,
@@ -277,3 +287,135 @@ describe("sendTokenExpirationWarning", () => {
});
});
});
describe("email structured logging", () => {
afterEach(() => {
vi.clearAllMocks();
});
const sampleDailyEmailData: DailyEmailData = {
to: "user@example.com",
cycleDay: 15,
phase: "OVULATION",
decision: {
status: "TRAIN",
reason: "Body battery high",
icon: "💪",
},
bodyBatteryCurrent: 85,
bodyBatteryYesterdayLow: 45,
hrvStatus: "Balanced",
weekIntensity: 60,
phaseLimit: 80,
remainingMinutes: 20,
seeds: "Sesame",
carbRange: "100-150g",
ketoGuidance: "No",
};
describe("sendDailyEmail logging", () => {
it("logs email sent with info level on success", async () => {
await sendDailyEmail(sampleDailyEmailData, "user-123");
expect(mockLoggerInfo).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-123",
type: "daily",
recipient: "user@example.com",
}),
"Email sent",
);
});
it("logs email failed with error level on failure", async () => {
const error = new Error("Resend API failed");
mockSend.mockRejectedValueOnce(error);
await expect(
sendDailyEmail(sampleDailyEmailData, "user-123"),
).rejects.toThrow("Resend API failed");
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-123",
type: "daily",
err: error,
}),
"Email failed",
);
});
});
describe("sendPeriodConfirmationEmail logging", () => {
it("logs email sent with info level on success", async () => {
await sendPeriodConfirmationEmail(
"user@example.com",
new Date("2025-01-15"),
31,
"user-456",
);
expect(mockLoggerInfo).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-456",
type: "period_confirmation",
recipient: "user@example.com",
}),
"Email sent",
);
});
it("logs email failed with error level on failure", async () => {
const error = new Error("Resend API failed");
mockSend.mockRejectedValueOnce(error);
await expect(
sendPeriodConfirmationEmail(
"user@example.com",
new Date("2025-01-15"),
31,
"user-456",
),
).rejects.toThrow("Resend API failed");
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-456",
type: "period_confirmation",
err: error,
}),
"Email failed",
);
});
});
describe("sendTokenExpirationWarning logging", () => {
it("logs email sent with info level on success", async () => {
await sendTokenExpirationWarning("user@example.com", 14, "user-789");
expect(mockLoggerInfo).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-789",
type: "warning",
recipient: "user@example.com",
}),
"Email sent",
);
});
it("logs email failed with error level on failure", async () => {
const error = new Error("Resend API failed");
mockSend.mockRejectedValueOnce(error);
await expect(
sendTokenExpirationWarning("user@example.com", 14, "user-789"),
).rejects.toThrow("Resend API failed");
expect(mockLoggerError).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-789",
type: "warning",
err: error,
}),
"Email failed",
);
});
});
});

View File

@@ -2,6 +2,7 @@
// ABOUTME: Sends daily training notifications and period confirmation emails.
import { Resend } from "resend";
import { logger } from "@/lib/logger";
import { emailSentTotal } from "@/lib/metrics";
const resend = new Resend(process.env.RESEND_API_KEY);
@@ -28,7 +29,10 @@ export interface DailyEmailData {
ketoGuidance: string;
}
export async function sendDailyEmail(data: DailyEmailData): Promise<void> {
export async function sendDailyEmail(
data: DailyEmailData,
userId?: string,
): Promise<void> {
const subject = `Today's Training: ${data.decision.icon} ${data.decision.status}`;
const body = `Good morning!
@@ -53,6 +57,7 @@ ${data.decision.icon} ${data.decision.reason}
---
Auto-generated by PhaseFlow`;
try {
await resend.emails.send({
from: EMAIL_FROM,
to: data.to,
@@ -60,13 +65,19 @@ Auto-generated by PhaseFlow`;
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";
@@ -78,17 +89,28 @@ 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;
@@ -112,6 +134,7 @@ 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,
@@ -119,5 +142,10 @@ Auto-generated by PhaseFlow`;
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;
}
}