diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 308fae9..dea5311 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,8 @@ jobs: MAILIFY_SMTP__HOST: localhost MAILIFY_SMTP__PORT: "1025" MAILIFY_SMTP__TLS: none + MAILPIT_API_URL: http://localhost:8025 + MAILIFY_DOTENV: "false" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable diff --git a/.gitignore b/.gitignore index 2833880..9c62d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ templates-parser/emails/catalog.json # Logs *.log render-test +TODO.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f3bd081 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +All routine tasks go through the `Makefile` (run `make help` for the full list). + +### Rust +- `make dev` — `cargo run --bin mailify` (assumes Postgres + Mailpit already up via `make up-deps`) +- `make build` — release build +- `make test` — `cargo test --workspace` +- `make check` — `cargo check --workspace --all-targets` +- `make clippy` — `cargo clippy --workspace --all-targets -- -D warnings` (CI treats warnings as errors) +- `make fmt` / `make fmt-check` +- `make ci` — same checks as CI: fmt-check + clippy + test + +Run a single test: `cargo test -p ` (e.g. `cargo test -p mailify-auth jwt::tests::issues_and_validates`). Testcontainers is declared but integration tests are still planned. + +### Templates (Bun / React Email) +- `make setup` — `bun install` inside `templates-parser/` (run once) +- `make gen` — regenerate `.tsx` + sidecar files from `scripts/templates.config.ts` +- `make build-templates` — full pipeline: generate → `email export` → `post-build.ts` (entity-decodes minijinja spans, copies `subject.*.txt` / `text.*.txt`, writes `catalog.json`) into `templates-parser/out//.html` +- `make dev-templates` — React Email preview server on `:3000` + +Rust reads the compiled HTML at boot from `MAILIFY_TEMPLATES__PATH` (default `./templates-parser/out`). Editing a `.tsx` requires `make build-templates` before restarting the server. + +### Docker stack +- `make up` — full stack (postgres + mailpit + mailify) via `docker-compose.yml` +- `make up-deps` — only postgres + mailpit (for local `make dev`) +- `make down` / `make down-volumes` (drops queue data) +- `make docker-build` — local image `mailify:local` + +### Ops helpers +- `make hash-key KEY= ID=<id>` — argon2-hash an API key; prints the `MAILIFY_AUTH__API_KEYS__<ID>=…` line to paste into `.env`. Uses `cargo run -p mailify-auth --example hash-key`. +- `make issue-token SUBJECT=<sub> SCOPES=<csv>` — mint a JWT offline with the server's secret (`--example issue-token`). +- `make openapi` — curl the running server's `/api-docs/openapi.json` into `openapi.json`. + +## Architecture + +Cargo workspace (`resolver = "2"`). Seven crates layered so that `mailify-api` is the only binary: + +``` +mailify-core → domain types (EmailMessage, Priority, SmtpOverride, CoreError) +mailify-config → figment loader + Theme (TOML + dotenv + env; precedence: defaults → TOML → .env chain → process env) +mailify-templates → TemplateRegistry (loads compiled HTML dir) + minijinja renderer +mailify-smtp → lettre wrapper, accepts per-job SmtpOverride (in-memory credentials) +mailify-queue → apalis + apalis-sql/postgres MailJob storage + worker runtime +mailify-auth → argon2 API-key verify + JWT issuer + axum `require_jwt` middleware +mailify-api → axum router, OpenAPI (utoipa), Swagger UI, binary `mailify` +``` + +### Request → send flow +1. `POST /auth/token` verifies `api_key` against the argon2 hash in `cfg.auth.api_keys` and returns a short-lived JWT. +2. Protected routes run through `AuthLayer` / `require_jwt` (bearer in `Authorization`). +3. `/mail/send` resolves a `template_id` against `TemplateRegistry`; `/mail/send-custom` accepts raw HTML + optional `smtp_override`. +4. Handlers build a `MailJob` and push it onto `QueueHandle` (apalis `PostgresStorage<MailJob>`). Priority comes from `Priority::weight()` (lower = earlier). +5. `QueueRuntime::run` (spawned in `main.rs`) runs the apalis worker with `worker_concurrency`, `max_retries`, `retry_backoff_secs`. Worker renders the template via `TemplateRenderer` with a `RenderContext { theme, vars, locale }`, then dispatches through `default_sender` **or** a per-job `SmtpSender` built from `SmtpOverride`. +6. Jobs persist in Postgres (`PostgresStorage::setup` runs apalis migrations at boot) — they survive restarts and resume. + +### Config precedence (`AppConfig::load`) +1. Built-in defaults (`AppConfig::default` in `crates/mailify-config/src/lib.rs:117`) +2. Optional TOML at `$MAILIFY_CONFIG` +3. Dotenv chain (first match wins, does NOT override already-set vars): `$MAILIFY_DOTENV_PATH` → `.env.<MAILIFY_ENV>.local` → `.env.<MAILIFY_ENV>` → `.env.local` → `.env` → `../../.env`. Disable with `MAILIFY_DOTENV=false`. +4. Env vars prefixed `MAILIFY_`, nested via `__` (e.g. `MAILIFY_THEME__COLORS__PRIMARY=#2563eb`). + +`AppConfig` is cloned into `AppState` (shared `Arc`) + passed to `QueueRuntime::init`. Re-theming / re-branding is a config change only — templates read `{{ theme.* }}` at render time. + +### Template contract +Directory layout the Rust registry expects at `templates.path`: +``` +<id>/<locale>.html # pre-rendered React Email output with minijinja spans intact +<id>/subject.<locale>.txt # optional, minijinja-rendered at send +<id>/text.<locale>.txt # optional plaintext alt +``` +Source of truth is `templates-parser/scripts/templates.config.ts`. The `post-build.ts` step is critical: React Email HTML-encodes `{{ }}` / `{% %}`; post-build decodes them back so minijinja can parse at runtime. `strict` mode fails boot if any built-in id is missing for the default locale. + +### Docker build +Three-stage `docker/Dockerfile`: `oven/bun:1.3-alpine` compiles templates → `rust:1.82-slim` builds the release binary (cargo registry + `/w/target` cache mounts) → `gcr.io/distroless/cc-debian12:nonroot` ships `/app/mailify` + `/app/templates` (~20 MB, non-root, port 8080). CI builds multi-arch (linux/amd64 + linux/arm64) and pushes to Docker Hub on `v*` tag; requires `DOCKERHUB_USERNAME` + `DOCKERHUB_TOKEN` secrets. diff --git a/Cargo.lock b/Cargo.lock index f265247..83d7db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -1343,6 +1343,7 @@ dependencies = [ "mailify-queue", "mailify-smtp", "mailify-templates", + "reqwest", "serde", "serde_json", "thiserror 1.0.69", @@ -1353,6 +1354,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "urlencoding", "utoipa", "utoipa-swagger-ui", "uuid", @@ -1418,6 +1420,7 @@ dependencies = [ "mailify-core", "mailify-smtp", "mailify-templates", + "reqwest", "serde", "serde_json", "sqlx", @@ -1425,6 +1428,7 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "urlencoding", "uuid", ] @@ -1437,9 +1441,13 @@ dependencies = [ "lettre", "mailify-config", "mailify-core", + "reqwest", + "serde_json", "thiserror 1.0.69", "tokio", "tracing", + "urlencoding", + "uuid", ] [[package]] @@ -3188,6 +3196,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index dd504c3..ffc1908 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ [workspace.package] version = "0.1.0" edition = "2021" -rust-version = "1.80" +rust-version = "1.88" authors = ["Doni Lite"] license = "MIT" repository = "https://github.com/donilite/mailify" diff --git a/crates/mailify-api/Cargo.toml b/crates/mailify-api/Cargo.toml index be3ad18..4f98cb8 100644 --- a/crates/mailify-api/Cargo.toml +++ b/crates/mailify-api/Cargo.toml @@ -40,3 +40,11 @@ utoipa-swagger-ui = { workspace = true } [dev-dependencies] tokio = { workspace = true } tower = { workspace = true } +tokio-util = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +urlencoding = "2" +mailify-auth = { path = "../mailify-auth" } +mailify-queue = { path = "../mailify-queue" } +mailify-smtp = { path = "../mailify-smtp" } +mailify-templates = { path = "../mailify-templates" } +mailify-core = { path = "../mailify-core" } diff --git a/crates/mailify-api/tests/it_e2e.rs b/crates/mailify-api/tests/it_e2e.rs new file mode 100644 index 0000000..ea7c8e4 --- /dev/null +++ b/crates/mailify-api/tests/it_e2e.rs @@ -0,0 +1,259 @@ +//! End-to-end integration test — spins up the full axum app (router, queue, SMTP), issues a JWT, +//! POSTs `/mail/send-custom`, and verifies the message hits Mailpit. +//! +//! Skipped unless `MAILIFY_DATABASE__URL` is set *and* Mailpit is reachable. + +use std::{sync::Arc, time::Duration}; + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use mailify_auth::JwtIssuer; +use mailify_config::{ + AppConfig, AuthConfig, DatabaseConfig, I18nConfig, LogFormat, ObservabilityConfig, QueueConfig, + ServerConfig, SmtpConfig, TemplatesConfig, Theme, +}; +use mailify_core::smtp_override::TlsMode; +use mailify_queue::{worker::WorkerDeps, QueueRuntime}; +use mailify_smtp::SmtpSender; +use mailify_templates::TemplateRegistry; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; + +fn database_url() -> Option<String> { + std::env::var("MAILIFY_DATABASE__URL").ok() +} +fn smtp_host() -> String { + std::env::var("MAILIFY_SMTP__HOST").unwrap_or_else(|_| "localhost".to_string()) +} +fn mailpit_api() -> String { + std::env::var("MAILPIT_API_URL").unwrap_or_else(|_| "http://localhost:8025".to_string()) +} + +async fn mailpit_reachable() -> bool { + reqwest::get(format!("{}/api/v1/info", mailpit_api())) + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) +} + +fn build_test_cfg(url: String) -> AppConfig { + AppConfig { + server: ServerConfig { + host: "0.0.0.0".into(), + port: 0, + request_timeout_secs: 30, + body_limit_bytes: 1024 * 1024, + }, + database: DatabaseConfig { + url, + max_connections: 4, + min_connections: 1, + }, + smtp: SmtpConfig { + host: smtp_host(), + port: 1025, + username: None, + password: None, + tls: TlsMode::None, + default_from_email: "from@mailify.test".into(), + default_from_name: Some("Mailify E2E".into()), + timeout_secs: 10, + }, + auth: AuthConfig { + jwt_secret: "e2e-secret".into(), + jwt_issuer: "mailify-e2e".into(), + jwt_ttl_secs: 300, + api_keys: Default::default(), + }, + queue: QueueConfig { + worker_concurrency: 2, + max_retries: 0, + retry_backoff_secs: 1, + }, + templates: TemplatesConfig { + path: std::path::PathBuf::from("./out"), + strict: false, + }, + theme: Theme::default(), + i18n: I18nConfig { + default_locale: "en".into(), + fallback_chain: vec!["en".into()], + supported_locales: vec!["en".into()], + }, + observability: ObservabilityConfig { + log_level: "warn".into(), + log_format: LogFormat::Pretty, + }, + } +} + +#[tokio::test] +async fn send_custom_via_http_delivers_to_mailpit() { + let Some(url) = database_url() else { + eprintln!("SKIP: MAILIFY_DATABASE__URL not set"); + return; + }; + if !mailpit_reachable().await { + eprintln!("SKIP: mailpit not reachable"); + return; + } + + // Purge previous messages. + let _ = reqwest::Client::new() + .delete(format!("{}/api/v1/messages", mailpit_api())) + .send() + .await; + + let cfg = build_test_cfg(url); + let registry = Arc::new(TemplateRegistry::empty(cfg.i18n.clone())); + let sender = Arc::new(SmtpSender::from_config(&cfg.smtp).expect("smtp")); + + let (runtime, queue_handle) = QueueRuntime::init( + &cfg, + WorkerDeps { + registry: registry.clone(), + default_sender: sender.clone(), + theme: cfg.theme.clone(), + }, + ) + .await + .expect("queue init"); + + let cancel = CancellationToken::new(); + let worker_cancel = cancel.clone(); + let worker = tokio::spawn(async move { runtime.run(worker_cancel).await }); + + let jwt_issuer = Arc::new(JwtIssuer::new( + cfg.auth.jwt_secret.clone(), + cfg.auth.jwt_issuer.clone(), + cfg.auth.jwt_ttl_secs, + )); + let token = jwt_issuer.issue("e2e-test", vec![]).expect("issue"); + + let state = mailify_api::AppState { + cfg: Arc::new(cfg.clone()), + registry: registry.clone(), + queue: queue_handle, + jwt: jwt_issuer, + }; + let app = mailify_api::build_router(state); + + let marker = format!("mailify-e2e-{}", uuid::Uuid::new_v4()); + let body = serde_json::json!({ + "html": format!("<p>{marker}</p>"), + "subject": marker, + "to": [{"email": "recipient@mailify.test"}], + "priority": "critical" + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/mail/send-custom") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .expect("http"); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), 8 * 1024) + .await + .unwrap(); + let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(resp["status"], "queued"); + + // Wait for mailpit delivery. + let search_url = format!( + "{}/api/v1/search?query={}", + mailpit_api(), + urlencoding::encode(&format!("subject:\"{marker}\"")) + ); + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let mut delivered = false; + while std::time::Instant::now() < deadline { + let resp: serde_json::Value = match reqwest::get(&search_url) + .await + .and_then(|r| r.error_for_status()) + { + Ok(r) => r.json().await.unwrap_or(serde_json::Value::Null), + Err(_) => { + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + }; + if resp.get("total").and_then(|v| v.as_u64()).unwrap_or(0) > 0 { + delivered = true; + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + + cancel.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), worker).await; + + assert!(delivered, "e2e: email not delivered to mailpit"); +} + +#[tokio::test] +async fn protected_routes_reject_missing_token() { + let app = mailify_api::build_router(mailify_api::AppState { + cfg: Arc::new(build_test_cfg("postgres://x/x".into())), + registry: Arc::new(TemplateRegistry::empty(I18nConfig { + default_locale: "en".into(), + fallback_chain: vec!["en".into()], + supported_locales: vec!["en".into()], + })), + queue: { + // Can't create a real QueueHandle without postgres, so skip if no DB. + let Some(url) = database_url() else { + eprintln!("SKIP: MAILIFY_DATABASE__URL not set"); + return; + }; + let cfg = build_test_cfg(url); + let (_rt, handle) = QueueRuntime::init( + &cfg, + WorkerDeps { + registry: Arc::new(TemplateRegistry::empty(cfg.i18n.clone())), + default_sender: Arc::new(SmtpSender::from_config(&cfg.smtp).expect("smtp")), + theme: cfg.theme.clone(), + }, + ) + .await + .expect("queue init"); + handle + }, + jwt: Arc::new(JwtIssuer::new("s", "mailify", 60)), + }); + + let resp = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + let resp = app + .oneshot( + Request::builder() + .method("GET") + .uri("/templates") + .header("authorization", "Bearer bogus.jwt.value") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} diff --git a/crates/mailify-config/tests/load.rs b/crates/mailify-config/tests/load.rs new file mode 100644 index 0000000..1f926ef --- /dev/null +++ b/crates/mailify-config/tests/load.rs @@ -0,0 +1,81 @@ +//! AppConfig loader behavior: defaults, env override, nested keys. +//! +//! Mutating `std::env` is process-global — these tests run serially within this file via shared +//! state, but should not be split across threads with conflicting env mutations. Each test uses +//! a distinct env prefix to avoid cross-contamination. + +use std::sync::Mutex; + +use mailify_config::{AppConfig, LogFormat}; +use mailify_core::smtp_override::TlsMode; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +fn clear_mailify_vars() { + for (k, _) in std::env::vars() { + if k.starts_with("MAILIFY_") { + unsafe { std::env::remove_var(&k) }; + } + } + unsafe { std::env::set_var("MAILIFY_DOTENV", "false") }; +} + +#[test] +fn defaults_load_when_no_env() { + let _g = ENV_LOCK.lock().unwrap(); + clear_mailify_vars(); + + let cfg = AppConfig::load().expect("load defaults"); + assert_eq!(cfg.server.port, 8080); + assert_eq!(cfg.i18n.default_locale, "en"); + assert_eq!(cfg.observability.log_format, LogFormat::Pretty); + assert_eq!(cfg.smtp.tls, TlsMode::None); +} + +#[test] +fn env_override_top_level_and_nested() { + let _g = ENV_LOCK.lock().unwrap(); + clear_mailify_vars(); + unsafe { + std::env::set_var("MAILIFY_SERVER__PORT", "9090"); + std::env::set_var("MAILIFY_SMTP__HOST", "smtp.example.com"); + std::env::set_var("MAILIFY_SMTP__TLS", "tls"); + std::env::set_var("MAILIFY_THEME__BRAND_NAME", "Acme"); + std::env::set_var("MAILIFY_OBSERVABILITY__LOG_FORMAT", "json"); + } + + let cfg = AppConfig::load().expect("load with env"); + assert_eq!(cfg.server.port, 9090); + assert_eq!(cfg.smtp.host, "smtp.example.com"); + assert_eq!(cfg.smtp.tls, TlsMode::Tls); + assert_eq!(cfg.theme.brand_name, "Acme"); + assert_eq!(cfg.observability.log_format, LogFormat::Json); +} + +#[test] +fn api_keys_map_loaded_from_env() { + let _g = ENV_LOCK.lock().unwrap(); + clear_mailify_vars(); + unsafe { + std::env::set_var("MAILIFY_AUTH__API_KEYS__WEB", "$argon2id$fakehash1"); + std::env::set_var("MAILIFY_AUTH__API_KEYS__CLI", "$argon2id$fakehash2"); + } + + let cfg = AppConfig::load().expect("load"); + let keys: std::collections::HashSet<&str> = + cfg.auth.api_keys.keys().map(String::as_str).collect(); + assert!(keys.contains("web")); + assert!(keys.contains("cli")); + assert_eq!(cfg.auth.api_keys["web"], "$argon2id$fakehash1"); +} + +#[test] +fn invalid_port_returns_error() { + let _g = ENV_LOCK.lock().unwrap(); + clear_mailify_vars(); + unsafe { std::env::set_var("MAILIFY_SERVER__PORT", "not-a-number") }; + + let err = AppConfig::load().expect_err("port must fail to parse"); + let s = err.to_string(); + assert!(s.contains("port") || s.to_lowercase().contains("invalid")); +} diff --git a/crates/mailify-core/tests/smtp_override.rs b/crates/mailify-core/tests/smtp_override.rs new file mode 100644 index 0000000..a285080 --- /dev/null +++ b/crates/mailify-core/tests/smtp_override.rs @@ -0,0 +1,42 @@ +use mailify_core::smtp_override::{SmtpOverride, TlsMode}; + +#[test] +fn tls_mode_display() { + assert_eq!(TlsMode::None.to_string(), "none"); + assert_eq!(TlsMode::StartTls.to_string(), "starttls"); + assert_eq!(TlsMode::Tls.to_string(), "tls"); +} + +#[test] +fn tls_mode_serialization_lowercase() { + assert_eq!( + serde_json::to_string(&TlsMode::StartTls).unwrap(), + "\"starttls\"" + ); + let back: TlsMode = serde_json::from_str("\"tls\"").unwrap(); + assert_eq!(back, TlsMode::Tls); +} + +#[test] +fn smtp_override_default_tls_is_starttls_when_field_missing() { + let s: SmtpOverride = + serde_json::from_str(r#"{"host":"smtp.example.com","port":587}"#).expect("deserialize"); + assert_eq!(s.tls, TlsMode::StartTls); +} + +#[test] +fn smtp_override_roundtrip() { + let s = SmtpOverride { + host: "smtp.example.com".into(), + port: 465, + username: Some("user".into()), + password: Some("pass".into()), + tls: TlsMode::Tls, + timeout_secs: Some(30), + }; + let json = serde_json::to_string(&s).unwrap(); + let back: SmtpOverride = serde_json::from_str(&json).unwrap(); + assert_eq!(back.host, s.host); + assert_eq!(back.port, s.port); + assert_eq!(back.tls, s.tls); +} diff --git a/crates/mailify-queue/Cargo.toml b/crates/mailify-queue/Cargo.toml index d9f248b..29b0420 100644 --- a/crates/mailify-queue/Cargo.toml +++ b/crates/mailify-queue/Cargo.toml @@ -24,3 +24,9 @@ tracing = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } async-trait = { workspace = true } + +[dev-dependencies] +uuid = { workspace = true } +serde_json = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +urlencoding = "2" diff --git a/crates/mailify-queue/tests/it_postgres.rs b/crates/mailify-queue/tests/it_postgres.rs new file mode 100644 index 0000000..55b8e54 --- /dev/null +++ b/crates/mailify-queue/tests/it_postgres.rs @@ -0,0 +1,191 @@ +//! Integration test — verifies the queue persists jobs in Postgres and that a worker dispatches +//! them all the way through the renderer + SMTP sender (via Mailpit). +//! +//! Skipped unless `MAILIFY_DATABASE__URL` is set and reachable. + +use std::sync::Arc; +use std::time::Duration; + +use mailify_config::{ + AppConfig, AuthConfig, DatabaseConfig, I18nConfig, LogFormat, ObservabilityConfig, QueueConfig, + ServerConfig, SmtpConfig, TemplatesConfig, Theme, +}; +use mailify_core::{email::EmailAddress, priority::Priority, smtp_override::TlsMode}; +use mailify_queue::{ + job::{MailJob, MailJobKind}, + worker::WorkerDeps, + QueueRuntime, +}; +use mailify_smtp::SmtpSender; +use mailify_templates::TemplateRegistry; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +fn database_url() -> Option<String> { + std::env::var("MAILIFY_DATABASE__URL").ok() +} + +fn smtp_host() -> String { + std::env::var("MAILIFY_SMTP__HOST").unwrap_or_else(|_| "localhost".to_string()) +} + +fn mailpit_api() -> String { + std::env::var("MAILPIT_API_URL").unwrap_or_else(|_| "http://localhost:8025".to_string()) +} + +async fn mailpit_reachable() -> bool { + let url = format!("{}/api/v1/info", mailpit_api()); + reqwest::get(&url) + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) +} + +fn test_cfg(url: String) -> AppConfig { + AppConfig { + server: ServerConfig { + host: "0.0.0.0".into(), + port: 0, + request_timeout_secs: 30, + body_limit_bytes: 1024 * 1024, + }, + database: DatabaseConfig { + url, + max_connections: 4, + min_connections: 1, + }, + smtp: SmtpConfig { + host: smtp_host(), + port: 1025, + username: None, + password: None, + tls: TlsMode::None, + default_from_email: "from@mailify.test".into(), + default_from_name: None, + timeout_secs: 10, + }, + auth: AuthConfig { + jwt_secret: "test".into(), + jwt_issuer: "mailify".into(), + jwt_ttl_secs: 60, + api_keys: Default::default(), + }, + queue: QueueConfig { + worker_concurrency: 2, + max_retries: 0, + retry_backoff_secs: 1, + }, + templates: TemplatesConfig { + path: std::path::PathBuf::from("./out"), + strict: false, + }, + theme: Theme::default(), + i18n: I18nConfig { + default_locale: "en".into(), + fallback_chain: vec!["en".into()], + supported_locales: vec!["en".into()], + }, + observability: ObservabilityConfig { + log_level: "warn".into(), + log_format: LogFormat::Pretty, + }, + } +} + +#[tokio::test] +async fn queue_persists_and_worker_delivers_to_mailpit() { + let Some(url) = database_url() else { + eprintln!("SKIP: MAILIFY_DATABASE__URL not set"); + return; + }; + if !mailpit_reachable().await { + eprintln!("SKIP: mailpit not reachable"); + return; + } + + let cfg = test_cfg(url); + let registry = Arc::new(TemplateRegistry::empty(cfg.i18n.clone())); + let sender = Arc::new(SmtpSender::from_config(&cfg.smtp).expect("smtp sender")); + + // Purge previous messages so our assertion is unambiguous. + let _ = reqwest::Client::new() + .delete(format!("{}/api/v1/messages", mailpit_api())) + .send() + .await; + + let (runtime, mut handle) = QueueRuntime::init( + &cfg, + WorkerDeps { + registry: registry.clone(), + default_sender: sender.clone(), + theme: cfg.theme.clone(), + }, + ) + .await + .expect("queue init"); + + let cancel = CancellationToken::new(); + let worker_cancel = cancel.clone(); + let worker = tokio::spawn(async move { runtime.run(worker_cancel).await }); + + let marker = format!("mailify-queue-it-{}", Uuid::new_v4()); + let job = MailJob { + id: Uuid::new_v4(), + priority: Priority::Critical, + kind: MailJobKind::Custom { + html: format!("<p>{marker}</p>"), + subject: marker.clone(), + text: None, + }, + from: EmailAddress { + email: "from@mailify.test".into(), + name: None, + }, + to: vec![EmailAddress { + email: "to@mailify.test".into(), + name: None, + }], + cc: vec![], + bcc: vec![], + reply_to: None, + attachments: vec![], + headers: Default::default(), + locale: "en".into(), + vars: serde_json::Value::Null, + smtp_override: None, + subject_override: None, + }; + let job_id = handle.push(job).await.expect("push"); + eprintln!("enqueued job {job_id}"); + + // Wait for worker to deliver. + let deadline = std::time::Instant::now() + Duration::from_secs(20); + let search_url = format!( + "{}/api/v1/search?query={}", + mailpit_api(), + urlencoding::encode(&format!("subject:\"{marker}\"")) + ); + let mut delivered = false; + while std::time::Instant::now() < deadline { + let resp: serde_json::Value = match reqwest::get(&search_url) + .await + .and_then(|r| r.error_for_status()) + { + Ok(r) => r.json().await.unwrap_or(serde_json::Value::Null), + Err(_) => { + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + }; + if resp.get("total").and_then(|v| v.as_u64()).unwrap_or(0) > 0 { + delivered = true; + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + + cancel.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(5), worker).await; + + assert!(delivered, "queued job was not delivered to mailpit"); +} diff --git a/crates/mailify-queue/tests/job_serde.rs b/crates/mailify-queue/tests/job_serde.rs new file mode 100644 index 0000000..0ac6250 --- /dev/null +++ b/crates/mailify-queue/tests/job_serde.rs @@ -0,0 +1,80 @@ +use mailify_core::{email::EmailAddress, priority::Priority}; +use mailify_queue::job::{MailJob, MailJobKind}; +use serde_json::json; + +fn addr(e: &str) -> EmailAddress { + EmailAddress { + email: e.into(), + name: None, + } +} + +#[test] +fn registered_job_roundtrips_through_json() { + let job = MailJob { + id: uuid::Uuid::nil(), + priority: Priority::High, + kind: MailJobKind::Registered { + template_id: "welcome".into(), + }, + from: addr("from@example.com"), + to: vec![addr("to@example.com")], + cc: vec![], + bcc: vec![], + reply_to: None, + attachments: vec![], + headers: Default::default(), + locale: "fr".into(), + vars: json!({ "name": "Alice" }), + smtp_override: None, + subject_override: Some("Custom".into()), + }; + + let s = serde_json::to_string(&job).unwrap(); + let back: MailJob = serde_json::from_str(&s).unwrap(); + assert_eq!(back.id, job.id); + assert_eq!(back.priority, Priority::High); + assert_eq!(back.locale, "fr"); + match back.kind { + MailJobKind::Registered { template_id } => assert_eq!(template_id, "welcome"), + _ => panic!("expected Registered"), + } +} + +#[test] +fn custom_job_serialization_preserves_kind() { + let job = MailJob { + id: uuid::Uuid::nil(), + priority: Priority::Bulk, + kind: MailJobKind::Custom { + html: "<p>hi</p>".into(), + subject: "subject".into(), + text: Some("hi".into()), + }, + from: addr("x@x"), + to: vec![addr("y@y")], + cc: vec![], + bcc: vec![], + reply_to: None, + attachments: vec![], + headers: Default::default(), + locale: "en".into(), + vars: serde_json::Value::Null, + smtp_override: None, + subject_override: None, + }; + let s = serde_json::to_string(&job).unwrap(); + assert!(s.contains("\"type\":\"custom\"")); + let back: MailJob = serde_json::from_str(&s).unwrap(); + match back.kind { + MailJobKind::Custom { html, .. } => assert_eq!(html, "<p>hi</p>"), + _ => panic!("expected Custom"), + } +} + +#[test] +fn smtp_override_omitted_from_json_when_none() { + let job = MailJob::new_registered("welcome", addr("f@f"), vec![addr("t@t")], "en"); + let s = serde_json::to_string(&job).unwrap(); + assert!(!s.contains("smtp_override")); +} diff --git a/crates/mailify-smtp/Cargo.toml b/crates/mailify-smtp/Cargo.toml index 7978b45..66d0789 100644 --- a/crates/mailify-smtp/Cargo.toml +++ b/crates/mailify-smtp/Cargo.toml @@ -15,3 +15,11 @@ tracing = { workspace = true } async-trait = { workspace = true } tokio = { workspace = true } base64 = "0.22" + +[dev-dependencies] +tokio = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde_json = { workspace = true } +uuid = { workspace = true } +urlencoding = "2" +mailify-config = { path = "../mailify-config" } diff --git a/crates/mailify-smtp/src/lib.rs b/crates/mailify-smtp/src/lib.rs index 3e7299f..d3253c1 100644 --- a/crates/mailify-smtp/src/lib.rs +++ b/crates/mailify-smtp/src/lib.rs @@ -184,3 +184,115 @@ fn fallback_text(html: &str) -> String { } out.split_whitespace().collect::<Vec<_>>().join(" ") } + +#[cfg(test)] +mod tests { + use super::*; + use mailify_core::email::EmailAddress; + + #[test] + fn fallback_text_strips_tags_and_collapses_whitespace() { + let html = "<p>Hello <b>world</b></p>\n<p> Second </p>"; + assert_eq!(fallback_text(html), "Hello world Second"); + } + + #[test] + fn fallback_text_handles_empty_input() { + assert_eq!(fallback_text(""), ""); + assert_eq!(fallback_text("<p></p>"), ""); + } + + #[test] + fn parse_mailbox_plain_address() { + let a = EmailAddress { + email: "alice@example.com".into(), + name: None, + }; + let mb = parse_mailbox(&a).expect("parse"); + assert_eq!(mb.email.to_string(), "alice@example.com"); + } + + #[test] + fn parse_mailbox_with_display_name() { + let a = EmailAddress { + email: "bob@example.com".into(), + name: Some("Bob Smith".into()), + }; + let mb = parse_mailbox(&a).expect("parse"); + let formatted = mb.to_string(); + assert!(formatted.contains("Bob Smith")); + assert!(formatted.contains("bob@example.com")); + } + + #[test] + fn parse_mailbox_rejects_invalid() { + let a = EmailAddress { + email: "not-an-email".into(), + name: None, + }; + assert!(parse_mailbox(&a).is_err()); + } + + #[test] + fn build_message_sets_subject_and_recipients() { + use mailify_core::email::RenderedEmail; + + let envelope = Envelope { + from: EmailAddress { + email: "from@example.com".into(), + name: None, + }, + to: vec![EmailAddress { + email: "to@example.com".into(), + name: None, + }], + cc: vec![], + bcc: vec![], + reply_to: None, + headers: Default::default(), + attachments: vec![], + }; + let rendered = RenderedEmail { + subject: "Test Subject".into(), + html: "<p>hi</p>".into(), + text: Some("hi".into()), + }; + let msg = build_message(&envelope, &rendered).expect("build"); + let raw = String::from_utf8_lossy(&msg.formatted()).to_string(); + assert!(raw.contains("Subject: Test Subject")); + assert!(raw.contains("to@example.com")); + assert!(raw.contains("from@example.com")); + } + + #[test] + fn build_message_includes_custom_headers() { + use mailify_core::email::RenderedEmail; + + let mut headers = std::collections::HashMap::new(); + headers.insert("X-Mailify-Tag".to_string(), "welcome".to_string()); + + let envelope = Envelope { + from: EmailAddress { + email: "from@example.com".into(), + name: None, + }, + to: vec![EmailAddress { + email: "to@example.com".into(), + name: None, + }], + cc: vec![], + bcc: vec![], + reply_to: None, + headers, + attachments: vec![], + }; + let rendered = RenderedEmail { + subject: "s".into(), + html: "<p>x</p>".into(), + text: None, + }; + let msg = build_message(&envelope, &rendered).expect("build"); + let raw = String::from_utf8_lossy(&msg.formatted()).to_string(); + assert!(raw.contains("X-Mailify-Tag: welcome")); + } +} diff --git a/crates/mailify-smtp/tests/it_mailpit.rs b/crates/mailify-smtp/tests/it_mailpit.rs new file mode 100644 index 0000000..2c6be22 --- /dev/null +++ b/crates/mailify-smtp/tests/it_mailpit.rs @@ -0,0 +1,110 @@ +//! Integration test — sends a real email through Mailpit and verifies it arrived via Mailpit's API. +//! +//! Skipped automatically unless both are reachable: +//! MAILIFY_SMTP__HOST (defaults to localhost) +//! MAILPIT_API_URL (defaults to http://localhost:8025) +//! +//! CI sets these via docker-compose services. Locally, run `make up-deps`. + +use mailify_config::SmtpConfig; +use mailify_core::{ + email::{EmailAddress, RenderedEmail}, + smtp_override::TlsMode, +}; +use mailify_smtp::{Envelope, SmtpSender}; + +fn smtp_host() -> String { + std::env::var("MAILIFY_SMTP__HOST").unwrap_or_else(|_| "localhost".to_string()) +} + +fn mailpit_api() -> String { + std::env::var("MAILPIT_API_URL").unwrap_or_else(|_| "http://localhost:8025".to_string()) +} + +async fn mailpit_reachable() -> bool { + let url = format!("{}/api/v1/info", mailpit_api()); + match reqwest::get(&url).await { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } +} + +async fn purge_mailpit() { + let _ = reqwest::Client::new() + .delete(format!("{}/api/v1/messages", mailpit_api())) + .send() + .await; +} + +#[tokio::test] +async fn sends_email_through_mailpit_end_to_end() { + if !mailpit_reachable().await { + eprintln!("SKIP: mailpit not reachable at {}", mailpit_api()); + return; + } + + purge_mailpit().await; + + let cfg = SmtpConfig { + host: smtp_host(), + port: 1025, + username: None, + password: None, + tls: TlsMode::None, + default_from_email: "from@mailify.test".into(), + default_from_name: Some("Mailify CI".into()), + timeout_secs: 10, + }; + let sender = SmtpSender::from_config(&cfg).expect("build sender"); + + let marker = format!("mailify-it-{}", uuid::Uuid::new_v4()); + let envelope = Envelope { + from: EmailAddress { + email: "from@mailify.test".into(), + name: None, + }, + to: vec![EmailAddress { + email: "to@mailify.test".into(), + name: None, + }], + cc: vec![], + bcc: vec![], + reply_to: None, + headers: Default::default(), + attachments: vec![], + }; + let rendered = RenderedEmail { + subject: marker.clone(), + html: format!("<p>{marker}</p>"), + text: Some(marker.clone()), + }; + + sender.send(&envelope, &rendered).await.expect("send"); + + // Poll mailpit API for the message. Mailpit ingests within milliseconds but give some slack. + let url = format!( + "{}/api/v1/search?query={}", + mailpit_api(), + urlencoding::encode(&format!("subject:\"{marker}\"")) + ); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let mut found = false; + while std::time::Instant::now() < deadline { + let resp: serde_json::Value = reqwest::get(&url) + .await + .expect("query mailpit") + .json() + .await + .expect("parse json"); + if resp.get("total").and_then(|v| v.as_u64()).unwrap_or(0) > 0 { + found = true; + break; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + assert!( + found, + "email with subject {marker} not delivered to mailpit" + ); +} diff --git a/crates/mailify-templates/tests/renderer.rs b/crates/mailify-templates/tests/renderer.rs index 050a428..98442a8 100644 --- a/crates/mailify-templates/tests/renderer.rs +++ b/crates/mailify-templates/tests/renderer.rs @@ -49,3 +49,54 @@ fn renders_theme_tokens() { .unwrap(); assert!(out.html.contains("color: #0f172a")); } + +#[test] +fn renders_minijinja_control_flow() { + let reg = empty_registry(); + let renderer = TemplateRenderer::new(&reg); + + let html = "{% if vars.name %}Hi {{ vars.name }}{% else %}Hi there{% endif %}"; + let with = renderer + .render_raw(html, "s", None, &ctx(json!({ "name": "Ada" }))) + .unwrap(); + assert!(with.html.contains("Hi Ada")); + + let without = renderer + .render_raw(html, "s", None, &ctx(json!({}))) + .unwrap(); + assert!(without.html.contains("Hi there")); +} + +#[test] +fn render_accepts_missing_vars_gracefully_with_default() { + let reg = empty_registry(); + let renderer = TemplateRenderer::new(&reg); + let out = renderer + .render_raw( + "Hello {{ vars.name or 'stranger' }}", + "s", + None, + &ctx(json!({})), + ) + .unwrap(); + assert!(out.html.contains("Hello stranger")); +} + +#[test] +fn render_invalid_syntax_returns_error() { + let reg = empty_registry(); + let renderer = TemplateRenderer::new(&reg); + // Unterminated {% %} block. + let res = renderer.render_raw("{% if vars.x %}oops", "s", None, &ctx(json!({ "x": true }))); + assert!(res.is_err()); +} + +#[test] +fn locale_is_exposed_to_templates() { + let reg = empty_registry(); + let renderer = TemplateRenderer::new(&reg); + let out = renderer + .render_raw("lang={{ locale }}", "s", None, &ctx(json!({}))) + .unwrap(); + assert_eq!(out.html, "lang=en"); +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 7d970b3..787fbf5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -10,7 +10,7 @@ COPY templates-parser/ ./ RUN bun run build # ─────────────────────────── 2. Rust builder ─────────────────────────── -FROM rust:1.82-slim AS rs-builder +FROM rust:1.88-slim AS rs-builder RUN apt-get update && apt-get install -y --no-install-recommends \ pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..b6d42c4 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.88" +components = ["rustfmt", "clippy"] +profile = "minimal"