Tenant-Branded Emails
How LearnCard resolves the active tenant on every request and renders branded, version-controlled transactional emails end-to-end
LearnCard runs multiple products (LearnCard, VetPass, and partner tenants) from the same backend services. Every transactional email — login OTP, recovery key, inbox claim, endorsement request, guardian approval — is rendered at send time with the active tenant's branding so each product feels native, not like a generic LearnCard notification with a partner logo glued on.
This page explains how that works end-to-end. For operator-facing setup, see Configure Tenant-Branded Emails.
The three moving parts
The client (web or native) tells the server which tenant it's acting on behalf of via the
X-Tenant-Idrequest header.The backend service (
lca-apiorbrain-service) resolves that header into aResolvedTenantincreateContext, and passes itsemailBrandinginto everydeliveryService.send()call.@learncard/email-templatesrenders a React Email template with the branding applied, returns{ html, text, subject }, and the service hands the raw HTML to Postmark.
Postmark's template engine is not used for any email that has a local template — it's only a fallback for legacy aliases that haven't been migrated yet.
Tenant resolution (server-side)
Every request to lca-api and brain-service runs through resolveTenantFromRequest(headers) in @learncard/email-templates:
The resolution order is deliberate:
X-Tenant-Idis explicit and wins over everything else. Native apps and any SSS client withtenantIdconfigured always send it.Origin/Referercovers web browsers, which send the page origin but can't inject custom headers for all requests. Hostnames are looked up inORIGIN_MAPwith progressive subdomain stripping —alpha.vetpass.appmatches thevetpass.appentry.DEFAULT_TENANT_IDenv var is for server-to-server callers: cron jobs, webhooks, and per-tenant deploys that have no meaningful request headers.'learncard'is the final fallback.
resolveTenantFromRequest() returns a ResolvedTenant:
In lca-api, this is attached to ctx.tenant in @/services/learn-card-network/lca-api/src/routes/index.ts. In brain-service, the same pattern.
The branding registry
Tenant branding overrides live in @/packages/email-templates/src/tenant-registry.ts:
Every field is optional. resolveBranding(partial) in @/packages/email-templates/src/branding.ts fills in any missing fields from DEFAULT_BRANDING, so an unknown tenant renders as LearnCard rather than breaking.
This registry is the single source of truth for server-side branding today. The email section of apps/learn-card-app/environments/<tenant>/config.json is validated by tenantEmailConfigSchema but is not yet wired into the backend — tenants register branding via a PR to the package. Future work will let the config drive the registry.
Render pipeline
Once ctx.tenant.emailBranding reaches deliveryService.send(), the PostmarkAdapter:
Key points:
Sentinel aliases (
'recovery-key','recovery-email-code','login-verification-code', etc.) are pre-registered inLOCAL_TEMPLATE_ALIASESso callers can pass them directly without needing an env var override. This is the default path.Heuristic matching kicks in when a caller passes a real Postmark alias (e.g. from
POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS) and the adapter infers the local template from thetemplateModelshape.When local render fails for a sentinel, the adapter re-throws rather than falling through to
sendEmailWithTemplate, because the sentinel alias doesn't exist in Postmark. You see the real rendering error instead of a confusing "template not found" from Postmark.Fallback to Postmark's template engine is reserved for legacy aliases that haven't been migrated yet. All currently-sent emails go through local rendering.
Template IDs
Every transactional email has a stable local template ID that routes use as its templateAlias:
login-verification-code
lca-api
VerificationCode (login variant)
recovery-email-code
lca-api
VerificationCode (recovery variant)
recovery-key
lca-api
RecoveryKey
endorsement-request
lca-api
EndorsementRequest
embed-email-verification
brain-service
VerificationCode (embed variant)
contact-method-verification
brain-service
EmailVerification (link-based)
inbox-claim
brain-service
InboxClaim
guardian-approval
brain-service
GuardianApproval
guardian-email-otp
brain-service
VerificationCode (guardian variant)
guardian-credential-approval
brain-service
GuardianCredentialApproval
guardian-approved-claim
brain-service
GuardianApprovedClaim
guardian-rejected-credential
brain-service
GuardianRejectedCredential
credential-awaiting-guardian
brain-service
CredentialAwaitingGuardian
account-approved
brain-service
AccountApproved
The full catalog, plus legacy Postmark alias mappings, lives in the @learncard/email-templates README.
SSS strategy & the X-Tenant-Id header
X-Tenant-Id headerClient-side, createSSSStrategy({ tenantId }) in @learncard/sss-key-manager forwards X-Tenant-Id on every request to lca-api. This is what ensures recovery-email OTPs and recovery-key emails are branded for the tenant the user is signed into — even before any session cookie or Origin hint is established.
In learn-card-app, tenantId is resolved at SSS factory construction time from getResolvedTenantConfig().tenantId:
Integrators embedding the SSS client directly pass tenantId themselves.
What this means for callers
If you're writing a route handler that sends email:
Always pass
branding: ctx.tenant?.emailBrandingtodeliveryService.send(). Forget this and your email renders as LearnCard regardless of the caller's tenant.Always pass
from: getFrom({ mailbox, branding: ctx.tenant?.emailBranding })so the sender domain matches the tenant.Use the local template ID as
templateAlias(e.g.'recovery-key'). Env-var overrides exist but are not required for any current template.Do not write plain-text fallbacks. The adapter always renders locally; fallback branches are dead code.
If you're writing a client that talks to lca-api or brain-service:
Send
X-Tenant-Id: <tenant>on every request, or rely onOriginif you're in a browser on a mapped hostname.Don't assume
'learncard'— if you're building for a specific tenant, be explicit.
What this doesn't solve (yet)
config.json→TENANT_EMAIL_BRANDINGwiring. The schema field exists; the backend wiring does not. Today, adding a tenant requires a PR to@learncard/email-templates.Scouts. The scouts app has no tenant config system, so it doesn't send
X-Tenant-Id. Emails sent on behalf of scouts users use LearnCard defaults. This is intentional — scouts is single-tenant.Per-user branding overrides. Branding is resolved per-tenant, not per-user. A user who claims a VetPass credential while signed into LearnCard gets LearnCard branding on the resulting notification, because the request carries the LearnCard tenant header.
Related
Configure Tenant-Branded Emails — operator-facing how-to
SSS Key Management Configuration — env vars for deployers
@learncard/email-templatesREADME — package-level reference@learncard/sss-key-manager— client-side SSS +tenantId
Last updated
Was this helpful?