Skip to content

Epic 39 — Email Delivery & Notification Preferences

Decision (user, 2026-06-14): Wire up real email sending, fronted by a user-facing notifications manager (preference settings). Provider = Resend. Email is delivered as a channel layered over the existing createNotification (not a parallel set of sendEmail calls scattered across resolvers). Preferences = per-category email toggles + a master switch, with per-category opt-out and a one-click unsubscribe link.

✅ SHIPPED 2026-06-19. services/email (sendEmail via Resend; dev/no-key logs, prod refuses+retries, impersonation-silent) + HTML/text templates (shared layout, signed-unsubscribe footer) + workers/emailSender.ts BullMQ email-send queue (3 attempts, fail-open enqueue) started in index.ts. services/notifications/registry.ts maps all 29 NotificationTypes → {category, channels, emailTemplate?, subject} (compile-time exhaustive via Record<NotificationType,…> + runtime parity test); createNotification now enqueues email after the in-app write when the type has an EMAIL channel + prefs allow the category + not impersonation (v1 email types: APPLICATION_STATUS_CHANGE, SHORTLISTED, SUPPORT_REPLY, BOUNTY_HIRED, NEW_FOLLOWER, ACCOUNT_SUSPENDED_MODERATION). User.notificationPrefs Json? (migration 20260619000000_epic39_notification_prefs) + notificationPreferences query / updateNotificationPreferences mutation (6 categories; ACCOUNT always-on) + public signed unsubscribeFromEmails mutation and GET /unsubscribe?token= one-click route. Frontend: NotificationSettingsPage (/settings/notifications, linked from Profile → Settings) with master + per-category DS Switches (optimistic save), useNotificationPreferences hook, i18n en/pt/es + pageTitle. Config: RESEND_API_KEY (optional), EMAIL_FROM, APP_BASE_URL, API_BASE_URL; resend dep added. Tests: email render + token sign/verify + impersonation suppression; registry parity + prefs gating + enqueue-exactly-once + fail-open; prefs normalize/update + unsubscribe; FE settings toggle/optimistic-save. BE 1448 / FE app 577 / DS 44 green; typecheck + i18n-key lint + authz-matrix (393) clean. Deploy: run the Prisma migration; set RESEND_API_KEY + EMAIL_FROM + APP_BASE_URL/API_BASE_URL in prod. Unblocks Epic 48 (off-platform job claim invite email). FE-FLIER-002's sendFlierEmail can now use the generic template.

Why: The platform fires ~24 notification types from ~12 trigger sites ([[Notifications vs Messages]] · services/notifications/index.ts), but those are in-app only — there is zero email infrastructure today (no provider, no services/email, no templates, no preference fields). The roadmap already assumes email exists: Epic 24 guards reference src/services/email/index.ts sendEmail(…) and FE-FLIER-002 calls a sendFlierEmail mutation that "sends via SMTP/Resend." This epic builds that missing foundation so high-signal events (application status, shortlist, support reply, hire, new follower, moderation suspension) reach users off-platform, while respecting their preferences and the impersonation-silence rule (Epic 24).

Architecture (locked): A channel registry maps each NotificationType{ category, channels[], emailTemplate, subject }. createNotification stays the single entry point: it writes the in-app Mongo row as today, then — if the type declares an EMAIL channel, the user's prefs allow that category, and the call is not under impersonation — enqueues an email job onto a BullMQ email-send queue. Email send happens in a worker (Resend API), so SMTP latency never blocks a resolver. Everything is fire-and-forget / fail-open: email failures never touch the notification write or core business logic, mirroring the moderation/reel queue blueprints. The 7 existing typed helpers (notifyBountyClaimed, etc.) need no signature change — they flow through createNotification.

Categories (6): JOBS (job match, application status, shortlisted, new job posted), SOCIAL (new follower, post comment, profile view spike), BOUNTY_CASTARS (bounty claimed/hired, CasTars awarded/milestone, concierge, folder saved), SUPPORT (support reply, status changed), MODERATION (content flagged/removed/restored, account suspended, report resolved), ACCOUNT (security/transactional — not user-disableable).

v1 email-enabled types (high-signal, low-frequency): APPLICATION_STATUS_CHANGE, SHORTLISTED, SUPPORT_REPLY, BOUNTY_HIRED, NEW_FOLLOWER, ACCOUNT_SUSPENDED_MODERATION. Noisy types (PROFILE_VIEW, POST_COMMENT) stay in-app-only — promoting one later is a one-line registry change.

Scope notes: All UI lands in @castyou/design-system first ([[Design System Rule]]); the settings page route is registered centrally ([[Page Titles]]) with i18n in all 3 locales. Postgres User change needs a real prisma migrate ([[CasTyou Tech Stack]] — generate alone is not enough). Email send must read ctx.impersonation and emit nothing under impersonation (Epic 24, regression test required). Not blocked by the Epic 15 landing gate (app + backend only). Once BE-EMAIL-001 lands, FE-FLIER-002's sendFlierEmail becomes trivial — cross-reference it there.

