import { NextRequest, NextResponse } from "next/server"; import { allow, clientIp } from "@/lib/ratelimit"; import { sameOriginJson } from "@/lib/csrf"; import { verifyTurnstile } from "@/lib/turnstile"; export const dynamic = "force-dynamic"; /* Newsletter sign-up. * * ThreadCount keeps two promises that shape this endpoint. The contact form says, at the point of * collection, "No mailing list, no follow-up sequence" — so nothing that arrives through the * contact form ever reaches this list, and the two paths share no code and no storage. And the * list is double opt-in: this handler only ever creates an *unconfirmed* subscriber, and Listmonk * emails a confirmation link that the person has to click before they can be sent anything. * * It posts to ThreadCount's own Listmonk (lists.threadcount.tech), which is a separate instance * from ClearAudit's: Listmonk has a single global from-address, so sharing one would have sent * ThreadCount's confirmation emails from ClearAudit and failed SPF/DKIM alignment for this domain. * * The list uuid is not a secret — it is designed to sit in a public subscription form — so it is * committed rather than left to an env var that a build could forget. */ const LIST_UUID = process.env.LISTMONK_LIST_UUID || "734e9011-5fd5-48a7-b0ae-4ea0e1deb972"; const LISTMONK = process.env.LISTMONK_URL || "https://lists.threadcount.tech"; const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max); export async function POST(req: NextRequest) { const csrf = sameOriginJson(req); if (csrf) return NextResponse.json({ error: csrf }, { status: 403 }); const ip = clientIp(req.headers); if (!allow("subscribe:" + ip, 5, 60 * 60 * 1000) || !allow("subscribe-day:" + ip, 20, 24 * 60 * 60 * 1000)) { return NextResponse.json({ error: "That's a few attempts in a short time. Try again later." }, { status: 429 }); } let b: Record; try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); } // Honeypot, same as the contact form: a real person never fills this in. if (str(b.company, 100)) return NextResponse.json({ ok: true }); const email = str(b.email, 160).toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return NextResponse.json({ error: "That email address doesn't look right." }, { status: 400 }); } const cfErr = await verifyTurnstile(b.cfToken, ip); if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 }); // Listmonk's public subscription handler. It creates the subscriber as unconfirmed and sends the // opt-in email itself, which is why this endpoint never needs an admin token. const form = new URLSearchParams({ email, name: "", l: LIST_UUID }); try { const r = await fetch(`${LISTMONK}/subscription/form`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: form.toString(), redirect: "manual", // success is a 302 back to a thank-you page signal: AbortSignal.timeout(8000), }); if (r.status >= 500) { return NextResponse.json({ error: "Sign-up is unavailable for a moment — please try again shortly." }, { status: 502 }); } } catch { return NextResponse.json({ error: "Sign-up is unavailable for a moment — please try again shortly." }, { status: 502 }); } // Deliberately the same answer whether or not the address was already on the list: otherwise // this endpoint would confirm to a stranger who is subscribed. return NextResponse.json({ ok: true }); }