"use server"; import { CONTACT_EMAIL, SITE_NAME } from "./site"; import type { WaitlistState } from "./waitlist-state"; /** * Waitlist signup. * * The address is delivered as a notification email and nowhere else, so * the inbox is the only copy of the list. `deliver()` is deliberately the * single place that knows this — adding a database later means adding one * call there, not touching the action or the form. */ /** * Deliberately stricter than the browser's type=email (which accepts * "a@b"), but not a full RFC 5322 parser — those reject addresses that * work in practice. Requires a dotted domain and no whitespace. */ const EMAIL = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/; /** Crude in-process rate limit: same IP, at most 5 signups per hour. */ const RECENT = new Map(); const WINDOW_MS = 60 * 60 * 1000; const MAX_PER_WINDOW = 5; function rateLimited(key: string): boolean { const now = Date.now(); const hits = (RECENT.get(key) ?? []).filter((t) => now - t < WINDOW_MS); hits.push(now); RECENT.set(key, hits); // Serverless instances are short-lived, so this map cannot grow without // bound in practice — but trim it anyway for long-running instances. if (RECENT.size > 5000) { for (const [k, v] of RECENT) { if (v.every((t) => now - t >= WINDOW_MS)) RECENT.delete(k); } } return hits.length > MAX_PER_WINDOW; } async function deliver(email: string, meta: { at: string; source: string }) { const host = process.env.SMTP_HOST; const user = process.env.SMTP_USER; const pass = process.env.SMTP_PASSWORD; const to = process.env.WAITLIST_NOTIFY_TO ?? CONTACT_EMAIL; const from = process.env.WAITLIST_FROM ?? user; if (!host || !user || !pass) { // Not configured yet. Log instead of throwing, so missing credentials // in a preview deployment do not show the visitor an error — but make // it loud enough to notice in the runtime logs. console.warn( "[waitlist] SMTP_HOST/SMTP_USER/SMTP_PASSWORD missing — signup not delivered:", email, ); return; } // Port 465 speaks TLS from the first byte; 587 starts plain and upgrades // via STARTTLS. Getting `secure` wrong for the port is the single most // common cause of a hanging connection. const port = Number(process.env.SMTP_PORT ?? 587); const secure = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === "true" : port === 465; // Imported lazily so nodemailer stays out of any bundle that merely // imports this module's types. const nodemailer = (await import("nodemailer")).default; const transporter = nodemailer.createTransport({ host, port, secure, auth: { user, pass }, // Serverless functions are killed once the response is sent, so a // hanging socket must fail fast rather than stall the request. connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 15_000, }); try { await transporter.sendMail({ from, to, // So a reply goes straight to the person who signed up. replyTo: email, subject: `Warteliste: ${email}`, text: [ `Neue Anmeldung für die ${SITE_NAME}-Warteliste.`, "", `E-Mail: ${email}`, `Zeitpunkt: ${meta.at}`, `Formular: ${meta.source}`, "", "Die Person hat der Kontaktaufnahme per E-Mail zugestimmt", "(Checkbox im Formular, Single Opt-in).", ].join("\n"), }); } finally { // Without this the pooled connection can keep the function alive // until the platform times it out. transporter.close(); } } export async function joinWaitlist( _previous: WaitlistState, formData: FormData, ): Promise { const email = String(formData.get("email") ?? "").trim(); const consent = formData.get("consent"); const source = String(formData.get("source") ?? "unbekannt"); // Bots fill every field they find; humans never see this one. const honeypot = String(formData.get("website") ?? ""); if (honeypot) { // Claim success rather than explain the trap. return { status: "success" }; } if (!email) { return { status: "error", message: "Bitte trag deine E-Mail-Adresse ein.", email, }; } if (email.length > 254 || !EMAIL.test(email)) { return { status: "error", message: "Diese E-Mail-Adresse sieht nicht gültig aus.", email, }; } if (!consent) { return { status: "error", message: "Bitte bestätige, dass wir dich per E-Mail informieren dürfen.", email, }; } const { headers } = await import("next/headers"); const headerList = await headers(); const ip = headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unbekannt"; if (rateLimited(ip)) { return { status: "error", message: "Zu viele Versuche. Bitte probier es später noch einmal.", email, }; } try { await deliver(email, { at: new Date().toLocaleString("de-DE", { timeZone: "Europe/Berlin" }), source, }); } catch (error) { // The address is lost if this throws, so make it findable in the logs. console.error("[waitlist] Zustellung fehlgeschlagen für", email, error); return { status: "error", message: "Das hat gerade nicht geklappt. Schreib uns kurz an " + `${CONTACT_EMAIL} — dann tragen wir dich von Hand ein.`, email, }; } return { status: "success" }; }