Skip to content

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 as max-width. Outlook renders through the Word engine and ignores max-width on 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 own href — Outlook does not inherit it from the anchor.
  • A hidden preheader sets the inbox preview line, with mso-hide:all so 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).

PlaceholderWhere it's availableValue
all templatesNUXT_PUBLIC_THEME_NAME
all templatesNUXT_PUBLIC_THEME_PRIMARY_COLOR (built-ins use it on the button only)
all templatesNUXT_PUBLIC_THEME_LOGO_URL, made absolute — empty unless you set it; see Logo in email
all templatesthe 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 + layoutrecipient's first name
verification + invite + password-reset + new-sign-in bodiesaction link (account page for new-sign-in)
login-code body6-digit 2FA code
new-sign-in bodyIP, User-Agent and time of the flagged login
layout onlyalready-substituted body

Logo in email ​

The built-in layout shows a mark in the header, following the same rule the login page uses:

Your configurationWhat the email header shows
Nothing customisedthe bundled Schleuse mark
NUXT_PUBLIC_THEME_LOGO_URL setyour logo
NUXT_PUBLIC_THEME_NAME set, no logoyour 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:

bash
NUXT_APP_URL=https://id.example.com
NUXT_PUBLIC_THEME_LOGO_URL=/brand.png    # sent as https://id.example.com/brand.png

An absolute URL is passed through untouched, and is still the more portable choice if your logo lives on a CDN:

bash
NUXT_PUBLIC_THEME_LOGO_URL=https://cdn.example.com/logo.png

Use 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_TEXT

Per-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 overrideBody overrideBehaviour
nonobuilt-in template, in the recipient's locale
noyesbuilt-in layout wraps your body
yesnoyour layout wraps built-in body, in the recipient's locale
yesyesyour layout wraps your body

Each piece resolves independently, in this order:

  1. <VAR>_<LOCALE> — your override for that language
  2. <VAR> — your override for all languages
  3. the built-in, in the recipient's locale (en / de / fr / it, else English)

Worked example ​

yaml
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.md for why.
  • Template inheritance / partials. Each string is self-contained.

A small but polished sidecar identity provider. · llms.txt