Skip to content

Dev: feat/newletter-capture#68

Merged
santy311 merged 7 commits into
mainfrom
dev
Jun 20, 2026
Merged

Dev: feat/newletter-capture#68
santy311 merged 7 commits into
mainfrom
dev

Conversation

@jenny07007

Copy link
Copy Markdown
Contributor

Summary

Newsletter signup form in the site footer with a five-layer server action pipeline: honeypot,Zod validation, Upstash rate limiting, Cloudflare Turnstile verification, and Resend subscription. The form uses React useActionState with a client-generated submission ID for response correlation, and an extracted useTurnstile hook for Turnstile widget lifecycle management.

Architecture

flowchart TD
  A[Form submit] --> B{Honeypot filled?}
  B -->|yes| C[Fake success - no subscribe]
  B -->|no| D[Zod email validation]
  D -->|invalid| E[Email error]
  D -->|valid| F[Upstash rate limit - 5/min per IP]
  F -->|limited| G[Too many attempts]
  F -->|ok| H[Cloudflare Siteverify]
  H -->|fail| I[Generic verification error]
  H -->|ok| J[Resend subscribe]
  J -->|fail| K[Generic or config error]
  J -->|ok| L[Success message]
Loading

Pipeline steps

Step Guard Failure behavior
Honeypot (company field) Hidden field filled by bots Returns fake success, never calls Resend
Email validation (Zod) trim, lowercase, min(1), max(254), z.email() Inline field error with aria-invalid
Rate limit (Upstash) 5 requests / 1 min per client IP "Too many attempts" error
Turnstile (Siteverify) Server-side token verification with action + hostname check Generic verification error
Resend subscribe Create contact or reactivate unsubscribed + add to segment Generic or configuration error

Rate limiting

  • Uses @upstash/ratelimit sliding window (5 req / 1 min) keyed by client IP
  • IP resolution: x-vercel-forwarded-for > x-real-ip > x-forwarded-for > Forwarded
    header
  • Normalizes IPv4-with-port, bracketed IPv6, IPv6-mapped IPv4
  • Skips loopback identifiers in production; fails closed when IP is unknown
  • Runs before Turnstile Siteverify to prevent verification API abuse
  • Gracefully skipped when Upstash env vars are absent (local dev)

Turnstile integration

Client (useTurnstile hook):

  • Loads Cloudflare script with render=explicit, handles remount and prior failed loads
  • Renders invisible managed widget, executes challenge on form submit
  • Re-entry ref prevents single-use token reuse on quick re-submits
  • Exposes error state; late callbacks (expired/timeout after reset) are no-ops

Server (verifyTurnstileToken):

  • Validates token against Cloudflare Siteverify API
  • Checks action matches newsletter (allows test action with test secret keys)
  • Checks hostname against allowed list (TURNSTILE_ALLOWED_HOSTNAMES + VERCEL_URL)
  • Auto-allows localhost / 127.0.0.1 in development
  • Conditionally skipped when TURNSTILE_SECRET_KEY is absent

Resend subscribe flow

flowchart TD
  A[Get existing contact] --> B{Contact exists?}
  B -->|not found| C[Create contact with segment] --> D[Done - new]
  B -->|found| E{Unsubscribed?}
  E -->|yes| F[Update unsubscribed=false] --> G[Check segment membership]
  E -->|no| G
  G --> H{In segment?}
  H -->|yes| I[Done - already subscribed]
  H -->|no| J[Add to segment with retry] --> K[Done]
Loading
  • Re-subscribe (previously unsubscribed) contacts are reactivated and shown success copy
  • Segment add has a 1-second retry on first failure
  • Configuration errors (missing RESEND_API_KEY / RESEND_SEGMENT_ID) fail with specific
    message

Client state management

  • submissionId: client-generated crypto.randomUUID() sent as hidden field, echoed by
    server in response state. All feedback (showMessage, showEmailError,
    showConfirmedMessage) is gated on stateBelongsToActiveSubmission to prevent stale
    responses from prior submissions leaking through.
  • messageDismissed: set true on email input change, reset false on submit and on
    successful subscription. Prevents typing during a pending request from permanently hiding the
    success message.
  • Success feedback auto-hides after 3 seconds via CSS animation keyed by messageKey.

Test coverage

42 tests across 4 test files (1,050 lines):

  • submit.test.ts (17 tests) -- full pipeline: honeypot bypass, validation errors, rate
    limit gating, Turnstile rejection, Resend success/failure/already-subscribed, submission ID
    echo
  • turnstile.test.ts (11 tests) -- Siteverify: missing config, missing token, action
    mismatch, hostname mismatch, Vercel URL auto-allow, test secret key handling, network errors
  • resend.test.ts (8 tests) -- contact create, reactivate unsubscribed, segment
    membership check, segment add retry, configuration errors
  • rate-limit.test.ts (6 tests) -- IP parsing (x-vercel-forwarded-for, x-forwarded-for,
    Forwarded header), IPv6-mapped IPv4 normalization, loopback skip, null identifier fail-closed

bun test src/lib/integrations/resend.test.ts src/lib/integrations/turnstile.test.ts
src/lib/newsletter/rate-limit.test.ts src/lib/newsletter/submit.test.ts
42 pass, 0 fail, 87 expect() calls

@jenny07007 jenny07007 requested review from jhuhnke and santy311 June 20, 2026 15:59
@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cortex-website Ready Ready Preview, Comment Jun 20, 2026 3:59pm

@santy311 santy311 merged commit 4024422 into main Jun 20, 2026
3 checks passed
@jenny07007

Copy link
Copy Markdown
Contributor Author

Close #10 Close #19

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants