From 75097a601cc630e832d938945c2fa5c9d65b0a00 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sun, 17 May 2026 04:54:11 -0700 Subject: [PATCH] feat(notifications): P2d dispatcher cron route POST /api/cron/notifications drains <=20 pending rows/min, sends via the transport with the row id as Resend Idempotency-Key (overlap-safe), marks sent or increments attempts (retryable -> stay pending, permanent or budget-exhausted -> failed). Constant-time CRON_SECRET bearer check; 503 when unset. ~4.5/s spacing under Resend's 5/s limit. proxy.ts whitelists /api/cron (no session; own shared secret). CRON_SECRET added to .env.example. --- .env.example | 5 ++ app/api/cron/notifications/route.ts | 94 +++++++++++++++++++++++++++++ proxy.ts | 3 + 3 files changed, 102 insertions(+) create mode 100644 app/api/cron/notifications/route.ts diff --git a/.env.example b/.env.example index 35b4099..9a59a8c 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,11 @@ RESEND_API_KEY= # in Resend (SPF + DKIM) for production delivery. Friendly name optional. # Example: HeatSync Labs EMAIL_FROM= +# Shared secret guarding the notification dispatcher (/api/cron/notifications). +# The droplet's crontab POSTs with "Authorization: Bearer $CRON_SECRET" once a +# minute. Unset = the dispatcher returns 503 and never sends. Generate with: +# openssl rand -hex 32 +CRON_SECRET= # Docker build flag (only when building the standalone image) # Set to "1" in the Dockerfile so next.config.mjs emits standalone output. diff --git a/app/api/cron/notifications/route.ts b/app/api/cron/notifications/route.ts new file mode 100644 index 0000000..f9519bd --- /dev/null +++ b/app/api/cron/notifications/route.ts @@ -0,0 +1,94 @@ +// Notification dispatcher. Drains a bounded batch of pending outbox rows and +// sends them via the email transport. Designed to be hit once a minute by the +// droplet's crontab (see docs/DEPLOYMENT.md). Unauthenticated by session +// (cron has none) so proxy.ts whitelists /api/cron; trust is the CRON_SECRET +// shared-secret header, compared in constant time. +// +// Re-entrancy: a row is updated immediately after its own send, and sendEmail +// passes the row id as Resend's Idempotency-Key, so an overlapping run cannot +// double-send. Transient failures (retryable) stay pending for the next +// minute until the attempt budget is exhausted; permanent ones go to 'failed'. +import { NextRequest, NextResponse } from 'next/server' +import { timingSafeEqual } from 'node:crypto' +import { createAdminClient } from '@/lib/supabase/admin' +import { sendEmail } from '@/lib/email/send' +import { isTerminalAttempt, MAX_NOTIFICATION_ATTEMPTS } from '@/lib/notifications-logic' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const BATCH = 20 +// ~4.5 sends/sec, under Resend's 5 req/sec team limit. +const SPACING_MS = 220 + +const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) + +function authorized(req: NextRequest): boolean { + const expected = process.env.CRON_SECRET + if (!expected) return false + const header = req.headers.get('authorization') ?? '' + const provided = header.startsWith('Bearer ') ? header.slice(7) : header + const a = Buffer.from(provided) + const b = Buffer.from(expected) + return a.length === b.length && timingSafeEqual(a, b) +} + +export async function POST(req: NextRequest) { + if (!process.env.CRON_SECRET) { + return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 503 }) + } + if (!authorized(req)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const admin = createAdminClient() + const { data: rows, error } = await admin + .from('notifications') + .select('id, recipient, subject, body_html, body_text, attempts') + .eq('status', 'pending') + .eq('channel', 'email') + .lt('attempts', MAX_NOTIFICATION_ATTEMPTS) + .order('created_at', { ascending: true }) + .limit(BATCH) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + let sent = 0 + let failed = 0 + let retried = 0 + + for (const row of rows ?? []) { + const res = await sendEmail({ + to: row.recipient as string, + subject: row.subject as string, + html: row.body_html as string, + text: row.body_text as string, + idempotencyKey: row.id as string, + }) + + if (res.ok) { + await admin + .from('notifications') + .update({ status: 'sent', sent_at: new Date().toISOString(), last_error: null }) + .eq('id', row.id) + sent++ + } else { + const attempts = (row.attempts as number) + 1 + const terminal = !res.retryable || isTerminalAttempt(attempts) + await admin + .from('notifications') + .update({ + status: terminal ? 'failed' : 'pending', + attempts, + last_error: res.error, + }) + .eq('id', row.id) + if (terminal) failed++ + else retried++ + } + + if (SPACING_MS) await sleep(SPACING_MS) + } + + return NextResponse.json({ scanned: rows?.length ?? 0, sent, failed, retried }) +} diff --git a/proxy.ts b/proxy.ts index 97c3c19..4a4737d 100644 --- a/proxy.ts +++ b/proxy.ts @@ -21,6 +21,9 @@ const PUBLIC_ROUTES = [ // (Stripe does not follow redirects). Scoped to the webhook path only — // the Stripe server actions are invoked from authenticated pages. '/api/stripe/webhook', + // The notification dispatcher is hit by the droplet's crontab (no + // session); it enforces its own CRON_SECRET shared-secret header. + '/api/cron', // Public form / waiver fill page. Only /f/[slug] lives here and must be // reachable anonymously (the page serves only published public forms and // submitForm enforces visibility). The /forms* management routes do NOT