feat(log): improve logging for emails

This commit is contained in:
2026-03-11 14:23:39 +01:00
parent 5e5efcaf6f
commit 17aa8956a7
2 changed files with 299 additions and 93 deletions

View File

@@ -31,6 +31,25 @@ function getTransport(): nodemailer.Transporter | null {
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>"; const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be"; 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<string, unknown>,
): void {
console.log(JSON.stringify({ level, event, ...extra }));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Primitives // Primitives
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -474,29 +493,45 @@ export async function sendConfirmationEmail(params: {
/** Pre-filled signup URL, e.g. /login?signup=1&email=…&next=/account */ /** Pre-filled signup URL, e.g. /login?signup=1&email=…&next=/account */
signupUrl?: string; signupUrl?: string;
}) { }) {
const type = "registrationConfirmation";
emailLog("info", "email.attempt", { type, to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn("SMTP not configured — skipping confirmation email"); emailLog("warn", "email.skipped", {
type,
to: params.to,
reason: "smtp_not_configured",
});
return; return;
} }
const manageUrl = `${baseUrl}/manage/${params.managementToken}`; const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
const signupUrl = const signupUrl =
params.signupUrl ?? params.signupUrl ??
`${baseUrl}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`; `${baseUrl}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`;
await transport.sendMail({ try {
from, await transport.sendMail({
to: params.to, from,
subject: "Bevestiging inschrijving — Open Mic Night", to: params.to,
html: registrationConfirmationHtml({ subject: "Bevestiging inschrijving — Open Mic Night",
firstName: params.firstName, html: registrationConfirmationHtml({
manageUrl, firstName: params.firstName,
signupUrl, manageUrl,
wantsToPerform: params.wantsToPerform, signupUrl,
artForm: params.artForm, wantsToPerform: params.wantsToPerform,
giftAmount: params.giftAmount, artForm: params.artForm,
drinkCardValue: params.drinkCardValue, 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: { export async function sendUpdateEmail(params: {
@@ -508,94 +543,172 @@ export async function sendUpdateEmail(params: {
giftAmount?: number; giftAmount?: number;
drinkCardValue?: number; drinkCardValue?: number;
}) { }) {
const type = "updateConfirmation";
emailLog("info", "email.attempt", { type, to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn("SMTP not configured — skipping update email"); emailLog("warn", "email.skipped", {
type,
to: params.to,
reason: "smtp_not_configured",
});
return; return;
} }
const manageUrl = `${baseUrl}/manage/${params.managementToken}`; const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({ try {
from, await transport.sendMail({
to: params.to, from,
subject: "Inschrijving bijgewerkt — Open Mic Night", to: params.to,
html: updateConfirmationHtml({ subject: "Inschrijving bijgewerkt — Open Mic Night",
firstName: params.firstName, html: updateConfirmationHtml({
manageUrl, firstName: params.firstName,
wantsToPerform: params.wantsToPerform, manageUrl,
artForm: params.artForm, wantsToPerform: params.wantsToPerform,
giftAmount: params.giftAmount, artForm: params.artForm,
drinkCardValue: params.drinkCardValue, 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: { export async function sendCancellationEmail(params: {
to: string; to: string;
firstName: string; firstName: string;
}) { }) {
const type = "cancellation";
emailLog("info", "email.attempt", { type, to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn("SMTP not configured — skipping cancellation email"); emailLog("warn", "email.skipped", {
type,
to: params.to,
reason: "smtp_not_configured",
});
return; return;
} }
await transport.sendMail({ try {
from, await transport.sendMail({
to: params.to, from,
subject: "Inschrijving geannuleerd — Open Mic Night", to: params.to,
html: cancellationHtml({ firstName: params.firstName }), 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: { export async function sendSubscriptionConfirmationEmail(params: {
to: string; to: string;
}) { }) {
const type = "subscriptionConfirmation";
emailLog("info", "email.attempt", { type, to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn( emailLog("warn", "email.skipped", {
"SMTP not configured — skipping subscription confirmation email", type,
); to: params.to,
reason: "smtp_not_configured",
});
return; return;
} }
await transport.sendMail({ try {
from, await transport.sendMail({
to: params.to, from,
subject: "Herinnering ingepland — Open Mic Night", to: params.to,
html: subscriptionConfirmationHtml({ email: 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: { export async function sendReminder24hEmail(params: {
to: string; to: string;
firstName?: string | null; firstName?: string | null;
}) { }) {
const type = "reminder24h";
emailLog("info", "email.attempt", { type, to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn("SMTP not configured — skipping 24h reminder email"); emailLog("warn", "email.skipped", {
type,
to: params.to,
reason: "smtp_not_configured",
});
return; return;
} }
await transport.sendMail({ try {
from, await transport.sendMail({
to: params.to, from,
subject: "Nog 24 uur — inschrijvingen openen morgen!", to: params.to,
html: reminder24hHtml({ firstName: params.firstName }), 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: { export async function sendReminderEmail(params: {
to: string; to: string;
firstName?: string | null; firstName?: string | null;
}) { }) {
const type = "reminder1h";
emailLog("info", "email.attempt", { type, to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn("SMTP not configured — skipping reminder email"); emailLog("warn", "email.skipped", {
type,
to: params.to,
reason: "smtp_not_configured",
});
return; return;
} }
await transport.sendMail({ try {
from, await transport.sendMail({
to: params.to, from,
subject: "Nog 1 uur — inschrijvingen openen straks!", to: params.to,
html: reminderHtml({ firstName: params.firstName }), 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: { export async function sendPaymentReminderEmail(params: {
@@ -605,25 +718,41 @@ export async function sendPaymentReminderEmail(params: {
drinkCardValue?: number; drinkCardValue?: number;
giftAmount?: number; giftAmount?: number;
}) { }) {
const type = "paymentReminder";
emailLog("info", "email.attempt", { type, to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn("SMTP not configured — skipping payment reminder email"); emailLog("warn", "email.skipped", {
type,
to: params.to,
reason: "smtp_not_configured",
});
return; return;
} }
const accountUrl = `${baseUrl}/account`; const accountUrl = `${baseUrl}/account`;
const manageUrl = `${baseUrl}/manage/${params.managementToken}`; const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({ try {
from, await transport.sendMail({
to: params.to, from,
subject: "Herinnering: betaling open — Open Mic Night", to: params.to,
html: paymentReminderHtml({ subject: "Herinnering: betaling open — Open Mic Night",
firstName: params.firstName, html: paymentReminderHtml({
accountUrl, firstName: params.firstName,
manageUrl, accountUrl,
drinkCardValue: params.drinkCardValue, manageUrl,
giftAmount: params.giftAmount, 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: { export async function sendPaymentConfirmationEmail(params: {
@@ -633,21 +762,37 @@ export async function sendPaymentConfirmationEmail(params: {
drinkCardValue?: number; drinkCardValue?: number;
giftAmount?: number; giftAmount?: number;
}) { }) {
const type = "paymentConfirmation";
emailLog("info", "email.attempt", { type, to: params.to });
const transport = getTransport(); const transport = getTransport();
if (!transport) { if (!transport) {
console.warn("SMTP not configured — skipping payment confirmation email"); emailLog("warn", "email.skipped", {
type,
to: params.to,
reason: "smtp_not_configured",
});
return; return;
} }
const manageUrl = `${baseUrl}/manage/${params.managementToken}`; const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({ try {
from, await transport.sendMail({
to: params.to, from,
subject: "Betaling ontvangen — Open Mic Night", to: params.to,
html: paymentConfirmationHtml({ subject: "Betaling ontvangen — Open Mic Night",
firstName: params.firstName, html: paymentConfirmationHtml({
manageUrl, firstName: params.firstName,
drinkCardValue: params.drinkCardValue, manageUrl,
giftAmount: params.giftAmount, 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;
}
} }

View File

@@ -19,6 +19,7 @@ import {
} from "drizzle-orm"; } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { import {
emailLog,
sendCancellationEmail, sendCancellationEmail,
sendConfirmationEmail, sendConfirmationEmail,
sendPaymentReminderEmail, sendPaymentReminderEmail,
@@ -324,7 +325,10 @@ export const appRouter = {
giftAmount: input.giftAmount, giftAmount: input.giftAmount,
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
}).catch((err) => }).catch((err) =>
console.error("Failed to send confirmation email:", err), emailLog("error", "email.catch", {
type: "confirmation",
error: String(err),
}),
); );
return { success: true, managementToken }; return { success: true, managementToken };
@@ -412,7 +416,12 @@ export const appRouter = {
artForm: input.artForm, artForm: input.artForm,
giftAmount: input.giftAmount, giftAmount: input.giftAmount,
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length), 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 }; return { success: true };
}), }),
@@ -431,7 +440,10 @@ export const appRouter = {
to: row.email, to: row.email,
firstName: row.firstName, firstName: row.firstName,
}).catch((err) => }).catch((err) =>
console.error("Failed to send cancellation email:", err), emailLog("error", "email.catch", {
type: "cancellation",
error: String(err),
}),
); );
return { success: true }; return { success: true };
@@ -869,7 +881,11 @@ export const appRouter = {
// Fire-and-forget — don't let a mail failure block the response // Fire-and-forget — don't let a mail failure block the response
sendSubscriptionConfirmationEmail({ to: input.email }).catch((err) => 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 }; return { ok: true };
@@ -901,6 +917,9 @@ export const appRouter = {
now >= REMINDER_1H_WINDOW_START.getTime() && now >= REMINDER_1H_WINDOW_START.getTime() &&
now < REGISTRATION_OPENS_AT.getTime(); now < REGISTRATION_OPENS_AT.getTime();
const activeWindow = in24hWindow ? "24h" : in1hWindow ? "1h" : null;
emailLog("info", "reminders.cron.tick", { activeWindow });
if (!in24hWindow && !in1hWindow) { if (!in24hWindow && !in1hWindow) {
return { sent: 0, skipped: true, reason: "outside_window" as const }; return { sent: 0, skipped: true, reason: "outside_window" as const };
} }
@@ -915,6 +934,8 @@ export const appRouter = {
.from(reminder) .from(reminder)
.where(isNull(reminder.sent24hAt)); .where(isNull(reminder.sent24hAt));
emailLog("info", "reminders.24h.pending", { count: pending.length });
for (const row of pending) { for (const row of pending) {
try { try {
await sendReminder24hEmail({ to: row.email }); await sendReminder24hEmail({ to: row.email });
@@ -925,7 +946,11 @@ export const appRouter = {
sent++; sent++;
} catch (err) { } catch (err) {
errors.push(`24h ${row.email}: ${String(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) .from(reminder)
.where(isNull(reminder.sentAt)); .where(isNull(reminder.sentAt));
emailLog("info", "reminders.1h.pending", { count: pending.length });
for (const row of pending) { for (const row of pending) {
try { try {
await sendReminderEmail({ to: row.email }); await sendReminderEmail({ to: row.email });
@@ -947,11 +974,20 @@ export const appRouter = {
sent++; sent++;
} catch (err) { } catch (err) {
errors.push(`1h ${row.email}: ${String(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 }; return { sent, skipped: false, errors };
}), }),
@@ -1081,6 +1117,9 @@ export async function runSendReminders(): Promise<{
now >= REMINDER_1H_WINDOW_START.getTime() && now >= REMINDER_1H_WINDOW_START.getTime() &&
now < REGISTRATION_OPENS_AT.getTime(); now < REGISTRATION_OPENS_AT.getTime();
const activeWindow = in24hWindow ? "24h" : in1hWindow ? "1h" : null;
emailLog("info", "reminders.cron.tick", { activeWindow });
if (!in24hWindow && !in1hWindow) { if (!in24hWindow && !in1hWindow) {
return { sent: 0, skipped: true, reason: "outside_window", errors: [] }; return { sent: 0, skipped: true, reason: "outside_window", errors: [] };
} }
@@ -1094,6 +1133,8 @@ export async function runSendReminders(): Promise<{
.from(reminder) .from(reminder)
.where(isNull(reminder.sent24hAt)); .where(isNull(reminder.sent24hAt));
emailLog("info", "reminders.24h.pending", { count: pending.length });
for (const row of pending) { for (const row of pending) {
try { try {
await sendReminder24hEmail({ to: row.email }); await sendReminder24hEmail({ to: row.email });
@@ -1104,7 +1145,11 @@ export async function runSendReminders(): Promise<{
sent++; sent++;
} catch (err) { } catch (err) {
errors.push(`24h ${row.email}: ${String(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) .from(reminder)
.where(isNull(reminder.sentAt)); .where(isNull(reminder.sentAt));
emailLog("info", "reminders.1h.pending", { count: pending.length });
for (const row of pending) { for (const row of pending) {
try { try {
await sendReminderEmail({ to: row.email }); await sendReminderEmail({ to: row.email });
@@ -1125,7 +1172,11 @@ export async function runSendReminders(): Promise<{
sent++; sent++;
} catch (err) { } catch (err) {
errors.push(`1h ${row.email}: ${String(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) { for (const reg of unpaidWatchers) {
if (!reg.managementToken) continue; if (!reg.managementToken) continue;
try { try {
@@ -1161,9 +1216,15 @@ export async function runSendReminders(): Promise<{
sent++; sent++;
} catch (err) { } catch (err) {
errors.push(`payment-reminder ${reg.email}: ${String(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 }; return { sent, skipped: false, errors };
} }