BE-EMAIL-001 — Email service (Resend) + queued delivery worker

Files:

  • Create: castyou-backend/src/services/email/index.tssendEmail({ to, subject, template, data, ctx? }); returns silently when ctx.impersonation is present (Epic 24). In prod, refuses (logs + throws to the worker's retry) if RESEND_API_KEY is unset; in dev with no key, logs the rendered email to console instead of sending.
  • Create: castyou-backend/src/services/email/templates/ — shared layout + per-template render (HTML + text); start with the v1 set. Every email footer carries the signed unsubscribe link (see BE-EMAIL-003).
  • Create: castyou-backend/src/workers/emailSender.ts — BullMQ worker, queue 'email-send'; mirrors workers/moderationScanner.ts (fire-and-forget enqueue, fail-open on Redis outage, 3 attempts + exponential backoff 10s). Actual resend.emails.send() runs here.
  • Edit: castyou-backend/src/index.ts (~line 145) — start the email worker alongside the existing 3.
  • Edit: castyou-backend/src/config/index.ts — add RESEND_API_KEY (optional), EMAIL_FROM (default "CasTyou <noreply@castyou.com>"), APP_BASE_URL (deep-link CTAs).
  • Edit: castyou-backend/package.json — add resend.

Acceptance criteria:

  • Each template renders (unit test); a queued job in dev with no key logs instead of sending.
  • sendEmail called under an impersonation context sends nothing (regression test, per Epic 24 BE-IMPERSONATE-002).
  • A transient Resend failure retries (3×) then dead-letters without touching any notification row.

BE-EMAIL-002 — Channel layer over createNotification

Files:

  • Create: castyou-backend/src/services/notifications/registry.tsNotificationType → { category, channels: ('IN_APP'|'EMAIL')[], emailTemplate?, subject(payload) } for all 24 types. Parity guard test: every NotificationTypes value has a registry entry.
  • Edit: castyou-backend/src/services/notifications/index.ts — after the existing Notification.create, if the type declares EMAIL AND the user's prefs allow that category AND not impersonation, enqueue an email-send job (resolve the user's email + prefs from Prisma in one lookup). Keep fire-and-forget — email enqueue failures are logged, never thrown.

Acceptance criteria:

  • Firing an email-enabled type with prefs on enqueues exactly one email job; prefs off → zero jobs, in-app row still written; impersonation → neither channel fires.
  • The 7 typed helpers are unchanged and route email correctly through the registry.

BE-EMAIL-003 — User notification preferences (model + GraphQL + unsubscribe)

Files:

  • Edit: castyou-backend/prisma/schema.prismaUser.notificationPrefs Json? (per-category email toggles + emailEnabled master switch; null = all-on default). Requires prisma migrate.
  • Edit: castyou-backend/src/graphql/schema/index.tsNotificationPreferences type, EmailCategory enum, notificationPreferences query, updateNotificationPreferences(input) mutation.
  • Edit: castyou-backend/src/graphql/resolvers/notifications.ts — read/write the JSON field with category-key validation; add Zod schema in src/graphql/validation/index.ts.
  • Add a public, signed (LIVENESS_SECRET-style HMAC) unsubscribe mutation/route that flips the relevant category off without auth (CAN-SPAM hygiene); the link is embedded in every email footer.

Acceptance criteria:

  • Updating prefs persists and round-trips; a category toggled off suppresses that category's emails (integration test through BE-EMAIL-002).
  • The unsubscribe link flips exactly the right category for the right user without a session, and rejects tampered/expired signatures.

FE-EMAIL-001 — Notification preferences settings UI ("notifications manager")

Files:

  • Create: castyou-frontend/apps/app/src/pages/settings/NotificationSettingsPage.tsx — master "Email notifications" switch + per-category toggles, all from @castyou/design-system. Disabled categories grey out; ACCOUNT is shown as always-on (non-toggleable).
  • Create: castyou-frontend/apps/app/src/lib/queries/notificationPreferences.ts — query + mutation.
  • Edit: castyou-frontend/apps/app/src/lib/pageTitles.ts — register the route + pageTitle keys in en/pt/es.
  • Link from the existing Settings/Profile area.

Acceptance criteria:

  • Toggles load current state, save optimistically, and reflect after reload; the master switch disables the per-category toggles.
  • Tab title is localized in all 3 locales; every control comes from the design system.

TEST-EMAIL-001 — Email + preferences test coverage

Files:

  • Create: castyou-backend/src/services/email/__tests__/email.test.ts — template render + impersonation suppression.
  • Create: castyou-backend/src/services/notifications/__tests__/channels.test.ts — registry parity, prefs gating, enqueue-exactly-once.
  • Edit: castyou-frontend/apps/app/.../NotificationSettingsPage.test.tsx — toggle + optimistic-save behavior.

Acceptance criteria:

  • Suite proves: prefs off suppresses email, impersonation suppresses both channels, registry covers every type, and the settings UI persists changes.