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 {