import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { allow, clientIp } from "@/lib/ratelimit"; import { sameOriginJson } from "@/lib/csrf"; import { verifyTurnstile } from "@/lib/turnstile"; import { mailConfigured, sendMail } from "@/lib/mail"; export const dynamic = "force-dynamic"; const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max); /* Retention. * * A message through the contact form carries a name, a work address, a facility, a role, whatever * the person chose to write and the address they wrote it from. It was kept forever: the model has * no facility to cascade from, so nothing would ever have deleted one. Twelve months is long * enough for the enquiry and any follow-up it turns into, and the privacy note says the same * number — this is the mechanism that makes that sentence true rather than aspirational. * * Swept from here rather than from a cron, because a cron is a second thing to deploy and this * table only grows when this handler runs. The limiter is doing duty as an interval: one sweep an * hour, and the message the person is sending never waits on it. */ const RETENTION_DAYS = 365; function pruneOldMessages() { if (!allow("contact-prune", 1, 60 * 60 * 1000)) return; const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000); void prisma.contactMessage .deleteMany({ where: { createdAt: { lt: cutoff } } }) .then((r) => { if (r.count) console.log(`[contact] retention: removed ${r.count} message(s) older than ${RETENTION_DAYS} days`); }) .catch((e) => console.error("[contact] retention sweep failed:", (e as Error).message)); } export async function POST(req: NextRequest) { const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); const ip = clientIp(req.headers); // Two buckets: a burst guard and a slower daily ceiling, so one address can't grind through it. if (!allow("contact:" + ip, 5, 60 * 60 * 1000) || !allow("contact-day:" + ip, 20, 24 * 60 * 60 * 1000)) { return NextResponse.json({ error: "That's a few messages in a short time. Try again later, or email hello@threadcount.tech." }, { status: 429 }); } let b: Record; try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } // Honeypot: a real person never fills this in. if (str(b.company, 100)) return NextResponse.json({ ok: true }); const name = str(b.name, 120); const email = str(b.email, 160).toLowerCase(); const message = str(b.message, 4000); if (!name) return NextResponse.json({ error: "Add your name so I know who I'm replying to." }, { status: 400 }); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Add an email address I can reply to." }, { status: 400 }); if (message.length < 10) return NextResponse.json({ error: "Say a little more about what you need." }, { status: 400 }); const cfErr = await verifyTurnstile(b.cfToken, ip); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); pruneOldMessages(); const row = await prisma.contactMessage.create({ data: { name, email, message, ip, role: str(b.role, 120), facility: str(b.facility, 160), topic: str(b.topic, 60), slot: str(b.slot, 60), }, }); const emailed = await sendMail( `ThreadCount contact — ${row.topic || "A question"} — ${name}`, [`From: ${name}${row.role ? ` (${row.role})` : ""}`, row.facility && `Facility: ${row.facility}`, `Email: ${email}`, row.topic && `Topic: ${row.topic}`, row.slot && `Walkthrough: ${row.slot}`, "", message, "", `Received ${row.createdAt.toISOString()} from ${ip}`] .filter(Boolean).join("\n"), email, ); if (emailed) await prisma.contactMessage.update({ where: { id: row.id }, data: { emailed: true } }); else if (!mailConfigured()) console.warn("[contact] stored", row.id, "— SMTP not configured, no notification sent"); return NextResponse.json({ ok: true }); }