//! Layered configuration: defaults <- TOML <- POSTHORN_* env. use std::net::{IpAddr, Ipv6Addr}; use std::path::Path; use figment::providers::{Env, Format, Serialized, Toml}; use figment::Figment; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct Config { pub daemon: DaemonConfig, pub imap: ImapConfig, pub jmap: JmapConfig, pub smtp: SmtpConfig, pub llm: LlmConfig, pub users: Vec, } impl Config { pub async fn load(path: &Path) -> anyhow::Result { let figment = Figment::from(Serialized::defaults(Config::default())); let figment = if path.exists() { figment.merge(Toml::file(path)) } else { tracing::warn!(?path, "config file not found, using defaults + env"); figment }; let figment = figment.merge(Env::prefixed("POSTHORN_").split("__")); figment .extract() .map_err(|e| anyhow::anyhow!("config error: {e}")) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct DaemonConfig { pub domain: String, pub data_dir: String, } impl Default for DaemonConfig { fn default() -> Self { Self { domain: "posthorn.localhost".to_string(), data_dir: "/var/lib/posthorn".to_string(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct ImapConfig { pub host: IpAddr, pub port: u16, } impl Default for ImapConfig { fn default() -> Self { Self { host: IpAddr::V6(Ipv6Addr::UNSPECIFIED), port: 1143, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct JmapConfig { pub host: IpAddr, pub port: u16, } impl Default for JmapConfig { fn default() -> Self { Self { host: IpAddr::V6(Ipv6Addr::UNSPECIFIED), port: 8080, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct SmtpConfig { pub host: IpAddr, pub port: u16, pub max_message_bytes: usize, } impl Default for SmtpConfig { fn default() -> Self { Self { host: IpAddr::V6(Ipv6Addr::UNSPECIFIED), port: 1025, max_message_bytes: 25 * 1024 * 1024, } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct LlmConfig { pub providers: Vec, pub default_system_prompt: String, pub default_provider: String, } /// A single LLM gateway. The `name` is the subdomain used for routing: /// mail to `model@.` is sent to this gateway. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProviderConfig { pub name: String, pub gateway_url: String, pub api_key_env: Option, } impl LlmConfig { /// Look up a provider by name (case-insensitive). pub fn provider(&self, name: &str) -> Option<&ProviderConfig> { self.providers .iter() .find(|p| p.name.eq_ignore_ascii_case(name)) } } impl Default for LlmConfig { fn default() -> Self { Self { providers: vec![ProviderConfig { name: "ollama".to_string(), gateway_url: "http://127.0.0.1:11434".to_string(), api_key_env: None, }], default_system_prompt: "You are a helpful assistant. Respond by email; be concise but complete." .to_string(), default_provider: "ollama".to_string(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserConfig { pub email: String, pub password: String, } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn env_cannot_override_into_users_sequence() { // Document a figment limitation that shapes the deploy design: // Env::prefixed("POSTHORN_").split("__") turns POSTHORN_USERS__0__PASSWORD // into the path users.0.password, but figment interprets a numeric // sub-key as a *map* (`{"0": {...}}`), not a sequence index — so the // merge fails with "found map, expected a sequence for key USERS". // Consequence: the user password cannot be supplied via a separate // env Secret; it must live in the config file itself. The k8s deploy // therefore mounts the whole config.toml as a Secret, not a // ConfigMap + env-Secret split. let dir = std::env::temp_dir().join(format!("posthorn-cfg-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); std::fs::write( &path, "[daemon]\ndomain = \"clee.wtf\"\ndata_dir = \"/data\"\n\ [imap]\nhost = \"127.0.0.1\"\nport = 11143\n\ [smtp]\nhost = \"127.0.0.1\"\nport = 11025\n\ [llm]\ndefault_provider = \"ollama\"\n\ [[llm.providers]]\nname = \"ollama\"\ngateway_url = \"http://x\"\n\ [[users]]\nemail = \"me@clee.wtf\"\npassword = \"\"\n", ) .unwrap(); std::env::set_var("POSTHORN_USERS__0__PASSWORD", "hunter2"); let err = Config::load(&path).await.unwrap_err(); std::env::remove_var("POSTHORN_USERS__0__PASSWORD"); std::fs::remove_dir_all(&dir).ok(); let msg = format!("{err:#}"); assert!( msg.contains("expected a sequence") && msg.contains("USERS"), "expected a sequence/map mismatch error, got: {msg}" ); } }