← back to Govarbitrage

src/app/api/newsletter/subscribe/route.ts

63 lines

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
import {
  buildConfirmEmail,
  newToken,
  normalizeEmail,
  sendNewsletterEmail,
} from "@/lib/newsletter";

export const dynamic = "force-dynamic";

// `website` is a honeypot: hidden in the form, humans leave it empty, naive
// bots fill it. Filled honeypot → pretend success, do nothing.
const Schema = z.object({
  email: z.string().email().max(254),
  website: z.string().optional(),
});

// Same body whether the email was new, already pending, or already confirmed —
// never reveals whether an address exists in the list.
const GENERIC_OK = {
  ok: true,
  message: "Check your inbox — if this address isn't already confirmed, we've sent a confirmation link.",
};

export async function POST(req: NextRequest) {
  const ip = clientIp(req);
  const ipLimit = rateLimit(`newsletter:ip:${ip}`, 8, 10 * 60_000);
  if (!ipLimit.allowed) return tooManyRequests(ipLimit);

  const parsed = Schema.safeParse(await req.json().catch(() => ({})));
  if (!parsed.success) {
    return NextResponse.json({ error: "A valid email address is required." }, { status: 400 });
  }
  if (parsed.data.website) return NextResponse.json(GENERIC_OK);

  const email = normalizeEmail(parsed.data.email);
  const emailLimit = rateLimit(`newsletter:email:${email}`, 3, 15 * 60_000);
  if (!emailLimit.allowed) return tooManyRequests(emailLimit);

  const existing = await prisma.subscriber.findUnique({ where: { email } });

  if (existing?.status === "CONFIRMED") {
    // Already on the list — idempotent no-op, identical response.
    return NextResponse.json(GENERIC_OK);
  }

  const confirmToken = newToken();
  const unsubscribeToken = newToken();
  await prisma.subscriber.upsert({
    where: { email },
    create: { email, status: "PENDING", confirmToken, unsubscribeToken },
    update: { status: "PENDING", confirmToken, unsubscribeToken, unsubscribedAt: null },
  });

  const baseUrl = process.env.NEWSLETTER_BASE_URL || req.nextUrl.origin;
  await sendNewsletterEmail(buildConfirmEmail(email, baseUrl, confirmToken));

  return NextResponse.json(GENERIC_OK);
}