Documentation
Email RoutingEmail Routing

Email Routing

Every transactional email that contains a link picks the host it points at based on the recipient's role and whether the org (or its program) has a custom domain configured. This page is the single source of truth for that mapping; update it whenever a new email path is added.

Host selection rules

  • Supplier-facing emails prefer the org's custom domain, then the program's custom domain, then fall back to my.<baseDomain>.
  • Advisor-facing and program-manager emails always use platform.<baseDomain>.
  • Unauthenticated signup flows on a custom host keep the link on whatever host the original request came from (via the request's Origin header).
  • .verified = true is required for a Domain row to be picked for an outbound link, because the recipient clicks the link later — unlike an inbound request that already reached us on the host (which proves DNS/cert are live). An unverified row might exist before DNS propagates and would hand users a dead link.

Helper functions

Two helpers power the selection:

  • getCustomDomainForOrganization(organizationId)packages/database/prisma/queries/domains.ts. Returns the verified org-level Domain, else the verified program-level Domain, else undefined. Used by paths that have an organizationId but no Request.
  • rewriteUrlForCustomDomain(url, request)packages/auth/lib/url-rewrite.ts. Rewrites the URL's hostname to the request's Origin if the Origin is a known Domain row. Used by auth paths that have a Request.

Routing table

#EmailSourceRecipient roleTriggerHost after routing
1Email verification (initial signup)packages/auth/auth.tsemailVerification.sendVerificationEmailsupplier / advisor inviteePOST /sign-up/email with sendOnSignUpRequest Origin if a known Domain → else advisor → platform.*, supplier → my.*
2Email verification ("Resend" button)packages/auth/plugins/firebase/index.ts/firebase/resendsame as #1user clicks Resend verification email on the signup screenSame as #1 via rewriteUrlForCustomDomain
3Email change confirmationpackages/auth/auth.tsuser.changeEmail.sendChangeEmailVerificationexisting usersettings → change emailRequest Origin (custom domain) → else my.*
4Magic linkpackages/auth/auth.tsmagicLink.sendMagicLinkexisting usermagic-link login flowRequest Origin (custom domain) → else my.*
5Forgot-password resetpackages/auth/auth.tssendResetPasswordexisting userforgot-password formRequest Origin — platform subdomain or custom domain → else my.*
6Organization invitation (in-app)packages/auth/auth.tsorganization.sendInvitationEmailinviteeorg admin invites someone from org settingsAdvisor org → platform.*. Supplier org → getCustomDomainForOrganization(org.id) → else my.*
7Supplier admin invite (AdminJS)packages/api/modules/admin/procedures/invite-supplier-admin.tsnew supplier adminadmin creates a supplier tenant in AdminJSgetCustomDomainForOrganization(tenantId) → else my.*
8Advisor admin invite (AdminJS)packages/api/modules/admin/procedures/advisor-orgs.tsinviteAdvisorAdminnew advisor adminadmin adds advisor to an orgHardcoded platform.*
9Program manager invitepackages/api/modules/programs/procedures/set-program-manager.tsnew program manageradmin grants PM roleIntended to be platform.* — see Known gaps below
10Assessment submitted ("we've received it")packages/database/prisma/queries/assessment.tssendEmailForAssessment("submitAssessment")supplier adminsupplier submits the onboarding assessmentgetCustomDomainForOrganization(org.id) → else my.*
11Onboarding approved ("your request has been approved")packages/database/prisma/queries/assessment.tssendEmailForAssessment("onboardingAssessmentApprove")supplier adminPM approves the onboarding assessmentgetCustomDomainForOrganization(org.id) → else my.*
12Onboarding rejectedsame file, onboardingAssessmentRejectsupplier adminPM rejects the onboarding assessmentNo link in body — no host to route
13Newsletter subscribe confirmationpackages/api/modules/newsletter/procedures/subscribe-to-newsletter.tssubscriberpublic newsletter formNo link in body
14Contact-form submissionpackages/api/modules/contact/procedures/submit-contact-form.tsNext Street adminpublic contact formNo link — internal admin notification

Role × host summary

RoleDefault hostCustom domain wins?
Supplier (admin or member)my.dev-demo-ssa.comYes — when the org (or its program) has a verified Domain row
Advisor (admin or member)platform.dev-demo-ssa.comNo — always platform.*
Program managerplatform.dev-demo-ssa.comNo — always platform.*
Unauthenticated signup on a custom hostthe custom host itselfYes — via the request Origin header

Known gaps

  • #9 set-program-manager still builds its invite link with getBaseUrl() (which resolves to my.*). Program manager invites should land on platform.* like the other PM flows. Swap the one call site to getPlatformBaseUrl() when addressing.
  • Firebase actionCodeSettings.url (packages/auth/plugins/firebase/index.ts after-hook) passes a URL into Firebase's native verification link generator that's still built from getBaseUrl(). Cosmetic today — we don't surface Firebase-generated links as the primary verification flow — but update if Firebase-native verification is re-enabled.

Adding a new email

When you add a new email path:

  1. Identify the recipient role (supplier / advisor / program manager / unauthenticated / platform-internal).
  2. Pick the rule from Host selection rules above — use getCustomDomainForOrganization when you have an organizationId but no Request, and rewriteUrlForCustomDomain when you have a Request.
  3. Add a row to the Routing table in this doc so the next person doesn't have to re-derive it.