Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"]}]

Expand Down
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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}"
Expand Down
55 changes: 55 additions & 0 deletions email-templates/README.md
Original file line number Diff line number Diff line change
@@ -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

<!doctype html>
<html>...<a href="{{ verification_url }}">Verify email</a>...</html>
```

| 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.
5 changes: 5 additions & 0 deletions email-templates/invitation.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Subject: You have been invited

Accept your invitation by opening this link:

{{ invitation_url }}
5 changes: 5 additions & 0 deletions email-templates/password_reset.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Subject: Reset your Atom password

Reset your Atom password by opening this link:

{{ reset_url }}
5 changes: 5 additions & 0 deletions email-templates/verification.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Subject: Verify your Atom account

Verify your Atom account by opening this link:

{{ verification_url }}
35 changes: 35 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ pub struct Config {
pub oauth_error_redirect: String,
pub oidc_providers: Vec<OidcProviderConfig>,
pub smtp: Option<SmtpConfig>,
/// 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<String>,
pub email_verification_expiry_secs: u64,
pub invitation_expiry_secs: u64,
pub oauth_state_expiry_secs: u64,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading