diff options
Diffstat (limited to 'src/config.rs')
| -rw-r--r-- | src/config.rs | 453 |
1 files changed, 453 insertions, 0 deletions
diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..880685b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,453 @@ +//! 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<String>, + #[serde(default)] + pub accounts: BTreeMap<String, AccountConfig>, +} + +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccountConfig { + /// Address used in `From:` when sending. + pub email: Option<String>, + /// Human name for the `From:` header. + pub display_name: Option<String>, + /// `imap://user@host[:port]` (STARTTLS) or `imaps://` (implicit TLS). + pub imap_url: Option<String>, + /// `smtp://user@host[:port]` (STARTTLS) or `smtps://` (implicit TLS). + pub smtp_url: Option<String>, + /// Shared password for both protocols. Prefer `password_cmd`. + pub password: Option<String>, + /// Command whose first stdout line is the password (e.g. `pass show mail`). + pub password_cmd: Option<String>, + pub imap_password: Option<String>, + pub imap_password_cmd: Option<String>, + pub smtp_password: Option<String>, + pub smtp_password_cmd: Option<String>, + /// Where sent mail is appended (default: "Sent"). + pub sent_folder: Option<String>, + /// Where `delete` moves messages (default: "Trash"). + pub trash_folder: Option<String>, +} + +/// 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<String> { + 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<String>, + pub display_name: Option<String>, + pub imap: Option<Endpoint>, + pub smtp: Option<Endpoint>, + imap_secret: Option<Secret>, + smtp_secret: Option<Secret>, + 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 <email>` for the From header. + pub fn sender_mailbox(&self) -> Result<String> { + 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<String> { + 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<PathBuf>) -> Option<PathBuf> { + 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<ConfigFile> { + 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<String>) -> 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::<Vec<_>>().join(", ") + ), + }, + }; + let acc = cfg.accounts.get(&name).ok_or_else(|| { + anyhow!( + "account '{}' not found in config. Available: {}", + name, + cfg.accounts.keys().cloned().collect::<Vec<_>>().join(", ") + ) + })?; + Ok((name, acc.clone())) +} + +fn secret_for( + specific_literal: &Option<String>, + specific_cmd: &Option<String>, + shared_literal: &Option<String>, + shared_cmd: &Option<String>, +) -> Option<Secret> { + 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<PathBuf>, cli_account: &Option<String>) -> Result<Account> { + 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::<ConfigFile>(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 <alice@example.com>"); + 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")); + } +} |