From d9738b4be2add82ff955f061c79316b0dc91768f Mon Sep 17 00:00:00 2001 From: Elvis Claros Castro Date: Sat, 26 Sep 2026 18:47:40 -0300 Subject: posta: generic IMAP/SMTP mail CLI with JSON output --- src/cli.rs | 133 +++++++++++++++++ src/client.rs | 92 ++++++++++++ src/commands.rs | 323 ++++++++++++++++++++++++++++++++++++++++ src/config.rs | 453 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/mailurl.rs | 126 ++++++++++++++++ src/main.rs | 61 ++++++++ src/message.rs | 168 +++++++++++++++++++++ src/output.rs | 98 ++++++++++++ 8 files changed, 1454 insertions(+) create mode 100644 src/cli.rs create mode 100644 src/client.rs create mode 100644 src/commands.rs create mode 100644 src/config.rs create mode 100644 src/mailurl.rs create mode 100644 src/main.rs create mode 100644 src/message.rs create mode 100644 src/output.rs (limited to 'src') 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, + + /// Account name from the config (default: $POSTA_ACCOUNT or `default_account`) + #[arg(long, short = 'a', global = true, value_name = "NAME")] + pub account: Option, + + /// 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, + #[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, + #[arg(long)] + cc: Vec, + #[arg(long)] + subject: String, + #[arg(long)] + body: Option, + #[arg(long)] + body_file: Option, + /// 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, + #[arg(long, default_value = "INBOX")] + folder: String, + }, + /// Move messages to another folder + Move { + #[arg(required = true)] + uids: Vec, + #[arg(long)] + to: String, + #[arg(long, default_value = "INBOX")] + folder: String, + }, + /// Move messages to the trash folder + Delete { + #[arg(required = true)] + uids: Vec, + #[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>; + +/// Opens a logged-in IMAP session, using STARTTLS or implicit TLS as configured. +pub fn connect(acc: &Account) -> Result { + 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::>() + .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) -> Vec { + let mut v: Vec = uids.into_iter().collect(); + v.sort_unstable_by(|a, b| b.cmp(a)); + v +} + +fn fetch_summaries(session: &mut ImapSession, uids: &[u32]) -> Result> { + 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 = 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, 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 = 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 = names + .iter() + .map(|n| { + json!({ + "name": n.name(), + "delimiter": n.delimiter(), + "attributes": n.attributes().iter().map(|a| format!("{a:?}")).collect::>(), + }) + }) + .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, + 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 = 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::>(), + "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, + body_file: Option, + 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, + #[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")); + } +} 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 { + 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 { + bail!("URL must start with imap://, imaps://, smtp:// or smtps:// (got: {url})") + }; + + let rest = rest.trim_end_matches('/'); + let (user, hostport) = rest + .rsplit_once('@') + .ok_or_else(|| anyhow!("URL is missing the user part (user@host): {url}"))?; + if user.is_empty() { + bail!("URL has an empty user part: {url}"); + } + + let (host, port) = match hostport.rsplit_once(':') { + Some((h, p)) => ( + h.to_string(), + p.parse::() + .with_context(|| format!("invalid port in URL: {url}"))?, + ), + None => (hostport.to_string(), default_port), + }; + if host.is_empty() { + bail!("URL has an empty host: {url}"); + } + + if port == 993 || port == 465 { + implicit_tls = true; + } + + Ok(Endpoint { + user: user.to_string(), + host, + port, + implicit_tls, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn imap_starttls_with_default_port() { + let ep = parse_mail_url("imap://alice@mail.example.com").unwrap(); + assert_eq!(ep.user, "alice"); + assert_eq!(ep.host, "mail.example.com"); + assert_eq!(ep.port, 143); + assert!(!ep.implicit_tls); + } + + #[test] + fn imaps_defaults_to_993_implicit() { + let ep = parse_mail_url("imaps://alice@imap.example.com").unwrap(); + assert_eq!(ep.port, 993); + assert!(ep.implicit_tls); + } + + #[test] + fn user_may_contain_at_sign() { + let ep = parse_mail_url("imap://alice@example.com@mail.example.com:143").unwrap(); + assert_eq!(ep.user, "alice@example.com"); + assert_eq!(ep.host, "mail.example.com"); + assert_eq!(ep.port, 143); + } + + #[test] + fn smtp_defaults_to_587_starttls() { + let ep = parse_mail_url("smtp://bob@smtp.example.com").unwrap(); + assert_eq!(ep.port, 587); + assert!(!ep.implicit_tls); + } + + #[test] + fn smtps_defaults_to_465_implicit() { + let ep = parse_mail_url("smtps://bob@smtp.example.com").unwrap(); + assert_eq!(ep.port, 465); + assert!(ep.implicit_tls); + } + + #[test] + fn plain_scheme_on_implicit_port_upgrades() { + assert!(parse_mail_url("imap://a@h:993").unwrap().implicit_tls); + assert!(parse_mail_url("smtp://a@h:465").unwrap().implicit_tls); + } + + #[test] + fn trailing_slash_is_tolerated() { + let ep = parse_mail_url("imap://alice@mail.example.com/").unwrap(); + assert_eq!(ep.host, "mail.example.com"); + } + + #[test] + fn rejects_unknown_scheme_and_malformed() { + assert!(parse_mail_url("http://a@h").is_err()); + assert!(parse_mail_url("imap://nouser").is_err()); + assert!(parse_mail_url("imap://@host").is_err()); + assert!(parse_mail_url("imap://user@host:notaport").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a60226b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,61 @@ +mod cli; +mod client; +mod commands; +mod config; +mod mailurl; +mod message; +mod output; + +use anyhow::Result; +use clap::Parser; + +use cli::{Cli, Cmd}; + +fn main() -> Result<()> { + let cli = Cli::parse(); + + // `accounts` inspects the config file itself; everything else needs one + // resolved account. + if let Cmd::Accounts = cli.cmd { + return commands::accounts(&cli.config, cli.format); + } + let acc = config::load(&cli.config, &cli.account)?; + + match cli.cmd { + Cmd::Accounts => unreachable!("handled above"), + Cmd::Folders => commands::folders(&acc, cli.format), + Cmd::Status { folder } => commands::status(&acc, &folder, cli.format), + Cmd::List { + folder, + unread, + query, + max, + } => commands::list(&acc, &folder, unread, query, max, cli.format), + Cmd::Get { + uid, + folder, + raw, + html, + mark_read, + } => commands::get(&acc, uid, &folder, raw, html, mark_read, cli.format), + Cmd::Send { + to, + cc, + subject, + body, + body_file, + no_record, + } => commands::send(&acc, &to, &cc, &subject, body, body_file, no_record), + Cmd::Mark { + state, + uids, + folder, + } => commands::mark(&acc, state, &uids, &folder), + Cmd::Move { uids, to, folder } => commands::move_cmd(&acc, &uids, &to, &folder), + Cmd::Delete { uids, folder } => { + let trash = acc.trash_folder.clone(); + commands::move_cmd(&acc, &uids, &trash, &folder) + } + Cmd::Search { query, folder } => commands::search(&acc, &query, &folder, cli.format), + } +} diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..57d50d0 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,168 @@ +//! Message summaries and body extraction. + +use mailparse::{MailHeaderMap, ParsedMail}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct MsgSummary { + pub uid: u32, + pub flags: Vec, + pub size: Option, + pub date: Option, + pub from: Option, + pub to: Option, + pub subject: Option, +} + +/// The header fields shown in listings, decoded (RFC 2047) by mailparse. +#[derive(Debug, Default, PartialEq)] +pub struct HeaderSummary { + pub date: Option, + pub from: Option, + pub to: Option, + pub subject: Option, +} + +pub fn parse_header_summary(raw: &[u8]) -> HeaderSummary { + match mailparse::parse_headers(raw) { + Ok((headers, _)) => HeaderSummary { + date: headers.get_first_value("Date"), + from: headers.get_first_value("From"), + to: headers.get_first_value("To"), + subject: headers.get_first_value("Subject"), + }, + Err(_) => HeaderSummary::default(), + } +} + +pub fn flag_to_string(f: &imap::types::Flag) -> String { + use imap::types::Flag::*; + match f { + Seen => "\\Seen".into(), + Answered => "\\Answered".into(), + Flagged => "\\Flagged".into(), + Deleted => "\\Deleted".into(), + Draft => "\\Draft".into(), + Recent => "\\Recent".into(), + Custom(c) => c.to_string(), + other => format!("{other:?}"), + } +} + +pub fn summarize(f: &imap::types::Fetch) -> Option { + let uid = f.uid?; + let h = f.header().map(parse_header_summary).unwrap_or_default(); + Some(MsgSummary { + uid, + flags: f.flags().iter().map(flag_to_string).collect(), + size: f.size, + date: h.date, + from: h.from, + to: h.to, + subject: h.subject, + }) +} + +fn find_part(mail: &ParsedMail, mimetype: &str) -> Option { + if mail.ctype.mimetype.eq_ignore_ascii_case(mimetype) { + return mail.get_body().ok(); + } + mail.subparts.iter().find_map(|sp| find_part(sp, mimetype)) +} + +/// Picks the best displayable body: text/plain by default, text/html on request, +/// falling back to the other one when the preferred part is missing. +pub fn extract_body(mail: &ParsedMail, prefer_html: bool) -> Option { + let order: [&str; 2] = if prefer_html { + ["text/html", "text/plain"] + } else { + ["text/plain", "text/html"] + }; + order.iter().find_map(|mt| find_part(mail, mt)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PLAIN: &[u8] = b"From: Alice \r\n\ +To: bob@example.com\r\n\ +Date: Fri, 17 Jul 2026 10:00:00 -0300\r\n\ +Subject: Hello\r\n\ +Content-Type: text/plain; charset=utf-8\r\n\ +\r\n\ +Just checking in.\r\n"; + + // Subject uses RFC 2047 encoded words; body parts use quoted-printable/base64. + const MULTIPART: &[u8] = b"From: =?utf-8?Q?Mar=C3=ADa?= \r\n\ +To: bob@example.com\r\n\ +Subject: =?utf-8?B?SG9sYSDwn5qA?=\r\n\ +MIME-Version: 1.0\r\n\ +Content-Type: multipart/alternative; boundary=XYZ\r\n\ +\r\n\ +--XYZ\r\n\ +Content-Type: text/plain; charset=utf-8\r\n\ +Content-Transfer-Encoding: quoted-printable\r\n\ +\r\n\ +Hola, =C2=BFtodo bien?\r\n\ +--XYZ\r\n\ +Content-Type: text/html; charset=utf-8\r\n\ +Content-Transfer-Encoding: base64\r\n\ +\r\n\ +PGI+SG9sYTwvYj4=\r\n\ +--XYZ--\r\n"; + + #[test] + fn header_summary_reads_basic_fields() { + let h = parse_header_summary(PLAIN); + assert_eq!(h.from.as_deref(), Some("Alice ")); + assert_eq!(h.subject.as_deref(), Some("Hello")); + assert_eq!(h.date.as_deref(), Some("Fri, 17 Jul 2026 10:00:00 -0300")); + } + + #[test] + fn header_summary_decodes_rfc2047() { + let h = parse_header_summary(MULTIPART); + assert_eq!(h.from.as_deref(), Some("María ")); + assert_eq!(h.subject.as_deref(), Some("Hola 🚀")); + } + + #[test] + fn garbage_headers_yield_empty_summary() { + assert_eq!( + parse_header_summary(b"\xff\xfe\x00"), + HeaderSummary::default() + ); + } + + #[test] + fn plain_body_is_extracted() { + let mail = mailparse::parse_mail(PLAIN).unwrap(); + assert_eq!( + extract_body(&mail, false).unwrap().trim(), + "Just checking in." + ); + } + + #[test] + fn multipart_prefers_plain_and_decodes_qp() { + let mail = mailparse::parse_mail(MULTIPART).unwrap(); + assert_eq!( + extract_body(&mail, false).unwrap().trim(), + "Hola, ¿todo bien?" + ); + } + + #[test] + fn html_flag_prefers_html_and_decodes_base64() { + let mail = mailparse::parse_mail(MULTIPART).unwrap(); + assert_eq!(extract_body(&mail, true).unwrap().trim(), "Hola"); + } + + #[test] + fn html_only_message_falls_back_when_plain_requested() { + let raw = b"Content-Type: text/html\r\n\r\n

