diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cli.rs | 133 | ||||
| -rw-r--r-- | src/client.rs | 92 | ||||
| -rw-r--r-- | src/commands.rs | 323 | ||||
| -rw-r--r-- | src/config.rs | 453 | ||||
| -rw-r--r-- | src/mailurl.rs | 126 | ||||
| -rw-r--r-- | src/main.rs | 61 | ||||
| -rw-r--r-- | src/message.rs | 168 | ||||
| -rw-r--r-- | src/output.rs | 98 |
8 files changed, 1454 insertions, 0 deletions
diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..920f7bf --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,133 @@ +//! Command-line interface definition. + +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Parser)] +#[command( + name = "posta", + version, + about = "A tiny, generic IMAP/SMTP mail CLI", + long_about = "posta — manage any IMAP/SMTP mailbox from your terminal.\n\ + Accounts live in a TOML config file; every command prints JSON \ + by default so it plays nicely with jq and scripts." +)] +pub struct Cli { + /// Config file (default: $POSTA_CONFIG or ~/.config/posta/config.toml) + #[arg(long, global = true, value_name = "FILE")] + pub config: Option<PathBuf>, + + /// Account name from the config (default: $POSTA_ACCOUNT or `default_account`) + #[arg(long, short = 'a', global = true, value_name = "NAME")] + pub account: Option<String>, + + /// Output format + #[arg(long, global = true, value_enum, default_value_t = Format::Json)] + pub format: Format, + + #[command(subcommand)] + pub cmd: Cmd, +} + +#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Format { + Json, + Table, +} + +#[derive(Subcommand)] +pub enum Cmd { + /// List the accounts defined in the config file + Accounts, + /// List mailbox folders + Folders, + /// Message and unread counts for a folder + Status { + #[arg(long, default_value = "INBOX")] + folder: String, + }, + /// List messages, newest first + List { + #[arg(long, default_value = "INBOX")] + folder: String, + /// Only unread messages (shorthand for --query UNSEEN) + #[arg(long)] + unread: bool, + /// Raw IMAP SEARCH query, e.g. 'FROM bank SINCE 1-Jul-2026' + #[arg(long)] + query: Option<String>, + #[arg(long, default_value_t = 20)] + max: usize, + }, + /// Show a message by UID + Get { + uid: u32, + #[arg(long, default_value = "INBOX")] + folder: String, + /// Print the raw RFC 822 source + #[arg(long)] + raw: bool, + /// Prefer the text/html part over text/plain + #[arg(long)] + html: bool, + /// Mark the message as read after fetching it + #[arg(long)] + mark_read: bool, + }, + /// Send a message (body from --body, --body-file, or stdin) + Send { + #[arg(long, required = true)] + to: Vec<String>, + #[arg(long)] + cc: Vec<String>, + #[arg(long)] + subject: String, + #[arg(long)] + body: Option<String>, + #[arg(long)] + body_file: Option<PathBuf>, + /// Skip saving a copy to the sent folder + #[arg(long)] + no_record: bool, + }, + /// Add or remove message flags + Mark { + #[arg(value_enum)] + state: MarkState, + #[arg(required = true)] + uids: Vec<u32>, + #[arg(long, default_value = "INBOX")] + folder: String, + }, + /// Move messages to another folder + Move { + #[arg(required = true)] + uids: Vec<u32>, + #[arg(long)] + to: String, + #[arg(long, default_value = "INBOX")] + folder: String, + }, + /// Move messages to the trash folder + Delete { + #[arg(required = true)] + uids: Vec<u32>, + #[arg(long, default_value = "INBOX")] + folder: String, + }, + /// Run an IMAP SEARCH query and print matching UIDs + Search { + query: String, + #[arg(long, default_value = "INBOX")] + folder: String, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +pub enum MarkState { + Read, + Unread, + Flagged, + Unflagged, +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..3d9b719 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,92 @@ +//! IMAP connection and small protocol helpers. + +use std::net::TcpStream; + +use anyhow::{anyhow, Result}; +use native_tls::{TlsConnector, TlsStream}; + +use crate::config::Account; + +pub type ImapSession = imap::Session<TlsStream<TcpStream>>; + +/// Opens a logged-in IMAP session, using STARTTLS or implicit TLS as configured. +pub fn connect(acc: &Account) -> Result<ImapSession> { + let (ep, pass) = acc.imap()?; + let tls = TlsConnector::new()?; + let client = if ep.implicit_tls { + imap::connect((ep.host.as_str(), ep.port), ep.host.as_str(), &tls)? + } else { + imap::connect_starttls((ep.host.as_str(), ep.port), ep.host.as_str(), &tls)? + }; + client.login(&ep.user, &pass).map_err(|e| { + anyhow!( + "IMAP login failed for {} (account '{}'): {:?}", + ep.user, + acc.name, + e.0 + ) + }) +} + +/// Quotes a mailbox name when it needs it (spaces, quotes, parentheses). +pub fn quote_mailbox(name: &str) -> String { + if name.starts_with('"') || !name.contains([' ', '"', '(', ')']) { + name.to_string() + } else { + format!("\"{}\"", name.replace('"', "\\\"")) + } +} + +/// Renders a UID list as an IMAP sequence set. +pub fn uid_set(uids: &[u32]) -> String { + uids.iter() + .map(u32::to_string) + .collect::<Vec<_>>() + .join(",") +} + +/// Moves messages, preferring `UID MOVE` with a COPY+EXPUNGE fallback for +/// servers without the MOVE capability. +pub fn move_uids(session: &mut ImapSession, uids: &[u32], to: &str) -> Result<()> { + let set = uid_set(uids); + let dest = quote_mailbox(to); + if session.uid_mv(&set, &dest).is_ok() { + return Ok(()); + } + session.uid_copy(&set, &dest)?; + session.uid_store(&set, "+FLAGS (\\Deleted)")?; + session.expunge()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_names_stay_unquoted() { + assert_eq!(quote_mailbox("INBOX"), "INBOX"); + assert_eq!(quote_mailbox("Archives/2025"), "Archives/2025"); + } + + #[test] + fn names_with_spaces_get_quoted() { + assert_eq!(quote_mailbox("Sent Mail"), "\"Sent Mail\""); + } + + #[test] + fn embedded_quotes_are_escaped() { + assert_eq!(quote_mailbox("a\"b"), "\"a\\\"b\""); + } + + #[test] + fn already_quoted_names_pass_through() { + assert_eq!(quote_mailbox("\"Sent Mail\""), "\"Sent Mail\""); + } + + #[test] + fn uid_sets_join_with_commas() { + assert_eq!(uid_set(&[7, 9, 11]), "7,9,11"); + assert_eq!(uid_set(&[42]), "42"); + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..f43c8a5 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,323 @@ +//! One function per subcommand. + +use std::collections::HashSet; +use std::io::{Read as _, Write as _}; +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use serde_json::{json, Value}; + +use crate::cli::{Format, MarkState}; +use crate::client::{self, quote_mailbox, uid_set, ImapSession}; +use crate::config::{self, Account}; +use crate::message::{extract_body, flag_to_string, summarize, MsgSummary}; +use crate::output::{message_table, print_json}; + +fn sorted_desc(uids: HashSet<u32>) -> Vec<u32> { + let mut v: Vec<u32> = uids.into_iter().collect(); + v.sort_unstable_by(|a, b| b.cmp(a)); + v +} + +fn fetch_summaries(session: &mut ImapSession, uids: &[u32]) -> Result<Vec<MsgSummary>> { + if uids.is_empty() { + return Ok(vec![]); + } + let fetches = session.uid_fetch(uid_set(uids), "(UID FLAGS RFC822.SIZE RFC822.HEADER)")?; + let mut items: Vec<MsgSummary> = fetches.iter().filter_map(summarize).collect(); + items.sort_by_key(|m| std::cmp::Reverse(m.uid)); + Ok(items) +} + +/// `accounts` needs the raw config file, not a resolved account. +pub fn accounts(cli_config: &Option<PathBuf>, format: Format) -> Result<()> { + let path = config::locate(cli_config).ok_or_else(|| { + anyhow!( + "no config file found (default: {})", + config::DEFAULT_CONFIG_PATH + ) + })?; + let cfg = config::read_config_file(&path)?; + let list: Vec<Value> = cfg + .accounts + .iter() + .map(|(name, a)| { + json!({ + "name": name, + "default": Some(name) == cfg.default_account.as_ref(), + "email": a.email, + "imap_url": a.imap_url, + "smtp_url": a.smtp_url, + }) + }) + .collect(); + match format { + Format::Json => print_json(&json!({ + "config": path.display().to_string(), + "accounts": list, + })), + Format::Table => { + for a in &list { + let mark = if a["default"].as_bool() == Some(true) { + "*" + } else { + " " + }; + println!( + "{} {} {}", + mark, + a["name"].as_str().unwrap_or("?"), + a["email"].as_str().unwrap_or("-") + ); + } + Ok(()) + } + } +} + +pub fn folders(acc: &Account, format: Format) -> Result<()> { + let mut s = client::connect(acc)?; + let names = s.list(Some(""), Some("*"))?; + let mut folders: Vec<Value> = names + .iter() + .map(|n| { + json!({ + "name": n.name(), + "delimiter": n.delimiter(), + "attributes": n.attributes().iter().map(|a| format!("{a:?}")).collect::<Vec<_>>(), + }) + }) + .collect(); + folders.sort_by_key(|f| f["name"].as_str().unwrap_or("").to_string()); + let _ = s.logout(); + match format { + Format::Json => print_json(&json!({ "folders": folders })), + Format::Table => { + for f in &folders { + println!("{}", f["name"].as_str().unwrap_or("?")); + } + Ok(()) + } + } +} + +pub fn status(acc: &Account, folder: &str, format: Format) -> Result<()> { + let mut s = client::connect(acc)?; + let mbox = s.examine(quote_mailbox(folder))?; + let unseen = s.uid_search("UNSEEN")?.len(); + let _ = s.logout(); + match format { + Format::Json => { + print_json(&json!({ "folder": folder, "messages": mbox.exists, "unread": unseen })) + } + Format::Table => { + println!("{folder}: {} messages, {} unread", mbox.exists, unseen); + Ok(()) + } + } +} + +pub fn list( + acc: &Account, + folder: &str, + unread: bool, + query: Option<String>, + max: usize, + format: Format, +) -> Result<()> { + let query = query.unwrap_or_else(|| { + if unread { + "UNSEEN".into() + } else { + "ALL".into() + } + }); + let mut s = client::connect(acc)?; + s.examine(quote_mailbox(folder))?; + let uids = sorted_desc(s.uid_search(&query)?); + let page: Vec<u32> = uids.into_iter().take(max).collect(); + let items = fetch_summaries(&mut s, &page)?; + let _ = s.logout(); + match format { + Format::Json => print_json(&json!({ + "folder": folder, + "query": query, + "count": items.len(), + "messages": items, + })), + Format::Table => { + print!("{}", message_table(&items)); + Ok(()) + } + } +} + +pub fn get( + acc: &Account, + uid: u32, + folder: &str, + raw: bool, + html: bool, + mark_read: bool, + format: Format, +) -> Result<()> { + let mut s = client::connect(acc)?; + if mark_read { + s.select(quote_mailbox(folder))?; + } else { + s.examine(quote_mailbox(folder))?; + } + let fetches = s.uid_fetch(uid.to_string(), "(UID FLAGS BODY.PEEK[])")?; + let f = fetches + .iter() + .find(|f| f.uid == Some(uid)) + .ok_or_else(|| anyhow!("no message with UID {uid} in {folder}"))?; + let body_bytes = f.body().ok_or_else(|| anyhow!("server returned no body"))?; + if raw { + std::io::stdout().write_all(body_bytes)?; + let _ = s.logout(); + return Ok(()); + } + let mail = mailparse::parse_mail(body_bytes)?; + let h = crate::message::parse_header_summary(body_bytes); + let out = json!({ + "uid": uid, + "flags": f.flags().iter().map(flag_to_string).collect::<Vec<_>>(), + "date": h.date, + "from": h.from, + "to": h.to, + "subject": h.subject, + "body": extract_body(&mail, html), + }); + if mark_read { + s.uid_store(uid.to_string(), "+FLAGS (\\Seen)")?; + } + let _ = s.logout(); + match format { + Format::Json => print_json(&out), + Format::Table => { + for k in ["date", "from", "to", "subject"] { + if let Some(v) = out[k].as_str() { + println!("{}: {}", k.to_uppercase(), v); + } + } + println!(); + println!("{}", out["body"].as_str().unwrap_or("(no text body)")); + Ok(()) + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn send( + acc: &Account, + to: &[String], + cc: &[String], + subject: &str, + body: Option<String>, + body_file: Option<PathBuf>, + no_record: bool, +) -> Result<()> { + use lettre::transport::smtp::authentication::Credentials; + use lettre::{Message, SmtpTransport, Transport}; + + let (ep, pass) = acc.smtp()?; + let from_mbox = acc.sender_mailbox()?; + + let body_text = match (body, body_file) { + (Some(b), _) => b, + (None, Some(p)) => { + std::fs::read_to_string(&p).with_context(|| format!("cannot read {}", p.display()))? + } + (None, None) => { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("reading body from stdin")?; + buf + } + }; + + let mut builder = Message::builder() + .from(from_mbox.parse().context("invalid From address")?) + .subject(subject); + for t in to { + builder = builder.to(t + .parse() + .with_context(|| format!("invalid recipient: {t}"))?); + } + for c in cc { + builder = builder.cc(c.parse().with_context(|| format!("invalid cc: {c}"))?); + } + let email = builder.body(body_text)?; + + let creds = Credentials::new(ep.user.clone(), pass); + let mailer = if ep.implicit_tls { + SmtpTransport::relay(&ep.host)? + } else { + SmtpTransport::starttls_relay(&ep.host)? + } + .port(ep.port) + .credentials(creds) + .build(); + mailer.send(&email).context("SMTP send failed")?; + + let mut recorded = None; + if !no_record && acc.imap.is_some() { + let mut s = client::connect(acc)?; + s.append(quote_mailbox(&acc.sent_folder), email.formatted())?; + let _ = s.logout(); + recorded = Some(acc.sent_folder.clone()); + } + print_json(&json!({ + "status": "sent", + "from": from_mbox, + "to": to, + "cc": cc, + "subject": subject, + "record": recorded, + })) +} + +pub fn mark(acc: &Account, state: MarkState, uids: &[u32], folder: &str) -> Result<()> { + let flags = match state { + MarkState::Read => "+FLAGS (\\Seen)", + MarkState::Unread => "-FLAGS (\\Seen)", + MarkState::Flagged => "+FLAGS (\\Flagged)", + MarkState::Unflagged => "-FLAGS (\\Flagged)", + }; + let mut s = client::connect(acc)?; + s.select(quote_mailbox(folder))?; + s.uid_store(uid_set(uids), flags)?; + let _ = s.logout(); + print_json(&json!({ "status": "ok", "folder": folder, "uids": uids, "flags": flags })) +} + +pub fn move_cmd(acc: &Account, uids: &[u32], to: &str, folder: &str) -> Result<()> { + let mut s = client::connect(acc)?; + s.select(quote_mailbox(folder))?; + client::move_uids(&mut s, uids, to)?; + let _ = s.logout(); + print_json(&json!({ "status": "moved", "from": folder, "to": to, "uids": uids })) +} + +pub fn search(acc: &Account, query: &str, folder: &str, format: Format) -> Result<()> { + let mut s = client::connect(acc)?; + s.examine(quote_mailbox(folder))?; + let uids = sorted_desc(s.uid_search(query)?); + let _ = s.logout(); + match format { + Format::Json => print_json(&json!({ + "folder": folder, + "query": query, + "count": uids.len(), + "uids": uids, + })), + Format::Table => { + for u in uids { + println!("{u}"); + } + Ok(()) + } + } +} 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")); + } +} diff --git a/src/mailurl.rs b/src/mailurl.rs new file mode 100644 index 0000000..c4285e4 --- /dev/null +++ b/src/mailurl.rs @@ -0,0 +1,126 @@ +//! Parsing of `imap[s]://` and `smtp[s]://` endpoint URLs. + +use anyhow::{anyhow, bail, Context, Result}; + +/// A mail server endpoint: who to log in as, where, and how TLS starts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Endpoint { + pub user: String, + pub host: String, + pub port: u16, + /// `true` for TLS-from-the-start (ports 993/465); `false` for STARTTLS. + pub implicit_tls: bool, +} + +/// Parses `imap[s]://user@host[:port]` or `smtp[s]://user@host[:port]`. +/// +/// The user part may itself contain `@` (e.g. `imap://me@example.com@mail.example.com`); +/// the split happens at the last `@`. Plain schemes upgrade to implicit TLS when the +/// port is the well-known implicit one (993/465) — STARTTLS makes no sense there. +pub fn parse_mail_url(url: &str) -> Result<Endpoint> { + let (rest, default_port, mut implicit_tls) = if let Some(r) = url.strip_prefix("imaps://") { + (r, 993, true) + } else if let Some(r) = url.strip_prefix("imap://") { + (r, 143, false) + } else if let Some(r) = url.strip_prefix("smtps://") { + (r, 465, true) + } else if let Some(r) = url.strip_prefix("smtp://") { + (r, 587, false) + } else { + b |