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:
| Module | Responsibility |
|---|---|
service.rs | EmailService — high-level send_* methods, locale selection, translation lookup |
templates.rs | Sailfish TemplateOnce structs (one per email layout) |
client.rs | EmailClient — 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:
| Provider | Value | Transport | Notes |
|---|---|---|---|
| SendGrid | "sendgrid" | HTTPS API (reqwest) to api.sendgrid.com/v3/mail/send | Requires 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 |
| Console | any other value | Logs 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.
| Method | i18n namespace | Template struct | Template file |
|---|---|---|---|
send_email_verification | email.verification | EmailVerificationTemplate | email_verification.stpl |
send_email_link_verification | email.email_link | EmailVerificationTemplate | email_verification.stpl |
send_email_change_verification | email.email_change | EmailChangeTemplate | email_change.stpl |
send_password_reset | email.password_reset | PasswordResetTemplate | password_reset.stpl |
send_welcome | email.welcome | WelcomeTemplate | welcome.stpl |
send_account_linked | email.account_linked | AccountLinkedTemplate | account_linked.stpl |
send_two_factor_enabled | email.two_factor_enabled | TwoFactorEnabledTemplate | two_factor_enabled.stpl |
send_two_factor_disabled | email.two_factor_disabled | TwoFactorDisabledTemplate | two_factor_disabled.stpl |
send_backup_codes_regenerated | email.backup_codes_regenerated | BackupCodesRegeneratedTemplate | backup_codes_regenerated.stpl |
send_backup_codes_low | email.backup_codes_low | BackupCodesLowTemplate | backup_codes_low.stpl |
send_audit_report_submitted | email.audit_report_submitted | AuditReportSubmittedTemplate | audit_report_submitted.stpl |
send_audit_report_updated | email.audit_report_updated | AuditReportUpdatedTemplate | audit_report_updated.stpl |
Notes:
send_email_verification(new registration) andsend_email_link_verification(linking a new email to an existing account) share the sameEmailVerificationTemplatebut use different subject/body translations.send_backup_codes_lowswitches between an "empty" and "low" variant based on the remaining-codes count (subject_empty/title_empty/body_emptyvs.subject_low/title/body_low).send_audit_report_updatedpicks subject/title/body and inline status colors pernew_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
| Asset | Location |
|---|---|
| Sailfish templates | crates/heimdall-email/templates/ |
| Shared template components | crates/heimdall-email/templates/components/ (logo.stpl, footer.stpl, success_icon.stpl, styles.stpl) |
| Locale files | crates/heimdall-email/locales/ (en.json, de.json) |
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_localeis used. rust-i18nadditionally falls back toenfor any missing key (fallback = "en").
Configuration ([email] section):
| Setting | Default | Meaning |
|---|---|---|
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"):
- Template — add
crates/heimdall-email/templates/<name>.stpl. Reuse the sharedcomponents/partials (logo, footer, styles). Put no hardcoded visible text in the template — render every string via at_*field. - Template struct — add a
#[derive(TemplateOnce)]struct intemplates.rswith#[template(path = "<name>.stpl")], listing the dynamic fields andt_*translation fields. - Service method — add a
send_*method inservice.rsthat resolves thet_*strings witht!("email.<namespace>.<key>", locale = locale, …), reusesfooter_translations()/identity_provider_translation()for shared copy, builds the struct, callsrender_once(), and sends viaself.client. - Locale keys — add the
email.<namespace>.*keys to bothlocales/en.jsonandlocales/de.json. Reuseemail.common.*for shared strings. No duplicate JSON keys — JSON silently overwrites them. - Rebuild — run
cargo build.rust-i18nembeds 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
- Crate Reference —
heimdall-emailin the crate map - Auth System — flows that trigger verification / 2FA emails
- Audit System — events behind the audit report emails