mirror of
https://github.com/Since-3/Kumasy-Marketing.git
synced 2026-10-12 20:51:18 +00:00
181 lines
5.4 KiB
TypeScript
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" };
|
|
}
|