use std::net::IpAddr; use figment::{ Figment, providers::{Env, Format, Toml}, }; use ipnet::IpNet; use serde::Deserialize; #[derive(Debug, Deserialize, Clone)] pub struct StorageConfig { #[serde(default = "default_storage_backend")] pub backend: String, #[serde(default)] pub path: Option, #[serde(default)] pub shared: bool, #[serde(default)] pub bucket: Option, #[serde(default = "default_s3_region")] pub region: String, #[serde(default)] pub endpoint: Option, #[serde(default)] pub prefix: String, #[serde(default)] pub path_style: bool, #[serde(default)] pub allow_http: bool, #[serde(default)] pub access_key_id: Option, #[serde(default)] pub secret_access_key: Option, #[serde(default)] pub session_token: Option, #[serde(default = "default_storage_request_timeout_secs")] pub request_timeout_secs: u64, #[serde(default = "default_storage_connect_timeout_secs")] pub connect_timeout_secs: u64, #[serde(default = "default_storage_max_retries")] pub max_retries: usize, #[serde(default = "default_storage_retry_timeout_secs")] pub retry_timeout_secs: u64, #[serde(default)] pub server_side_encryption: Option, #[serde(default)] pub kms_key_id: Option, } impl Default for StorageConfig { fn default() -> Self { Self { backend: default_storage_backend(), path: None, shared: false, bucket: None, region: default_s3_region(), endpoint: None, prefix: String::new(), path_style: false, allow_http: false, access_key_id: None, secret_access_key: None, session_token: None, request_timeout_secs: default_storage_request_timeout_secs(), connect_timeout_secs: default_storage_connect_timeout_secs(), max_retries: default_storage_max_retries(), retry_timeout_secs: default_storage_retry_timeout_secs(), server_side_encryption: None, kms_key_id: None, } } } #[derive(Debug, Deserialize, Clone)] pub struct SearchConfig { #[serde(default = "default_search_backend")] pub backend: String, #[serde(default)] pub path: Option, #[serde(default)] pub urls: Vec, #[serde(default = "default_search_index_prefix")] pub index_prefix: String, #[serde(default)] pub username: Option, #[serde(default)] pub password: Option, #[serde(default)] pub required: bool, #[serde(default)] pub allow_insecure_http: bool, #[serde(default = "default_search_request_timeout_secs")] pub request_timeout_secs: u64, #[serde(default = "default_search_worker_interval_secs")] pub worker_interval_secs: u64, #[serde(default = "default_search_worker_batch_size")] pub worker_batch_size: usize, #[serde(default = "default_search_worker_max_attempts")] pub worker_max_attempts: u32, } impl Default for SearchConfig { fn default() -> Self { Self { backend: default_search_backend(), path: None, urls: Vec::new(), index_prefix: default_search_index_prefix(), username: None, password: None, required: false, allow_insecure_http: false, request_timeout_secs: default_search_request_timeout_secs(), worker_interval_secs: default_search_worker_interval_secs(), worker_batch_size: default_search_worker_batch_size(), worker_max_attempts: default_search_worker_max_attempts(), } } } #[derive(Debug, Deserialize, Clone)] #[allow(dead_code)] pub struct AppConfig { #[serde(default = "default_host")] pub host: String, #[serde(default = "default_port")] pub port: u16, #[serde(default = "default_imap_host")] pub imap_host: Option, #[serde(default = "default_imap_port")] pub imap_port: u16, #[serde(default)] pub smtp_host: Option, #[serde(default = "default_smtp_port")] pub smtp_port: u16, #[serde(default = "default_tls_enabled")] pub tls_enabled: bool, #[serde(default)] pub tls_verify: Option, #[serde(default = "default_max_attachment_size_mb")] pub max_attachment_size_mb: usize, #[serde(default = "default_max_message_size_mb")] pub max_message_size_mb: usize, #[serde(default = "default_data_dir")] pub data_dir: String, #[serde(default)] pub log_dir: Option, #[serde(default = "default_log_level")] pub log_level: String, #[serde(default = "default_database_backend")] pub database_backend: String, #[serde(default)] pub database_url: Option, #[serde(default = "default_session_backend")] pub session_backend: String, #[serde(default)] pub session_encryption_key: Option, #[serde(default)] pub redis_url: Option, #[serde(default)] pub redis_sentinel_nodes: Vec, #[serde(default)] pub redis_sentinel_master: Option, #[serde(default)] pub redis_password: Option, #[serde(default)] pub redis_sentinel_password: Option, #[serde(default)] pub cluster_mode: bool, #[serde(default)] pub storage: StorageConfig, #[serde(default)] pub search: SearchConfig, #[serde(default = "default_session_timeout_hours")] pub session_timeout_hours: u64, #[serde(default = "default_remember_me_enabled")] pub remember_me_enabled: bool, #[serde(default = "default_remember_me_days")] pub remember_me_days: u64, #[serde(default = "default_environment")] pub environment: String, #[serde(default = "default_theme")] pub default_theme: String, #[serde(default = "default_interface_skin")] pub default_interface_skin: String, #[serde(default)] pub base_path: Option, #[serde(default = "default_feature_enabled")] pub sieve_enabled: bool, #[serde(default = "default_feature_enabled")] pub diagnostics_enabled: bool, #[serde(default = "default_feature_enabled")] pub about_enabled: bool, #[serde(default)] pub security_headers_enabled: bool, #[serde(default)] pub sieve_host: Option, #[serde(default = "default_sieve_port")] pub sieve_port: u16, #[serde(default = "default_sieve_tls_mode")] pub sieve_tls_mode: String, #[serde(default)] pub sieve_allow_insecure_auth: bool, #[serde(default = "default_sieve_timeout_secs")] pub sieve_timeout_secs: u64, #[serde(default)] pub trusted_proxies: Option, #[serde(default)] pub outgoing_headers: Vec, #[serde(default = "default_pgp_enabled")] pub pgp_enabled: bool, #[serde(default = "default_pgp_wkd_enabled")] pub pgp_wkd_enabled: bool, #[serde(default)] pub pgp_hkp_enabled: bool, #[serde(default = "default_pgp_hkp_url")] pub pgp_hkp_url: String, #[serde(default = "default_pgp_network_timeout_secs")] pub pgp_network_timeout_secs: u64, #[serde(default = "default_pgp_max_key_bytes")] pub pgp_max_key_bytes: usize, #[serde(default)] pub pgp_allow_private_export: bool, #[serde(default)] pub default_sent_folder: Option, #[serde(default)] pub default_drafts_folder: Option, #[serde(default)] pub default_trash_folder: Option, #[serde(default)] pub default_junk_folder: Option, #[serde(default)] pub default_archive_folder: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TrustedProxy { Ip(IpAddr), Cidr(IpNet), } impl TrustedProxy { pub fn contains(&self, ip: &IpAddr) -> bool { match self { Self::Ip(trusted_ip) => trusted_ip == ip, Self::Cidr(network) => network.contains(ip), } } } fn default_host() -> String { "0.0.0.0".to_string() } fn default_port() -> u16 { 3001 } fn default_imap_host() -> Option { Some("localhost".to_string()) } fn default_imap_port() -> u16 { 993 } fn default_smtp_port() -> u16 { 587 } fn default_tls_enabled() -> bool { true } fn default_max_attachment_size_mb() -> usize { 10 } fn default_max_message_size_mb() -> usize { 50 } fn default_data_dir() -> String { "/data".to_string() } fn default_s3_region() -> String { "us-east-1".to_string() } fn default_storage_request_timeout_secs() -> u64 { 30 } fn default_storage_connect_timeout_secs() -> u64 { 5 } fn default_storage_max_retries() -> usize { 3 } fn default_storage_retry_timeout_secs() -> u64 { 60 } fn default_search_index_prefix() -> String { "rustbox".to_string() } fn default_search_request_timeout_secs() -> u64 { 10 } fn default_search_worker_interval_secs() -> u64 { 2 } fn default_search_worker_batch_size() -> usize { 100 } fn default_search_worker_max_attempts() -> u32 { 12 } fn default_log_level() -> String { "info".to_string() } fn default_database_backend() -> String { "sqlite".to_string() } fn default_session_backend() -> String { "database".to_string() } fn default_storage_backend() -> String { "filesystem".to_string() } fn default_search_backend() -> String { "local_tantivy".to_string() } fn default_session_timeout_hours() -> u64 { 24 } fn default_remember_me_enabled() -> bool { true } fn default_remember_me_days() -> u64 { 30 } fn default_environment() -> String { "development".to_string() } fn default_theme() -> String { "system".to_string() } fn default_interface_skin() -> String { "rustbox".to_string() } fn default_feature_enabled() -> bool { true } fn default_sieve_port() -> u16 { 4190 } fn default_sieve_tls_mode() -> String { "starttls".to_string() } fn default_sieve_timeout_secs() -> u64 { 10 } fn default_pgp_enabled() -> bool { true } fn default_pgp_wkd_enabled() -> bool { true } fn default_pgp_hkp_url() -> String { "https://keys.openpgp.org".to_string() } fn default_pgp_network_timeout_secs() -> u64 { 10 } fn default_pgp_max_key_bytes() -> usize { 1024 * 1024 } impl Default for AppConfig { fn default() -> Self { Self { host: default_host(), port: default_port(), imap_host: default_imap_host(), imap_port: default_imap_port(), smtp_host: None, smtp_port: default_smtp_port(), tls_enabled: default_tls_enabled(), tls_verify: None, max_attachment_size_mb: default_max_attachment_size_mb(), max_message_size_mb: default_max_message_size_mb(), data_dir: default_data_dir(), log_dir: None, log_level: default_log_level(), database_backend: default_database_backend(), database_url: None, session_backend: default_session_backend(), session_encryption_key: None, redis_url: None, redis_sentinel_nodes: Vec::new(), redis_sentinel_master: None, redis_password: None, redis_sentinel_password: None, cluster_mode: false, storage: StorageConfig::default(), search: SearchConfig::default(), session_timeout_hours: default_session_timeout_hours(), remember_me_enabled: default_remember_me_enabled(), remember_me_days: default_remember_me_days(), environment: default_environment(), default_theme: default_theme(), default_interface_skin: default_interface_skin(), base_path: None, sieve_enabled: true, diagnostics_enabled: true, about_enabled: true, security_headers_enabled: false, sieve_host: None, sieve_port: default_sieve_port(), sieve_tls_mode: default_sieve_tls_mode(), sieve_allow_insecure_auth: false, sieve_timeout_secs: default_sieve_timeout_secs(), trusted_proxies: None, outgoing_headers: Vec::new(), pgp_enabled: default_pgp_enabled(), pgp_wkd_enabled: default_pgp_wkd_enabled(), pgp_hkp_enabled: false, pgp_hkp_url: default_pgp_hkp_url(), pgp_network_timeout_secs: default_pgp_network_timeout_secs(), pgp_max_key_bytes: default_pgp_max_key_bytes(), pgp_allow_private_export: false, default_sent_folder: None, default_drafts_folder: None, default_trash_folder: None, default_junk_folder: None, default_archive_folder: None, } } } impl AppConfig { pub fn normalized_base_path(&self) -> &str { self.base_path .as_deref() .filter(|path| !path.is_empty()) .unwrap_or("/") } pub fn tls_verify_for_host(&self, host: &str) -> bool { if let Some(value) = self.tls_verify { return value; } let normalized = host .trim() .trim_matches(|ch| ch == '[' || ch == ']') .to_ascii_lowercase(); !matches!(normalized.as_str(), "localhost" | "127.0.0.1" | "::1") } pub fn max_attachment_size_bytes(&self) -> usize { self.max_attachment_size_mb.saturating_mul(1024 * 1024) } pub fn max_message_size_bytes(&self) -> usize { self.max_message_size_mb.saturating_mul(1024 * 1024) } pub fn parsed_trusted_proxies(&self) -> Vec { let Some(ref proxies) = self.trusted_proxies else { return vec![]; }; proxies .split(',') .filter_map(|s| { let trimmed = s.trim(); if trimmed.is_empty() { return None; } if let Ok(ip) = trimmed.parse::() { return Some(TrustedProxy::Ip(ip)); } match trimmed.parse::() { Ok(network) => Some(TrustedProxy::Cidr(network)), Err(e) => { tracing::warn!(entry = trimmed, error = %e, "ignoring unparseable trusted proxy"); None } } }) .collect() } pub fn parsed_outgoing_headers(&self) -> Result, String> { const RESERVED: &[&str] = &[ "from", "sender", "to", "cc", "bcc", "reply-to", "subject", "date", "message-id", "in-reply-to", "references", "mime-version", "content-type", "content-transfer-encoding", "content-disposition", ]; let mut parsed = Vec::new(); for entry in &self.outgoing_headers { let (raw_name, raw_value) = entry.split_once(':').ok_or_else(|| { format!("Invalid outgoing header '{entry}': expected 'Name: value'") })?; let name = raw_name.trim(); let value = raw_value.trim(); if name.is_empty() || !name .bytes() .all(|byte| (33..=126).contains(&byte) && byte != b':') { return Err(format!("Invalid outgoing header name: {name}")); } if RESERVED .iter() .any(|reserved| name.eq_ignore_ascii_case(reserved)) { return Err(format!( "Outgoing header '{name}' is managed by RustBox and cannot be overridden" )); } if value.contains('\r') || value.contains('\n') { return Err(format!("Outgoing header '{name}' contains a line break")); } if value.is_empty() { return Err(format!("Outgoing header '{name}' has an empty value")); } parsed.push((name.to_string(), value.to_string())); } Ok(parsed) } #[allow(clippy::result_large_err)] pub fn load() -> Result { let path = std::env::var("RUSTBOX_CONFIG") .unwrap_or_else(|_| "/etc/rustbox/rustbox.toml".to_string()); let mut figment = Figment::new(); if std::path::Path::new(&path).is_file() { figment = figment.merge(Toml::file(path)); } let figment = figment.merge(Env::raw().split("__")); let data_dir_explicit = figment.find_value("data_dir").is_ok(); let mut config: Self = figment.extract()?; config.apply_cluster_data_dir_default(data_dir_explicit); Ok(config) } fn apply_cluster_data_dir_default(&mut self, data_dir_explicit: bool) { if self.cluster_mode && !data_dir_explicit { self.data_dir.clear(); } } pub fn effective_storage_backend(&self) -> String { self.storage.backend.trim().to_ascii_lowercase() } pub fn effective_search_backend(&self) -> String { self.search.backend.trim().to_ascii_lowercase() } pub fn effective_storage_path(&self) -> &str { self.storage .path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(&self.data_dir) } pub fn effective_search_path(&self) -> &str { self.search .path .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(&self.data_dir) } pub fn validate(&self) -> Result<(), String> { if self .imap_host .as_deref() .map(|host| host.trim().is_empty()) .unwrap_or(true) { return Err("IMAP_HOST / imap_host must be configured".to_string()); } if self.port == 0 || self.imap_port == 0 || self.smtp_port == 0 || (self.sieve_enabled && self.sieve_port == 0) { return Err("enabled network ports must be greater than zero".to_string()); } if let Some(log_dir) = self.log_dir.as_deref() { let log_dir = log_dir.trim(); if log_dir.is_empty() { return Err("LOG_DIR / log_dir cannot be empty".to_string()); } if !std::path::Path::new(log_dir).is_absolute() { return Err("LOG_DIR / log_dir must be an absolute path".to_string()); } } let log_level = self.log_level.trim().to_ascii_lowercase(); if !matches!( log_level.as_str(), "trace" | "debug" | "info" | "warn" | "error" | "off" ) { return Err( "LOG_LEVEL / log_level must be one of: trace, debug, info, warn, error, off" .to_string(), ); } if self.max_attachment_size_mb == 0 || self.max_attachment_size_mb > 512 { return Err("MAX_ATTACHMENT_SIZE_MB must be between 1 and 512".to_string()); } if self.max_message_size_mb == 0 || self.max_message_size_mb > 1024 { return Err("MAX_MESSAGE_SIZE_MB must be between 1 and 1024".to_string()); } if self.max_attachment_size_mb > self.max_message_size_mb { return Err("MAX_ATTACHMENT_SIZE_MB cannot exceed MAX_MESSAGE_SIZE_MB".to_string()); } if !matches!(self.database_backend.as_str(), "sqlite" | "mysql" | "pgsql") { return Err("DATABASE_BACKEND must be one of: sqlite, mysql, pgsql".to_string()); } if self.database_backend != "sqlite" && self .database_url .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .is_none() { return Err("DATABASE_URL is required for mysql/pgsql".to_string()); } if !matches!(self.session_backend.as_str(), "database" | "redis") { return Err("SESSION_BACKEND must be one of: database, redis".to_string()); } if self.session_backend == "redis" { let direct = self .redis_url .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .is_some(); let sentinel = !self.redis_sentinel_nodes.is_empty() && self .redis_sentinel_master .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .is_some(); if !direct && !sentinel { return Err("Redis sessions require REDIS_URL or REDIS_SENTINEL_NODES + REDIS_SENTINEL_MASTER".to_string()); } } let storage_backend = self.effective_storage_backend(); if !matches!(storage_backend.as_str(), "filesystem" | "s3") { return Err("storage.backend must be one of: filesystem, s3".to_string()); } if storage_backend == "filesystem" { let path = self.effective_storage_path(); if path.trim().is_empty() { return Err( "storage.path/data_dir cannot be empty for filesystem storage".to_string(), ); } if self.cluster_mode && !std::path::Path::new(path).is_absolute() { return Err("cluster filesystem storage.path must be an absolute path".to_string()); } } else { if self .storage .bucket .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .is_none() { return Err("storage.bucket is required when storage.backend=s3".to_string()); } if self.storage.region.trim().is_empty() { return Err("storage.region cannot be empty".to_string()); } if let Some(endpoint) = self .storage .endpoint .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) { let url = url::Url::parse(endpoint) .map_err(|e| format!("invalid storage.endpoint: {e}"))?; if url.scheme() != "https" && !(self.storage.allow_http && url.scheme() == "http") { return Err( "storage.endpoint must use HTTPS unless storage.allow_http=true" .to_string(), ); } } } if self.storage.request_timeout_secs == 0 || self.storage.connect_timeout_secs == 0 || self.storage.retry_timeout_secs == 0 { return Err("storage timeouts must be greater than zero".to_string()); } if let Some(mode) = self .storage .server_side_encryption .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) { if !matches!(mode, "AES256" | "aws:kms") { return Err("storage.server_side_encryption must be AES256 or aws:kms".to_string()); } if mode == "aws:kms" && self .storage .kms_key_id .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .is_none() { return Err("storage.kms_key_id is required for aws:kms encryption".to_string()); } } let search_backend = self.effective_search_backend(); if !matches!( search_backend.as_str(), "local_tantivy" | "disabled" | "opensearch" ) { return Err( "search.backend must be one of: disabled, local_tantivy, opensearch".to_string(), ); } if search_backend == "local_tantivy" { let path = self.effective_search_path(); if path.trim().is_empty() { return Err("search.path/data_dir cannot be empty for local_tantivy".to_string()); } } if search_backend == "opensearch" { if self.search.urls.is_empty() { return Err("search.urls must contain at least one URL for opensearch".to_string()); } for raw in &self.search.urls { let parsed = url::Url::parse(raw.trim()) .map_err(|e| format!("invalid OpenSearch URL '{raw}': {e}"))?; if parsed.scheme() != "https" && !(self.search.allow_insecure_http && parsed.scheme() == "http") { return Err( "OpenSearch URLs must use HTTPS unless search.allow_insecure_http=true" .to_string(), ); } if !parsed.username().is_empty() || parsed.password().is_some() { return Err("OpenSearch credentials must not be embedded in URLs".to_string()); } } let prefix = self.search.index_prefix.as_str(); if prefix.is_empty() || !prefix .chars() .next() .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) || !prefix .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_')) { return Err("search.index_prefix must start with a lowercase ASCII letter/digit and contain only lowercase ASCII letters, digits, '-' or '_'".to_string()); } } if self.search.request_timeout_secs == 0 || self.search.worker_interval_secs == 0 || self.search.worker_batch_size == 0 || self.search.worker_max_attempts == 0 { return Err("search timeout/worker settings must be greater than zero".to_string()); } if self.cluster_mode && self.database_backend == "sqlite" { return Err( "CLUSTER_MODE requires a shared mysql/pgsql application database; Redis only replaces session persistence" .to_string(), ); } let storage_cluster_safe = storage_backend == "s3" || (storage_backend == "filesystem" && self.storage.shared); if self.cluster_mode && !storage_cluster_safe { return Err( "CLUSTER_MODE requires cluster-safe blob storage: storage.backend=s3 or storage.backend=filesystem with storage.shared=true" .to_string(), ); } if self.cluster_mode && search_backend == "local_tantivy" { return Err( "CLUSTER_MODE forbids search.backend=local_tantivy; use disabled or an external indexer" .to_string(), ); } if self.cluster_mode && self.pgp_enabled { return Err( "CLUSTER_MODE requires PGP_ENABLED=false; OpenPGP private keys use node-local filesystem storage" .to_string(), ); } if (self.cluster_mode || self.session_backend == "redis" || self.database_backend != "sqlite") && self .session_encryption_key .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .is_none() { return Err( "shared session storage requires SESSION_ENCRYPTION_KEY shared by all nodes" .to_string(), ); } if self.session_timeout_hours == 0 || self.session_timeout_hours > 24 * 365 { return Err("SESSION_TIMEOUT_HOURS must be between 1 and 8760".to_string()); } if self.remember_me_days == 0 || self.remember_me_days > 365 { return Err("REMEMBER_ME_DAYS must be between 1 and 365".to_string()); } if !matches!(self.default_theme.as_str(), "system" | "dark" | "light") { return Err("DEFAULT_THEME must be one of: system, dark, light".to_string()); } if !matches!( self.default_interface_skin.as_str(), "rustbox" | "roundcube-classic" | "roundcube-larry" | "roundcube-elastic" | "clean" | "gmail" | "proton" | "zoho" | "outlook" | "minimal" | "graphite" | "high-contrast" | "fastmail" | "apple-mail" | "yahoo" | "hey" | "nord" | "github" | "gitlab" | "discord" | "slack" | "reddit" | "notion" ) { return Err("DEFAULT_INTERFACE_SKIN must be one of: rustbox, roundcube-classic, roundcube-larry, roundcube-elastic, clean, gmail, proton, zoho, outlook, minimal, graphite, high-contrast, fastmail, apple-mail, yahoo, hey, nord, github, gitlab, discord, slack, reddit, notion".to_string()); } if self.sieve_enabled { if !matches!( self.sieve_tls_mode.as_str(), "starttls" | "implicit" | "none" ) { return Err("SIEVE_TLS_MODE must be one of: starttls, implicit, none".to_string()); } if self.sieve_tls_mode == "none" && !self.sieve_allow_insecure_auth { tracing::warn!( "ManageSieve plaintext transport selected; authentication will remain blocked until SIEVE_ALLOW_INSECURE_AUTH=true" ); } } self.parsed_outgoing_headers()?; if self.pgp_network_timeout_secs == 0 || self.pgp_network_timeout_secs > 60 { return Err("PGP_NETWORK_TIMEOUT_SECS must be between 1 and 60".to_string()); } if self.pgp_max_key_bytes < 16 * 1024 || self.pgp_max_key_bytes > 4 * 1024 * 1024 { return Err("PGP_MAX_KEY_BYTES must be between 16384 and 4194304".to_string()); } if self.pgp_hkp_enabled { let url = url::Url::parse(&self.pgp_hkp_url) .map_err(|e| format!("invalid PGP_HKP_URL: {e}"))?; if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() { return Err("PGP_HKP_URL must be a credential-free HTTPS URL".to_string()); } } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn default_config_values_load_correctly() { let config: AppConfig = Figment::new().extract().expect("defaults should load"); assert_eq!(config.host, "0.0.0.0"); assert_eq!(config.port, 3001); assert_eq!(config.imap_host.as_deref(), Some("localhost")); assert_eq!(config.imap_port, 993); assert!(config.smtp_host.is_none()); assert_eq!(config.smtp_port, 587); assert!(config.tls_enabled); assert_eq!(config.tls_verify, None); assert!(!config.tls_verify_for_host("localhost")); assert!(config.tls_verify_for_host("mail.example.com")); assert_eq!(config.max_attachment_size_mb, 10); assert_eq!(config.max_message_size_mb, 50); assert_eq!(config.data_dir, "/data"); assert!(config.log_dir.is_none()); assert_eq!(config.database_backend, "sqlite"); assert!(config.database_url.is_none()); assert_eq!(config.session_backend, "database"); assert!(config.session_encryption_key.is_none()); assert!(config.redis_url.is_none()); assert!(config.redis_sentinel_nodes.is_empty()); assert!(config.redis_sentinel_master.is_none()); assert!(config.redis_sentinel_password.is_none()); assert!(!config.cluster_mode); assert_eq!(config.storage.backend, "filesystem"); assert_eq!(config.search.backend, "local_tantivy"); assert_eq!(config.session_timeout_hours, 24); assert!(config.remember_me_enabled); assert_eq!(config.remember_me_days, 30); assert_eq!(config.environment, "development"); assert_eq!(config.default_theme, "system"); assert_eq!(config.default_interface_skin, "rustbox"); assert!(config.sieve_enabled); assert!(config.diagnostics_enabled); assert!(config.about_enabled); assert!(!config.security_headers_enabled); assert!(config.sieve_host.is_none()); assert_eq!(config.sieve_port, 4190); assert_eq!(config.sieve_tls_mode, "starttls"); assert!(!config.sieve_allow_insecure_auth); assert_eq!(config.sieve_timeout_secs, 10); assert!(config.trusted_proxies.is_none()); assert!(config.outgoing_headers.is_empty()); assert!(!config.pgp_allow_private_export); assert!(config.default_sent_folder.is_none()); assert!(config.default_drafts_folder.is_none()); assert!(config.default_trash_folder.is_none()); assert!(config.default_junk_folder.is_none()); assert!(config.default_archive_folder.is_none()); } #[test] fn env_var_overrides_work() { let config: AppConfig = Figment::new() .merge(("host", "127.0.0.1")) .merge(("port", 8080u16)) .merge(("imap_host", "mail.example.com")) .merge(("imap_port", 143u16)) .merge(("smtp_host", "smtp.example.com")) .merge(("smtp_port", 465u16)) .merge(("tls_enabled", false)) .merge(("tls_verify", true)) .merge(("max_attachment_size_mb", 40usize)) .merge(("max_message_size_mb", 80usize)) .merge(("data_dir", "/var/rustbox")) .merge(("log_dir", "/var/log/rustbox")) .merge(("session_timeout_hours", 48u64)) .merge(("remember_me_enabled", false)) .merge(("remember_me_days", 14u64)) .merge(("environment", "production")) .merge(("default_theme", "light")) .merge(("default_interface_skin", "gmail")) .merge(("sieve_enabled", false)) .merge(("diagnostics_enabled", false)) .merge(("about_enabled", false)) .merge(("security_headers_enabled", true)) .merge(("sieve_host", "sieve.example.com")) .merge(("sieve_port", 4191u16)) .merge(("sieve_tls_mode", "implicit")) .merge(("sieve_allow_insecure_auth", true)) .merge(("sieve_timeout_secs", 15u64)) .merge(("trusted_proxies", "127.0.0.1,::1")) .extract() .expect("overrides should load"); assert_eq!(config.host, "127.0.0.1"); assert_eq!(config.port, 8080); assert_eq!(config.imap_host.as_deref(), Some("mail.example.com")); assert_eq!(config.imap_port, 143); assert_eq!(config.smtp_host.as_deref(), Some("smtp.example.com")); assert_eq!(config.smtp_port, 465); assert!(!config.tls_enabled); assert_eq!(config.tls_verify, Some(true)); assert_eq!(config.max_attachment_size_mb, 40); assert_eq!(config.max_message_size_mb, 80); assert_eq!(config.data_dir, "/var/rustbox"); assert_eq!(config.log_dir.as_deref(), Some("/var/log/rustbox")); assert_eq!(config.session_timeout_hours, 48); assert!(!config.remember_me_enabled); assert_eq!(config.remember_me_days, 14); assert_eq!(config.environment, "production"); assert_eq!(config.default_theme, "light"); assert_eq!(config.default_interface_skin, "gmail"); assert!(!config.sieve_enabled); assert!(!config.diagnostics_enabled); assert!(!config.about_enabled); assert!(config.security_headers_enabled); assert_eq!(config.sieve_host.as_deref(), Some("sieve.example.com")); assert_eq!(config.sieve_port, 4191); assert_eq!(config.sieve_tls_mode, "implicit"); assert!(config.sieve_allow_insecure_auth); assert_eq!(config.sieve_timeout_secs, 15); assert_eq!(config.trusted_proxies.as_deref(), Some("127.0.0.1,::1")); } #[test] fn real_env_vars_override_multi_word_fields() { unsafe { std::env::set_var("IMAP_HOST", "test-imap.example.com"); std::env::set_var("DATA_DIR", "/tmp/oxi-test"); } let config = AppConfig::load().expect("load with env vars should succeed"); assert_eq!(config.imap_host.as_deref(), Some("test-imap.example.com")); assert_eq!(config.data_dir, "/tmp/oxi-test"); unsafe { std::env::remove_var("IMAP_HOST"); std::env::remove_var("DATA_DIR"); } } #[test] fn disabled_sieve_skips_sieve_specific_validation() { let config = AppConfig { imap_host: Some("imap.example.com".to_string()), sieve_enabled: false, sieve_port: 0, sieve_tls_mode: "unsupported".to_string(), ..AppConfig::default() }; assert!(config.validate().is_ok()); } #[test] fn invalid_default_theme_is_rejected() { let config = AppConfig { imap_host: Some("imap.example.com".to_string()), default_theme: "sepia".to_string(), ..AppConfig::default() }; assert!(config.validate().unwrap_err().contains("DEFAULT_THEME")); } #[test] fn invalid_default_interface_skin_is_rejected() { let config = AppConfig { imap_host: Some("imap.example.com".to_string()), default_interface_skin: "hotmail-1999".to_string(), ..AppConfig::default() }; assert!( config .validate() .unwrap_err() .contains("DEFAULT_INTERFACE_SKIN") ); } #[test] fn outgoing_headers_are_parsed_and_structural_headers_rejected() { let config = AppConfig { outgoing_headers: vec![ "X-Company-Mailer: RustBox".to_string(), "X-Environment: production".to_string(), ], ..Figment::new().extract().expect("defaults should load") }; assert_eq!( config.parsed_outgoing_headers().unwrap(), vec![ ("X-Company-Mailer".to_string(), "RustBox".to_string()), ("X-Environment".to_string(), "production".to_string()), ] ); let reserved = AppConfig { outgoing_headers: vec!["Subject: injected".to_string()], ..Figment::new().extract().expect("defaults should load") }; assert!(reserved.parsed_outgoing_headers().is_err()); let injected = AppConfig { outgoing_headers: vec!["X-Test: ok\r\nBcc: hidden@example.com".to_string()], ..Figment::new().extract().expect("defaults should load") }; assert!(injected.parsed_outgoing_headers().is_err()); } #[test] fn parsed_trusted_proxies_accepts_individual_ip() { let config = AppConfig { trusted_proxies: Some("127.0.0.1".to_string()), ..Figment::new().extract().expect("defaults should load") }; assert_eq!( config.parsed_trusted_proxies(), vec![TrustedProxy::Ip("127.0.0.1".parse().unwrap())] ); } #[test] fn parsed_trusted_proxies_matches_cidr() { let config = AppConfig { trusted_proxies: Some("10.0.0.0/8".to_string()), ..Figment::new().extract().expect("defaults should load") }; let parsed = config.parsed_trusted_proxies(); assert_eq!(parsed.len(), 1); assert!(parsed[0].contains(&"10.1.2.3".parse().unwrap())); } #[test] fn parsed_trusted_proxies_does_not_match_outside_cidr() { let config = AppConfig { trusted_proxies: Some("10.0.0.0/8".to_string()), ..Figment::new().extract().expect("defaults should load") }; let parsed = config.parsed_trusted_proxies(); assert_eq!(parsed.len(), 1); assert!(!parsed[0].contains(&"192.168.1.1".parse().unwrap())); } #[test] fn parsed_trusted_proxies_ignores_invalid_entries() { let config = AppConfig { trusted_proxies: Some("bad-entry,127.0.0.1,10.0.0.0/8/extra".to_string()), ..Figment::new().extract().expect("defaults should load") }; assert_eq!( config.parsed_trusted_proxies(), vec![TrustedProxy::Ip("127.0.0.1".parse().unwrap())] ); } #[test] fn single_node_pgsql_can_still_use_data_dir_backends() { let config = AppConfig { imap_host: Some("imap.example.com".to_string()), database_backend: "pgsql".to_string(), database_url: Some("postgres://rustbox:secret@db/rustbox".to_string()), session_encryption_key: Some("shared-key".to_string()), data_dir: "/srv/rustbox".to_string(), ..AppConfig::default() }; assert!(config.validate().is_ok()); assert_eq!(config.effective_storage_backend(), "filesystem"); assert_eq!(config.effective_search_backend(), "local_tantivy"); } #[test] fn cluster_mode_disables_implicit_data_dir_but_preserves_explicit_value() { let mut implicit = AppConfig { cluster_mode: true, data_dir: "/data".to_string(), ..AppConfig::default() }; implicit.apply_cluster_data_dir_default(false); assert!(implicit.data_dir.is_empty()); let mut explicit = AppConfig { cluster_mode: true, data_dir: "/mnt/shared-rustbox".to_string(), ..AppConfig::default() }; explicit.apply_cluster_data_dir_default(true); assert_eq!(explicit.data_dir, "/mnt/shared-rustbox"); } #[test] fn cluster_with_s3_and_opensearch_does_not_require_data_dir() { let mut config = AppConfig { imap_host: Some("imap.example.com".to_string()), database_backend: "pgsql".to_string(), database_url: Some("postgres://rustbox:secret@db/rustbox".to_string()), session_encryption_key: Some("shared-key".to_string()), cluster_mode: true, pgp_enabled: false, data_dir: String::new(), ..AppConfig::default() }; config.storage.backend = "s3".to_string(); config.storage.bucket = Some("rustbox".to_string()); config.storage.endpoint = Some("https://s3.example.com".to_string()); config.search.backend = "opensearch".to_string(); config.search.urls = vec!["https://search.example.com".to_string()]; assert!(config.validate().is_ok()); } #[test] fn cluster_accepts_shared_posix_storage_without_s3() { let mut config = AppConfig { imap_host: Some("imap.example.com".to_string()), database_backend: "mysql".to_string(), database_url: Some("mysql://rustbox:secret@db/rustbox".to_string()), session_encryption_key: Some("shared-key".to_string()), cluster_mode: true, pgp_enabled: false, ..AppConfig::default() }; config.storage.backend = "filesystem".to_string(); config.storage.path = Some("/mnt/rustbox-shared".to_string()); config.storage.shared = true; config.search.backend = "disabled".to_string(); assert!(config.validate().is_ok()); } #[test] fn pre_0_2_backend_aliases_are_rejected() { let mut config = AppConfig { imap_host: Some("imap.example.com".to_string()), ..AppConfig::default() }; config.search.backend = "tantivy".to_string(); assert!(config.validate().unwrap_err().contains("search.backend")); config.search.backend = "local_tantivy".to_string(); for backend in ["postgres", "postgresql"] { config.database_backend = backend.to_string(); assert!(config.validate().unwrap_err().contains("DATABASE_BACKEND")); } } #[test] fn cluster_mode_rejects_local_sqlite_even_with_redis_sessions() { let config = AppConfig { database_backend: "sqlite".to_string(), session_backend: "redis".to_string(), redis_url: Some("redis://127.0.0.1:6379/".to_string()), session_encryption_key: Some("configured-shared-key".to_string()), cluster_mode: true, ..AppConfig::default() }; let error = config.validate().unwrap_err(); assert!(error.contains("shared mysql/pgsql application database")); } }