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
24 changes: 22 additions & 2 deletions crates/mailify-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ use std::sync::Arc;
use axum::{http::StatusCode, middleware, routing::get, Router};
use mailify_auth::{middleware::AuthLayer, require_jwt};
use tower_http::{
cors::CorsLayer, limit::RequestBodyLimitLayer, timeout::TimeoutLayer, trace::TraceLayer,
cors::CorsLayer,
limit::RequestBodyLimitLayer,
timeout::TimeoutLayer,
trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer},
LatencyUnit,
};
use tracing::Level;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;

Expand All @@ -29,6 +34,7 @@ pub fn build_router(state: AppState) -> Router {
"/mail/send-custom",
axum::routing::post(routes::mail::send_custom),
)
.route("/mail/jobs/:id", get(routes::mail::get_job_state))
.route("/templates", get(routes::templates::list))
.route(
"/templates/:id/preview",
Expand All @@ -52,7 +58,21 @@ pub fn build_router(state: AppState) -> Router {
.merge(protected)
.with_state(Arc::new(state))
.merge(swagger)
.layer(TraceLayer::new_for_http())
.layer(
TraceLayer::new_for_http()
.make_span_with(
DefaultMakeSpan::new()
.level(Level::INFO)
.include_headers(false),
)
.on_request(DefaultOnRequest::new().level(Level::INFO))
.on_response(
DefaultOnResponse::new()
.level(Level::INFO)
.latency_unit(LatencyUnit::Millis)
.include_headers(false),
),
)
.layer(CorsLayer::very_permissive())
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))
.layer(TimeoutLayer::with_status_code(
Expand Down
57 changes: 52 additions & 5 deletions crates/mailify-api/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use std::sync::Arc;

use mailify_api::{build_router, AppState};
use mailify_auth::JwtIssuer;
use mailify_auth::{
generate_bootstrap_key, generate_jwt_secret, print_bootstrap_banner, JwtIssuer,
};
use mailify_config::{AppConfig, LogFormat};
use mailify_queue::{worker::WorkerDeps, QueueRuntime};
use mailify_smtp::SmtpSender;
Expand All @@ -12,11 +14,13 @@ use tracing_subscriber::{prelude::*, EnvFilter};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cfg = AppConfig::load()?;
let mut cfg = AppConfig::load()?;
init_tracing(&cfg);

info!(version = env!("CARGO_PKG_VERSION"), "starting mailify");

maybe_bootstrap_auth(&mut cfg);

info!(api_key_ids = ?cfg.auth.api_keys.keys().cloned().collect::<Vec<_>>(), "loaded auth api_key ids");

let registry = Arc::new(TemplateRegistry::load_from_dir(
Expand Down Expand Up @@ -80,15 +84,58 @@ async fn main() -> anyhow::Result<()> {
Ok(())
}

const DEFAULT_JWT_SECRET: &str = "CHANGE_ME_IN_PRODUCTION";
const BOOTSTRAP_KEY_ID: &str = "DEFAULT";

fn maybe_bootstrap_auth(cfg: &mut AppConfig) {
if !cfg.auth.bootstrap || !cfg.auth.api_keys.is_empty() {
return;
}

let key = match generate_bootstrap_key(BOOTSTRAP_KEY_ID) {
Ok(k) => k,
Err(e) => {
error!(error = %e, "failed to generate bootstrap api key");
Comment on lines +95 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Bootstrap auth failure is only logged, leaving the server running without any configured API key.

In maybe_bootstrap_auth, if generate_bootstrap_key fails, we log and return but still start with an empty api_keys map even though auth.bootstrap is enabled. This undermines the expectation that a secured instance always has at least one key. Instead, treat this as a hard startup failure (e.g., return an error from main / abort) when bootstrap is requested but key generation fails, rather than continuing without auth material.

return;
}
};

let jwt_override = if cfg.auth.jwt_secret == DEFAULT_JWT_SECRET {
let secret = generate_jwt_secret();
cfg.auth.jwt_secret = secret.clone();
Some(secret)
} else {
None
};

cfg.auth.api_keys.insert(key.id.clone(), key.hash.clone());

print_bootstrap_banner(&key, jwt_override.as_deref());
}

fn init_tracing(cfg: &AppConfig) {
let filter =
EnvFilter::try_new(&cfg.observability.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
// Precedence: RUST_LOG (standard) > cfg.observability.log_level > built-in fallback.
let filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(&cfg.observability.log_level))
.unwrap_or_else(|_| {
EnvFilter::new(
"info,mailify=debug,mailify_api=debug,mailify_queue=debug,tower_http=info",
)
});
let registry = tracing_subscriber::registry().with(filter);
match cfg.observability.log_format {
LogFormat::Json => registry
.with(tracing_subscriber::fmt::layer().json())
.init(),
LogFormat::Pretty => registry.with(tracing_subscriber::fmt::layer()).init(),
LogFormat::Pretty => registry
.with(
tracing_subscriber::fmt::layer()
.with_target(true)
.with_thread_ids(false)
.with_level(true)
.compact(),
)
.init(),
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/mailify-api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ impl Modify for SecurityAddon {
routes::config::get_config,
routes::mail::send_registered,
routes::mail::send_custom,
routes::mail::get_job_state,
),
components(schemas(
// Domain types
Expand Down Expand Up @@ -69,6 +70,7 @@ impl Modify for SecurityAddon {
routes::mail::SendRegisteredRequest,
routes::mail::SendCustomRequest,
routes::mail::EnqueuedResponse,
routes::mail::JobStateResponse,
)),
tags(
(name = "auth", description = "JWT issuance"),
Expand Down
74 changes: 69 additions & 5 deletions crates/mailify-api/src/routes/mail.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
use std::sync::Arc;

use axum::{extract::State, Json};
use axum::{
extract::{Path, State},
Json,
};
use mailify_core::{
email::{Attachment, EmailAddress},
priority::Priority,
smtp_override::SmtpOverride,
};
use mailify_queue::job::{MailJob, MailJobKind};
use mailify_queue::{
job::{MailJob, MailJobKind},
JobSnapshot,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use utoipa::ToSchema;
Expand Down Expand Up @@ -66,8 +72,39 @@ pub struct SendCustomRequest {

#[derive(Debug, Serialize, ToSchema)]
pub struct EnqueuedResponse {
pub job_id: Uuid,
/// Queue task id (ULID). Use it with `GET /mail/jobs/{id}` to poll state.
pub job_id: String,
pub status: String,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct JobStateResponse {
pub task_id: String,
pub mail_id: Uuid,
/// One of: `pending`, `scheduled`, `running`, `done`, `failed`, `killed`.
pub status: String,
pub attempts: usize,
pub max_attempts: i32,
pub last_error: Option<String>,
pub run_at: chrono::DateTime<chrono::Utc>,
pub lock_at: Option<i64>,
pub done_at: Option<i64>,
}

impl From<JobSnapshot> for JobStateResponse {
fn from(s: JobSnapshot) -> Self {
Self {
task_id: s.task_id,
mail_id: s.mail_id,
status: s.status,
attempts: s.attempts,
max_attempts: s.max_attempts,
last_error: s.last_error,
run_at: s.run_at,
lock_at: s.lock_at,
done_at: s.done_at,
}
}
}

/// Queue an email using a built-in template (see `GET /templates` for available ids).
Expand Down Expand Up @@ -121,7 +158,7 @@ pub async fn send_registered(
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(EnqueuedResponse {
job_id: id,
status: "queued".into(),
status: "pending".into(),
}))
}

Expand Down Expand Up @@ -179,10 +216,37 @@ pub async fn send_custom(
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(Json(EnqueuedResponse {
job_id: id,
status: "queued".into(),
status: "pending".into(),
}))
}

/// Fetch the current state of a previously-enqueued job.
#[utoipa::path(
get,
path = "/mail/jobs/{id}",
tag = "mail",
params(("id" = String, Path, description = "Task id returned by /mail/send or /mail/send-custom")),
security(("bearer_jwt" = [])),
responses(
(status = 200, description = "Job state", body = JobStateResponse),
(status = 400, description = "Malformed id"),
(status = 401, description = "Missing or invalid JWT"),
(status = 404, description = "Unknown job id (or vacuumed)"),
)
)]
pub async fn get_job_state(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<JobStateResponse>, ApiError> {
let mut queue = state.queue.clone();
match queue.fetch(&id).await {
Ok(Some(snap)) => Ok(Json(snap.into())),
Ok(None) => Err(ApiError::NotFound),
Err(mailify_queue::worker::QueueError::InvalidId(m)) => Err(ApiError::BadRequest(m)),
Err(e) => Err(ApiError::Internal(e.to_string())),
}
}

fn default_from(
state: &AppState,
supplied: Option<EmailAddress>,
Expand Down
3 changes: 2 additions & 1 deletion crates/mailify-api/tests/it_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ fn build_test_cfg(url: String) -> AppConfig {
jwt_issuer: "mailify-e2e".into(),
jwt_ttl_secs: 300,
api_keys: Default::default(),
bootstrap: false,
},
queue: QueueConfig {
worker_concurrency: 2,
Expand Down Expand Up @@ -166,7 +167,7 @@ async fn send_custom_via_http_delivers_to_mailpit() {
.await
.unwrap();
let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(resp["status"], "queued");
assert_eq!(resp["status"], "pending");

// Wait for mailpit delivery.
let search_url = format!(
Expand Down
93 changes: 93 additions & 0 deletions crates/mailify-auth/src/bootstrap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//! First-boot bootstrap: when no API key is configured, generate an ephemeral one
//! and print the env line the operator needs to copy back into their environment.

use rand_core::{OsRng, RngCore};

use crate::api_key::{hash_api_key, ApiKeyError};

/// Result of a bootstrap run.
pub struct BootstrapKey {
pub id: String,
pub plaintext: String,
pub hash: String,
}

/// Generate a random url-safe plaintext key and return it alongside its argon2 hash.
pub fn generate_bootstrap_key(id: impl Into<String>) -> Result<BootstrapKey, ApiKeyError> {
let plaintext = random_token(32);
let hash = hash_api_key(&plaintext)?;
Ok(BootstrapKey {
id: id.into(),
plaintext,
hash,
})
}

/// Generate a random JWT secret (used when the default placeholder is still in place).
pub fn generate_jwt_secret() -> String {
random_token(48)
}

fn random_token(byte_len: usize) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let mut bytes = vec![0u8; byte_len];
OsRng.fill_bytes(&mut bytes);
bytes
.into_iter()
.map(|b| ALPHABET[(b as usize) % ALPHABET.len()] as char)
.collect()
Comment on lines +31 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 suggestion (security): random_token introduces modulo bias when mapping bytes into the allowed alphabet.

The mapping ALPHABET[(b as usize) % ALPHABET.len()] creates a non-uniform distribution because 256 is not divisible by ALPHABET.len(). Since these tokens are used for API keys / JWT secrets, consider using rejection sampling to ensure uniformity, e.g. only accept bytes < ALPHABET.len() * (256 / ALPHABET.len()) and redraw otherwise. This removes the modulo bias with modest added complexity.

Suggested implementation:

fn random_token(len: usize) -> String {
    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

    // Rejection sampling to avoid modulo bias:
    // Draw from a large uniform range and only keep values that fall within
    // a multiple of ALPHABET.len(), then map with `% ALPHABET.len()`.
    let mut rng = OsRng;
    let alphabet_len = ALPHABET.len() as u32;
    let zone = (u32::MAX / alphabet_len) * alphabet_len;

    let mut out = String::with_capacity(len);
    while out.len() < len {
        let v = rng.next_u32();
        if v < zone {
            let idx = (v % alphabet_len) as usize;
            out.push(ALPHABET[idx] as char);
        }
    }
    out
}

None required, assuming random_token is only called via generate_jwt_secret() and similar call sites that do not depend on the parameter name change from byte_len to len. If there are direct callers relying on that parameter name (e.g. via macros), adjust the argument name accordingly, though in normal function calls this is not an issue in Rust.

}

/// Print a human-readable banner to stderr so it survives JSON log mode.
pub fn print_bootstrap_banner(key: &BootstrapKey, jwt_secret_generated: Option<&str>) {
let mut lines = vec![
"".to_string(),
"===================== MAILIFY BOOTSTRAP =====================".to_string(),
"No API key was configured. An ephemeral key has been generated".to_string(),
"for this session. It will NOT survive a restart unless you add".to_string(),
"the hash below to your environment (e.g. .env / compose env).".to_string(),
"".to_string(),
format!(" Key id: {}", key.id),
format!(" Plaintext: {}", key.plaintext),
"".to_string(),
" Paste into your env and restart to persist:".to_string(),
format!(" MAILIFY_AUTH__API_KEYS__{}={}", key.id, key.hash),
];
if let Some(secret) = jwt_secret_generated {
lines.push("".to_string());
lines
.push(" JWT secret was the default placeholder — generated a random one.".to_string());
lines.push(" Persist it too (otherwise issued tokens die on restart):".to_string());
lines.push(format!(" MAILIFY_AUTH__JWT_SECRET={secret}"));
}
lines.push("=============================================================".to_string());
lines.push("".to_string());
eprintln!("{}", lines.join("\n"));
}

#[cfg(test)]
mod tests {
use super::*;
use crate::api_key::verify_api_key;

#[test]
fn bootstrap_key_verifies_against_its_hash() {
let key = generate_bootstrap_key("DEFAULT").unwrap();
assert_eq!(key.id, "DEFAULT");
assert!(verify_api_key(&key.plaintext, &key.hash).unwrap());
}

#[test]
fn random_tokens_are_unique_and_sized() {
let a = random_token(32);
let b = random_token(32);
assert_eq!(a.len(), 32);
assert_ne!(a, b);
}

#[test]
fn jwt_secret_is_non_empty() {
let s = generate_jwt_secret();
assert_eq!(s.len(), 48);
}
}
4 changes: 4 additions & 0 deletions crates/mailify-auth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
pub mod api_key;
pub mod bootstrap;
pub mod jwt;
pub mod middleware;

pub use api_key::{hash_api_key, verify_api_key, ApiKeyError};
pub use bootstrap::{
generate_bootstrap_key, generate_jwt_secret, print_bootstrap_banner, BootstrapKey,
};
pub use jwt::{Claims, JwtError, JwtIssuer};
pub use middleware::{require_jwt, AuthLayer};
Loading
Loading