//! 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(()) } } }