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
Originheader). .verified = trueis required for aDomainrow 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, elseundefined. Used by paths that have anorganizationIdbut noRequest.rewriteUrlForCustomDomain(url, request)—packages/auth/lib/url-rewrite.ts. Rewrites the URL's hostname to the request'sOriginif the Origin is a known Domain row. Used by auth paths that have aRequest.
Routing table
| # | Source | Recipient role | Trigger | Host after routing | |
|---|---|---|---|---|---|
| 1 | Email verification (initial signup) | packages/auth/auth.ts → emailVerification.sendVerificationEmail | supplier / advisor invitee | POST /sign-up/email with sendOnSignUp | Request Origin if a known Domain → else advisor → platform.*, supplier → my.* |
| 2 | Email verification ("Resend" button) | packages/auth/plugins/firebase/index.ts → /firebase/resend | same as #1 | user clicks Resend verification email on the signup screen | Same as #1 via rewriteUrlForCustomDomain |
| 3 | Email change confirmation | packages/auth/auth.ts → user.changeEmail.sendChangeEmailVerification | existing user | settings → change email | Request Origin (custom domain) → else my.* |
| 4 | Magic link | packages/auth/auth.ts → magicLink.sendMagicLink | existing user | magic-link login flow | Request Origin (custom domain) → else my.* |
| 5 | Forgot-password reset | packages/auth/auth.ts → sendResetPassword | existing user | forgot-password form | Request Origin — platform subdomain or custom domain → else my.* |
| 6 | Organization invitation (in-app) | packages/auth/auth.ts → organization.sendInvitationEmail | invitee | org admin invites someone from org settings | Advisor org → platform.*. Supplier org → getCustomDomainForOrganization(org.id) → else my.* |
| 7 | Supplier admin invite (AdminJS) | packages/api/modules/admin/procedures/invite-supplier-admin.ts | new supplier admin | admin creates a supplier tenant in AdminJS | getCustomDomainForOrganization(tenantId) → else my.* |
| 8 | Advisor admin invite (AdminJS) | packages/api/modules/admin/procedures/advisor-orgs.ts → inviteAdvisorAdmin | new advisor admin | admin adds advisor to an org | Hardcoded platform.* |
| 9 | Program manager invite | packages/api/modules/programs/procedures/set-program-manager.ts | new program manager | admin grants PM role | Intended to be platform.* — see Known gaps below |
| 10 | Assessment submitted ("we've received it") | packages/database/prisma/queries/assessment.ts → sendEmailForAssessment("submitAssessment") | supplier admin | supplier submits the onboarding assessment | getCustomDomainForOrganization(org.id) → else my.* |
| 11 | Onboarding approved ("your request has been approved") | packages/database/prisma/queries/assessment.ts → sendEmailForAssessment("onboardingAssessmentApprove") | supplier admin | PM approves the onboarding assessment | getCustomDomainForOrganization(org.id) → else my.* |
| 12 | Onboarding rejected | same file, onboardingAssessmentReject | supplier admin | PM rejects the onboarding assessment | No link in body — no host to route |
| 13 | Newsletter subscribe confirmation | packages/api/modules/newsletter/procedures/subscribe-to-newsletter.ts | subscriber | public newsletter form | No link in body |
| 14 | Contact-form submission | packages/api/modules/contact/procedures/submit-contact-form.ts | Next Street admin | public contact form | No link — internal admin notification |
Role × host summary
| Role | Default host | Custom domain wins? |
|---|---|---|
| Supplier (admin or member) | my.dev-demo-ssa.com | Yes — when the org (or its program) has a verified Domain row |
| Advisor (admin or member) | platform.dev-demo-ssa.com | No — always platform.* |
| Program manager | platform.dev-demo-ssa.com | No — always platform.* |
| Unauthenticated signup on a custom host | the custom host itself | Yes — via the request Origin header |
Known gaps
- #9
set-program-managerstill builds its invite link withgetBaseUrl()(which resolves tomy.*). Program manager invites should land onplatform.*like the other PM flows. Swap the one call site togetPlatformBaseUrl()when addressing. - Firebase
actionCodeSettings.url(packages/auth/plugins/firebase/index.tsafter-hook) passes a URL into Firebase's native verification link generator that's still built fromgetBaseUrl(). 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:
- Identify the recipient role (supplier / advisor / program manager / unauthenticated / platform-internal).
- Pick the rule from Host selection rules above — use
getCustomDomainForOrganizationwhen you have anorganizationIdbut noRequest, andrewriteUrlForCustomDomainwhen you have aRequest. - Add a row to the Routing table in this doc so the next person doesn't have to re-derive it.