← 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
M .env.exampleA src/app/api/newsletter/confirm/route.tsA src/app/api/newsletter/result-page.tsA src/app/api/newsletter/subscribe/route.tsA src/app/api/newsletter/unsubscribe/route.tsA src/lib/newsletter.tsM src/middleware.ts
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 →