Product documentation
Customize transactional notification emails
Per-tenant (and per-locale) customization of identity-service's transactional emails — the magic-link, invitation, password-reset, security-alert, account-unlock, email-verification, and organization-invitation messages — through the customer-plane login-experience API, with an SSTI-safe placeholder allowlist and a locale fallback chain, all grounded in the shipped code.
Customize transactional notification emails
Thoryn's own identity provider (identity-service) sends a small set of transactional emails — the sign-in magic link, a user invitation, a password-reset link, a security alert, an account-unlock link, and an email-verification link. Out of the box each renders from a built-in template. A tenant admin can override the subject and HTML body of any of these on a per-tenant basis, and — since a recipient may prefer a different language — per locale as well.
A customization is stored as text and rendered by a restricted
{{variableName}} substitution, never by a full template engine, so a tenant
can style and reword the email without opening a server-side template-injection
hole (see Security notes). When a tenant has not customized a
given email, the sender falls back to the built-in default unchanged — so every
tenant keeps working until it opts in.
These emails come from Thoryn's identity-service — the messages it sends for the local (password / magic-link) accounts it hosts. They are unrelated to how a federated sign-in behaves: when a tenant's users authenticate through an external IdP (Entra ID, Okta, Google, …), that provider sends its own mail. See Add a federation member for the federation model.
Which emails are customizable
Exactly eight transactional email types can be customized today. Each type has a
stable wire code (used on the API path and stored in the database) and a
closed allowlist of {{variable}} placeholders its body may reference.
| Wire code | Sent when | Placeholders (the closed allowlist) | |
|---|---|---|---|
| Magic-link sign-in | magic_link | A user requests a passwordless sign-in link | {{magicLinkUrl}}, {{displayName}}, {{expiryMinutes}} |
| User invitation | invitation | An admin invites a user to an organization | {{acceptUrl}}, {{inviterName}}, {{orgName}}, {{role}}, {{expiryDays}} |
| Password reset | password_reset | A user requests a password reset | {{resetUrl}}, {{displayName}}, {{expiryMinutes}} |
| Security alert | security_alert | A refresh-token-theft (replay) event fires | {{displayName}}, {{clientId}}, {{ipAddress}}, {{userAgent}}, {{timestamp}}, {{action}}, {{secureAccountUrl}} |
| Account unlock | account_unlock | A locked-out user requests a self-service unlock link | {{unlockUrl}}, {{displayName}}, {{expiryMinutes}} |
| Email verification | email_verification | A new user registers (or requests a resend) and must confirm their address | {{verificationUrl}}, {{displayName}}, {{expiryHours}} |
| Organization invitation | organization_invitation | A tenant admin invites someone to a B2B organization (SSO-2261) | {{acceptUrl}}, {{inviterName}}, {{orgName}}, {{expiryDays}} |
| Co-admin invitation | co_admin_invitation | A tenant admin invites someone to help administer the workspace (SSO-2441) | {{acceptUrl}}, {{inviterName}}, {{expiryDays}} |
The allowlist is the exact set of variables the corresponding built-in template already binds, so a tenant can reproduce the default and then tweak it. A body that references any name outside its type's allowlist is rejected at save time — see Template variables.
Not customizable through this store. Other identity-service emails — for example new-country-login and the account-deletion lifecycle notices — render only from their built-in templates today; they are not wired to the per-tenant store, so there is no override path for them. Only the seven types above are customizable.
How a send resolves a template
When one of these senders builds an email, it asks the template store for the most specific override for the recipient, and falls back step by step:
- Tenant override for (type, recipient's language) — e.g. a
nlmagic-link template for a Dutch-speaking recipient; - Tenant base override for (type,
*) — the tenant's locale-independent customization; - Built-in default, localized to the recipient — the classpath template rendered with the recipient's language when a bundle ships for it (English and Dutch ship today);
- Built-in default, base English — the final fallback.
The recipient's language is resolved on the request thread from the user's
stored locale first, then the request's Accept-Language (constrained to
the shipped en / nl set), then English. Resolution never throws: if the
override lookup hits a transient database error it is treated as "no override"
and the send degrades to the built-in default rather than dropping the email.
Customize a template (tenant admin)
Tenant admins manage templates through the standard customer plane — the same console → BFF → api-gateway → product-api path used for the rest of the login-experience surface (branding, social connectors, login analytics). The product-api facade forwards each call to identity-service, which owns the storage and all validation.
All calls carry a bearer token minted for the tenant; the tenant comes from the
token's tnt claim, so a tenant only ever reads and writes its own templates.
1. Read the current override
GET /api/v1/login-experience/email-templates/{type}
Authorization: Bearer <token with tenant:idp.read>{type} is one of the wire codes in the table above (e.g. magic_link). If the
tenant has customized that template, the response is the stored subject and body
plus the allowlist the console renders as editing hints:
{
"templateType": "magic_link",
"locale": "*",
"subject": "Sign in to {{displayName}}'s workspace",
"bodyHtml": "<p>Hi {{displayName}}, <a href=\"{{magicLinkUrl}}\">sign in</a> — expires in {{expiryMinutes}} minutes.</p>",
"allowedVariables": ["displayName", "expiryMinutes", "magicLinkUrl"],
"updatedAt": "2026-07-20T10:15:30"
}When the tenant has not customized that template, the response is
404 template_not_customized — this is expected and simply means the built-in
default is in use. The 404 body still carries the allowedVariables extension
so an editor can pre-populate the placeholder list for a first-time edit.
2. Create or replace an override
PUT the new subject and HTML body. This is an upsert — the same call
creates the override the first time and replaces it on every subsequent edit.
PUT /api/v1/login-experience/email-templates/{type}
Authorization: Bearer <token with tenant:idp.write>
Content-Type: application/json
{
"subject": "Your sign-in link for {{displayName}}",
"bodyHtml": "<p>Hi {{displayName}},</p><p><a href=\"{{magicLinkUrl}}\">Sign in</a>. This link expires in {{expiryMinutes}} minutes.</p>"
}A successful save returns 200 with the stored template (the same shape as the
GET). Validation failures are RFC 9457 problem-details (see
Troubleshooting).
3. Customize per locale
Add a locale query parameter to address a language-specific variant. Omitting
locale addresses the tenant's base template (stored under the *
sentinel), which matches every recipient one fallback step after a
language-specific row.
PUT /api/v1/login-experience/email-templates/{type}?locale=nl
Authorization: Bearer <token with tenant:idp.write>
Content-Type: application/json
{
"subject": "Je inloglink",
"bodyHtml": "<p>Hallo {{displayName}}, <a href=\"{{magicLinkUrl}}\">log in</a>.</p>"
}locale must be a 2–3 letter ISO 639 language code (case-insensitive, stored
lowercase — e.g. en, nl, de), or omitted / * for the base template.
Anything else is rejected 400 invalid_template_locale. You may store a
template for a language the platform ships no bundle for — the tenant override is
used directly; only the built-in fallback is limited to the shipped en / nl
set.
4. Revert to the built-in default
DELETE removes the override so the built-in template applies again. There is
no "restore the default" write — the default is the absence of an override,
so deleting is exactly reverting, and the next email sends the built-in copy.
The scope is the same (type, locale) cell GET and PUT address, so a
revert can never quietly drop a locale you did not name:
DELETE /api/v1/login-experience/email-templates/{type} # the base template only
DELETE /api/v1/login-experience/email-templates/{type}?locale=nl # the Dutch variant only
DELETE /api/v1/login-experience/email-templates/{type}?allLocales=true # every locale for this type
Authorization: Bearer <token with tenant:idp.write>locale and allLocales name different scopes and cannot be combined —
sending both is rejected 400 conflicting_reset_scope rather than resolved by a
silent precedence rule.
The response is always 200 and reports both what it cleared and what it left
alone:
{
"templateType": "magic_link",
"allLocales": false,
"resetLocales": ["*"],
"remainingLocales": ["nl"]
}resetLocales— the cells that now render the built-in default.remainingLocales— the cells that still carry an override. After the call above, Dutch recipients keep the tenant's Dutch template; everyone else gets the built-in default.
The call is idempotent: reverting a template that is already on the default
returns 200 with an empty resetLocales, not a 404. Reverting is a
desired-state operation — "this tenant uses the platform default here" — and
that state holds whether or not an override existed, so a retry is safe and no
read-before-delete is needed.
Reverting is tenant-scoped like every other call on this surface: the override
addressed is always the caller's own, resolved from the tnt claim (or the
{tenantId} path segment on the internal facade). One tenant's revert never
touches another's template.
Endpoint reference
The customer-plane surface (below) is what tenant admins use. It is a thin proxy over identity-service's internal facade, which in turn shares one service and one store with the operator-plane surface — so the wire shapes and error codes are identical across all three.
Customer plane (tenant admin)
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/login-experience/email-templates/{type} | tenant:idp.read | Read the tenant's override for {type} (optional ?locale=). 404 template_not_customized when not customized. |
PUT | /api/v1/login-experience/email-templates/{type} | tenant:idp.write | Create or replace the override (optional ?locale=). Returns 200 with the stored template. |
DELETE | /api/v1/login-experience/email-templates/{type} | tenant:idp.write | Revert to the built-in default (optional ?locale=, or ?allLocales=true). Returns 200 with resetLocales + remainingLocales; idempotent. |
Internal facade + operator plane
These share the same EmailTemplateService and the same tenant_email_template
store as the customer-plane path; they exist for the product-api facade and for
Thoryn operator tooling respectively. Both require SCOPE_admin on a
hub-validated JWT.
| Method | Path | Scope | Description |
|---|---|---|---|
GET / PUT / DELETE | /internal/tenants/{tenantId}/email-templates/{type} | SCOPE_admin | Tenant-parameterized facade endpoint product-api calls; {tenantId} is the sole tenant input. |
GET / PUT / DELETE | /admin/email-templates/{type} | SCOPE_admin | Operator-plane surface; the tenant comes from the admin token's tenant_id claim. |
Every path accepts the optional ?locale= query parameter with identical
semantics, and DELETE additionally accepts ?allLocales=true.
Template variables
Each template type has a closed allowlist of placeholders — the exact set the
built-in template binds. Reference them with double braces: {{unlockUrl}},
{{displayName}}, and so on.
| Wire code | Allowed placeholders |
|---|---|
magic_link | {{magicLinkUrl}} · {{displayName}} · {{expiryMinutes}} |
invitation | {{acceptUrl}} · {{inviterName}} · {{orgName}} · {{role}} · {{expiryDays}} |
password_reset | {{resetUrl}} · {{displayName}} · {{expiryMinutes}} |
security_alert | {{displayName}} · {{clientId}} · {{ipAddress}} · {{userAgent}} · {{timestamp}} · {{action}} · {{secureAccountUrl}} |
account_unlock | {{unlockUrl}} · {{displayName}} · {{expiryMinutes}} |
email_verification | {{verificationUrl}} · {{displayName}} · {{expiryHours}} |
organization_invitation | {{acceptUrl}} · {{inviterName}} · {{orgName}} · {{expiryDays}} |
co_admin_invitation | {{acceptUrl}} · {{inviterName}} · {{expiryDays}} |
Rules the renderer enforces:
- Only
{{name}}is recognized, wherenameis a plain identifier ([A-Za-z][A-Za-z0-9]*). There are no dots, calls, or operators —{{user.email}},{{beans.foo()}}, and{{ 1+1 }}are not placeholders and pass through as inert text. - Every other templating syntax is inert.
${...},#{...},*{...},[[...]],<th:...>, and similar forms are copied through verbatim — never evaluated. This is what makes the store safe to expose to tenants. - Substituted values are HTML-escaped, so a value like
<script>renders as harmless text. - An unknown or unset placeholder renders empty. A
{{name}}outside the allowlist (or allowlisted but not supplied for this send) becomes the empty string — it is never echoed back. The save-time check below is the primary guard; empty-render is defence in depth. - Save-time rejection. If a subject or body references any placeholder
outside the type's allowlist, the
PUTis rejected400 unknown_template_variable, and the response lists both the allowed set and the offending names so the editor can correct the typo.
Configuration
Tenant admin (managed and self-managed)
Templates are customized entirely through the API above — no deployment change is required. This is the recommended path for both the managed SaaS and self-managed installs.
Operator / self-managed platform defaults
The store itself has two operator-tunable size caps, enforced in the application
layer (the subject / body_html columns are TEXT, so the cap gives a
structured error instead of an opaque SQL failure). The values below are the
built-in defaults; override per environment via application-<profile>.yml or
the matching env vars.
oauthy:
identity:
# Size caps for a tenant-supplied override (SSO-1898).
email-templates:
max-subject-chars: 998 # RFC 5322 line-length ceiling for the subject header
max-body-bytes: 65536 # 64 KiB HTML body cap
# Public origin of this identity-service. The links embedded in these emails
# (magic-link, reset, unlock, "secure my account") are built from this —
# never from request headers (the Host-header-poisoning rule). Required at
# deploy time; the localhost default is dev-only.
public-base-url: ${OAUTHY_IDENTITY_PUBLIC_BASE_URL:http://localhost:9100}Notes:
- Oversize input returns
413(template_subject_too_large/template_body_too_large) with the actual size in the sanitized detail — it never reaches the database as a truncation or an opaque error. - The From address and SMTP transport are configured separately from the template body and are not part of a template override — a customization changes only the subject and the HTML body.
Per-locale behaviour
The locale dimension (migration V50) lets a tenant store one row per (type,
language) plus a base row under the * sentinel. The resolution order for an
outbound email is:
tenant (type, recipient-language) → tenant (type, "*")
→ built-in default (recipient-language) → built-in default (English)
Key points, all grounded in the resolver:
- The base template is stored under
*, not NULL. A sentinel is used so the(tenant, type, locale)uniqueness constraint holds — two NULL base rows could otherwise coexist and silently fork the base template. - Existing customizations keep working. Overrides created before the locale
dimension were backfilled to
locale = *, so they still match every recipient one step after any language-specific row. - Stored locale is not restricted to the shipped set. A tenant may store, say,
a
detemplate even though the platform ships onlyen/nlbuilt-in bundles; the tenant override is used directly. The shipped-set constraint only bounds which language the built-in fallback localizes to. - Recipient language comes from the user's stored locale first, then the
request
Accept-Language(narrowed toen/nl), then English — resolved on the request thread before the async send.
Troubleshooting
GET returns 404 template_not_customized. Expected when the tenant has not
customized that (type, locale) cell — the built-in default is in use. This is not
an error; the 404 body carries allowedVariables so an editor can start from
the placeholder list.
400 unknown_template_type. The {type} path segment is not one of the eight
wire codes. The response lists the valid set (magic_link, invitation,
password_reset, security_alert, account_unlock, email_verification,
organization_invitation, co_admin_invitation) in its allowedTypes extension.
400 invalid_template_locale. The ?locale= value is not a 2–3 letter ISO
639 code. Use e.g. en or nl, or omit locale for the base template. On a
DELETE this is deliberately an error rather than a silent no-op, so a typo can
never look like a successful revert.
400 conflicting_reset_scope on DELETE. ?locale= and ?allLocales=true
were sent together. Pick one: a locale names a single cell, allLocales names
every cell for the type.
DELETE returned 200 with an empty resetLocales. That cell already used
the built-in default — the revert is idempotent, so this is success, not a
no-such-template error. Check remainingLocales if you expected another locale
to be cleared too.
I reverted one locale and another language still shows custom copy. Expected
— a revert only clears the cell you addressed. remainingLocales in the response
lists every override still standing; pass ?allLocales=true to clear them all.
400 unknown_template_variable. The subject or body references a {{name}}
that is not on the type's allowlist (often a typo, e.g. {{userName}} instead of
{{displayName}}). The response's allowedVariables and unknownVariables
extensions name the allowed set and the offending names.
413 on save. The subject exceeds 998 characters or the body exceeds 64 KiB.
Trim the content or raise the operator cap.
My {{placeholder}} renders as blank in the delivered email. The name is
outside the allowlist, or was not supplied for that send — an unknown/unset
placeholder renders empty by design. Check the spelling against the allowlist for
that type.
My ${...} / <th:...> shows up literally in the email. Correct: only
{{name}} is substituted. Every other templating syntax is inert text — this is
the SSTI defence, not a bug.
A tenant edit isn't reflected in the sent email. Confirm the recipient's
resolved language: a nl recipient with only an en override falls through to
the tenant base (*) or the built-in default. Store the override under the base
locale (omit locale) to cover every recipient, or add a row for the specific
language.
503 facade_not_configured. The customer-plane login-experience facade is
not provisioned on this deployment (the service-account token is absent). This is
a deployment/config issue, not a template error.
Security notes
- No server-side template injection. Tenant-supplied text is rendered by a
restricted
{{name}}substitution — never fed to Thymeleaf / SpEL / FreeMarker or any expression engine. Expression forms (${...},#{...},*{...},[[...]],<th:...>) are structurally invisible to the renderer and pass through as inert text, so a malicious body cannot read Spring beans, the environment, or execute code inside identity-service. - Values are HTML-escaped, and only allowlisted names substitute. Each
template type has a closed placeholder allowlist; a body referencing anything
outside it is rejected at save time, and any stray placeholder renders empty at
send time. Substituted values are HTML-escaped, so a value such as
<script>is neutralized. - Tenant-isolated. Every read and write is scoped to the caller's tenant —
the
tntclaim on the customer-plane path, the{tenantId}segment on the internal facade, thetenant_idclaim on the operator path. There is one row per (tenant, type, locale); a tenant can never see or overwrite another tenant's template. - Links come from configuration, not request headers. The URLs embedded in
these emails (
{{magicLinkUrl}},{{resetUrl}},{{unlockUrl}},{{secureAccountUrl}}) are built from the configuredoauthy.identity.public-base-url, never from theHost:header — closing the Host-header-poisoning account-takeover class. - Fails safe on a storage blip. Override lookup on the send path never throws: a transient database error degrades to the built-in default rather than dropping a security-relevant email (unlock, security alert).
- Sanitized errors. All validation failures are RFC 9457 problem-details with
a machine-readable
errorCodeand a sanitizeddetail— no tenant ids and no template contents are echoed back. - Deliverability is configured separately. The From address and the SMTP transport are not part of a template override; customizing a template changes only the subject and the HTML body.
Related surfaces: the account-unlock and security-alert emails themselves are covered by Account lockout, self-service unlock, and login analytics; the branding of the hosted login page shares the same login-experience facade.