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 — empty unless you set it; see Logo in email |
| 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
is whatever you put in NUXT_PUBLIC_THEME_LOGO_URL, and nothing else. Unlike the login page, email does not fall back to the bundled Schleuse mark — it falls back to the app name as text. Two reasons, both about mail clients rather than preference:
- The bundled mark is served from a path (
/logo.svg). A mail client has no origin to resolve a path against, so it would render as a broken image. - It is an SVG, and Gmail and Outlook do not render SVG images at all.
So if you want a logo in your emails, point NUXT_PUBLIC_THEME_LOGO_URL at an absolute URL to a PNG or JPEG that is reachable without authentication:
NUXT_PUBLIC_THEME_LOGO_URL=https://cdn.example.com/logo.pngThat same URL is then used on the login pages too. 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.
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_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.