1343 lines
45 KiB
Rust
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"));
|
|
}
|
|
}
|