Appearance
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 ofsendEmailcalls 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(sendEmailvia Resend; dev/no-key logs, prod refuses+retries, impersonation-silent) + HTML/text templates (shared layout, signed-unsubscribe footer) +workers/emailSender.tsBullMQemail-sendqueue (3 attempts, fail-open enqueue) started inindex.ts.services/notifications/registry.tsmaps all 29NotificationTypes →{category, channels, emailTemplate?, subject}(compile-time exhaustive viaRecord<NotificationType,…>+ runtime parity test);createNotificationnow 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?(migration20260619000000_epic39_notification_prefs) +notificationPreferencesquery /updateNotificationPreferencesmutation (6 categories; ACCOUNT always-on) + public signedunsubscribeFromEmailsmutation andGET /unsubscribe?token=one-click route. Frontend:NotificationSettingsPage(/settings/notifications, linked from Profile → Settings) with master + per-category DSSwitches (optimistic save),useNotificationPreferenceshook, i18n en/pt/es +pageTitle. Config:RESEND_API_KEY(optional),EMAIL_FROM,APP_BASE_URL,API_BASE_URL;resenddep 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; setRESEND_API_KEY+EMAIL_FROM+APP_BASE_URL/API_BASE_URLin prod. Unblocks Epic 48 (off-platform job claim invite email). FE-FLIER-002'ssendFlierEmailcan now use thegenerictemplate.
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.ts—sendEmail({ to, subject, template, data, ctx? }); returns silently whenctx.impersonationis present (Epic 24). In prod, refuses (logs + throws to the worker's retry) ifRESEND_API_KEYis 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'; mirrorsworkers/moderationScanner.ts(fire-and-forget enqueue, fail-open on Redis outage, 3 attempts + exponential backoff 10s). Actualresend.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— addRESEND_API_KEY(optional),EMAIL_FROM(default"CasTyou <noreply@castyou.com>"),APP_BASE_URL(deep-link CTAs). - Edit:
castyou-backend/package.json— addresend.
Acceptance criteria:
- Each template renders (unit test); a queued job in dev with no key logs instead of sending.
sendEmailcalled 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.ts—NotificationType → { category, channels: ('IN_APP'|'EMAIL')[], emailTemplate?, subject(payload) }for all 24 types. Parity guard test: everyNotificationTypesvalue has a registry entry. - Edit:
castyou-backend/src/services/notifications/index.ts— after the existingNotification.create, if the type declaresEMAILAND the user's prefs allow that category AND not impersonation, enqueue anemail-sendjob (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.prisma—User.notificationPrefs Json?(per-category email toggles +emailEnabledmaster switch;null= all-on default). Requiresprisma migrate. - Edit:
castyou-backend/src/graphql/schema/index.ts—NotificationPreferencestype,EmailCategoryenum,notificationPreferencesquery,updateNotificationPreferences(input)mutation. - Edit:
castyou-backend/src/graphql/resolvers/notifications.ts— read/write the JSON field with category-key validation; add Zod schema insrc/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;ACCOUNTis 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 +pageTitlekeys 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.