feat: Kontaktformular für ein Erstgespräch
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s

Neuer Abschnitt nach den Fragen, im Menü als Erstgespräch. Anfrage geht als E-Mail an die Veranstalter-Adresse (Admin) bzw. ADMIN_NOTIFY_EMAIL, sofort zugestellt, sonst über den Worker wiederholt. Gleicher Schutz wie die Buchung: nur JSON von der eigenen Seite, Rate Limit (5/h je IP), Prüfung auf dem Server, Honeypot gegen Bots, doppeltes Absenden ergibt eine Mail. Keine automatische Mail an die angegebene Adresse (sonst Missbrauch für Spam an Dritte).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 11:37:27 +02:00
1 parent d45700a9ef
commit 7745d5862d
9 files changed
+336 -1

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
/**
* Anfrage fuer ein unverbindliches Erstgespraech. Kein eigenes Datenmodell:
* die Anfrage geht als E-Mail in den Postausgang (EmailMessage, mit
* Wiederholungen und Anzeige unzustellbarer Mails im Admin) an den
* Veranstalter bzw. ADMIN_NOTIFY_EMAIL.
*
* Bewusst keine automatische Bestaetigungsmail an die angegebene Adresse:
* sonst liesse sich das Formular nutzen, um Dritten Mails zu schicken.
*/
import "./zod-de";
import { z } from "zod";
import { audit, CUSTOMER } from "./audit";
import { getCamp } from "./camp";
import type { Db } from "./db";
import { enqueueEmail } from "./email";
import { contactInquiryEmail } from "./email-templates";
import { env } from "./env";
import { hmacHex } from "./tokens";
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.optional()
.transform((v) => v || null);
export const contactRequestSchema = z.object({
name: z.string().trim().min(2, "Bitte gib deinen Namen an.").max(120),
email: z.string().trim().max(200).pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
phone: optionalText(60),
availability: optionalText(200),
message: optionalText(2000),
privacy: z.literal(true, { error: "Bitte bestätige die Datenschutzhinweise." }),
/** Honeypot: fuer Menschen unsichtbar, Bots fuellen es aus. */
website: z.string().max(200).optional(),
});
export type ContactRequest = z.infer<typeof contactRequestSchema>;
export type ContactResult = { ok: true; delivered: boolean } | { ok: false; error: "NOT_CONFIGURED" };
/** Empfaenger: Veranstalter-Adresse aus dem Admin, sonst ADMIN_NOTIFY_EMAIL. */
export async function contactRecipient(db: Db): Promise<string | null> {
const camp = await getCamp(db);
return camp.organizerEmail || env().ADMIN_NOTIFY_EMAIL || null;
}
export async function submitContactRequest(db: Db, input: ContactRequest, ctx: { now: Date; secret: string }): Promise<ContactResult> {
// Bot: so tun, als waere alles gut, aber nichts verschicken.
if (input.website) return { ok: true, delivered: false };
const to = await contactRecipient(db);
if (!to) return { ok: false, error: "NOT_CONFIGURED" };
// Doppelt abgeschicktes Formular (gleiche Angaben in derselben Minute)
// ergibt nur eine Mail.
const minute = Math.floor(ctx.now.getTime() / 60_000);
const fingerprint = hmacHex(ctx.secret, `contact:${input.email.toLowerCase()}:${input.message ?? ""}:${minute}`).slice(0, 32);
await enqueueEmail(db, contactInquiryEmail(to, input, `contact:${fingerprint}`, ctx.now));
await audit(db, CUSTOMER, "contact.requested", "Contact", fingerprint);
return { ok: true, delivered: true };
}
+19 -1
View File
@@ -3,12 +3,13 @@
* bestaetigten Status — eine Teilzahlung heisst nie "vollstaendig bezahlt".
*/
import type { Booking, Installment } from "@/generated/prisma/client";
import type { ContactRequest } from "./contact";
import { escapeHtml, type EmailDraft } from "./email";
import { appUrl } from "./env";
import { formatEuro } from "./money";
import { PLAN_LABEL } from "./payment-plan";
import { PHASE_LABEL } from "./pricing";
import { formatDate } from "./time";
import { formatDate, formatDateTime } from "./time";
type BookingWithInstallments = Booking & { installments: Installment[] };
@@ -163,3 +164,20 @@ export function reviewCustomerEmail(booking: Booking, token: string): EmailDraft
),
);
}
/** Anfrage fuer ein Erstgespraech an den Veranstalter (src/lib/contact.ts). */
export function contactInquiryEmail(to: string, inquiry: ContactRequest, dedupeKey: string, receivedAt: Date): EmailDraft {
const subject = `Anfrage Erstgespräch: ${inquiry.name}`;
const lines = [
`Über die Webseite kam eine Anfrage für ein Erstgespräch (${formatDateTime(receivedAt)}).`,
[
`Name: ${inquiry.name}`,
`E-Mail: ${inquiry.email}`,
`Telefon: ${inquiry.phone ?? "–"}`,
`Erreichbar: ${inquiry.availability ?? "–"}`,
].join("\n"),
inquiry.message ? `Nachricht:\n${inquiry.message}` : "Keine Nachricht angegeben.",
];
const reply = `mailto:${encodeURIComponent(inquiry.email)}?subject=${encodeURIComponent(`Dein Erstgespräch – ${BRAND}`)}`;
return { dedupeKey, to, subject: `${subject} | ${BRAND}`, ...wrap(subject, lines, { href: reply, label: "Antworten" }) };
}
+1
View File
@@ -15,6 +15,7 @@ export const RATE_LIMITS = {
voucher: { action: "voucher", limit: 10, windowSeconds: 900 },
checkout: { action: "checkout", limit: 8, windowSeconds: 900 },
statusAction: { action: "status-action", limit: 20, windowSeconds: 900 },
contact: { action: "contact", limit: 5, windowSeconds: 3600 },
} satisfies Record<string, RateLimitRule>;
export type RateLimitResult = { allowed: boolean; retryAfterSeconds: number };