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/commands.rs | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 src/commands.rs (limited to 'src/commands.rs') 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(()) + } + } +} -- cgit v1.2.3