[object Object]

← back to Govarbitrage

Newsletter API: subscribe (honeypot+rate-limit, enumeration-safe), confirm, one-click unsubscribe; TEST-mode email outbox

d24273071218207db1b0634c0462814cecdaef0f · 2026-07-13 01:19:22 -0700 · Steve Abrams

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit d24273071218207db1b0634c0462814cecdaef0f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 01:19:22 2026 -0700

    Newsletter API: subscribe (honeypot+rate-limit, enumeration-safe), confirm, one-click unsubscribe; TEST-mode email outbox
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 .env.example                                |  14 ++++
 src/app/api/newsletter/confirm/route.ts     |  30 ++++++++
 src/app/api/newsletter/result-page.ts       |  22 ++++++
 src/app/api/newsletter/subscribe/route.ts   |  62 +++++++++++++++++
 src/app/api/newsletter/unsubscribe/route.ts |  33 +++++++++
 src/lib/newsletter.ts                       | 102 ++++++++++++++++++++++++++++
 src/middleware.ts                           |   2 +
 7 files changed, 265 insertions(+)

diff --git a/.env.example b/.env.example
index 5fd89ea..2511e2f 100644
--- a/.env.example
+++ b/.env.example
@@ -42,3 +42,17 @@ NODE_ENV="development"
 # MCP env in ~/.claude.json by scripts/run-digest.sh — NOT stored here.
 DIGEST_TO="steve@designerwallcoverings.com"
 APP_URL="http://localhost:3737"
