import { env } from "@kk/env/server"; import nodemailer from "nodemailer"; // --------------------------------------------------------------------------- // Transport // --------------------------------------------------------------------------- // 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; function getTransport(): nodemailer.Transporter | null { if (_transport !== undefined) return _transport; if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) { _transport = null; 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, }, }); return _transport; } const from = env.SMTP_FROM ?? "Kunstenkamp "; const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be"; // --------------------------------------------------------------------------- // Primitives // --------------------------------------------------------------------------- /** 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( href: string, label: string, style: "primary" | "secondary" = "primary", ): string { const bg = style === "primary" ? "#ffffff" : "rgba(255,255,255,0.15)"; const color = style === "primary" ? "#214e51" : "#ffffff"; return `
${label}
`; } /** * A small muted link line rendered below a CTA button, e.g. "Of kopieer deze link: …" */ function ctaSubtext(text: string): string { return `

${text}

`; } /** * 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 ""; const total = drinkCardCents + giftCents; return `

${heading}

${drinkCardCents > 0 ? `

Drinkkaart: ${formatEuro(drinkCardCents)}

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

Vrijwillige gift: ${formatEuro(giftCents)}

` : ""}

Totaal: ${formatEuro(total)}

`; } // --------------------------------------------------------------------------- // 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 "); return `<!DOCTYPE html> <html lang="nl"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>${title}

${headerLabel}

${heading}

${bodyHtml}

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

`; } // --------------------------------------------------------------------------- // Shared role / payment helpers // --------------------------------------------------------------------------- function roleLabel(wantsToPerform: boolean, artForm?: string | null): string { return wantsToPerform ? `Optreden${artForm ? ` — ${artForm}` : ""}` : "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, }; } // --------------------------------------------------------------------------- // 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 // --------------------------------------------------------------------------- export async function sendConfirmationEmail(params: { to: string; firstName: string; managementToken: string; wantsToPerform: boolean; artForm?: string | null; giftAmount?: number; drinkCardValue?: number; /** Pre-filled signup URL, e.g. /login?signup=1&email=…&next=/account */ signupUrl?: string; }) { const transport = getTransport(); if (!transport) { console.warn("SMTP not configured — skipping confirmation email"); 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, }), }); } export async function sendUpdateEmail(params: { to: string; firstName: string; managementToken: string; wantsToPerform: boolean; artForm?: string | null; giftAmount?: number; drinkCardValue?: number; }) { const transport = getTransport(); if (!transport) { console.warn("SMTP not configured — skipping update email"); 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, }), }); } export async function sendCancellationEmail(params: { to: string; firstName: string; }) { const transport = getTransport(); if (!transport) { console.warn("SMTP not configured — skipping cancellation email"); return; } await transport.sendMail({ from, to: params.to, subject: "Inschrijving geannuleerd — Open Mic Night", html: cancellationHtml({ firstName: params.firstName }), }); } export async function sendSubscriptionConfirmationEmail(params: { to: string; }) { const transport = getTransport(); if (!transport) { console.warn( "SMTP not configured — skipping subscription confirmation email", ); return; } await transport.sendMail({ from, to: params.to, subject: "Herinnering ingepland — Open Mic Night", html: subscriptionConfirmationHtml({ email: params.to }), }); } export async function sendReminder24hEmail(params: { to: string; firstName?: string | null; }) { const transport = getTransport(); if (!transport) { console.warn("SMTP not configured — skipping 24h reminder email"); return; } await transport.sendMail({ from, to: params.to, subject: "Nog 24 uur — inschrijvingen openen morgen!", html: reminder24hHtml({ firstName: params.firstName }), }); } export async function sendReminderEmail(params: { to: string; firstName?: string | null; }) { const transport = getTransport(); if (!transport) { console.warn("SMTP not configured — skipping reminder email"); return; } await transport.sendMail({ from, to: params.to, subject: "Nog 1 uur — inschrijvingen openen straks!", html: reminderHtml({ firstName: params.firstName }), }); } export async function sendPaymentReminderEmail(params: { to: string; firstName: string; managementToken: string; drinkCardValue?: number; giftAmount?: number; }) { const transport = getTransport(); if (!transport) { console.warn("SMTP not configured — skipping payment reminder email"); 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, }), }); } export async function sendPaymentConfirmationEmail(params: { to: string; firstName: string; managementToken: string; drinkCardValue?: number; giftAmount?: number; }) { const transport = getTransport(); if (!transport) { console.warn("SMTP not configured — skipping payment confirmation email"); 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, }), }); }