From 17aa8956a7721440c67709db144f39fc31f25180 Mon Sep 17 00:00:00 2001 From: zias Date: Wed, 11 Mar 2026 14:23:39 +0100 Subject: [PATCH] feat(log): improve logging for emails --- packages/api/src/email.ts | 313 ++++++++++++++++++++++-------- packages/api/src/routers/index.ts | 79 +++++++- 2 files changed, 299 insertions(+), 93 deletions(-) diff --git a/packages/api/src/email.ts b/packages/api/src/email.ts index 8693218..53aff6c 100644 --- a/packages/api/src/email.ts +++ b/packages/api/src/email.ts @@ -31,6 +31,25 @@ function getTransport(): nodemailer.Transporter | null { const from = env.SMTP_FROM ?? "Kunstenkamp "; const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be"; +// --------------------------------------------------------------------------- +// Structured logger +// --------------------------------------------------------------------------- + +/** + * Emits a single-line JSON log that Cloudflare captures in its invocation + * log output (visible in the CF dashboard and via `wrangler tail`). + * + * Plain `console.warn` / `console.error` calls are NOT surfaced in the CF + * dashboard — only `console.log` with a JSON string payload is captured. + */ +export function emailLog( + level: "info" | "warn" | "error", + event: string, + extra?: Record, +): void { + console.log(JSON.stringify({ level, event, ...extra })); +} + // --------------------------------------------------------------------------- // Primitives // --------------------------------------------------------------------------- @@ -474,29 +493,45 @@ export async function sendConfirmationEmail(params: { /** Pre-filled signup URL, e.g. /login?signup=1&email=…&next=/account */ signupUrl?: string; }) { + const type = "registrationConfirmation"; + emailLog("info", "email.attempt", { type, to: params.to }); const transport = getTransport(); if (!transport) { - console.warn("SMTP not configured — skipping confirmation email"); + emailLog("warn", "email.skipped", { + type, + to: params.to, + reason: "smtp_not_configured", + }); return; } const manageUrl = `${baseUrl}/manage/${params.managementToken}`; const signupUrl = params.signupUrl ?? `${baseUrl}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`; - await transport.sendMail({ - from, - to: params.to, - subject: "Bevestiging inschrijving — Open Mic Night", - html: registrationConfirmationHtml({ - firstName: params.firstName, - manageUrl, - signupUrl, - wantsToPerform: params.wantsToPerform, - artForm: params.artForm, - giftAmount: params.giftAmount, - drinkCardValue: params.drinkCardValue, - }), - }); + try { + await transport.sendMail({ + from, + to: params.to, + subject: "Bevestiging inschrijving — Open Mic Night", + html: registrationConfirmationHtml({ + firstName: params.firstName, + manageUrl, + signupUrl, + wantsToPerform: params.wantsToPerform, + artForm: params.artForm, + giftAmount: params.giftAmount, + drinkCardValue: params.drinkCardValue, + }), + }); + emailLog("info", "email.sent", { type, to: params.to }); + } catch (err) { + emailLog("error", "email.error", { + type, + to: params.to, + error: String(err), + }); + throw err; + } } export async function sendUpdateEmail(params: { @@ -508,94 +543,172 @@ export async function sendUpdateEmail(params: { giftAmount?: number; drinkCardValue?: number; }) { + const type = "updateConfirmation"; + emailLog("info", "email.attempt", { type, to: params.to }); const transport = getTransport(); if (!transport) { - console.warn("SMTP not configured — skipping update email"); + emailLog("warn", "email.skipped", { + type, + to: params.to, + reason: "smtp_not_configured", + }); return; } const manageUrl = `${baseUrl}/manage/${params.managementToken}`; - await transport.sendMail({ - from, - to: params.to, - subject: "Inschrijving bijgewerkt — Open Mic Night", - html: updateConfirmationHtml({ - firstName: params.firstName, - manageUrl, - wantsToPerform: params.wantsToPerform, - artForm: params.artForm, - giftAmount: params.giftAmount, - drinkCardValue: params.drinkCardValue, - }), - }); + try { + await transport.sendMail({ + from, + to: params.to, + subject: "Inschrijving bijgewerkt — Open Mic Night", + html: updateConfirmationHtml({ + firstName: params.firstName, + manageUrl, + wantsToPerform: params.wantsToPerform, + artForm: params.artForm, + giftAmount: params.giftAmount, + drinkCardValue: params.drinkCardValue, + }), + }); + emailLog("info", "email.sent", { type, to: params.to }); + } catch (err) { + emailLog("error", "email.error", { + type, + to: params.to, + error: String(err), + }); + throw err; + } } export async function sendCancellationEmail(params: { to: string; firstName: string; }) { + const type = "cancellation"; + emailLog("info", "email.attempt", { type, to: params.to }); const transport = getTransport(); if (!transport) { - console.warn("SMTP not configured — skipping cancellation email"); + emailLog("warn", "email.skipped", { + type, + to: params.to, + reason: "smtp_not_configured", + }); return; } - await transport.sendMail({ - from, - to: params.to, - subject: "Inschrijving geannuleerd — Open Mic Night", - html: cancellationHtml({ firstName: params.firstName }), - }); + try { + await transport.sendMail({ + from, + to: params.to, + subject: "Inschrijving geannuleerd — Open Mic Night", + html: cancellationHtml({ firstName: params.firstName }), + }); + emailLog("info", "email.sent", { type, to: params.to }); + } catch (err) { + emailLog("error", "email.error", { + type, + to: params.to, + error: String(err), + }); + throw err; + } } export async function sendSubscriptionConfirmationEmail(params: { to: string; }) { + const type = "subscriptionConfirmation"; + emailLog("info", "email.attempt", { type, to: params.to }); const transport = getTransport(); if (!transport) { - console.warn( - "SMTP not configured — skipping subscription confirmation email", - ); + emailLog("warn", "email.skipped", { + type, + to: params.to, + reason: "smtp_not_configured", + }); return; } - await transport.sendMail({ - from, - to: params.to, - subject: "Herinnering ingepland — Open Mic Night", - html: subscriptionConfirmationHtml({ email: params.to }), - }); + try { + await transport.sendMail({ + from, + to: params.to, + subject: "Herinnering ingepland — Open Mic Night", + html: subscriptionConfirmationHtml({ email: params.to }), + }); + emailLog("info", "email.sent", { type, to: params.to }); + } catch (err) { + emailLog("error", "email.error", { + type, + to: params.to, + error: String(err), + }); + throw err; + } } export async function sendReminder24hEmail(params: { to: string; firstName?: string | null; }) { + const type = "reminder24h"; + emailLog("info", "email.attempt", { type, to: params.to }); const transport = getTransport(); if (!transport) { - console.warn("SMTP not configured — skipping 24h reminder email"); + emailLog("warn", "email.skipped", { + type, + to: params.to, + reason: "smtp_not_configured", + }); return; } - await transport.sendMail({ - from, - to: params.to, - subject: "Nog 24 uur — inschrijvingen openen morgen!", - html: reminder24hHtml({ firstName: params.firstName }), - }); + try { + await transport.sendMail({ + from, + to: params.to, + subject: "Nog 24 uur — inschrijvingen openen morgen!", + html: reminder24hHtml({ firstName: params.firstName }), + }); + emailLog("info", "email.sent", { type, to: params.to }); + } catch (err) { + emailLog("error", "email.error", { + type, + to: params.to, + error: String(err), + }); + throw err; + } } export async function sendReminderEmail(params: { to: string; firstName?: string | null; }) { + const type = "reminder1h"; + emailLog("info", "email.attempt", { type, to: params.to }); const transport = getTransport(); if (!transport) { - console.warn("SMTP not configured — skipping reminder email"); + emailLog("warn", "email.skipped", { + type, + to: params.to, + reason: "smtp_not_configured", + }); return; } - await transport.sendMail({ - from, - to: params.to, - subject: "Nog 1 uur — inschrijvingen openen straks!", - html: reminderHtml({ firstName: params.firstName }), - }); + try { + await transport.sendMail({ + from, + to: params.to, + subject: "Nog 1 uur — inschrijvingen openen straks!", + html: reminderHtml({ firstName: params.firstName }), + }); + emailLog("info", "email.sent", { type, to: params.to }); + } catch (err) { + emailLog("error", "email.error", { + type, + to: params.to, + error: String(err), + }); + throw err; + } } export async function sendPaymentReminderEmail(params: { @@ -605,25 +718,41 @@ export async function sendPaymentReminderEmail(params: { drinkCardValue?: number; giftAmount?: number; }) { + const type = "paymentReminder"; + emailLog("info", "email.attempt", { type, to: params.to }); const transport = getTransport(); if (!transport) { - console.warn("SMTP not configured — skipping payment reminder email"); + emailLog("warn", "email.skipped", { + type, + to: params.to, + reason: "smtp_not_configured", + }); return; } const accountUrl = `${baseUrl}/account`; const manageUrl = `${baseUrl}/manage/${params.managementToken}`; - await transport.sendMail({ - from, - to: params.to, - subject: "Herinnering: betaling open — Open Mic Night", - html: paymentReminderHtml({ - firstName: params.firstName, - accountUrl, - manageUrl, - drinkCardValue: params.drinkCardValue, - giftAmount: params.giftAmount, - }), - }); + try { + await transport.sendMail({ + from, + to: params.to, + subject: "Herinnering: betaling open — Open Mic Night", + html: paymentReminderHtml({ + firstName: params.firstName, + accountUrl, + manageUrl, + drinkCardValue: params.drinkCardValue, + giftAmount: params.giftAmount, + }), + }); + emailLog("info", "email.sent", { type, to: params.to }); + } catch (err) { + emailLog("error", "email.error", { + type, + to: params.to, + error: String(err), + }); + throw err; + } } export async function sendPaymentConfirmationEmail(params: { @@ -633,21 +762,37 @@ export async function sendPaymentConfirmationEmail(params: { drinkCardValue?: number; giftAmount?: number; }) { + const type = "paymentConfirmation"; + emailLog("info", "email.attempt", { type, to: params.to }); const transport = getTransport(); if (!transport) { - console.warn("SMTP not configured — skipping payment confirmation email"); + emailLog("warn", "email.skipped", { + type, + to: params.to, + reason: "smtp_not_configured", + }); return; } const manageUrl = `${baseUrl}/manage/${params.managementToken}`; - await transport.sendMail({ - from, - to: params.to, - subject: "Betaling ontvangen — Open Mic Night", - html: paymentConfirmationHtml({ - firstName: params.firstName, - manageUrl, - drinkCardValue: params.drinkCardValue, - giftAmount: params.giftAmount, - }), - }); + try { + await transport.sendMail({ + from, + to: params.to, + subject: "Betaling ontvangen — Open Mic Night", + html: paymentConfirmationHtml({ + firstName: params.firstName, + manageUrl, + drinkCardValue: params.drinkCardValue, + giftAmount: params.giftAmount, + }), + }); + emailLog("info", "email.sent", { type, to: params.to }); + } catch (err) { + emailLog("error", "email.error", { + type, + to: params.to, + error: String(err), + }); + throw err; + } } diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index f9912af..ff3ff97 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -19,6 +19,7 @@ import { } from "drizzle-orm"; import { z } from "zod"; import { + emailLog, sendCancellationEmail, sendConfirmationEmail, sendPaymentReminderEmail, @@ -324,7 +325,10 @@ export const appRouter = { giftAmount: input.giftAmount, drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), }).catch((err) => - console.error("Failed to send confirmation email:", err), + emailLog("error", "email.catch", { + type: "confirmation", + error: String(err), + }), ); return { success: true, managementToken }; @@ -412,7 +416,12 @@ export const appRouter = { artForm: input.artForm, giftAmount: input.giftAmount, drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), - }).catch((err) => console.error("Failed to send update email:", err)); + }).catch((err) => + emailLog("error", "email.catch", { + type: "update", + error: String(err), + }), + ); return { success: true }; }), @@ -431,7 +440,10 @@ export const appRouter = { to: row.email, firstName: row.firstName, }).catch((err) => - console.error("Failed to send cancellation email:", err), + emailLog("error", "email.catch", { + type: "cancellation", + error: String(err), + }), ); return { success: true }; @@ -869,7 +881,11 @@ export const appRouter = { // Fire-and-forget — don't let a mail failure block the response sendSubscriptionConfirmationEmail({ to: input.email }).catch((err) => - console.error("Failed to send subscription confirmation email:", err), + emailLog("error", "email.catch", { + type: "subscription_confirmation", + to: input.email, + error: String(err), + }), ); return { ok: true }; @@ -901,6 +917,9 @@ export const appRouter = { now >= REMINDER_1H_WINDOW_START.getTime() && now < REGISTRATION_OPENS_AT.getTime(); + const activeWindow = in24hWindow ? "24h" : in1hWindow ? "1h" : null; + emailLog("info", "reminders.cron.tick", { activeWindow }); + if (!in24hWindow && !in1hWindow) { return { sent: 0, skipped: true, reason: "outside_window" as const }; } @@ -915,6 +934,8 @@ export const appRouter = { .from(reminder) .where(isNull(reminder.sent24hAt)); + emailLog("info", "reminders.24h.pending", { count: pending.length }); + for (const row of pending) { try { await sendReminder24hEmail({ to: row.email }); @@ -925,7 +946,11 @@ export const appRouter = { sent++; } catch (err) { errors.push(`24h ${row.email}: ${String(err)}`); - console.error(`Failed to send 24h reminder to ${row.email}:`, err); + emailLog("error", "email.catch", { + type: "reminder_24h", + to: row.email, + error: String(err), + }); } } } @@ -937,6 +962,8 @@ export const appRouter = { .from(reminder) .where(isNull(reminder.sentAt)); + emailLog("info", "reminders.1h.pending", { count: pending.length }); + for (const row of pending) { try { await sendReminderEmail({ to: row.email }); @@ -947,11 +974,20 @@ export const appRouter = { sent++; } catch (err) { errors.push(`1h ${row.email}: ${String(err)}`); - console.error(`Failed to send 1h reminder to ${row.email}:`, err); + emailLog("error", "email.catch", { + type: "reminder_1h", + to: row.email, + error: String(err), + }); } } } + emailLog("info", "reminders.cron.done", { + sent, + errorCount: errors.length, + }); + return { sent, skipped: false, errors }; }), @@ -1081,6 +1117,9 @@ export async function runSendReminders(): Promise<{ now >= REMINDER_1H_WINDOW_START.getTime() && now < REGISTRATION_OPENS_AT.getTime(); + const activeWindow = in24hWindow ? "24h" : in1hWindow ? "1h" : null; + emailLog("info", "reminders.cron.tick", { activeWindow }); + if (!in24hWindow && !in1hWindow) { return { sent: 0, skipped: true, reason: "outside_window", errors: [] }; } @@ -1094,6 +1133,8 @@ export async function runSendReminders(): Promise<{ .from(reminder) .where(isNull(reminder.sent24hAt)); + emailLog("info", "reminders.24h.pending", { count: pending.length }); + for (const row of pending) { try { await sendReminder24hEmail({ to: row.email }); @@ -1104,7 +1145,11 @@ export async function runSendReminders(): Promise<{ sent++; } catch (err) { errors.push(`24h ${row.email}: ${String(err)}`); - console.error(`Failed to send 24h reminder to ${row.email}:`, err); + emailLog("error", "email.catch", { + type: "reminder_24h", + to: row.email, + error: String(err), + }); } } } @@ -1115,6 +1160,8 @@ export async function runSendReminders(): Promise<{ .from(reminder) .where(isNull(reminder.sentAt)); + emailLog("info", "reminders.1h.pending", { count: pending.length }); + for (const row of pending) { try { await sendReminderEmail({ to: row.email }); @@ -1125,7 +1172,11 @@ export async function runSendReminders(): Promise<{ sent++; } catch (err) { errors.push(`1h ${row.email}: ${String(err)}`); - console.error(`Failed to send 1h reminder to ${row.email}:`, err); + emailLog("error", "email.catch", { + type: "reminder_1h", + to: row.email, + error: String(err), + }); } } } @@ -1144,6 +1195,10 @@ export async function runSendReminders(): Promise<{ ), ); + emailLog("info", "reminders.payment.pending", { + count: unpaidWatchers.length, + }); + for (const reg of unpaidWatchers) { if (!reg.managementToken) continue; try { @@ -1161,9 +1216,15 @@ export async function runSendReminders(): Promise<{ sent++; } catch (err) { errors.push(`payment-reminder ${reg.email}: ${String(err)}`); - console.error(`Failed to send payment reminder to ${reg.email}:`, err); + emailLog("error", "email.catch", { + type: "payment_reminder", + to: reg.email, + error: String(err), + }); } } + emailLog("info", "reminders.cron.done", { sent, errorCount: errors.length }); + return { sent, skipped: false, errors }; }