import { env } from "@kk/env/server"; import nodemailer from "nodemailer"; import { EVENT, OPENS } from "./constants"; // --------------------------------------------------------------------------- // Transport — singleton so a warm CF isolate reuses the open TCP connection // --------------------------------------------------------------------------- let _transport: nodemailer.Transporter | undefined; function getTransport(): nodemailer.Transporter | null { if (_transport) return _transport; if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) { // 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 }, }); return _transport; } // --------------------------------------------------------------------------- // Logging — console.log(JSON) is the only thing visible in CF dashboard // --------------------------------------------------------------------------- export function emailLog( level: "info" | "warn" | "error", event: string, extra?: Record, ): void { console.log(JSON.stringify({ level, event, ...extra })); } // --------------------------------------------------------------------------- // HTML helpers // --------------------------------------------------------------------------- /** Primary or secondary CTA button */ function btn( href: string, label: string, variant: "primary" | "secondary" = "primary", ): string { const bg = variant === "primary" ? "#fff" : "rgba(255,255,255,0.15)"; const color = variant === "primary" ? "#214e51" : "#fff"; return `
${label}
`; } /** Info card — single label + value */ function card(label: string, value: string): string { return `

${label}

${value}

`; } /** Payment breakdown card — renders only when totalCents > 0 */ function paymentCard(drinkCardCents: number, giftCents: number): string { const total = drinkCardCents + giftCents; if (total === 0) return ""; return `

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}

`; } // --------------------------------------------------------------------------- // Email layout shell // --------------------------------------------------------------------------- function emailLayout(heading: string, body: string): string { return ` ${heading}

Kunstenkamp

${heading}

${body}

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

`; } // --------------------------------------------------------------------------- // 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 { return wantsToPerform ? `Optreden${artForm ? ` — ${artForm}` : ""}` : "Toeschouwer"; } function manageUrl(token: string): string { return `${env.BETTER_AUTH_URL}/manage/${token}`; } // --------------------------------------------------------------------------- // Emails // --------------------------------------------------------------------------- export async function sendConfirmationEmail(params: { to: string; firstName: string; managementToken: string; wantsToPerform: boolean; artForm?: string | null; giftAmount?: number; drinkCardValue?: number; signupUrl?: string; }) { const manage = manageUrl(params.managementToken); const signup = params.signupUrl ?? `${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: { to: string; firstName: string; managementToken: string; wantsToPerform: boolean; artForm?: string | null; giftAmount?: number; drinkCardValue?: number; }) { 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; }) { 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; }) { 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 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 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: { to: string; firstName: string; managementToken: string; drinkCardValue?: number; giftAmount?: number; }) { 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: { to: string; firstName: string; managementToken: string; drinkCardValue?: number; giftAmount?: number; }) { 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"), ), ); }