From 7f431c00910be84e8a46808849652e41d2bc435b Mon Sep 17 00:00:00 2001 From: zias Date: Wed, 11 Mar 2026 16:34:51 +0100 Subject: [PATCH] fix: email --- bun.lock | 6 +- packages/api/package.json | 2 +- packages/api/src/constants.ts | 9 + packages/api/src/email.ts | 934 ++++++++----------------- packages/api/src/routers/drinkkaart.ts | 2 +- packages/api/src/routers/index.ts | 20 +- packages/env/src/server.ts | 4 +- 7 files changed, 301 insertions(+), 676 deletions(-) create mode 100644 packages/api/src/constants.ts diff --git a/bun.lock b/bun.lock index c3d43b6..8d8666b 100644 --- a/bun.lock +++ b/bun.lock @@ -95,7 +95,7 @@ "@orpc/zod": "catalog:", "dotenv": "catalog:", "drizzle-orm": "^0.45.1", - "nodemailer": "^8.0.1", + "nodemailer": "^8.0.2", "zod": "catalog:", }, "devDependencies": { @@ -1511,7 +1511,7 @@ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], - "nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="], + "nodemailer": ["nodemailer@8.0.2", "", {}, "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -1987,8 +1987,6 @@ "@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@kk/auth/nodemailer": ["nodemailer@8.0.2", "", {}, "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw=="], - "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], diff --git a/packages/api/package.json b/packages/api/package.json index 348a15d..9fc8fec 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -20,7 +20,7 @@ "@orpc/zod": "catalog:", "dotenv": "catalog:", "drizzle-orm": "^0.45.1", - "nodemailer": "^8.0.1", + "nodemailer": "^8.0.2", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/api/src/constants.ts b/packages/api/src/constants.ts new file mode 100644 index 0000000..92e0642 --- /dev/null +++ b/packages/api/src/constants.ts @@ -0,0 +1,9 @@ +// --------------------------------------------------------------------------- +// Single source-of-truth for event details used across the API +// --------------------------------------------------------------------------- + +export const EVENT = "Open Mic Night — vrijdag 18 april 2026"; +export const OPENS = "maandag 16 maart 2026 om 19:00"; + +// Registration opens — used for reminder scheduling windows +export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00"); diff --git a/packages/api/src/email.ts b/packages/api/src/email.ts index 53aff6c..a9fac58 100644 --- a/packages/api/src/email.ts +++ b/packages/api/src/email.ts @@ -1,47 +1,33 @@ import { env } from "@kk/env/server"; import nodemailer from "nodemailer"; +import { EVENT, OPENS } from "./constants"; // --------------------------------------------------------------------------- -// Transport +// Transport — singleton so a warm CF isolate reuses the open TCP connection // --------------------------------------------------------------------------- -// Singleton transport — created once per module, reused across all email sends. -// Re-creating it on every call causes EventEmitter listener accumulation in -// long-lived Cloudflare Worker processes, triggering memory leak warnings. -let _transport: nodemailer.Transporter | null | undefined; +let _transport: nodemailer.Transporter | undefined; function getTransport(): nodemailer.Transporter | null { - if (_transport !== undefined) return _transport; + if (_transport) return _transport; if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) { - _transport = null; + // Not cached — re-evaluated on every call so a cold isolate that + // receives vars after module init can still pick them up. return null; } _transport = nodemailer.createTransport({ host: env.SMTP_HOST, port: env.SMTP_PORT, secure: env.SMTP_PORT === 465, - auth: { - user: env.SMTP_USER, - pass: env.SMTP_PASS, - }, + auth: { user: env.SMTP_USER, pass: env.SMTP_PASS }, }); return _transport; } -const from = env.SMTP_FROM ?? "Kunstenkamp "; -const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be"; - // --------------------------------------------------------------------------- -// Structured logger +// Logging — console.log(JSON) is the only thing visible in CF dashboard // --------------------------------------------------------------------------- -/** - * 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, @@ -51,172 +37,135 @@ export function emailLog( } // --------------------------------------------------------------------------- -// Primitives +// HTML helpers // --------------------------------------------------------------------------- -/** Format a cent amount as a Euro string, e.g. 500 → "€5", 550 → "€5,50". */ -function formatEuro(cents: number): string { - return `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; -} - -/** - * A shaded info box with a small uppercase label and a bold value. - * - * @example - * infoBox("Jouw rol", "Optreden — Muziek") - * infoBox("Inschrijvingen openen", "maandag 16 maart 2026 om 19:00") - */ -function infoBox(label: string, value: string): string { - return ` - - - -
-

