Skip to main content

Transactional Email System

Heimdall sends transactional emails (verification, password reset, security notifications) through the heimdall-email crate. Each email is a self-contained HTML message rendered from a Sailfish template, with all visible text supplied by rust-i18n translations. Delivery happens over one of three pluggable clients.

Overview

The crate is split into three modules:

ModuleResponsibility
service.rsEmailService — high-level send_* methods, locale selection, translation lookup
templates.rsSailfish TemplateOnce structs (one per email layout)
client.rsEmailClient — provider transport (SendGrid / SMTP / Console)

Location: crates/heimdall-email/

Key exports (lib.rs): EmailService, EmailClient, EmailError.

EmailService.send_*() → build template struct (+ i18n strings)
→ template.render_once() (Sailfish → HTML)
→ EmailClient.send_email() (SendGrid | SMTP | Console)

rust-i18n is initialised at the crate root with rust_i18n::i18n!("locales", fallback = "en"), embedding the locale files at compile time.

Delivery providers

EmailClient::from_config() selects the transport from the provider config value:

ProviderValueTransportNotes
SendGrid"sendgrid"HTTPS API (reqwest) to api.sendgrid.com/v3/mail/sendRequires sendgrid.api_key; errors with NotConfigured if empty
SMTP"smtp"lettre AsyncSmtpTransport (Tokio)STARTTLS relay when tls = true, otherwise plain; optional credentials; requires smtp.host
Consoleany other valueLogs the email to stdout (HTML omitted)Development fallback — nothing is actually sent

When enabled = false, every send_* method logs a warning and returns Ok(()) without sending.

Transactional emails

The following emails are implemented as send_* methods on EmailService (service.rs). Each maps to a Sailfish template and an email.<key> namespace in the locale files.

Methodi18n namespaceTemplate structTemplate file
send_email_verificationemail.verificationEmailVerificationTemplateemail_verification.stpl
send_email_link_verificationemail.email_linkEmailVerificationTemplateemail_verification.stpl
send_email_change_verificationemail.email_changeEmailChangeTemplateemail_change.stpl
send_password_resetemail.password_resetPasswordResetTemplatepassword_reset.stpl
send_welcomeemail.welcomeWelcomeTemplatewelcome.stpl
send_account_linkedemail.account_linkedAccountLinkedTemplateaccount_linked.stpl
send_two_factor_enabledemail.two_factor_enabledTwoFactorEnabledTemplatetwo_factor_enabled.stpl
send_two_factor_disabledemail.two_factor_disabledTwoFactorDisabledTemplatetwo_factor_disabled.stpl
send_backup_codes_regeneratedemail.backup_codes_regeneratedBackupCodesRegeneratedTemplatebackup_codes_regenerated.stpl
send_backup_codes_lowemail.backup_codes_lowBackupCodesLowTemplatebackup_codes_low.stpl
send_audit_report_submittedemail.audit_report_submittedAuditReportSubmittedTemplateaudit_report_submitted.stpl
send_audit_report_updatedemail.audit_report_updatedAuditReportUpdatedTemplateaudit_report_updated.stpl

Notes:

  • send_email_verification (new registration) and send_email_link_verification (linking a new email to an existing account) share the same EmailVerificationTemplate but use different subject/body translations.
  • send_backup_codes_low switches between an "empty" and "low" variant based on the remaining-codes count (subject_empty/title_empty/body_empty vs. subject_low/title/body_low).
  • send_audit_report_updated picks subject/title/body and inline status colors per new_status (reviewed, resolved, dismissed, with a fallback).
  • Token expiry differs per email: registration verification is hard-coded to 24h, password reset to 1h, and email-link / email-change use the configured token_expiry_hours.

Templates & locales

AssetLocation
Sailfish templatescrates/heimdall-email/templates/
Shared template componentscrates/heimdall-email/templates/components/ (logo.stpl, footer.stpl, success_icon.stpl, styles.stpl)
Locale filescrates/heimdall-email/locales/ (en.json, de.json)
note

The project guidelines (AGENTS.md → "Email Templates") historically refer to platform/api/templates/ and platform/api/locales/. The live source of truth is the heimdall-email crate at the paths above.

Every template struct carries its dynamic fields (URLs, counts, dates) plus a set of pre-resolved translation strings (t_title, t_greeting, t_body, t_button, footer strings, etc.). The service resolves these with rust_i18n::t! before calling render_once(), so the templates themselves contain no hardcoded copy.

Common strings are shared under the email.common namespace (e.g. footer_copyright, footer_imprint, footer_terms, footer_privacy, copy_link, platform_label, username_label, identity_provider, security_settings) and reused across emails via helper functions (footer_translations, identity_provider_translation).

Localization

Locale is chosen per email via the locale: Option<&str> argument and EmailService::effective_locale():

  • If a locale is provided and it is in supported_locales, it is used.
  • Otherwise the configured default_locale is used.
  • rust-i18n additionally falls back to en for any missing key (fallback = "en").

Configuration ([email] section):

SettingDefaultMeaning
default_locale"en"Fallback locale when the caller has no preference
supported_locales["en", "de"]Locales accepted from callers

Currently shipped locales: English (en) and German (de).

Adding a new email

To add a transactional email (see AGENTS.md → "Email Templates"):

  1. Template — add crates/heimdall-email/templates/<name>.stpl. Reuse the shared components/ partials (logo, footer, styles). Put no hardcoded visible text in the template — render every string via a t_* field.
  2. Template struct — add a #[derive(TemplateOnce)] struct in templates.rs with #[template(path = "<name>.stpl")], listing the dynamic fields and t_* translation fields.
  3. Service method — add a send_* method in service.rs that resolves the t_* strings with t!("email.<namespace>.<key>", locale = locale, …), reuses footer_translations() / identity_provider_translation() for shared copy, builds the struct, calls render_once(), and sends via self.client.
  4. Locale keys — add the email.<namespace>.* keys to both locales/en.json and locales/de.json. Reuse email.common.* for shared strings. No duplicate JSON keys — JSON silently overwrites them.
  5. Rebuild — run cargo build. rust-i18n embeds locale files at compile time, so translation changes require a rebuild to take effect.

Development with MailDev

The dev stack ships MailDev as an SMTP sink so emails can be inspected without a real provider.

  • Start it with the dev stack (just stack-up) or on its own (just mail-up).
  • Web UI: http://localhost:1080
  • SMTP endpoint: localhost:1025

Point the email config at MailDev by selecting the smtp provider with host = "localhost", port = 1025, and tls = false. Alternatively, set provider = "console" to log emails to stdout without sending anything.

Next Steps