//! Configuration: a TOML file with named accounts, plus environment overrides. //! //! Resolution order (later wins): //! 1. Config file: `--config`, `$POSTA_CONFIG`, or `~/.config/posta/config.toml`. //! 2. Account picked with `--account`, `$POSTA_ACCOUNT`, `default_account`, //! or the only account defined. //! 3. `POSTA_*` environment variables override individual fields (and allow //! running with no config file at all). use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{anyhow, bail, Context, Result}; use serde::Deserialize; use crate::mailurl::{parse_mail_url, Endpoint}; pub const DEFAULT_CONFIG_PATH: &str = "~/.config/posta/config.toml"; #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct ConfigFile { /// Account used when `--account` / `$POSTA_ACCOUNT` are not given. pub default_account: Option, #[serde(default)] pub accounts: BTreeMap, } #[derive(Debug, Default, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct AccountConfig { /// Address used in `From:` when sending. pub email: Option, /// Human name for the `From:` header. pub display_name: Option, /// `imap://user@host[:port]` (STARTTLS) or `imaps://` (implicit TLS). pub imap_url: Option, /// `smtp://user@host[:port]` (STARTTLS) or `smtps://` (implicit TLS). pub smtp_url: Option, /// Shared password for both protocols. Prefer `password_cmd`. pub password: Option, /// Command whose first stdout line is the password (e.g. `pass show mail`). pub password_cmd: Option, pub imap_password: Option, pub imap_password_cmd: Option, pub smtp_password: Option, pub smtp_password_cmd: Option, /// Where sent mail is appended (default: "Sent"). pub sent_folder: Option, /// Where `delete` moves messages (default: "Trash"). pub trash_folder: Option, } /// A password that may be a literal or a command to run. #[derive(Debug, Clone)] pub enum Secret { Literal(String), Command(String), } impl Secret { pub fn resolve(&self) -> Result { match self { Secret::Literal(s) => Ok(s.clone()), Secret::Command(cmd) => { let out = Command::new("sh") .arg("-c") .arg(cmd) .output() .with_context(|| format!("failed to run password_cmd: {cmd}"))?; if !out.status.success() { bail!( "password_cmd exited with {}: {}", out.status, String::from_utf8_lossy(&out.stderr).trim() ); } let pass = String::from_utf8(out.stdout) .context("password_cmd printed non-UTF-8 output")?; let pass = pass.lines().next().unwrap_or("").to_string(); if pass.is_empty() { bail!("password_cmd printed nothing: {cmd}"); } Ok(pass) } } } } /// A fully resolved account, ready to connect with. #[derive(Debug, Clone)] pub struct Account { pub name: String, pub email: Option, pub display_name: Option, pub imap: Option, pub smtp: Option, imap_secret: Option, smtp_secret: Option, pub sent_folder: String, pub trash_folder: String, } impl Account { pub fn imap(&self) -> Result<(&Endpoint, String)> { let ep = self.imap.as_ref().ok_or_else(|| { anyhow!("no IMAP endpoint configured (set `imap_url` or $POSTA_IMAP_URL)") })?; let pass = self .imap_secret .as_ref() .ok_or_else(|| { anyhow!("no IMAP password configured (set `password`, `password_cmd` or $POSTA_PASSWORD)") })? .resolve()?; Ok((ep, pass)) } pub fn smtp(&self) -> Result<(&Endpoint, String)> { let ep = self.smtp.as_ref().ok_or_else(|| { anyhow!("no SMTP endpoint configured (set `smtp_url` or $POSTA_SMTP_URL)") })?; let pass = self .smtp_secret .as_ref() .ok_or_else(|| { anyhow!("no SMTP password configured (set `password`, `password_cmd` or $POSTA_PASSWORD)") })? .resolve()?; Ok((ep, pass)) } /// `Display Name ` for the From header. pub fn sender_mailbox(&self) -> Result { let email = self .email .as_deref() .ok_or_else(|| anyhow!("no sender address configured (set `email` or $POSTA_EMAIL)"))?; Ok(match &self.display_name { Some(name) => format!("{name} <{email}>"), None => email.to_string(), }) } } fn expand_tilde(p: &str) -> PathBuf { if let Some(rest) = p.strip_prefix("~/") { if let Some(home) = std::env::var_os("HOME") { return PathBuf::from(home).join(rest); } } PathBuf::from(p) } fn env_var(key: &str) -> Option { std::env::var(key).ok().filter(|v| !v.is_empty()) } /// Locates the config file: explicit flag > $POSTA_CONFIG > default path (if present). pub fn locate(cli_config: &Option) -> Option { cli_config .clone() .or_else(|| env_var("POSTA_CONFIG").map(|p| expand_tilde(&p))) .or_else(|| { let d = expand_tilde(DEFAULT_CONFIG_PATH); d.exists().then_some(d) }) } pub fn read_config_file(path: &Path) -> Result { let text = std::fs::read_to_string(path) .with_context(|| format!("cannot read config file {}", path.display()))?; let cfg: ConfigFile = toml::from_str(&text).with_context(|| format!("invalid config file {}", path.display()))?; warn_if_world_readable(path, &text); Ok(cfg) } /// A config file holding literal passwords should not be readable by others. fn warn_if_world_readable(path: &Path, text: &str) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let has_literal_password = text.lines().any(|l| { let l = l.trim_start(); l.starts_with("password") && !l.starts_with("password_cmd") || l.starts_with("imap_password ") || l.starts_with("imap_password=") || l.starts_with("smtp_password ") || l.starts_with("smtp_password=") }); if let Ok(meta) = std::fs::metadata(path) { if has_literal_password && meta.permissions().mode() & 0o077 != 0 { eprintln!( "warning: {} contains passwords but is readable by other users; \ run: chmod 600 {}", path.display(), path.display() ); } } } #[cfg(not(unix))] { let _ = (path, text); } } fn pick_account(cfg: &ConfigFile, requested: &Option) -> Result<(String, AccountConfig)> { let requested = requested.clone().or_else(|| env_var("POSTA_ACCOUNT")); let name = match requested { Some(n) => n, None => match (&cfg.default_account, cfg.accounts.len()) { (Some(d), _) => d.clone(), (None, 1) => cfg.accounts.keys().next().unwrap().clone(), (None, 0) => bail!("the config file defines no accounts"), (None, _) => bail!( "several accounts defined and no default; pass --account or set \ `default_account`. Available: {}", cfg.accounts.keys().cloned().collect::>().join(", ") ), }, }; let acc = cfg.accounts.get(&name).ok_or_else(|| { anyhow!( "account '{}' not found in config. Available: {}", name, cfg.accounts.keys().cloned().collect::>().join(", ") ) })?; Ok((name, acc.clone())) } fn secret_for( specific_literal: &Option, specific_cmd: &Option, shared_literal: &Option, shared_cmd: &Option, ) -> Option { specific_literal .clone() .map(Secret::Literal) .or_else(|| specific_cmd.clone().map(Secret::Command)) .or_else(|| shared_literal.clone().map(Secret::Literal)) .or_else(|| shared_cmd.clone().map(Secret::Command)) } /// Loads and resolves the account to operate on. pub fn load(cli_config: &Option, cli_account: &Option) -> Result { let (name, mut raw) = match locate(cli_config) { Some(path) => { let cfg = read_config_file(&path)?; pick_account(&cfg, cli_account)? } None => ("env".to_string(), AccountConfig::default()), }; // Environment overrides individual fields of the selected account. if let Some(v) = env_var("POSTA_EMAIL") { raw.email = Some(v); } if let Some(v) = env_var("POSTA_DISPLAY_NAME") { raw.display_name = Some(v); } if let Some(v) = env_var("POSTA_IMAP_URL") { raw.imap_url = Some(v); } if let Some(v) = env_var("POSTA_SMTP_URL") { raw.smtp_url = Some(v); } if let Some(v) = env_var("POSTA_PASSWORD") { raw.password = Some(v); raw.imap_password = None; raw.smtp_password = None; } if let Some(v) = env_var("POSTA_PASSWORD_CMD") { raw.password_cmd = Some(v); } if let Some(v) = env_var("POSTA_IMAP_PASSWORD") { raw.imap_password = Some(v); } if let Some(v) = env_var("POSTA_SMTP_PASSWORD") { raw.smtp_password = Some(v); } if let Some(v) = env_var("POSTA_SENT_FOLDER") { raw.sent_folder = Some(v); } if let Some(v) = env_var("POSTA_TRASH_FOLDER") { raw.trash_folder = Some(v); } let imap = raw.imap_url.as_deref().map(parse_mail_url).transpose()?; let smtp = raw.smtp_url.as_deref().map(parse_mail_url).transpose()?; // With no explicit email, fall back to an IMAP login that looks like an address. let email = raw.email.clone().or_else(|| { imap.as_ref() .map(|e| e.user.clone()) .filter(|u| u.contains('@')) }); Ok(Account { name, email, display_name: raw.display_name.clone(), imap, smtp, imap_secret: secret_for( &raw.imap_password, &raw.imap_password_cmd, &raw.password, &raw.password_cmd, ), smtp_secret: secret_for( &raw.smtp_password, &raw.smtp_password_cmd, &raw.password, &raw.password_cmd, ), sent_folder: raw.sent_folder.unwrap_or_else(|| "Sent".to_string()), trash_folder: raw.trash_folder.unwrap_or_else(|| "Trash".to_string()), }) } #[cfg(test)] mod tests { use super::*; use std::io::Write; const SAMPLE: &str = r#" default_account = "personal" [accounts.personal] email = "alice@example.com" display_name = "Alice" imap_url = "imap://alice@example.com@mail.example.com:143" smtp_url = "smtp://alice@example.com@mail.example.com:587" password = "hunter2" sent_folder = "Sent" trash_folder = "Trash" [accounts.work] email = "alice@work.example" imap_url = "imaps://alice@imap.work.example" smtp_url = "smtps://alice@smtp.work.example" password_cmd = "echo s3cret" "#; fn write_sample() -> tempfile::NamedTempFile { let mut f = tempfile::NamedTempFile::new().unwrap(); f.write_all(SAMPLE.as_bytes()).unwrap(); f } #[test] fn parses_accounts_and_default() { let cfg: ConfigFile = toml::from_str(SAMPLE).unwrap(); assert_eq!(cfg.default_account.as_deref(), Some("personal")); assert_eq!(cfg.accounts.len(), 2); assert_eq!( cfg.accounts["personal"].email.as_deref(), Some("alice@example.com") ); } #[test] fn rejects_unknown_keys() { let bad = "[accounts.a]\npasword = \"typo\"\n"; assert!(toml::from_str::(bad).is_err()); } #[test] fn picks_default_account() { let cfg: ConfigFile = toml::from_str(SAMPLE).unwrap(); let (name, acc) = pick_account(&cfg, &None).unwrap(); assert_eq!(name, "personal"); assert_eq!(acc.display_name.as_deref(), Some("Alice")); } #[test] fn picks_requested_account() { let cfg: ConfigFile = toml::from_str(SAMPLE).unwrap(); let (name, acc) = pick_account(&cfg, &Some("work".into())).unwrap(); assert_eq!(name, "work"); assert!(acc.password_cmd.is_some()); } #[test] fn unknown_account_lists_available() { let cfg: ConfigFile = toml::from_str(SAMPLE).unwrap(); let err = pick_account(&cfg, &Some("nope".into())) .unwrap_err() .to_string(); assert!( err.contains("personal") && err.contains("work"), "err: {err}" ); } #[test] fn single_account_needs_no_default() { let cfg: ConfigFile = toml::from_str("[accounts.only]\nemail = \"a@b.c\"\n").unwrap(); let (name, _) = pick_account(&cfg, &None).unwrap(); assert_eq!(name, "only"); } #[test] fn several_accounts_without_default_is_an_error() { let cfg: ConfigFile = toml::from_str("[accounts.a]\n[accounts.b]\n").unwrap(); assert!(pick_account(&cfg, &None).is_err()); } #[test] fn load_resolves_endpoints_and_secret() { let f = write_sample(); let acc = load(&Some(f.path().to_path_buf()), &Some("personal".into())).unwrap(); assert_eq!(acc.imap.as_ref().unwrap().host, "mail.example.com"); assert!(!acc.imap.as_ref().unwrap().implicit_tls); assert_eq!(acc.smtp.as_ref().unwrap().port, 587); let (_, pass) = acc.imap().unwrap(); assert_eq!(pass, "hunter2"); assert_eq!(acc.sender_mailbox().unwrap(), "Alice "); assert_eq!(acc.sent_folder, "Sent"); assert_eq!(acc.trash_folder, "Trash"); } #[test] fn password_cmd_runs_and_takes_first_line() { let f = write_sample(); let acc = load(&Some(f.path().to_path_buf()), &Some("work".into())).unwrap(); let (_, pass) = acc.imap().unwrap(); assert_eq!(pass, "s3cret"); } #[test] fn failing_password_cmd_is_an_error() { assert!(Secret::Command("exit 3".into()).resolve().is_err()); assert!(Secret::Command("true".into()).resolve().is_err()); // prints nothing } #[test] fn email_falls_back_to_imap_login() { let toml_src = r#" [accounts.a] imap_url = "imap://me@example.com@mail.example.com" password = "x" "#; let mut f = tempfile::NamedTempFile::new().unwrap(); f.write_all(toml_src.as_bytes()).unwrap(); let acc = load(&Some(f.path().to_path_buf()), &None).unwrap(); assert_eq!(acc.email.as_deref(), Some("me@example.com")); } }