Appearance
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 TaskSchedulerqueueNotification() 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 alarmRetry & 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 emailVerification 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):
| Category | Notification Types |
|---|---|
challengeAlerts | CHALLENGE_PASSED, CHALLENGE_FAILED |
tradeAlerts | POSITION_LIQUIDATED, STOP_LOSS_TRIGGERED, TAKE_PROFIT_TRIGGERED, LIMIT_ORDER_TRIGGERED |
accountAlerts | MLL_WARNING |
subscriptionAlerts | SUBSCRIPTION_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
skippedinstead offailed— no retries wasted - No email set / unverified:
queueNotification()silently returns without inserting - Rate limit hit: Notification marked
skippedwith 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
- NotificationManager (in-app notifications)