use std::net::IpAddr; use figment::{ Figment, providers::{Env, Format, Toml}, }; use ipnet::IpNet; use serde::Deserialize; /// Application configuration loaded via figment. /// /// Layers (lowest to highest priority): /// 1. Serde defaults /// 2. Optional TOML (`RUSTBOX_CONFIG`, default `/etc/rustbox/rustbox.toml`) /// 3. Environment variables (flat, e.g. `PORT`, `IMAP_HOST`) #[derive(Debug, Deserialize, Clone)] #[allow(dead_code)] pub struct AppConfig { /// Bind address for the HTTP server. #[serde(default = "default_host")] pub host: String, /// Port for the HTTP server. #[serde(default = "default_port")] pub port: u16, /// IMAP server hostname. Defaults to localhost when omitted. #[serde(default = "default_imap_host")] pub imap_host: Option, /// IMAP server port. #[serde(default = "default_imap_port")] pub imap_port: u16, /// SMTP server hostname. When unset or blank, IMAP host is used. #[serde(default)] pub smtp_host: Option, /// SMTP server port. #[serde(default = "default_smtp_port")] pub smtp_port: u16, /// Whether TLS is enabled for mail connections. #[serde(default = "default_tls_enabled")] pub tls_enabled: bool, /// Optional certificate/hostname verification override for IMAP and SMTP. /// When omitted, verification is disabled only for localhost/loopback endpoints /// and enabled for remote hosts. #[serde(default)] pub tls_verify: Option, /// Maximum size of one uploaded compose attachment, in MiB. #[serde(default = "default_max_attachment_size_mb")] pub max_attachment_size_mb: usize, /// Maximum size of the complete RFC822/MIME message submitted to the MTA, in MiB. #[serde(default = "default_max_message_size_mb")] pub max_message_size_mb: usize, /// Directory for persistent data storage. #[serde(default = "default_data_dir")] pub data_dir: String, /// Session timeout in hours. #[serde(default = "default_session_timeout_hours")] pub session_timeout_hours: u64, /// Whether users may request a persistent "remember me" session. #[serde(default = "default_remember_me_enabled")] pub remember_me_enabled: bool, /// Lifetime of a remembered session in days. #[serde(default = "default_remember_me_days")] pub remember_me_days: u64, /// Environment the application is running in (development, production) #[serde(default = "default_environment")] pub environment: String, /// Server-wide light/dark theme used before authentication (including the login screen). /// Authenticated per-user display preferences still take precedence. #[serde(default = "default_theme")] pub default_theme: String, /// Server-wide interface style used on the login screen and by users whose /// interface style is set to `default`. Examples: `gmail`, `proton`, `zoho`. #[serde(default = "default_interface_skin")] pub default_interface_skin: String, /// Optional base path prefix (e.g. "/mail") for serving behind a reverse proxy subpath. #[serde(default)] pub base_path: Option, /// ManageSieve host. When unset, the authenticated account's IMAP host is used. #[serde(default)] pub sieve_host: Option, /// ManageSieve TCP port. #[serde(default = "default_sieve_port")] pub sieve_port: u16, /// ManageSieve transport: `starttls`, `implicit` or `none`. #[serde(default = "default_sieve_tls_mode")] pub sieve_tls_mode: String, /// Explicit opt-in for sending reusable credentials over a cleartext Sieve connection. #[serde(default)] pub sieve_allow_insecure_auth: bool, /// Network timeout for ManageSieve connect/read/write operations. #[serde(default = "default_sieve_timeout_secs")] pub sieve_timeout_secs: u64, /// Comma-separated list of trusted proxy IPs or CIDR ranges that are allowed to set X-Forwarded-For. #[serde(default)] pub trusted_proxies: Option, /// Administrator-defined extra headers added to every outgoing message. /// Each entry uses `Header-Name: value`. Structural/MIME headers are rejected. #[serde(default)] pub outgoing_headers: Vec, /// Enable Web Key Directory lookups for OpenPGP recipient keys. #[serde(default = "default_pgp_wkd_enabled")] pub pgp_wkd_enabled: bool, /// Allow optional HKP-over-HTTPS fallback for OpenPGP discovery. #[serde(default)] pub pgp_hkp_enabled: bool, /// HKP-compatible HTTPS endpoint used when the fallback is enabled. #[serde(default = "default_pgp_hkp_url")] pub pgp_hkp_url: String, /// Timeout for WKD/HKP network operations. #[serde(default = "default_pgp_network_timeout_secs")] pub pgp_network_timeout_secs: u64, /// Maximum accepted OpenPGP key response size. #[serde(default = "default_pgp_max_key_bytes")] pub pgp_max_key_bytes: usize, /// Allow authenticated users to export private OpenPGP key material. /// Disabled by default; public-key export is always available. #[serde(default)] pub pgp_allow_private_export: bool, /// Administrator defaults for special IMAP folders. Per-account settings override these. #[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_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_sieve_port() -> u16 { 4190 } fn default_sieve_tls_mode() -> String { "starttls".to_string() } fn default_sieve_timeout_secs() -> u64 { 10 } 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(), 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_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_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 { /// Use the root path when no reverse-proxy prefix is configured. pub fn normalized_base_path(&self) -> &str { self.base_path .as_deref() .filter(|path| !path.is_empty()) .unwrap_or("/") } /// Certificate verification policy for a configured mail host. Explicit /// `tls_verify` wins. Without it, only localhost/loopback skips validation. 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) } /// Parse the `trusted_proxies` field into IP or CIDR entries. /// Logs warnings for unparseable entries. 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) } /// Load configuration by layering serde defaults, optional TOML, and environment variables. /// /// Environment variables are read without a prefix and mapped directly to /// struct fields via case-insensitive matching (e.g. `IMAP_HOST` → `imap_host`). #[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)); } // Keep Oxi-compatible flat environment names (IMAP_HOST, SMTP_HOST, ...). // Environment variables intentionally override the TOML file. figment.merge(Env::raw()).extract() } 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_port == 0 { return Err("network ports must be greater than zero".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 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" ) { 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".to_string()); } 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() { // With no env vars set, all defaults should apply. // We use figment with no providers (only serde defaults). 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_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_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() { // Simulate env overrides via figment's tuple provider // to verify all fields are overridable. 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(("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_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.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_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() { // Verify that actual environment variables with underscores // (e.g. IMAP_HOST) correctly map to struct fields (imap_host). // SAFETY: This test runs single-threaded (--test-threads=1) // and cleans up env vars after use. 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"); // Clean up unsafe { std::env::remove_var("IMAP_HOST"); std::env::remove_var("DATA_DIR"); } } #[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())] ); } }