${label}

-

${value}

-
`; -} - -/** - * A CTA button. - * - * - `"primary"` — white background, teal text (main action) - * - `"secondary"` — translucent white background, white text (secondary action) - */ -function ctaButton( +/** Primary or secondary CTA button */ +function btn( href: string, label: string, - style: "primary" | "secondary" = "primary", + variant: "primary" | "secondary" = "primary", ): string { - const bg = style === "primary" ? "#ffffff" : "rgba(255,255,255,0.15)"; - const color = style === "primary" ? "#214e51" : "#ffffff"; - return ` - - - + const bg = variant === "primary" ? "#fff" : "rgba(255,255,255,0.15)"; + const color = variant === "primary" ? "#214e51" : "#fff"; + return `
- - ${label} - -
+
+ ${label} +
`; } -/** - * A small muted link line rendered below a CTA button, e.g. "Of kopieer deze link: …" - */ -function ctaSubtext(text: string): string { - return `

${text}

`; +/** Info card — single label + value */ +function card(label: string, value: string): string { + return ` + +
+

${label}

+

${value}

+
`; } -/** - * A standard body paragraph (16 px, 85% white, 1.6 line-height). - * Pass raw HTML as the content, e.g. include `` tags freely. - */ -function p(content: string): string { - return `

${content}

`; -} - -/** - * Payment summary box (drinkcard + gift + total). - * Returns an empty string when both amounts are zero. - */ -function paymentSummaryBox( - drinkCardCents: number, - giftCents: number, - heading = "Betaling", -): string { - if (drinkCardCents === 0 && giftCents === 0) return ""; +/** Payment breakdown card — renders only when totalCents > 0 */ +function paymentCard(drinkCardCents: number, giftCents: number): string { const total = drinkCardCents + giftCents; + if (total === 0) return ""; return ` - - - +
-

${heading}

- ${drinkCardCents > 0 ? `

Drinkkaart: ${formatEuro(drinkCardCents)}

` : ""} - ${giftCents > 0 ? `

Vrijwillige gift: ${formatEuro(giftCents)}

` : ""} -

Totaal: ${formatEuro(total)}

-
+

Betaling

+ ${drinkCardCents > 0 ? `

Drinkkaart: ${euro(drinkCardCents)}

` : ""} + ${giftCents > 0 ? `

Vrijwillige gift: ${euro(giftCents)}

` : ""} +

Totaal: ${euro(total)}

+
`; } +/** Shared text paragraph */ +function p(text: string): string { + return `

${text}

`; +} + +/** Muted footnote */ +function note(html: string): string { + return `

${html}

`; +} + // --------------------------------------------------------------------------- -// Layout shell +// Email layout shell // --------------------------------------------------------------------------- -/** - * Wraps content in the full Kunstenkamp email chrome: - * outer grey background → teal card → header → body → footer. - * - * @param title HTML `` value - * @param heading Large white `<h1>` in the card header - * @param bodyHtml Everything between the header and the footer - * @param footerLines Optional extra lines rendered above the default contact - * line in the footer (e.g. unsubscribe notice). Pass an - * array of plain strings or HTML snippets. - * @param headerLabel Small uppercase label above the heading (default: "Kunstenkamp") - */ -function emailLayout({ - title, - heading, - bodyHtml, - footerLines = [], - headerLabel = "Kunstenkamp", -}: { - title: string; - heading: string; - bodyHtml: string; - footerLines?: string[]; - headerLabel?: string; -}): string { - const extraFooter = footerLines - .map((line) => `${line}<br/>`) - .join("\n "); - +function emailLayout(heading: string, body: string): string { return `<!DOCTYPE html> <html lang="nl"> <head> <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>${title} + + ${heading} - - - +
- - - - - - - - - - - - - -
-

${headerLabel}

-

${heading}

-
- ${bodyHtml} -
-

- ${extraFooter} - Vragen? Mail ons op info@kunstenkamp.be
- Kunstenkamp vzw — Een initiatief voor en door kunstenaars. -

-
-
+ + + + +
+

Kunstenkamp

+

${heading}

+
${body}
+

+ Vragen? Mail ons op info@kunstenkamp.be
+ Kunstenkamp vzw — Een initiatief voor en door kunstenaars. +

+
+
`; } // --------------------------------------------------------------------------- -// Shared role / payment helpers +// Shared send helper — logs attempt/sent/error, skips when SMTP not configured +// --------------------------------------------------------------------------- + +async function send( + type: string, + to: string, + subject: string, + html: string, +): Promise { + emailLog("info", "email.attempt", { type, to }); + const transport = getTransport(); + if (!transport) { + emailLog("warn", "email.skipped", { + type, + to, + reason: "smtp_not_configured", + // Which vars are missing — helps diagnose CF env issues + hasHost: !!env.SMTP_HOST, + hasUser: !!env.SMTP_USER, + hasPass: !!env.SMTP_PASS, + }); + return; + } + try { + await transport.sendMail({ from: env.SMTP_FROM, to, subject, html }); + emailLog("info", "email.sent", { type, to }); + } catch (err) { + emailLog("error", "email.error", { type, to, error: String(err) }); + throw err; + } +} + +// --------------------------------------------------------------------------- +// Euro formatter +// --------------------------------------------------------------------------- + +function euro(cents: number): string { + return `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`; +} + +// --------------------------------------------------------------------------- +// Helpers for registration emails // --------------------------------------------------------------------------- function roleLabel(wantsToPerform: boolean, artForm?: string | null): string { @@ -225,261 +174,12 @@ function roleLabel(wantsToPerform: boolean, artForm?: string | null): string { : "Toeschouwer"; } -function toCents( - drinkCardValue: number | undefined, - giftAmount: number | undefined, -): { drinkCardCents: number; giftCents: number } { - // drinkCardValue is stored in euros (e.g., 5); giftAmount in cents (e.g., 5000) - return { - drinkCardCents: (drinkCardValue ?? 0) * 100, - giftCents: giftAmount ?? 0, - }; +function manageUrl(token: string): string { + return `${env.BETTER_AUTH_URL}/manage/${token}`; } // --------------------------------------------------------------------------- -// Email composers -// --------------------------------------------------------------------------- - -function registrationConfirmationHtml(params: { - firstName: string; - manageUrl: string; - signupUrl: string; - wantsToPerform: boolean; - artForm?: string | null; - giftAmount?: number; - drinkCardValue?: number; -}): string { - const { drinkCardCents, giftCents } = toCents( - params.drinkCardValue, - params.giftAmount, - ); - - const body = [ - p(`Hoi ${params.firstName},`), - p( - `We hebben je inschrijving voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen.`, - ), - infoBox("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)), - paymentSummaryBox(drinkCardCents, giftCents), - p( - "Maak een account aan om je inschrijving te beheren en (voor bezoekers) je Drinkkaart te activeren.", - ), - ctaButton(params.signupUrl, "Account aanmaken"), - `
`, - p( - "Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.", - ), - ctaButton(params.manageUrl, "Beheer mijn inschrijving", "secondary"), - ctaSubtext( - `Of kopieer deze link: ${params.manageUrl}`, - ), - ].join("\n"); - - return emailLayout({ - title: "Bevestiging inschrijving", - heading: "Je inschrijving is bevestigd!", - bodyHtml: body, - }); -} - -function updateConfirmationHtml(params: { - firstName: string; - manageUrl: string; - wantsToPerform: boolean; - artForm?: string | null; - giftAmount?: number; - drinkCardValue?: number; -}): string { - const { drinkCardCents, giftCents } = toCents( - params.drinkCardValue, - params.giftAmount, - ); - - const body = [ - p(`Hoi ${params.firstName},`), - p( - `Je inschrijving voor Open Mic Night — vrijdag 18 april 2026 is succesvol bijgewerkt.`, - ), - infoBox("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)), - paymentSummaryBox(drinkCardCents, giftCents), - ctaButton(params.manageUrl, "Bekijk mijn inschrijving"), - ].join("\n"); - - return emailLayout({ - title: "Inschrijving bijgewerkt", - heading: "Inschrijving bijgewerkt", - bodyHtml: body, - }); -} - -function cancellationHtml(params: { firstName: string }): string { - const body = [ - p(`Hoi ${params.firstName},`), - p( - `Je inschrijving voor Open Mic Night — vrijdag 18 april 2026 is geannuleerd.`, - ), - p( - `Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via kunstenkamp.be.`, - ), - ].join("\n"); - - return emailLayout({ - title: "Inschrijving geannuleerd", - heading: "Inschrijving geannuleerd", - bodyHtml: body, - }); -} - -function reminder24hHtml(params: { firstName?: string | null }): string { - const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!"; - const openDateStr = "maandag 16 maart 2026 om 19:00"; - const registrationUrl = `${baseUrl}/#registration`; - - const body = [ - p(greeting), - p( - `Morgen openen de inschrijvingen voor Open Mic Night — vrijdag 18 april 2026. Zet je wekker!`, - ), - infoBox("Inschrijvingen openen", openDateStr), - p("Wees er snel bij — de plaatsen zijn beperkt!"), - ctaButton(registrationUrl, "Bekijk de pagina"), - ctaSubtext( - `Of ga naar: ${registrationUrl}`, - ), - ].join("\n"); - - return emailLayout({ - title: "Nog 24 uur — inschrijvingen openen morgen!", - heading: "Nog 24 uur!", - bodyHtml: body, - footerLines: [ - "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.", - ], - }); -} - -function reminderHtml(params: { firstName?: string | null }): string { - const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!"; - const openDateStr = "maandag 16 maart 2026 om 19:00"; - const registrationUrl = `${baseUrl}/#registration`; - - const body = [ - p(greeting), - p( - `Over ongeveer 1 uur openen de inschrijvingen voor Open Mic Night — vrijdag 18 april 2026.`, - ), - infoBox("Inschrijvingen openen", openDateStr), - p("Wees er snel bij — de plaatsen zijn beperkt!"), - ctaButton(registrationUrl, "Schrijf je in"), - ctaSubtext( - `Of ga naar: ${registrationUrl}`, - ), - ].join("\n"); - - return emailLayout({ - title: "Nog 1 uur — inschrijvingen openen straks!", - heading: "Nog 1 uur!", - bodyHtml: body, - footerLines: [ - "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd.", - ], - }); -} - -function subscriptionConfirmationHtml(params: { email: string }): string { - const openDateStr = "maandag 16 maart 2026 om 19:00"; - const registrationUrl = `${baseUrl}/#registration`; - - const body = [ - p( - `We sturen een herinnering naar ${params.email} wanneer de inschrijvingen openen.`, - ), - infoBox("Inschrijvingen openen", openDateStr), - p( - "Je ontvangt twee herinneringen: één 24 uur van tevoren en één 1 uur voor de opening.", - ), - p("Wees er snel bij — de plaatsen zijn beperkt!"), - ctaButton(registrationUrl, "Bekijk de pagina"), - ].join("\n"); - - return emailLayout({ - title: "Herinnering ingepland — Open Mic Night", - heading: "Je herinnering is ingepland!", - bodyHtml: body, - footerLines: [ - "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd op kunstenkamp.be.", - ], - }); -} - -function paymentReminderHtml(params: { - firstName: string; - accountUrl: string; - manageUrl: string; - drinkCardValue?: number; - giftAmount?: number; -}): string { - const { drinkCardCents, giftCents } = toCents( - params.drinkCardValue, - params.giftAmount, - ); - const totalCents = drinkCardCents + giftCents; - - const amountLine = - totalCents > 0 - ? p( - `Je betaling van ${formatEuro(totalCents)} staat nog open. Log in op je account om te betalen.`, - ) - : p("Je betaling staat nog open. Log in op je account om te betalen."); - - const body = [ - p(`Hoi ${params.firstName},`), - p( - `Je hebt je ingeschreven voor Open Mic Night — vrijdag 18 april 2026, maar we hebben nog geen betaling ontvangen.`, - ), - amountLine, - ctaButton(params.accountUrl, "Betaal nu"), - ctaSubtext( - `Je inschrijving beheren? Klik hier`, - ), - ].join("\n"); - - return emailLayout({ - title: "Herinnering: betaling open", - heading: "Betaling nog open", - bodyHtml: body, - }); -} - -function paymentConfirmationHtml(params: { - firstName: string; - manageUrl: string; - drinkCardValue?: number; - giftAmount?: number; -}): string { - const { drinkCardCents, giftCents } = toCents( - params.drinkCardValue, - params.giftAmount, - ); - - const body = [ - p(`Hoi ${params.firstName},`), - p( - `We hebben je betaling voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen. Tot dan!`, - ), - paymentSummaryBox(drinkCardCents, giftCents, "Betaling ontvangen"), - ctaButton(params.manageUrl, "Beheer mijn inschrijving", "secondary"), - ].join("\n"); - - return emailLayout({ - title: "Betaling ontvangen", - heading: "Betaling ontvangen!", - bodyHtml: body, - }); -} - -// --------------------------------------------------------------------------- -// Public send functions +// Emails // --------------------------------------------------------------------------- export async function sendConfirmationEmail(params: { @@ -490,48 +190,40 @@ export async function sendConfirmationEmail(params: { artForm?: string | null; giftAmount?: number; drinkCardValue?: number; - /** 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) { - emailLog("warn", "email.skipped", { - type, - to: params.to, - reason: "smtp_not_configured", - }); - return; - } - const manageUrl = `${baseUrl}/manage/${params.managementToken}`; - const signupUrl = + const manage = manageUrl(params.managementToken); + const signup = params.signupUrl ?? - `${baseUrl}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`; - 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; - } + `${env.BETTER_AUTH_URL}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`; + const drinkCardCents = (params.drinkCardValue ?? 0) * 100; + const giftCents = params.giftAmount ?? 0; + + await send( + "registrationConfirmation", + params.to, + "Bevestiging inschrijving — Open Mic Night", + emailLayout( + "Je inschrijving is bevestigd!", + p(`Hoi ${params.firstName},`) + + p( + `We hebben je inschrijving voor ${EVENT} in goede orde ontvangen.`, + ) + + card("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)) + + paymentCard(drinkCardCents, giftCents) + + p( + "Maak een account aan om je inschrijving te beheren en je Drinkkaart te activeren.", + ) + + btn(signup, "Account aanmaken") + + p( + "Wil je je gegevens aanpassen of je inschrijving annuleren? De link hieronder is uniek voor jou — deel hem niet.", + ) + + btn(manage, "Beheer mijn inschrijving", "secondary") + + note( + `Of kopieer: ${manage}`, + ), + ), + ); } export async function sendUpdateEmail(params: { @@ -543,172 +235,120 @@ 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) { - emailLog("warn", "email.skipped", { - type, - to: params.to, - reason: "smtp_not_configured", - }); - return; - } - const manageUrl = `${baseUrl}/manage/${params.managementToken}`; - 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; - } + const manage = manageUrl(params.managementToken); + const drinkCardCents = (params.drinkCardValue ?? 0) * 100; + const giftCents = params.giftAmount ?? 0; + + await send( + "updateConfirmation", + params.to, + "Inschrijving bijgewerkt — Open Mic Night", + emailLayout( + "Inschrijving bijgewerkt", + p(`Hoi ${params.firstName},`) + + p( + `Je inschrijving voor ${EVENT} is succesvol bijgewerkt.`, + ) + + card("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)) + + paymentCard(drinkCardCents, giftCents) + + btn(manage, "Bekijk mijn inschrijving"), + ), + ); } 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) { - emailLog("warn", "email.skipped", { - type, - to: params.to, - reason: "smtp_not_configured", - }); - return; - } - 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; - } + await send( + "cancellation", + params.to, + "Inschrijving geannuleerd — Open Mic Night", + emailLayout( + "Inschrijving geannuleerd", + p(`Hoi ${params.firstName},`) + + p( + `Je inschrijving voor ${EVENT} is geannuleerd.`, + ) + + p( + `Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via kunstenkamp.be.`, + ), + ), + ); } export async function sendSubscriptionConfirmationEmail(params: { to: string; }) { - const type = "subscriptionConfirmation"; - emailLog("info", "email.attempt", { type, to: params.to }); - const transport = getTransport(); - if (!transport) { - emailLog("warn", "email.skipped", { - type, - to: params.to, - reason: "smtp_not_configured", - }); - return; - } - 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; - } + await send( + "subscriptionConfirmation", + params.to, + "Herinnering ingepland — Open Mic Night", + emailLayout( + "Je herinnering is ingepland!", + p( + `We sturen een herinnering naar ${params.to} wanneer de inschrijvingen openen.`, + ) + + card("Inschrijvingen openen", OPENS) + + p( + "Je ontvangt twee herinneringen: één 24 uur van tevoren en één 1 uur voor de opening.", + ) + + p("Wees er snel bij — de plaatsen zijn beperkt!") + + btn(`${env.BETTER_AUTH_URL}/#registration`, "Bekijk de pagina"), + ), + ); } 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) { - emailLog("warn", "email.skipped", { - type, - to: params.to, - reason: "smtp_not_configured", - }); - return; - } - 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; - } + const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!"; + + await send( + "reminder24h", + params.to, + "Nog 24 uur — inschrijvingen openen morgen!", + emailLayout( + "Nog 24 uur!", + p(greeting) + + p( + `Morgen openen de inschrijvingen voor ${EVENT}. Zet je wekker!`, + ) + + card("Inschrijvingen openen", OPENS) + + p("Wees er snel bij — de plaatsen zijn beperkt!") + + btn(`${env.BETTER_AUTH_URL}/#registration`, "Bekijk de pagina") + + note( + "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd op kunstenkamp.be.", + ), + ), + ); } 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) { - emailLog("warn", "email.skipped", { - type, - to: params.to, - reason: "smtp_not_configured", - }); - return; - } - 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; - } + const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!"; + + await send( + "reminder1h", + params.to, + "Nog 1 uur — inschrijvingen openen straks!", + emailLayout( + "Nog 1 uur!", + p(greeting) + + p( + `Over ongeveer 1 uur openen de inschrijvingen voor ${EVENT}.`, + ) + + card("Inschrijvingen openen", OPENS) + + p("Wees er snel bij — de plaatsen zijn beperkt!") + + btn(`${env.BETTER_AUTH_URL}/#registration`, "Schrijf je in") + + note( + "Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd op kunstenkamp.be.", + ), + ), + ); } export async function sendPaymentReminderEmail(params: { @@ -718,41 +358,32 @@ 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) { - emailLog("warn", "email.skipped", { - type, - to: params.to, - reason: "smtp_not_configured", - }); - return; - } - const accountUrl = `${baseUrl}/account`; - const manageUrl = `${baseUrl}/manage/${params.managementToken}`; - 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; - } + const manage = manageUrl(params.managementToken); + const drinkCardCents = (params.drinkCardValue ?? 0) * 100; + const giftCents = params.giftAmount ?? 0; + const total = drinkCardCents + giftCents; + const amountNote = + total > 0 + ? `Je betaling van ${euro(total)} staat nog open.` + : "Je betaling staat nog open."; + + await send( + "paymentReminder", + params.to, + "Herinnering: betaling open — Open Mic Night", + emailLayout( + "Betaling nog open", + p(`Hoi ${params.firstName},`) + + p( + `Je hebt je ingeschreven voor ${EVENT}, maar we hebben nog geen betaling ontvangen.`, + ) + + p(`${amountNote} Log in op je account om te betalen.`) + + btn(`${env.BETTER_AUTH_URL}/account`, "Betaal nu") + + note( + `Je inschrijving beheren? Klik hier`, + ), + ), + ); } export async function sendPaymentConfirmationEmail(params: { @@ -762,37 +393,22 @@ 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) { - emailLog("warn", "email.skipped", { - type, - to: params.to, - reason: "smtp_not_configured", - }); - return; - } - const manageUrl = `${baseUrl}/manage/${params.managementToken}`; - 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; - } + const manage = manageUrl(params.managementToken); + const drinkCardCents = (params.drinkCardValue ?? 0) * 100; + const giftCents = params.giftAmount ?? 0; + + await send( + "paymentConfirmation", + params.to, + "Betaling ontvangen — Open Mic Night", + emailLayout( + "Betaling ontvangen!", + p(`Hoi ${params.firstName},`) + + p( + `We hebben je betaling voor ${EVENT} in goede orde ontvangen. Tot dan!`, + ) + + paymentCard(drinkCardCents, giftCents) + + btn(manage, "Beheer mijn inschrijving", "secondary"), + ), + ); } diff --git a/packages/api/src/routers/drinkkaart.ts b/packages/api/src/routers/drinkkaart.ts index ed74e24..6e3626d 100644 --- a/packages/api/src/routers/drinkkaart.ts +++ b/packages/api/src/routers/drinkkaart.ts @@ -348,7 +348,7 @@ export const drinkkaartRouter = { .then((r) => r[0]); if (cardUser) { - sendDeductionEmail({ + await sendDeductionEmail({ to: cardUser.email, firstName: cardUser.name.split(" ")[0] ?? cardUser.name, amountCents: input.amountCents, diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts index ff3ff97..8e213ca 100644 --- a/packages/api/src/routers/index.ts +++ b/packages/api/src/routers/index.ts @@ -18,6 +18,7 @@ import { sum, } from "drizzle-orm"; import { z } from "zod"; +import { REGISTRATION_OPENS_AT } from "../constants"; import { emailLog, sendCancellationEmail, @@ -32,8 +33,7 @@ import { adminProcedure, protectedProcedure, publicProcedure } from "../index"; import { generateQrSecret } from "../lib/drinkkaart-utils"; import { drinkkaartRouter } from "./drinkkaart"; -// Registration opens at this date — reminders fire 24 hours and 1 hour before -const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00"); +// Reminder windows derived from the canonical registration open date const REMINDER_1H_WINDOW_START = new Date( REGISTRATION_OPENS_AT.getTime() - 60 * 60 * 1000, ); @@ -879,13 +879,15 @@ export const appRouter = { email: input.email, }); - // Fire-and-forget — don't let a mail failure block the response - sendSubscriptionConfirmationEmail({ to: input.email }).catch((err) => - emailLog("error", "email.catch", { - type: "subscription_confirmation", - to: input.email, - error: String(err), - }), + // Awaited — CF Workers abandon unawaited promises when the response + // returns. Mail errors are caught so they don't fail the request. + await sendSubscriptionConfirmationEmail({ to: input.email }).catch( + (err) => + emailLog("error", "email.catch", { + type: "subscription_confirmation", + to: input.email, + error: String(err), + }), ); return { ok: true }; diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index c85881d..7255f3b 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -15,13 +15,13 @@ export const env = createEnv({ server: { DATABASE_URL: z.string().min(1), BETTER_AUTH_SECRET: z.string().min(32), - BETTER_AUTH_URL: z.url(), + BETTER_AUTH_URL: z.url().default("https://kunstenkamp.be"), CORS_ORIGIN: z.url(), SMTP_HOST: z.string().min(1).optional(), SMTP_PORT: z.coerce.number().default(587), SMTP_USER: z.string().min(1).optional(), SMTP_PASS: z.string().min(1).optional(), - SMTP_FROM: z.string().min(1).optional(), + SMTP_FROM: z.string().min(1).default("Kunstenkamp "), NODE_ENV: z .enum(["development", "production", "test"]) .default("development"),