+
+# ---- Public newsletter (double opt-in; TEST mode until Steve approves live) ----
+# TEST mode (default): confirmation/unsubscribe emails are logged to console +
+# logs/newsletter-outbox.jsonl, never sent. Live requires BOTH
+# NEWSLETTER_SEND_MODE=live AND NEWSLETTER_SEND_APPROVAL_TOKEN set — and the
+# actual send transport (George/SMTP) is deliberately not wired yet.
+NEWSLETTER_SEND_MODE="test"
+NEWSLETTER_SEND_APPROVAL_TOKEN=""
+NEWSLETTER_FROM_NAME="GovArbitrage Deal Digest"
+NEWSLETTER_FROM_EMAIL=""
+# CAN-SPAM: physical postal address, required in every commercial email footer.
+NEWSLETTER_MAILING_ADDRESS=""
+# Public origin used in confirm/unsubscribe links (falls back to request origin).
+NEWSLETTER_BASE_URL=""
diff --git a/src/app/api/newsletter/confirm/route.ts b/src/app/api/newsletter/confirm/route.ts
new file mode 100644
index 0000000..8cbed96
--- /dev/null
+++ b/src/app/api/newsletter/confirm/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest } from "next/server";
+import { prisma } from "@/lib/db";
+import { resultPage } from "../result-page";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(req: NextRequest) {
+  const token = req.nextUrl.searchParams.get("token") || "";
+  if (!token) {
+    return resultPage(400, "Missing link", "This confirmation link is incomplete. Please use the link from your email.");
+  }
+
+  const sub = await prisma.subscriber.findUnique({ where: { confirmToken: token } });
+  if (!sub) {
+    return resultPage(404, "Link not recognized", "This confirmation link is invalid or has been superseded by a newer one. Re-subscribe to get a fresh link.");
+  }
+
+  if (sub.status !== "CONFIRMED") {
+    await prisma.subscriber.update({
+      where: { id: sub.id },
+      data: { status: "CONFIRMED", confirmedAt: new Date() },
+    });
+  }
+
+  return resultPage(
+    200,
+    "You're subscribed",
+    "Your email is confirmed. The Top-10 government-surplus deals digest will arrive twice daily (6am & 5pm PT). Every digest includes a one-click unsubscribe link.",
+  );
+}
diff --git a/src/app/api/newsletter/result-page.ts b/src/app/api/newsletter/result-page.ts
new file mode 100644
index 0000000..f11b538
--- /dev/null
+++ b/src/app/api/newsletter/result-page.ts
@@ -0,0 +1,22 @@
+// Small friendly HTML result page for confirm/unsubscribe links, styled to
+// match the public pricing page.
+export function resultPage(status: number, title: string, body: string): Response {
+  const html = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${title} — GovArbitrage</title>
+</head>
+<body style="margin:0;background:#f8fafc;font-family:system-ui;color:#0f172a">
+  <main style="max-width:560px;margin:96px auto;padding:0 20px;text-align:center">
+    <div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:36px 28px">
+      <h1 style="font-size:26px;margin:0 0 10px">${title}</h1>
+      <p style="color:#475569;font-size:15px;line-height:1.6;margin:0">${body}</p>
+      <p style="margin-top:22px"><a href="/newsletter" style="color:#111827;font-weight:600">← Back to the newsletter page</a></p>
+    </div>
+  </main>
+</body>
+</html>`;
+  return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
+}
diff --git a/src/app/api/newsletter/subscribe/route.ts b/src/app/api/newsletter/subscribe/route.ts
new file mode 100644
index 0000000..130c87e
--- /dev/null
+++ b/src/app/api/newsletter/subscribe/route.ts
@@ -0,0 +1,62 @@
+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);
+}
diff --git a/src/app/api/newsletter/unsubscribe/route.ts b/src/app/api/newsletter/unsubscribe/route.ts
new file mode 100644
index 0000000..2b6a981
--- /dev/null
+++ b/src/app/api/newsletter/unsubscribe/route.ts
@@ -0,0 +1,33 @@
+import { NextRequest } from "next/server";
+import { prisma } from "@/lib/db";
+import { buildUnsubscribeNoticeEmail, sendNewsletterEmail } from "@/lib/newsletter";
+import { resultPage } from "../result-page";
+
+export const dynamic = "force-dynamic";
+
+// CAN-SPAM: single-click, no login, effective immediately.
+export async function GET(req: NextRequest) {
+  const token = req.nextUrl.searchParams.get("token") || "";
+  if (!token) {
+    return resultPage(400, "Missing link", "This unsubscribe link is incomplete. Please use the link from your email.");
+  }
+
+  const sub = await prisma.subscriber.findUnique({ where: { unsubscribeToken: token } });
+  if (!sub) {
+    return resultPage(404, "Link not recognized", "This unsubscribe link is invalid. If you keep receiving emails, reply to any digest and we'll remove you manually.");
+  }
+
+  if (sub.status !== "UNSUBSCRIBED") {
+    await prisma.subscriber.update({
+      where: { id: sub.id },
+      data: { status: "UNSUBSCRIBED", unsubscribedAt: new Date() },
+    });
+    await sendNewsletterEmail(buildUnsubscribeNoticeEmail(sub.email));
+  }
+
+  return resultPage(
+    200,
+    "You're unsubscribed",
+    "You will receive no further digests. This took effect immediately. Changed your mind? You can re-subscribe any time at /newsletter.",
+  );
+}
diff --git a/src/lib/newsletter.ts b/src/lib/newsletter.ts
new file mode 100644
index 0000000..99255aa
--- /dev/null
+++ b/src/lib/newsletter.ts
@@ -0,0 +1,102 @@
+import { randomBytes } from "node:crypto";
+import { appendFile, mkdir } from "node:fs/promises";
+import path from "node:path";
+
+// Newsletter email dispatch — TEST mode by default.
+//
+// Env placeholders (all optional until a live send is approved):
+//   NEWSLETTER_SEND_MODE          "test" (default) | "live". Live additionally
+//                                 requires NEWSLETTER_SEND_APPROVAL_TOKEN to be
+//                                 set — both must be present or we stay in test.
+//   NEWSLETTER_SEND_APPROVAL_TOKEN  Steve-issued token gating live sends.
+//   NEWSLETTER_FROM_NAME          Display name for the From header.
+//   NEWSLETTER_FROM_EMAIL         From address (live wiring TBD via George/SMTP).
+//   NEWSLETTER_MAILING_ADDRESS    CAN-SPAM physical postal address (required in
+//                                 every commercial email footer before go-live).
+//   NEWSLETTER_BASE_URL           Public origin for confirm/unsubscribe links;
+//                                 falls back to the request origin.
+//
+// In TEST mode every would-be email is written to the console AND appended to
+// logs/newsletter-outbox.jsonl. No network send path exists in this build —
+// flipping to live is a gated action (see pending-approval memo).
+
+export interface NewsletterEmail {
+  to: string;
+  subject: string;
+  text: string;
+}
+
+export function newsletterSendMode(): "test" | "live" {
+  const live =
+    process.env.NEWSLETTER_SEND_MODE === "live" &&
+    !!process.env.NEWSLETTER_SEND_APPROVAL_TOKEN;
+  return live ? "live" : "test";
+}
+
+const OUTBOX = path.join(process.cwd(), "logs", "newsletter-outbox.jsonl");
+
+export async function sendNewsletterEmail(email: NewsletterEmail): Promise<void> {
+  const record = {
+    ...email,
+    mode: newsletterSendMode(),
+    fromName: process.env.NEWSLETTER_FROM_NAME || "GovArbitrage Digest (unset)",
+    mailingAddress: process.env.NEWSLETTER_MAILING_ADDRESS || "(NEWSLETTER_MAILING_ADDRESS unset)",
+    at: new Date().toISOString(),
+  };
+
+  if (record.mode === "live") {
+    // Deliberately unimplemented: live sending requires George/SMTP wiring and
+    // Steve's approval (see ~/.claude/yolo-queue/pending-approval memo).
+    throw new Error("Newsletter live send is not wired. Remove NEWSLETTER_SEND_MODE=live.");
+  }
+
+  console.log(`[newsletter:test-outbox] to=${email.to} subject="${email.subject}"`);
+  console.log(email.text);
+  await mkdir(path.dirname(OUTBOX), { recursive: true });
+  await appendFile(OUTBOX, JSON.stringify(record) + "\n", "utf8");
+}
+
+export function newToken(): string {
+  return randomBytes(32).toString("base64url");
+}
+
+export function normalizeEmail(raw: string): string {
+  return raw.trim().toLowerCase();
+}
+
+function footer(): string {
+  const addr = process.env.NEWSLETTER_MAILING_ADDRESS || "(mailing address pending)";
+  const from = process.env.NEWSLETTER_FROM_NAME || "GovArbitrage Digest";
+  return `—\n${from}\n${addr}`;
+}
+
+export function buildConfirmEmail(to: string, baseUrl: string, confirmToken: string): NewsletterEmail {
+  const confirmUrl = `${baseUrl}/api/newsletter/confirm?token=${confirmToken}`;
+  return {
+    to,
+    subject: "Confirm your GovArbitrage digest subscription",
+    text: [
+      "You (or someone using this address) asked to receive the GovArbitrage",
+      "Top-10 government-surplus deals digest, sent twice daily (6am & 5pm PT).",
+      "",
+      `Confirm your subscription: ${confirmUrl}`,
+      "",
+      "If you didn't request this, ignore this email and you won't be subscribed.",
+      "",
+      footer(),
+    ].join("\n"),
+  };
+}
+
+export function buildUnsubscribeNoticeEmail(to: string): NewsletterEmail {
+  return {
+    to,
+    subject: "You've been unsubscribed from the GovArbitrage digest",
+    text: [
+      "You've been unsubscribed and will receive no further digests.",
+      "This was effective immediately — no login or confirmation was required.",
+      "",
+      footer(),
+    ].join("\n"),
+  };
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index 83937bd..57d34b0 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -12,6 +12,8 @@ const PUBLIC = [
   /^\/api\/listings\/[^/]+\/buyer-lead$/,
   /^\/pricing(?:\/|$)/, // public marketing page (upgrade CTA)
   /^\/api\/billing\/webhook$/, // Stripe calls this server-to-server, no session
+  /^\/newsletter(?:\/|$)/, // public digest subscribe landing
+  /^\/api\/newsletter\/(?:subscribe|confirm|unsubscribe)$/, // anonymous subscribers + email links
 ];
 
 function isPublic(pathname: string): boolean {

← 562ea5d Subscriber model: double-opt-in newsletter (PENDING/CONFIRME  ·  back to Govarbitrage  ·  Public /newsletter landing: digest pitch, subscribe form wit e626b08 →