Email templates
Five outbound emails are sent by the server: verification, invite (admin-created user "set your password"), password reset, 2FA login code, and the new-sign-in alert (only when NUXT_SUSPICIOUS_LOGIN_ALERTS is enabled). Each has three overridable pieces (subject, HTML body, text body) plus a shared HTML/text layout that wraps every email. Built-in templates ship as fallbacks; override only the pieces you want to change.
The built-ins are translated: English, German, French and Italian. They live in the email subtree of the shared i18n catalogue (app/i18n/<locale>.ts) — the same file as that locale's UI strings, so a locale is added in one place. The locale used is the one already resolved for the recipient (see Multilingual), region subtags stripped — de-CH gets the German copy. Any other locale gets the English built-in unless you supply NUXT_EMAIL_*_<LOCALE> overrides.
The built-in design
The defaults are deliberately restrained — editorial rather than decorated: a plain white ground with no card or shadow, left-aligned copy with generous spacing, the app name as a small letterspaced wordmark above a hairline rule, and one filled button per email. is used only on that button, so any brand colour stays legible; the button's label flips between white and near-black automatically depending on how light the colour is.
They are also written for how mail clients actually behave, which is worth knowing if you replace the layout:
- Table-based, with a
width="600"attribute as well asmax-width. Outlook renders through the Word engine and ignoresmax-widthon a block element — a<div>-based layout sprawls to the full window there. - Every declaration is inline. Some clients strip
<style>entirely, which turns a stylesheet-based design into unstyled text. The<style>block in the built-ins carries only media queries, so losing it degrades to the plain light design rather than to nothing. - The button has a VML fallback inside
<!--[if mso]>, carrying its ownhref— Outlook does not inherit it from the anchor. - A hidden preheader sets the inbox preview line, with
mso-hide:allso Outlook doesn't print it at the top of the message. - Dark mode via
prefers-color-scheme, with every declaration marked!important— an inline style outranks a stylesheet selector, so without it the overrides look right in the source and do nothing.
Preview any of them, in any configured language, on the admin Email & SMTP page.
Placeholders
Substituted literally — unknown placeholders render as-is (loud is better than silent).
| Placeholder | Where it's available | Value |
|---|---|---|
| all templates | NUXT_PUBLIC_THEME_NAME |
| all templates | NUXT_PUBLIC_THEME_PRIMARY_COLOR (built-ins use it on the button only) |
| all templates | NUXT_PUBLIC_THEME_LOGO_URL, made absolute — empty unless you set it; see Logo in email |
| all templates | the image the header should show: your logo, or the bundled mark when you have customised no branding; empty means "use the name as text" |
| body + layout | recipient's first name |
| verification + invite + password-reset + new-sign-in bodies | action link (account page for new-sign-in) |
| login-code body | 6-digit 2FA code |
| new-sign-in body | IP, User-Agent and time of the flagged login |
| layout only | already-substituted body |
Logo in email
The built-in layout shows a mark in the header, following the same rule the login page uses:
| Your configuration | What the email header shows |
|---|---|
| Nothing customised | the bundled Schleuse mark |
NUXT_PUBLIC_THEME_LOGO_URL set | your logo |
NUXT_PUBLIC_THEME_NAME set, no logo | your name, as text |
The third row is deliberate: Schleuse will not put its own logo above your brand, in email any more than on the login page.
Relative paths are resolved for you
A mail client has no origin to resolve a path against, so <img src="/brand.png"> is a broken image in every inbox. Schleuse therefore resolves a relative NUXT_PUBLIC_THEME_LOGO_URL against NUXT_APP_URL before putting it in the mail — so a self-hosted asset works:
NUXT_APP_URL=https://id.example.com
NUXT_PUBLIC_THEME_LOGO_URL=/brand.png # sent as https://id.example.com/brand.pngAn absolute URL is passed through untouched, and is still the more portable choice if your logo lives on a CDN:
NUXT_PUBLIC_THEME_LOGO_URL=https://cdn.example.com/logo.pngUse a PNG or JPEG, not an SVG
Gmail and Outlook do not render SVG images at all. This is why the bundled mark ships in two files — the login page loads /logo.svg, the email loads /logo.png — and why your own logo should be a raster image if you want it seen in an inbox. The same URL is used on the login pages either way; if you would rather keep the text brand in email while showing a mark in the app, that is not currently separable — one variable drives both.
Whatever you do, most clients block remote images
Gmail, Outlook and Apple Mail all block remote images until the recipient asks for them. A logo in email is what a recipient can see, not what they always see — which is why the alt text carries your app name, and why nothing in the email depends on the image loading.
Custom layouts are unaffected: still means "the value you configured" and stays empty when you have set none, so a layout that renders <img src=""> never inherits the Schleuse mark.
is the resolved image URL — your logo, or the bundled mark when you have customised nothing. It is empty in the third case above (custom name, no logo), where the built-in layout falls back to the app name as text. A custom layout has to handle that case itself, or it will render an empty <img src=""> where the built-in would have shown your name.
Env vars
All optional. Each is independent — you can override just the subject of one email and leave everything else as default.
Layout (wraps every email)
NUXT_EMAIL_LAYOUT_HTML # must contain {{slot}}
NUXT_EMAIL_LAYOUT_TEXT # must contain {{slot}}Layout vars must contain . If set without it, the container exits at startup with a clear error.
Per-email
NUXT_EMAIL_VERIFICATION_SUBJECT
NUXT_EMAIL_VERIFICATION_BODY_HTML
NUXT_EMAIL_VERIFICATION_BODY_TEXT
NUXT_EMAIL_INVITE_SUBJECT
NUXT_EMAIL_INVITE_BODY_HTML
NUXT_EMAIL_INVITE_BODY_TEXT
NUXT_EMAIL_PASSWORD_RESET_SUBJECT
NUXT_EMAIL_PASSWORD_RESET_BODY_HTML
NUXT_EMAIL_PASSWORD_RESET_BODY_TEXT
NUXT_EMAIL_LOGIN_CODE_SUBJECT
NUXT_EMAIL_LOGIN_CODE_BODY_HTML
NUXT_EMAIL_LOGIN_CODE_BODY_TEXT
NUXT_EMAIL_MAGIC_LINK_SUBJECT # sign-in link; vars: firstName, url
NUXT_EMAIL_MAGIC_LINK_BODY_HTML
NUXT_EMAIL_MAGIC_LINK_BODY_TEXT
NUXT_EMAIL_NEW_LOGIN_SUBJECT # new-sign-in alert; vars: firstName, ip, device, when, url
NUXT_EMAIL_NEW_LOGIN_BODY_HTML
NUXT_EMAIL_NEW_LOGIN_BODY_TEXTPer-locale overrides
Append _<LOCALE> (uppercased, region stripped) to any of the above. The loader prefers the locale-suffixed var when the user's locale matches, falling back to the unsuffixed var and finally the built-in for that locale.
NUXT_EMAIL_VERIFICATION_SUBJECT_DE="Bestätige dein Konto — {{appName}}"
NUXT_EMAIL_VERIFICATION_BODY_TEXT_DE="Hallo {{firstName}}, bitte bestätige: {{url}}"You need these only to change the wording for a bundled locale, or to add a locale beyond the four that ship translated. Note the asymmetry: an unsuffixed override replaces the built-in for every locale, so setting NUXT_EMAIL_VERIFICATION_SUBJECT alone gives every recipient that one subject regardless of their language. Override per locale, or not at all.
Checking your overrides
The admin panel's Email & SMTP page previews every template — pick the template and the language and you see the rendered subject, HTML and text, each part badged built-in, overridden or locale override. It renders through the same code path a real send uses, so it is the fastest way to confirm a NUXT_EMAIL_* value took effect and that a <VAR>_<LOCALE> variant resolves for the locale you expect. No mail is sent, and the preview works even with SMTP disabled.
Fallback matrix
Any of the four combinations works:
| Layout override | Body override | Behaviour |
|---|---|---|
| no | no | built-in template, in the recipient's locale |
| no | yes | built-in layout wraps your body |
| yes | no | your layout wraps built-in body, in the recipient's locale |
| yes | yes | your layout wraps your body |
Each piece resolves independently, in this order:
<VAR>_<LOCALE>— your override for that language<VAR>— your override for all languages- the built-in, in the recipient's locale (en / de / fr / it, else English)
Worked example
services:
schleuse:
environment:
NUXT_EMAIL_LAYOUT_HTML: |
<!doctype html>
<html><body style="font-family:sans-serif">
<main style="max-width:560px;margin:24px auto;padding:24px">
{{slot}}
</main>
</body></html>
NUXT_EMAIL_VERIFICATION_SUBJECT: "Confirm your {{appName}} account"
NUXT_EMAIL_VERIFICATION_BODY_HTML: |
<h2>Hi {{firstName}}!</h2>
<p><a href="{{url}}">Confirm your email →</a></p>
NUXT_EMAIL_VERIFICATION_BODY_TEXT: |
Hi {{firstName}}, confirm your email: {{url}}Delivery & retry
Verification, password-reset, resend and new-sign-in-alert emails are sent with bounded automatic retry: the first attempt runs in the background, and on a transient SMTP failure the send is re-attempted on a backoff schedule (NUXT_EMAIL_RETRY_DELAYS_MS, default immediate + 30 s + 2 min). The triggering request returns immediately — it is never blocked or failed by a slow or failing SMTP server. When every attempt fails, an error is logged and the schleuse_emails_total{result="failed"} metric increments.
Retries are held in memory only and are lost on restart (consistent with the single-instance, no-external-dependencies design). 2FA login codes are not retried — they are time-sensitive, so a failed send surfaces to the user immediately and they can request a new code. See Configuration → SMTP for the retry knob.
Security
Operators own the env var contents. No HTML escaping is applied to substitutions — the only user-supplied value is firstName, which is already constrained by the Zod schema on registration (max 100 chars, no inherent structure). If you override templates and worry about this specifically, escape firstName in your copy or move to a fully server-controlled template.
Emails are not rendered in the app UI — they go straight to the SMTP transport. There's no XSS vector in the IdP from email content.
Not supported
- Per-user template overrides. Templates are per-operator, not per-user.
- Storing templates in the database. Config is env-only in Schleuse; see the design principles in the repo's
CLAUDE.mdfor why. - Template inheritance / partials. Each string is self-contained.