Add contact email confirmation and abuse protection

This commit is contained in:
t.indorf
2026-05-16 11:38:30 +02:00
parent 9542def530
commit 0b80c62beb
12 changed files with 376 additions and 17 deletions
+59 -11
View File
@@ -1,20 +1,52 @@
"use server";
import { createElement } from "react";
import { headers } from "next/headers";
import { ContactConfirmationEmail } from "@/emails/ContactConfirmationEmail";
import { ContactRequestEmail } from "@/emails/ContactRequestEmail";
import {
contactRequestSchema,
contactRequestTypeLabels,
type ContactRequestInput,
type ContactSubmissionPayload,
} from "@/lib/contact-schema";
import { getAppEmailConfigError, sendAppEmail } from "@/lib/mail";
import { rateLimit } from "@/lib/rate-limit";
const DEFAULT_ADMIN_EMAIL = "kontakt@kita-planer.local";
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
async function resolveClientIp(): Promise<string> {
const h = await headers();
const forwarded = h.get("x-forwarded-for");
if (forwarded) {
return forwarded.split(",")[0]?.trim() || "unknown";
}
return h.get("x-real-ip") ?? "unknown";
}
export async function submitContactRequest(data: ContactSubmissionPayload) {
// Honeypot: silently absorb bot submissions without informing them.
if (data.website && data.website.trim().length > 0) {
return { success: true };
}
const ip = await resolveClientIp();
const limit = rateLimit(
`contact:${ip}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_SECONDS,
);
if (!limit.allowed) {
const minutes = Math.max(1, Math.ceil(limit.retryAfterSeconds / 60));
return {
success: false,
error: `Zu viele Anfragen. Bitte versuche es in ${minutes} Minuten erneut.`,
};
}
export async function submitContactRequest(data: ContactRequestInput) {
const parsed = contactRequestSchema.safeParse(data);
if (!parsed.success) {
return {
success: false,
@@ -32,19 +64,35 @@ export async function submitContactRequest(data: ContactRequestInput) {
const adminEmail = process.env.ADMIN_EMAIL || DEFAULT_ADMIN_EMAIL;
const inquiryLabel = contactRequestTypeLabels[parsed.data.requestType];
const result = await sendAppEmail({
to: adminEmail,
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
replyTo: parsed.data.email,
react: createElement(ContactRequestEmail, parsed.data),
});
if (!result.success) {
const [adminResult, confirmationResult] = await Promise.all([
sendAppEmail({
to: adminEmail,
subject: `Kontaktanfrage: ${inquiryLabel} von ${parsed.data.name}`,
replyTo: parsed.data.email,
react: createElement(ContactRequestEmail, parsed.data),
}),
sendAppEmail({
to: parsed.data.email,
subject: "Wir haben deine Anfrage erhalten — Kita-Planer",
replyTo: adminEmail,
react: createElement(ContactConfirmationEmail, parsed.data),
}),
]);
if (!adminResult.success) {
return {
success: false,
error: result.error,
error: adminResult.error,
};
}
if (!confirmationResult.success) {
console.error(
"Bestätigungs-E-Mail konnte nicht zugestellt werden:",
confirmationResult.error,
);
}
return { success: true };
}