aboutsummaryrefslogtreecommitdiffstats
path: root/src/config.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/config.rs')
-rw-r--r--src/config.rs453
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"));
+ }
+}