Skip to content

EmailNotificationManager

Manages email addresses, verification, preferences, and queue-based email delivery via Resend with retry and deduplication.

High-Level Design

Responsibility

EmailNotificationManager owns the full email lifecycle within a user's portfolio DO: email address storage and verification, per-category opt-in/out preferences, and asynchronous queue-based delivery through the Resend API. It is the single exit point for all outbound email from a user's DO.

Notification Pipeline

Other managers never send email directly. They call queueNotification(type, data) which runs through a gate chain before inserting into the email_notifications table:

caller (e.g. ChallengeManager)
  → queueNotification(CHALLENGE_PASSED, data)
    → gate: email set?
    → gate: email verified? (bypass for EMAIL_VERIFICATION only)
    → gate: preference category enabled?
    → gate: rate limit (10 sent/hour, successful sends only)
    → gate: dedup window (60s default, 24h for MLL_WARNING)
    → INSERT into email_notifications (status=pending)
    → scheduleEmailBatch() via TaskScheduler

queueNotification() is best-effort — it catches all errors and logs them, never throws. This keeps callers fire-and-forget.

Async Batch Processing

Email delivery is decoupled from queueing. processPendingNotifications() runs via TaskScheduler alarm and processes up to 10 pending notifications per cycle:

TaskScheduler alarm → processPendingNotifications()
  → SELECT status=pending AND (next_retry_at IS NULL OR next_retry_at <= now), LIMIT 10
  → for each notification:
      → render React Email template → HTML + plain text
      → resend.emails.send() with headers (X-Entity-Ref-ID, List-Unsubscribe)
      → on success: status=sent, store resend_email_id
      → on failure: increment retry_count, compute next_retry_at
      → after 5 retries: status=failed
  → if more pending remain, reschedule alarm

Retry & Backoff

Failed sends stay in pending status with next_retry_at set using exponential backoff: 1m → 5m → 15m → 1h → 2h (5 attempts max). The batch query filters by next_retry_at <= now to avoid premature retries. Only after exhausting all retries is the notification moved to failed status permanently.

Deduplication Strategy

Before inserting, the manager checks for recent notifications of the same type:

  • Default (60s): Prevents duplicate emails from rapid-fire events (e.g., multiple limit orders triggering in the same second)
  • MLL_WARNING (24h, type-only match): Prevents hourly spam when account value stays in the danger zone across multiple AccountValueTracker callbacks

Email Verification Flow

setEmail("user@example.com")
  → store email (verified=false)
  → generate UUID token (24h expiry)
  → queue EMAIL_VERIFICATION (bypasses verified gate)
  → schedule UserRegistry update

verifyEmail(token)
  → validate token + expiry
  → set verified=true
  → queue WELCOME email

Verification tokens are single-use UUIDs with a 24-hour expiry. resendVerificationEmail() regenerates the token for users who missed the first email.

Template System

Each EmailNotificationType maps to a React Email component under emails/templates/. The email-templates.ts module provides:

  • getEmailSubject(type, data) — dynamic subject lines (e.g., "Your BTC position was liquidated")
  • getEmailTemplate(type, data) — renders the React component to both HTML and plain text via @react-email/render

Sender address is determined by EMAIL_SENDER_MAP: all types use no-reply@acetrader.com except INTERVIEW_INVITE which uses support@acetrader.com.

Preference Categories

Users can opt out of 4 notification categories (all enabled by default):

CategoryNotification Types
challengeAlertsCHALLENGE_PASSED, CHALLENGE_FAILED
tradeAlertsPOSITION_LIQUIDATED, STOP_LOSS_TRIGGERED, TAKE_PROFIT_TRIGGERED, LIMIT_ORDER_TRIGGERED
accountAlertsMLL_WARNING
subscriptionAlertsSUBSCRIPTION_EXPIRING_*, SUBSCRIPTION_EXPIRED, SUBSCRIPTION_GRACE_PERIOD

WELCOME and INTERVIEW_INVITE skip preference checks (preference map = null) but still require a verified email. Only EMAIL_VERIFICATION bypasses both verification and preferences.

Graceful Degradation

  • No RESEND_API_KEY: Notifications are marked skipped instead of failed — no retries wasted
  • No email set / unverified: queueNotification() silently returns without inserting
  • Rate limit hit: Notification marked skipped with reason logged
  • Dedup match: Silently skipped, no record created

UserRegistry Sync

On email set/change, the manager schedules a USER_REGISTRY_UPDATE task (via TaskScheduler) to update the global UserRegistry DO. This keeps admin search consistent without blocking the user-facing operation.

See Also