740 lines
26 KiB
Rust
740 lines
26 KiB
Rust
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<String>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// 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<bool>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// ManageSieve host. When unset, the authenticated account's IMAP host is used.
|
|
#[serde(default)]
|
|
pub sieve_host: Option<String>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// 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<String>,
|
|
#[serde(default)]
|
|
pub default_drafts_folder: Option<String>,
|
|
#[serde(default)]
|
|
pub default_trash_folder: Option<String>,
|
|
#[serde(default)]
|
|
pub default_junk_folder: Option<String>,
|
|
#[serde(default)]
|
|
pub default_archive_folder: Option<String>,
|
|
}
|
|
|
|
#[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<String> {
|
|
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<TrustedProxy> {
|
|
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::<IpAddr>() {
|
|
return Some(TrustedProxy::Ip(ip));
|
|
}
|
|
match trimmed.parse::<IpNet>() {
|
|
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<Vec<(String, String)>, 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<Self, figment::Error> {
|
|
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())]
|
|
);
|
|
}
|
|
}
|