diff --git a/.env.example b/.env.example
index 0776d5d..a52b171 100644
--- a/.env.example
+++ b/.env.example
@@ -119,6 +119,15 @@ ATOM_CERTS_ENABLED=false
# ATOM_SMTP_FROM=atom@example.com
# ATOM_SMTP_TLS=starttls
+# --- Optional: email template overrides ---------------------------------
+# Compose mounts ATOM_EMAIL_TEMPLATES_HOST_DIR (default ./email-templates) at
+# /email-templates:ro and points ATOM_EMAIL_TEMPLATES_DIR there, so
+# overriding a template is just editing a file and restarting the container
+# — no rebuild required. For cargo run, set ATOM_EMAIL_TEMPLATES_DIR to a
+# host path directly instead, e.g. ./email-templates.
+# ATOM_EMAIL_TEMPLATES_HOST_DIR=./email-templates
+# ATOM_EMAIL_TEMPLATES_DIR=./email-templates
+
# --- Optional: OIDC providers JSON --------------------------------------
# ATOM_OIDC_PROVIDERS=[{"name":"google","issuer":"https://accounts.google.com","client_id":"...","client_secret":"...","scopes":["openid","email","profile"]}]
diff --git a/Cargo.lock b/Cargo.lock
index 65b5f9d..21dd473 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -288,6 +288,7 @@ dependencies = [
"lettre",
"metrics",
"metrics-exporter-prometheus",
+ "minijinja",
"ocsp",
"openidconnect",
"p256",
@@ -1840,6 +1841,12 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "memo-map"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
+
[[package]]
name = "metrics"
version = "0.24.6"
@@ -1886,6 +1893,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "minijinja"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
+dependencies = [
+ "memo-map",
+ "serde",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
diff --git a/Cargo.toml b/Cargo.toml
index 2b40f12..41667e0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,7 @@ tonic = { version = "0.12", features = ["tls"] }
tonic-health = "0.12"
prost = "0.13"
lettre = { version = "0.11.21", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
+minijinja = { version = "2", default-features = false, features = ["serde"] }
url = "2"
rcgen = { version = "0.14.8", features = ["pem", "x509-parser", "aws_lc_rs"] }
ocsp = "0.4.0"
diff --git a/Dockerfile b/Dockerfile
index a68e9e7..8348d85 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,6 +25,7 @@ RUN apk add --no-cache ca-certificates libgcc \
WORKDIR /app
COPY --from=builder-release /app/target/release/atom /usr/local/bin/atom
COPY migrations ./migrations
+COPY email-templates ./email-templates
RUN chown -R atom:atom /app /usr/local/bin/atom
USER atom
EXPOSE 8080 8081
@@ -39,6 +40,7 @@ RUN apk add --no-cache ca-certificates libgcc \
WORKDIR /app
COPY --from=builder-dev /app/target/debug/atom /usr/local/bin/atom
COPY migrations ./migrations
+COPY email-templates ./email-templates
RUN chown -R atom:atom /app /usr/local/bin/atom
USER atom
EXPOSE 8080 8081
diff --git a/README.md b/README.md
index bcde2fd..04db921 100644
--- a/README.md
+++ b/README.md
@@ -357,6 +357,33 @@ For a host `cargo run`, use host paths such as
`ATOM_GRPC_TLS_CERT_PATH=./certs/grpc-server.crt`; `/certs/...` paths apply
inside the Compose containers.
+### Email templates
+
+Atom sends three transactional emails — signup verification, password reset,
+and tenant invitation — each rendered from a single `.tmpl` file under
+[`email-templates/`](email-templates/): a `Subject: ...` header line, a
+blank line, then the plain-text body, both using
+[minijinja](https://docs.rs/minijinja) `{{ variable }}` syntax. The built-in
+defaults are baked into the image; Compose additionally mounts them
+read-only at `/email-templates` and points `ATOM_EMAIL_TEMPLATES_DIR` there
+by default, so overriding one is just editing a file and restarting the
+container — no rebuild required.
+
+To customize without touching this repository's own copy, point
+`ATOM_EMAIL_TEMPLATES_HOST_DIR` at your own directory. Only the files you
+want to change need to exist there; anything missing falls back to the
+built-in default, so overriding just one template (e.g.
+`verification.tmpl`) is enough:
+
+```dotenv
+ATOM_EMAIL_TEMPLATES_HOST_DIR=./my-email-templates
+```
+
+Since Atom is embedded by multiple platforms/projects, each deployment can
+mount its own branding without forking or rebuilding the image. See
+[`email-templates/README.md`](email-templates/README.md) for the full
+variable reference per template.
+
### Port overrides
If a host port is already occupied, override only the host-side port:
@@ -624,6 +651,7 @@ Generic application mapping:
| `ATOM_SMTP_HOST` / `ATOM_SMTP_FROM` | *(optional)* | Required pair for signup and password reset email delivery |
| `ATOM_SMTP_PORT` / `ATOM_SMTP_TLS` | `587` / `starttls` | SMTP port and TLS mode |
| `ATOM_SMTP_USERNAME` / `ATOM_SMTP_PASSWORD` | *(optional)* | SMTP credentials |
+| `ATOM_EMAIL_TEMPLATES_DIR` | *(unset; `/email-templates` in Compose)* | Directory checked first (per-file) for email template overrides, see [Email templates](#email-templates) |
| `ATOM_CERTS_ENABLED` | `true` | Enables certificate lifecycle support |
| `ATOM_CERTS_CA_MODE` | `file_intermediate_issuer` | CA mode: `file_intermediate_issuer` or `file_root_issuer` |
| `ATOM_CERTS_ROOT_CA_CERT_PATH` | *(optional)* | Mounted root CA certificate path |
@@ -633,6 +661,7 @@ Generic application mapping:
| `ATOM_CERTS_LEAF_DEFAULT_TTL_SECS` | `2592000` | Default issued certificate lifetime |
| `ATOM_CERTS_LEAF_MAX_TTL_SECS` | `2592000` | Maximum issued certificate lifetime |
| `ATOM_CERTS_CA_DIR` | `./certs` | Docker Compose host directory mounted at `/certs:ro` |
+| `ATOM_EMAIL_TEMPLATES_HOST_DIR` | `./email-templates` | Docker Compose host directory mounted at `/email-templates:ro` |
| `POSTGRES_HOST_PORT` / `ATOM_HTTP_PORT` / `ATOM_GRPC_PORT` / `ATOM_DEV_HTTP_PORT` / `ATOM_DEV_GRPC_PORT` / `ATOM_UI_HTTP_PORT` | `5432` / `8080` / `8081` / `8081` / `18081` / `3005` | Docker Compose host ports |
| `ATOM_GRAPHQL_URL` | `http://atom:8080/graphql` | GraphQL endpoint used by the Dockerized Next UI |
| `RUST_LOG` | *(legacy fallback)* | Used only when `ATOM_LOG_LEVEL` is unset |
diff --git a/docker-compose.yml b/docker-compose.yml
index 640dcc7..2018854 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -77,8 +77,10 @@ services:
ATOM_OIDC_PROVIDERS: ${ATOM_OIDC_PROVIDERS:-}
ATOM_LOG_LEVEL: ${ATOM_LOG_LEVEL:-${RUST_LOG:-info}}
ATOM_LOG_FORMAT: ${ATOM_LOG_FORMAT:-text}
+ ATOM_EMAIL_TEMPLATES_DIR: ${ATOM_EMAIL_TEMPLATES_DIR:-/email-templates}
volumes:
- ${ATOM_CERTS_CA_DIR:-./certs}:/certs:ro
+ - ${ATOM_EMAIL_TEMPLATES_HOST_DIR:-./email-templates}:/email-templates:ro
ports:
- "${ATOM_HTTP_PORT:-8080}:${ATOM_HTTP_PORT:-8080}"
- "${ATOM_GRPC_PORT:-8081}:${ATOM_GRPC_PORT:-8081}"
@@ -152,8 +154,10 @@ services:
ATOM_OIDC_PROVIDERS: ${ATOM_OIDC_PROVIDERS:-}
ATOM_LOG_LEVEL: ${ATOM_LOG_LEVEL:-${RUST_LOG:-info}}
ATOM_LOG_FORMAT: ${ATOM_LOG_FORMAT:-text}
+ ATOM_EMAIL_TEMPLATES_DIR: ${ATOM_EMAIL_TEMPLATES_DIR:-/email-templates}
volumes:
- ${ATOM_CERTS_CA_DIR:-./certs}:/certs:ro
+ - ${ATOM_EMAIL_TEMPLATES_HOST_DIR:-./email-templates}:/email-templates:ro
ports:
- "${ATOM_DEV_HTTP_PORT:-8081}:${ATOM_DEV_HTTP_PORT:-8081}"
- "${ATOM_DEV_GRPC_PORT:-18081}:${ATOM_DEV_GRPC_PORT:-18081}"
diff --git a/email-templates/README.md b/email-templates/README.md
new file mode 100644
index 0000000..1d6dae2
--- /dev/null
+++ b/email-templates/README.md
@@ -0,0 +1,55 @@
+These are Atom's built-in default email templates, baked into the container
+image at `/app/email-templates`. Each template is a single `.tmpl` file: a
+`Subject: ...` header line, an optional `Content-Type: ...` header line
+(defaults to `text/plain` when omitted — set it to `text/html` for a
+branded HTML email), a blank line, then the body — both header values and
+the body are rendered with [minijinja](https://docs.rs/minijinja)
+`{{ variable }}` syntax.
+
+```
+Subject: Verify your Atom account
+
+Verify your Atom account by opening this link:
+
+{{ verification_url }}
+```
+
+An HTML override looks like:
+
+```
+Subject: Verify your Atom account
+Content-Type: text/html
+
+
+...Verify email...
+```
+
+| Template | Variables | Trigger |
+| ------------------- | ----------------- | --------------------------------- |
+| `verification.tmpl` | `verification_url` | Signup and `/auth/email/resend` |
+| `password_reset.tmpl` | `reset_url` | `/auth/password/reset` request |
+| `invitation.tmpl` | `invitation_url` | Tenant invitation created |
+
+## Overriding at runtime
+
+To customize copy/branding without rebuilding the image, mount a directory
+over the default one and point `ATOM_EMAIL_TEMPLATES_DIR` at it. Only the
+files you actually want to override need to be present — anything missing
+falls back to the built-in default shown here. For example, to override only
+the verification email:
+
+```
+my-email-templates/
+ verification.tmpl
+```
+
+```dotenv
+# .env used by docker compose
+ATOM_EMAIL_TEMPLATES_DIR=/email-templates
+ATOM_EMAIL_TEMPLATES_HOST_DIR=./my-email-templates
+```
+
+`docker-compose.yml` mounts `${ATOM_EMAIL_TEMPLATES_HOST_DIR:-./email-templates}`
+read-only at `/email-templates` and passes `ATOM_EMAIL_TEMPLATES_DIR` through
+unchanged, so this works out of the box for both the `atom` and `atom-dev`
+services.
diff --git a/email-templates/invitation.tmpl b/email-templates/invitation.tmpl
new file mode 100644
index 0000000..b9388b1
--- /dev/null
+++ b/email-templates/invitation.tmpl
@@ -0,0 +1,5 @@
+Subject: You have been invited
+
+Accept your invitation by opening this link:
+
+{{ invitation_url }}
diff --git a/email-templates/password_reset.tmpl b/email-templates/password_reset.tmpl
new file mode 100644
index 0000000..bcaf361
--- /dev/null
+++ b/email-templates/password_reset.tmpl
@@ -0,0 +1,5 @@
+Subject: Reset your Atom password
+
+Reset your Atom password by opening this link:
+
+{{ reset_url }}
diff --git a/email-templates/verification.tmpl b/email-templates/verification.tmpl
new file mode 100644
index 0000000..4bbbeb4
--- /dev/null
+++ b/email-templates/verification.tmpl
@@ -0,0 +1,5 @@
+Subject: Verify your Atom account
+
+Verify your Atom account by opening this link:
+
+{{ verification_url }}
diff --git a/src/config.rs b/src/config.rs
index d459b1c..8d08980 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -54,6 +54,12 @@ pub struct Config {
pub oauth_error_redirect: String,
pub oidc_providers: Vec,
pub smtp: Option,
+ /// Operator-mounted directory overriding the built-in email templates
+ /// shipped at `mail::DEFAULT_TEMPLATES_DIR`. `None` means every template
+ /// uses its built-in default. Files present here take precedence
+ /// per-file, so an override directory need only contain the templates an
+ /// operator actually wants to customize.
+ pub email_templates_dir: Option,
pub email_verification_expiry_secs: u64,
pub invitation_expiry_secs: u64,
pub oauth_state_expiry_secs: u64,
@@ -444,6 +450,7 @@ impl Config {
.unwrap_or_else(|_| ui_auth_callback.clone()),
oidc_providers: parse_oidc_providers()?,
smtp: smtp_from_env(),
+ email_templates_dir: nonempty_env("ATOM_EMAIL_TEMPLATES_DIR"),
email_verification_expiry_secs: env_u64("ATOM_EMAIL_VERIFICATION_EXPIRY_SECS", 86_400),
invitation_expiry_secs: env_u64("ATOM_INVITATION_EXPIRY_SECS", 604_800),
oauth_state_expiry_secs: env_u64("ATOM_OAUTH_STATE_EXPIRY_SECS", 600),
@@ -513,6 +520,7 @@ impl Config {
oauth_error_redirect: "http://localhost:8080".into(),
oidc_providers: vec![],
smtp: None,
+ email_templates_dir: None,
email_verification_expiry_secs: 86_400,
invitation_expiry_secs: 604_800,
oauth_state_expiry_secs: 600,
@@ -1011,6 +1019,32 @@ mod tests {
clear_hardening_env();
}
+ #[test]
+ fn blank_email_templates_dir_env_is_treated_as_unset() {
+ let _guard = ENV_LOCK.lock().expect("env lock");
+ clear_hardening_env();
+ let _db_guard = DatabaseUrlGuard::set();
+ std::env::set_var("ATOM_EMAIL_TEMPLATES_DIR", " ");
+
+ let cfg = Config::from_env().expect("config");
+ assert!(cfg.email_templates_dir.is_none());
+
+ clear_hardening_env();
+ }
+
+ #[test]
+ fn email_templates_dir_env_is_read() {
+ let _guard = ENV_LOCK.lock().expect("env lock");
+ clear_hardening_env();
+ let _db_guard = DatabaseUrlGuard::set();
+ std::env::set_var("ATOM_EMAIL_TEMPLATES_DIR", "/email-templates");
+
+ let cfg = Config::from_env().expect("config");
+ assert_eq!(cfg.email_templates_dir.as_deref(), Some("/email-templates"));
+
+ clear_hardening_env();
+ }
+
#[test]
fn graphql_introspection_opts_in_via_env() {
let _guard = ENV_LOCK.lock().expect("env lock");
@@ -1152,6 +1186,7 @@ mod tests {
"ATOM_GRPC_TLS_CERT_PATH",
"ATOM_GRPC_TLS_KEY_PATH",
"ATOM_GRPC_TLS_CLIENT_CA_PATH",
+ "ATOM_EMAIL_TEMPLATES_DIR",
] {
std::env::remove_var(name);
}
diff --git a/src/identity/service.rs b/src/identity/service.rs
index a2cb877..eac5d1b 100644
--- a/src/identity/service.rs
+++ b/src/identity/service.rs
@@ -3,10 +3,6 @@ use argon2::{
Argon2,
};
use chrono::{DateTime, Duration, Utc};
-use lettre::{
- message::header::ContentType, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
- AsyncTransport, Message, Tokio1Executor,
-};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
reqwest, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
@@ -21,10 +17,11 @@ use uuid::Uuid;
use crate::{
audit,
auth::encode_jwt,
- config::{Config, OidcProviderConfig, SigningKeyConfig, SmtpTls},
+ config::{Config, OidcProviderConfig, SigningKeyConfig},
crypto,
error::{db_err, AppError},
keys::LoadedKey,
+ mail,
models::{
enums::{AuditOutcome, CredentialKind, CredentialStatus, EntityKind, EntityStatus},
session::{
@@ -1480,65 +1477,14 @@ async fn insert_email_token_in_tx(
async fn send_verification_email(cfg: &Config, email: &str, token: &str) -> Result<(), AppError> {
let verification_url = url_with_params(&cfg.email_verification_redirect, &[("token", token)]);
- let Some(smtp) = cfg.smtp.as_ref() else {
- if cfg.dev_allow_unverified_email_login {
- tracing::warn!(
- email,
- verification_url,
- "SMTP is not configured; skipping verification email in development bypass mode"
- );
- return Ok(());
- }
- return Err(AppError::Internal(anyhow::anyhow!(
- "SMTP is not configured"
- )));
- };
-
- let message = Message::builder()
- .from(
- smtp.from
- .parse()
- .map_err(|e| AppError::bad_request(format!("invalid SMTP from address: {e}")))?,
- )
- .to(email
- .parse()
- .map_err(|e| AppError::bad_request(format!("invalid email address: {e}")))?)
- .subject("Verify your Atom account")
- .header(ContentType::TEXT_PLAIN)
- .body(format!(
- "Verify your Atom account by opening this link:\n\n{verification_url}\n"
- ))
- .map_err(|e| AppError::Internal(anyhow::anyhow!("build email: {e}")))?;
-
- let mut builder = match smtp.tls {
- SmtpTls::None => AsyncSmtpTransport::::builder_dangerous(&smtp.host),
- SmtpTls::StartTls => AsyncSmtpTransport::::starttls_relay(&smtp.host)
- .map_err(|e| AppError::Internal(anyhow::anyhow!("smtp starttls: {e}")))?,
- SmtpTls::Tls => AsyncSmtpTransport::::relay(&smtp.host)
- .map_err(|e| AppError::Internal(anyhow::anyhow!("smtp tls: {e}")))?,
- }
- .port(smtp.port);
-
- if let (Some(username), Some(password)) = (&smtp.username, &smtp.password) {
- builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
- }
-
- let mailer = builder.build();
- if let Err(err) = mailer.send(message).await {
- if cfg.dev_allow_unverified_email_login {
- tracing::warn!(
- email,
- verification_url,
- error = %err,
- "SMTP send failed; skipping verification email in development bypass mode"
- );
- return Ok(());
- }
- return Err(AppError::Internal(anyhow::anyhow!(
- "send verification email: {err}"
- )));
- }
- Ok(())
+ mail::send_templated_email(
+ cfg,
+ mail::EmailTemplate::Verification,
+ email,
+ &verification_url,
+ &[("verification_url", verification_url.as_str())],
+ )
+ .await
}
async fn send_password_reset_email(
@@ -1548,65 +1494,14 @@ async fn send_password_reset_email(
token: &str,
) -> Result<(), AppError> {
let reset_url = url_with_params(redirect_url, &[("token", token)]);
- let Some(smtp) = cfg.smtp.as_ref() else {
- if cfg.dev_allow_unverified_email_login {
- tracing::warn!(
- email,
- reset_url,
- "SMTP is not configured; skipping password reset email in development bypass mode"
- );
- return Ok(());
- }
- return Err(AppError::Internal(anyhow::anyhow!(
- "SMTP is not configured"
- )));
- };
-
- let message = Message::builder()
- .from(
- smtp.from
- .parse()
- .map_err(|e| AppError::bad_request(format!("invalid SMTP from address: {e}")))?,
- )
- .to(email
- .parse()
- .map_err(|e| AppError::bad_request(format!("invalid email address: {e}")))?)
- .subject("Reset your Atom password")
- .header(ContentType::TEXT_PLAIN)
- .body(format!(
- "Reset your Atom password by opening this link:\n\n{reset_url}\n"
- ))
- .map_err(|e| AppError::Internal(anyhow::anyhow!("build email: {e}")))?;
-
- let mut builder = match smtp.tls {
- SmtpTls::None => AsyncSmtpTransport::::builder_dangerous(&smtp.host),
- SmtpTls::StartTls => AsyncSmtpTransport::::starttls_relay(&smtp.host)
- .map_err(|e| AppError::Internal(anyhow::anyhow!("smtp starttls: {e}")))?,
- SmtpTls::Tls => AsyncSmtpTransport::::relay(&smtp.host)
- .map_err(|e| AppError::Internal(anyhow::anyhow!("smtp tls: {e}")))?,
- }
- .port(smtp.port);
-
- if let (Some(username), Some(password)) = (&smtp.username, &smtp.password) {
- builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
- }
-
- let mailer = builder.build();
- if let Err(err) = mailer.send(message).await {
- if cfg.dev_allow_unverified_email_login {
- tracing::warn!(
- email,
- reset_url,
- error = %err,
- "SMTP send failed; skipping password reset email in development bypass mode"
- );
- return Ok(());
- }
- return Err(AppError::Internal(anyhow::anyhow!(
- "send password reset email: {err}"
- )));
- }
- Ok(())
+ mail::send_templated_email(
+ cfg,
+ mail::EmailTemplate::PasswordReset,
+ email,
+ &reset_url,
+ &[("reset_url", reset_url.as_str())],
+ )
+ .await
}
struct OAuthStateRow {
diff --git a/src/lib.rs b/src/lib.rs
index c0744be..226c889 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -19,6 +19,7 @@ pub mod guardrails;
pub mod health;
pub mod identity;
pub mod keys;
+pub mod mail;
pub mod metrics;
pub mod models;
pub mod purge;
diff --git a/src/mail.rs b/src/mail.rs
new file mode 100644
index 0000000..a105409
--- /dev/null
+++ b/src/mail.rs
@@ -0,0 +1,281 @@
+//! Renders and sends Atom's transactional emails (signup verification,
+//! password reset, tenant invitation) from templates that can be overridden
+//! at runtime, without rebuilding the image.
+//!
+//! Each template is a single `.tmpl` file shaped like a minimal RFC 5322
+//! message: a `Subject: ...` header line, an optional `Content-Type: ...`
+//! header line (defaults to `text/plain` when absent), a blank line, then
+//! the body — all rendered with [minijinja](https://docs.rs/minijinja)
+//! `{{ variable }}` placeholders. Every template ships a built-in default
+//! under `DEFAULT_TEMPLATES_DIR` (baked into the container image alongside
+//! the binary). Setting `cfg.email_templates_dir`
+//! (`ATOM_EMAIL_TEMPLATES_DIR`) points at a separate directory — typically a
+//! Compose bind mount — that is checked first, one file at a time, so an
+//! operator only needs to provide the templates they actually want to
+//! customize.
+
+use std::path::{Path, PathBuf};
+
+use lettre::{
+ message::header::ContentType, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
+ AsyncTransport, Message, Tokio1Executor,
+};
+
+use crate::config::{Config, SmtpConfig, SmtpTls};
+use crate::error::AppError;
+
+/// Built-in template directory, relative to the process's working directory
+/// (the crate root in `cargo run`/`cargo test`, `/app` — via the Dockerfile's
+/// `WORKDIR` and `COPY email-templates ./email-templates` — in the image).
+pub const DEFAULT_TEMPLATES_DIR: &str = "email-templates";
+
+#[derive(Clone, Copy)]
+pub enum EmailTemplate {
+ Verification,
+ PasswordReset,
+ Invitation,
+}
+
+impl EmailTemplate {
+ fn kind(self) -> &'static str {
+ match self {
+ EmailTemplate::Verification => "verification",
+ EmailTemplate::PasswordReset => "password_reset",
+ EmailTemplate::Invitation => "invitation",
+ }
+ }
+
+ fn file_name(self) -> String {
+ format!("{}.tmpl", self.kind())
+ }
+}
+
+/// Renders `template` with `vars` and sends it to `to` over SMTP. `log_url`
+/// is the single templated link, logged alongside the template kind so the
+/// existing dev-bypass warnings stay identifiable per email kind.
+pub async fn send_templated_email(
+ cfg: &Config,
+ template: EmailTemplate,
+ to: &str,
+ log_url: &str,
+ vars: &[(&str, &str)],
+) -> Result<(), AppError> {
+ let Some(smtp) = cfg.smtp.as_ref() else {
+ if cfg.dev_allow_unverified_email_login {
+ tracing::warn!(
+ email = to,
+ kind = template.kind(),
+ url = log_url,
+ "SMTP is not configured; skipping email in development bypass mode"
+ );
+ return Ok(());
+ }
+ return Err(AppError::Internal(anyhow::anyhow!(
+ "SMTP is not configured"
+ )));
+ };
+
+ let raw = read_template(cfg, template)?;
+ let parsed = parse_template(&raw)?;
+ let subject = render(parsed.subject, vars)?;
+ let body = render(parsed.body, vars)?;
+ let content_type = match parsed.content_type {
+ Some("text/html") => ContentType::TEXT_HTML,
+ _ => ContentType::TEXT_PLAIN,
+ };
+
+ let message = Message::builder()
+ .from(
+ smtp.from
+ .parse()
+ .map_err(|e| AppError::bad_request(format!("invalid SMTP from address: {e}")))?,
+ )
+ .to(to
+ .parse()
+ .map_err(|e| AppError::bad_request(format!("invalid email address: {e}")))?)
+ .subject(subject.trim())
+ .header(content_type)
+ .body(body)
+ .map_err(|e| AppError::Internal(anyhow::anyhow!("build email: {e}")))?;
+
+ let mailer = build_transport(smtp)?;
+ if let Err(err) = mailer.send(message).await {
+ if cfg.dev_allow_unverified_email_login {
+ tracing::warn!(
+ email = to,
+ kind = template.kind(),
+ url = log_url,
+ error = %err,
+ "SMTP send failed; skipping email in development bypass mode"
+ );
+ return Ok(());
+ }
+ return Err(AppError::Internal(anyhow::anyhow!(
+ "send {} email: {err}",
+ template.kind()
+ )));
+ }
+ Ok(())
+}
+
+/// Checks the operator override directory first, then falls back to the
+/// built-in default shipped at `DEFAULT_TEMPLATES_DIR`.
+fn read_template(cfg: &Config, template: EmailTemplate) -> Result {
+ if let Some(dir) = &cfg.email_templates_dir {
+ let override_path = Path::new(dir).join(template.file_name());
+ if let Ok(contents) = std::fs::read_to_string(&override_path) {
+ return Ok(contents);
+ }
+ }
+ let default_path: PathBuf = Path::new(DEFAULT_TEMPLATES_DIR).join(template.file_name());
+ std::fs::read_to_string(&default_path).map_err(|e| {
+ AppError::Internal(anyhow::anyhow!(
+ "read email template {}: {e}",
+ default_path.display()
+ ))
+ })
+}
+
+#[derive(Debug)]
+struct ParsedTemplate<'a> {
+ subject: &'a str,
+ content_type: Option<&'a str>,
+ body: &'a str,
+}
+
+/// Parses a template file's header block (`Subject:` required,
+/// `Content-Type:` optional, one per line) up to the first blank line, then
+/// the body — the same shape as a raw RFC 5322 message, chosen so both stay
+/// customizable without needing a second file per template.
+fn parse_template(raw: &str) -> Result, AppError> {
+ let (header_block, body) = raw.split_once("\n\n").ok_or_else(|| {
+ AppError::Internal(anyhow::anyhow!(
+ "email template must have a 'Subject: ...' line, a blank line, then the body"
+ ))
+ })?;
+
+ let mut subject = None;
+ let mut content_type = None;
+ for line in header_block.lines() {
+ let line = line.trim_end_matches('\r');
+ if let Some(value) = line.strip_prefix("Subject:") {
+ subject = Some(value.trim());
+ } else if let Some(value) = line.strip_prefix("Content-Type:") {
+ content_type = Some(value.trim());
+ }
+ }
+
+ let subject = subject.ok_or_else(|| {
+ AppError::Internal(anyhow::anyhow!(
+ "email template must have a 'Subject: ...' header line"
+ ))
+ })?;
+ Ok(ParsedTemplate {
+ subject,
+ content_type,
+ body,
+ })
+}
+
+fn render(template_str: &str, vars: &[(&str, &str)]) -> Result {
+ let env = minijinja::Environment::new();
+ let tmpl = env
+ .template_from_str(template_str)
+ .map_err(|e| AppError::Internal(anyhow::anyhow!("parse email template: {e}")))?;
+ let ctx = minijinja::Value::from_iter(
+ vars.iter()
+ .map(|(key, value)| (key.to_string(), value.to_string())),
+ );
+ tmpl.render(ctx)
+ .map_err(|e| AppError::Internal(anyhow::anyhow!("render email template: {e}")))
+}
+
+fn build_transport(smtp: &SmtpConfig) -> Result, AppError> {
+ let mut builder = match smtp.tls {
+ SmtpTls::None => AsyncSmtpTransport::::builder_dangerous(&smtp.host),
+ SmtpTls::StartTls => AsyncSmtpTransport::::starttls_relay(&smtp.host)
+ .map_err(|e| AppError::Internal(anyhow::anyhow!("smtp starttls: {e}")))?,
+ SmtpTls::Tls => AsyncSmtpTransport::::relay(&smtp.host)
+ .map_err(|e| AppError::Internal(anyhow::anyhow!("smtp tls: {e}")))?,
+ }
+ .port(smtp.port);
+
+ if let (Some(username), Some(password)) = (&smtp.username, &smtp.password) {
+ builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
+ }
+
+ Ok(builder.build())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn falls_back_to_default_template_when_no_override_dir_is_set() {
+ let cfg = Config::for_tests();
+ let raw = read_template(&cfg, EmailTemplate::Verification).expect("default template");
+ let parsed = parse_template(&raw).expect("parse");
+ assert_eq!(parsed.subject, "Verify your Atom account");
+ assert_eq!(parsed.content_type, None);
+ }
+
+ #[test]
+ fn override_file_takes_precedence_over_default() {
+ let dir = tempfile_dir();
+ std::fs::write(
+ dir.join("verification.tmpl"),
+ "Subject: Custom subject\nContent-Type: text/html\n\nCustom body {{ verification_url }}\n",
+ )
+ .unwrap();
+
+ let mut cfg = Config::for_tests();
+ cfg.email_templates_dir = Some(dir.to_string_lossy().to_string());
+
+ let raw = read_template(&cfg, EmailTemplate::Verification).expect("overridden template");
+ let parsed = parse_template(&raw).expect("parse");
+ assert_eq!(parsed.subject, "Custom subject");
+ assert_eq!(parsed.content_type, Some("text/html"));
+ assert!(parsed.body.contains("Custom body"));
+
+ // A template not present in the override dir still falls back to
+ // the built-in default rather than failing.
+ let raw = read_template(&cfg, EmailTemplate::Invitation).expect("default falls back");
+ let parsed = parse_template(&raw).expect("parse");
+ assert_eq!(parsed.subject, "You have been invited");
+
+ std::fs::remove_dir_all(&dir).ok();
+ }
+
+ #[test]
+ fn parse_rejects_template_missing_subject_header() {
+ let err = parse_template("no subject header\n\nbody").unwrap_err();
+ assert!(format!("{err:?}").contains("Subject:"));
+ }
+
+ #[test]
+ fn parse_rejects_template_missing_blank_line() {
+ let err = parse_template("Subject: only one line").unwrap_err();
+ assert!(format!("{err:?}").contains("blank line"));
+ }
+
+ #[test]
+ fn renders_variables_into_template() {
+ let rendered = render(
+ "Hello {{ name }}, click {{ url }}",
+ &[("name", "Ada"), ("url", "https://example.test")],
+ )
+ .expect("render");
+ assert_eq!(rendered, "Hello Ada, click https://example.test");
+ }
+
+ fn tempfile_dir() -> PathBuf {
+ let dir = std::env::temp_dir().join(format!(
+ "atom-mail-test-{}-{:?}",
+ std::process::id(),
+ std::time::Instant::now()
+ ));
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+}
diff --git a/src/tenants/email.rs b/src/tenants/email.rs
index 01d8c2e..05c8e5a 100644
--- a/src/tenants/email.rs
+++ b/src/tenants/email.rs
@@ -1,12 +1,4 @@
-use lettre::{
- message::header::ContentType, transport::smtp::authentication::Credentials, AsyncSmtpTransport,
- AsyncTransport, Message, Tokio1Executor,
-};
-
-use crate::{
- config::{Config, SmtpTls},
- error::AppError,
-};
+use crate::{config::Config, error::AppError, mail};
pub(crate) async fn send_invitation_email(
cfg: &Config,
@@ -15,55 +7,14 @@ pub(crate) async fn send_invitation_email(
token: &str,
) -> Result<(), AppError> {
let invitation_url = url_with_params(redirect_url, &[("token", token)]);
- let Some(smtp) = cfg.smtp.as_ref() else {
- if cfg.dev_allow_unverified_email_login {
- tracing::warn!(
- email,
- invitation_url,
- "SMTP is not configured; skipping invitation email in development bypass mode"
- );
- return Ok(());
- }
- return Err(AppError::Internal(anyhow::anyhow!(
- "SMTP is not configured"
- )));
- };
-
- let message = Message::builder()
- .from(
- smtp.from
- .parse()
- .map_err(|e| AppError::bad_request(format!("invalid SMTP from address: {e}")))?,
- )
- .to(email
- .parse()
- .map_err(|e| AppError::bad_request(format!("invalid email address: {e}")))?)
- .subject("You have been invited")
- .header(ContentType::TEXT_PLAIN)
- .body(format!(
- "Accept your invitation by opening this link:\n\n{invitation_url}\n"
- ))
- .map_err(|e| AppError::Internal(anyhow::anyhow!("build email: {e}")))?;
-
- let mut builder = match smtp.tls {
- SmtpTls::None => AsyncSmtpTransport::::builder_dangerous(&smtp.host),
- SmtpTls::StartTls => AsyncSmtpTransport::::starttls_relay(&smtp.host)
- .map_err(|e| AppError::Internal(anyhow::anyhow!("smtp starttls: {e}")))?,
- SmtpTls::Tls => AsyncSmtpTransport::::relay(&smtp.host)
- .map_err(|e| AppError::Internal(anyhow::anyhow!("smtp tls: {e}")))?,
- }
- .port(smtp.port);
-
- if let (Some(username), Some(password)) = (&smtp.username, &smtp.password) {
- builder = builder.credentials(Credentials::new(username.clone(), password.clone()));
- }
-
- builder
- .build()
- .send(message)
- .await
- .map_err(|e| AppError::Internal(anyhow::anyhow!("send invitation email: {e}")))?;
- Ok(())
+ mail::send_templated_email(
+ cfg,
+ mail::EmailTemplate::Invitation,
+ email,
+ &invitation_url,
+ &[("invitation_url", invitation_url.as_str())],
+ )
+ .await
}
fn url_with_params(base: &str, params: &[(&str, &str)]) -> String {