hi

\r\n"; + let mail = mailparse::parse_mail(raw).unwrap(); + assert_eq!(extract_body(&mail, false).unwrap().trim(), "

hi

"); + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..9cc21b2 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,98 @@ +//! JSON and table rendering. + +use anyhow::Result; +use serde_json::Value; + +use crate::message::MsgSummary; + +pub fn print_json(v: &Value) -> Result<()> { + println!("{}", serde_json::to_string_pretty(v)?); + Ok(()) +} + +/// Truncates to `max` characters, ellipsizing multi-byte text safely. +pub fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let cut: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{cut}…") + } +} + +pub fn message_table(items: &[MsgSummary]) -> String { + let mut out = String::new(); + out.push_str(&format!( + "{:>6} {:2} {:22} {:32} {}\n", + "UID", "", "DATE", "FROM", "SUBJECT" + )); + for m in items { + let unread = if m.flags.iter().any(|f| f == "\\Seen") { + " " + } else { + "●" + }; + out.push_str(&format!( + "{:>6} {:2} {:22} {:32} {}\n", + m.uid, + unread, + truncate(m.date.as_deref().unwrap_or("-"), 22), + truncate(m.from.as_deref().unwrap_or("-"), 32), + truncate(m.subject.as_deref().unwrap_or("-"), 60), + )); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_keeps_short_strings() { + assert_eq!(truncate("hola", 10), "hola"); + assert_eq!(truncate("hola", 4), "hola"); + } + + #[test] + fn truncate_ellipsizes_long_strings() { + assert_eq!(truncate("abcdefgh", 5), "abcd…"); + } + + #[test] + fn truncate_is_multibyte_safe() { + // Would panic with byte-based slicing. + assert_eq!(truncate("ñandú y cóndor", 6), "ñandú…"); + assert_eq!(truncate("🚀🚀🚀🚀", 3), "🚀🚀…"); + } + + fn sample(flags: &[&str]) -> MsgSummary { + MsgSummary { + uid: 7, + flags: flags.iter().map(|s| s.to_string()).collect(), + size: None, + date: Some("Fri, 17 Jul 2026 10:00:00 -0300".into()), + from: Some("Alice ".into()), + to: None, + subject: Some("Hello".into()), + } + } + + #[test] + fn table_marks_unread_messages() { + let unread = message_table(&[sample(&[])]); + assert!(unread.contains('●'), "unread marker missing:\n{unread}"); + let read = message_table(&[sample(&["\\Seen"])]); + assert!( + !read.contains('●'), + "read message should have no marker:\n{read}" + ); + } + + #[test] + fn table_contains_uid_and_subject() { + let t = message_table(&[sample(&[])]); + assert!(t.contains(" 7")); + assert!(t.contains("Hello")); + } +} -- cgit v1.2.3