Architecture
Architecture
Section titled “Architecture”Mailify is a Cargo workspace (resolver = "2") with seven crates, layered so that only mailify-api is binary-shaped. Every other crate is a reusable lib.
Crate map
Section titled “Crate map”mailify-core → domain types (EmailMessage, Priority, SmtpOverride, CoreError)mailify-config → figment loader + Theme (TOML + dotenv + env)mailify-templates → TemplateRegistry (loads compiled HTML dir) + minijinja renderermailify-smtp → lettre wrapper, accepts per-job SmtpOverridemailify-queue → apalis + apalis-sql/postgres MailJob storage + worker runtimemailify-auth → argon2 API-key verify + JWT issuer + axum require_jwt middlewaremailify-api → axum router, OpenAPI (utoipa), Swagger UI, binary `mailify`mailify-core
Section titled “mailify-core”Pure domain types. No I/O, no async runtime dependencies beyond tokio re-exports. Every other crate depends on it.
Key exports:
EmailMessage,EmailAddress,Attachment— value types for mail content.Priority— the enum used for queue weighting.SmtpOverride,TlsMode— per-job SMTP credentials. TheDebugimpl redacts secrets.CoreError— the error enum all other crates lift viaFrom.
mailify-config
Section titled “mailify-config”Responsible for turning environment + TOML + defaults into a single AppConfig struct at boot.
Built on figment for precedence-aware layering. The only public entry point is AppConfig::load(). Everything else — Theme, ServerConfig, etc. — is exposed so AppConfig can be cloned into AppState downstream.
mailify-templates
Section titled “mailify-templates”Owns the TemplateRegistry (file-system scan at boot) and the TemplateRenderer (minijinja, with theme + vars + locale context injection).
Critically: does not parse React Email. Templates are expected to be pre-rendered HTML. See Template contract for the layout.
mailify-smtp
Section titled “mailify-smtp”A thin wrapper around lettre. Two entry points:
SmtpSender::default_from_config(&SmtpConfig)— the process-wide default sender.SmtpSender::from_override(&SmtpOverride)— a one-off sender for a single job.
The distinction matters for multi-tenant flows — see Per-job SMTP override.
mailify-queue
Section titled “mailify-queue”Wraps apalis with PostgresStorage<MailJob>. Exposes:
QueueHandle— the producer side, used by HTTP handlers to enqueue jobs.QueueRuntime— the consumer side, spawned inmain.rs; runs the worker loop with configurable concurrency and retries.
Also owns the PostgreSQL connection + migrations. Running QueueRuntime::init() runs the apalis migrations automatically.
mailify-auth
Section titled “mailify-auth”Argon2 + JWT. Public surface:
hash_api_key(plaintext) -> ArgonHash— used by thehash-keyCargo example.verify_api_key(plaintext, &hash) -> bool— called by the/auth/tokenhandler.issue_jwt(sub, scopes, ttl, secret, issuer) -> String— mints tokens.require_jwt— the axum middleware applied via.route_layer(...)inmailify-api.
Plus the bootstrap module (ephemeral key generation at boot when no keys are configured).
mailify-api
Section titled “mailify-api”The only binary. Owns:
- The axum router (protected vs. public split).
AppState— theArc-shared state cloned into handlers.- OpenAPI spec via
utoipa+ Swagger UI at/swagger-ui. main.rs— boot sequence, tracing init, DB ping, template load, bootstrap auth, spawn queue worker, start HTTP server.
Request → send flow
Section titled “Request → send flow”Step by step, what happens when a client hits /mail/send:
- Auth. Client calls
POST /auth/tokenwith their plaintext API key.argon2::verify_passwordcompares against hashes incfg.auth.api_keys. On success, returns a short-lived JWT. - Protected request. Client calls
POST /mail/sendwithAuthorization: Bearer <jwt>. Therequire_jwtaxum middleware verifies the signature and claims. On success, the handler runs. - Template lookup. For
/mail/send, the handler looks uptemplate_idinTemplateRegistryto ensure it exists./mail/send-customskips this — the raw HTML comes in the request. - Job construction. Handler builds a
MailJob { id, priority, kind, from, to, locale, vars, smtp_override, ... }. - Enqueue.
QueueHandle::push(&job)inserts intoapalis.jobsviaPostgresStorage. apalis assigns its own ULID (TaskId), which is what Mailify returns to the caller asjob_id— not theMailJob.idUUID. - Worker pickup. The apalis worker loop polls
apalis.jobs, locks the next runnable row, and hands it to the worker fn. - Render. The worker fn builds a
RenderContext { theme, vars, locale }and callsTemplateRenderer::render(&job). ForCustomkind, the HTML is passed through as-is. - Dispatch. If
job.smtp_overrideisSome, build a one-offSmtpSender; otherwise use the process-widedefault_senderfromAppState. Send via lettre. - Persist outcome. apalis marks the job
Doneor incrementsattempts+ storeslast_errorand moves back toPending/Faileddepending on retry count.
Config precedence
Section titled “Config precedence”Implemented in AppConfig::load():
defaults → auto-discovered TOML → env vars (MAILIFY_*)TOML discovery order (first match wins):
$MAILIFY_CONFIG(explicit path)./Mailify.toml$XDG_CONFIG_HOME/mailify/config.toml(or fallback~/.config/mailify/config.toml/%APPDATA%\mailify\config.toml)/etc/mailify/config.toml
See the config reference for the full set of keys.
Data flow diagram (text)
Section titled “Data flow diagram (text)” ┌──────────┐ api_key Client ─┤ Backend ├──────────────► POST /auth/token └────┬─────┘ │ │ JWT ▼ │ argon2::verify │ │ ├───► POST /mail/send ◄─────┘ (bearer JWT) │ │ │ ▼ │ require_jwt │ │ │ ▼ │ TemplateRegistry (lookup) │ │ │ ▼ │ MailJob enqueue │ │ │ ▼ ┌──────────────────┐ │ apalis.jobs (Postgres) ───► worker ──┬──►│ default_sender │ │ │ │ (lettre + SMTP) │ │ │ └──────────────────┘ │ │ ┌──────────────────┐ │ └──►│ SmtpSender │ │ │ (from override) │ │ └──────────────────┘ ▼ GET /mail/jobs/:id ◄──── apalis_sql::fetch_by_id ───── apalis.jobsDockerfile shape
Section titled “Dockerfile shape”Three-stage build:
- tpl-builder (
oven/bun:1.3-alpine) — compiles.tsx→ HTML bundle. - rs-builder (
rust:1.88-slim+cargo-chef) — dep layer first (stable), sources layer second (changes often). Produces./target/release/mailify. - runtime (
gcr.io/distroless/cc-debian12:nonroot) — copies the binary + the template bundle + sets env defaults. No shell, no package manager, no root user.
Final image is ~20 MB and runs as uid nonroot.
Testing philosophy
Section titled “Testing philosophy”- Unit tests live next to the code.
#[cfg(test)] mod testsinside each module. - Integration tests under
crates/mailify-api/tests/spin up the full axum app, a real queue (viatestcontainers— declared, adoption in progress), and Mailpit as an SMTP sink. - CI runs the same
make citarget a contributor runs locally: fmt-check + clippy (-D warnings) + tests.
No mocking of the database. We’d rather rely on a throwaway Postgres in CI than accept the drift between mocked and real behavior.