feat: Kontaktformular für ein Erstgespräch
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 4m31s
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:
1 parent
d45700a9ef
commit
7745d5862d
9 files changed
+336
-1
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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" }) };
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user