Files
RustBox/src/config.rs
T
Mateusz Gruszczyński 2c98b8f925
RustBox CI / rustbox (push) Canceled after 0s
v0.2.3
2026-10-31 19:40:19 +01:00

1343 lines
45 KiB
Rust

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<String>,
#[serde(default)]
pub shared: bool,
#[serde(default)]
pub bucket: Option<String>,
#[serde(default = "default_s3_region")]
pub region: String,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub path_style: bool,
#[serde(default)]
pub allow_http: bool,
#[serde(default)]
pub access_key_id: Option<String>,
#[serde(default)]
pub secret_access_key: Option<String>,
#[serde(default)]
pub session_token: Option<String>,
#[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<String>,
#[serde(default)]
pub kms_key_id: Option<String>,
}
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<String>,
#[serde(default)]
pub urls: Vec<String>,
#[serde(default = "default_search_index_prefix")]
pub index_prefix: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
#[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<String>,
#[serde(default = "default_imap_port")]
pub imap_port: u16,
#[serde(default)]
pub smtp_host: Option<String>,
#[serde(default = "default_smtp_port")]
pub smtp_port: u16,
#[serde(default = "default_tls_enabled")]
pub tls_enabled: bool,
#[serde(default)]
pub tls_verify: Option<bool>,
#[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<String>,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default = "default_database_backend")]
pub database_backend: String,
#[serde(default)]
pub database_url: Option<String>,
#[serde(default = "default_session_backend")]
pub session_backend: String,
#[serde(default)]
pub session_encryption_key: Option<String>,
#[serde(default)]
pub redis_url: Option<String>,
#[serde(default)]
pub redis_sentinel_nodes: Vec<String>,
#[serde(default)]
pub redis_sentinel_master: Option<String>,
#[serde(default)]
pub redis_password: Option<String>,
#[serde(default)]
pub redis_sentinel_password: Option<String>,
#[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<String>,
#[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<String>,
#[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<String>,
#[serde(default)]
pub outgoing_headers: Vec<String>,
#[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<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_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<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)
}
#[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));
}
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"));
}
}