Kumasy-Marketing/app/lib/waitlist.ts

181 lines
5.4 KiB
TypeScript

"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<string, number[]>();
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<WaitlistState> {
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" };
}