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

+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, test } from "vitest";
import { contactRequestSchema, submitContactRequest } from "@/lib/contact";
import { resetEnvCache } from "@/lib/env";
import { db, resetDb, SECRET, setupCamp } from "./support/db";
const NOW = new Date("2026-11-15T10:00:00Z");
const ctx = { now: NOW, secret: SECRET };
function request(overrides: Record<string, unknown> = {}) {
return contactRequestSchema.parse({
name: "Lena Beispiel",
email: "[email protected]",
phone: "",
availability: "abends",
message: "Passt das Camp für Einsteigerinnen?",
privacy: true,
website: "",
...overrides,
});
}
beforeEach(async () => {
await resetDb();
await setupCamp({ organizerEmail: "[email protected]" });
delete process.env.ADMIN_NOTIFY_EMAIL;
resetEnvCache();
});
describe("Erstgespräch", () => {
test("Anfrage landet als Mail beim Veranstalter, mit allen Angaben", async () => {
expect(await submitContactRequest(db, request(), ctx)).toEqual({ ok: true, delivered: true });
const mails = await db.emailMessage.findMany();
expect(mails).toHaveLength(1);
expect(mails[0]!.toAddress).toBe("[email protected]");
expect(mails[0]!.subject).toContain("Lena Beispiel");
expect(mails[0]!.textBody).toContain("[email protected]");
expect(mails[0]!.textBody).toContain("Passt das Camp für Einsteigerinnen?");
expect(mails[0]!.htmlBody).toContain("mailto:lena%40example.org");
});
test("doppelt abgeschickt ergibt nur eine Mail", async () => {
await submitContactRequest(db, request(), ctx);
await submitContactRequest(db, request(), { ...ctx, now: new Date(NOW.getTime() + 10_000) });
expect(await db.emailMessage.count()).toBe(1);
});
test("Bot im Honeypot: Erfolg vortäuschen, nichts verschicken", async () => {
expect(await submitContactRequest(db, request({ website: "https://spam.example" }), ctx)).toEqual({ ok: true, delivered: false });
expect(await db.emailMessage.count()).toBe(0);
});
test("ohne Empfänger nicht verfügbar, mit ADMIN_NOTIFY_EMAIL als Ersatz", async () => {
await setupCamp({ organizerEmail: null });
expect(await submitContactRequest(db, request(), ctx)).toEqual({ ok: false, error: "NOT_CONFIGURED" });
process.env.ADMIN_NOTIFY_EMAIL = "[email protected]";
resetEnvCache();
await submitContactRequest(db, request(), ctx);
expect((await db.emailMessage.findFirstOrThrow()).toAddress).toBe("[email protected]");
});
test("Pflichtangaben und Einwilligung werden serverseitig geprüft", () => {
expect(contactRequestSchema.safeParse({ name: "L", email: "kein-mail", privacy: false }).success).toBe(false);
expect(contactRequestSchema.safeParse({ name: "Lena", email: "[email protected]", privacy: false }).success).toBe(false);
expect(request({ phone: " " }).phone).toBeNull();
});
});