Skip to content

systems email and rate limiting

Claude edited this page Sep 23, 2026 · 1 revision

Email and rate limiting

Active contributors: ferdiiskandar

Purpose

Two related safeguards live here. The email queue delivers transactional mail without blocking the request that triggered it, retrying transient failures and pruning old rows. The rate-limit layer throttles abuse-sensitive auth actions by IP and by email using shared counters, so repeated registration, password-reset, or verification-resend attempts are capped.

Directory layout

lib/
  email/queue.ts            welcome-email queue
  email/welcome.ts          Resend delivery and HTML
  auth/shared-rate-limit.ts fixed-window counter store
  auth/abuse-protection.ts  per-action IP and email rules

Key abstractions

Symbol Kind Path
enqueueWelcomeEmail, processEmailQueue queue functions lib/email/queue.ts
EmailQueueProcessingResult result type lib/email/queue.ts
sendWelcomeEmail Resend delivery lib/email/welcome.ts
consumeSharedRateLimit, SharedRateLimitResult counter functions lib/auth/shared-rate-limit.ts
enforceAuthAbuseProtection auth rules lib/auth/abuse-protection.ts

How it works

enqueueWelcomeEmail upserts an EmailJob keyed by the idempotency key welcome:<userId>, so re-running registration for the same user refreshes the row instead of creating a duplicate. Each job starts PENDING with maxAttempts 5 and nextAttemptAt set to now.

processEmailQueue returns an empty summary when RESEND_API_KEY is missing, otherwise loads due jobs (PENDING or RETRYING with nextAttemptAt <= now, oldest first, limited by limit). For each job it claims with a conditional updateMany to PROCESSING that increments attempts; if the claim updates zero rows, the job is skipped, which protects against two workers grabbing one job. Delivery dispatches on type, and the WELCOME branch parses the payload with Zod before calling sendWelcomeEmail. Success sets SENT and sentAt. Failure retries while attemptNumber < maxAttempts, using exponential backoff clamped between one minute and fifteen minutes, and marks FAILED once attempts are exhausted. maybePruneFinishedJobs runs on a 2 percent chance per pass and deletes SENT or FAILED rows whose updatedAt is older than 14 days.

sendWelcomeEmail skips silently when RESEND_API_KEY is unset, sends through Resend from RESEND_FROM_EMAIL, and sets List-Unsubscribe and List-Unsubscribe-Post headers pointing at the app unsubscribe URL.

graph TD
    Register[Registration flow] --> Enqueue[enqueueWelcomeEmail]
    Enqueue --> Job[(email_jobs)]
    Cron[Queue worker] --> Process[processEmailQueue]
    Process --> Claim[conditional claim to PROCESSING]
    Claim --> Deliver[sendWelcomeEmail via Resend]
    Deliver -->|success| Sent[status SENT]
    Deliver -->|error under max| Retry[status RETRYING with backoff]
    Deliver -->|error at max| Fail[status FAILED]
    Process --> Prune[prune SENT/FAILED older than 14 days]

    Request[Auth route] --> Abuse[enforceAuthAbuseProtection]
    Abuse --> IP[consumeSharedRateLimit scope IP]
    Abuse --> Email[consumeSharedRateLimit scope EMAIL]
    IP --> Counter[(rate_limit_counters)]
    Email --> Counter
Loading

consumeSharedRateLimit hashes the raw key with SHA-256 and computes a fixed window by flooring the timestamp to the window size. It inserts a counter with count 1 on first use, and on a unique-constraint conflict (Prisma P2002, meaning a concurrent insert) it retries recursively. When the stored count has reached the rule limit it returns allowed: false with retryAfterSeconds until windowEnd; otherwise it increments. maybePruneExpiredCounters runs on a 2 percent chance and deletes counters whose windowEnd is more than 24 hours old.

enforceAuthAbuseProtection applies the IP rule first, then the email rule, and returns as soon as either fails. Rules are REGISTER at 5 per 15 minutes by IP and 3 per hour by email, FORGOT_PASSWORD at the same pair, and RESEND_VERIFICATION at 6 per 15 minutes by IP and 3 per 30 minutes by email. The client IP comes from the first x-forwarded-for entry, then x-real-ip, then unknown; the email key is trimmed and lowercased.

Integration points

The queue is triggered after a new Prisma user is created, while enforceAuthAbuseProtection guards the registration, password-reset, and verification-resend routes. Both persist to tables described in Data and Prisma. The Resend and app-URL variables are listed in Configuration, and outbound mail handling is covered in Security.

Entry points for modification

  • Change retry backoff, attempt cap, or prune policy: lib/email/queue.ts.
  • Change sender, subject, or unsubscribe headers: lib/email/welcome.ts.
  • Change window hashing or prune policy: lib/auth/shared-rate-limit.ts.
  • Change per-action limits: the RULES map in lib/auth/abuse-protection.ts.

Key source files

File What it holds
lib/email/queue.ts Idempotent enqueue, claim, deliver, retry, fail, prune
lib/email/welcome.ts Welcome HTML and Resend send with unsubscribe headers
lib/auth/shared-rate-limit.ts SHA-256 keyed fixed-window counters
lib/auth/abuse-protection.ts REGISTER, FORGOT_PASSWORD, RESEND_VERIFICATION rules

Clone this wiki locally