aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands.rs
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 18:47:40 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 18:47:40 -0300
commitd9738b4be2add82ff955f061c79316b0dc91768f (patch)
treee0f31bb9f72dff315fc475f9e9f30322b69602ff /src/commands.rs
downloadposta-main.tar.gz
posta-main.zip
posta: generic IMAP/SMTP mail CLI with JSON outputHEADmain
Diffstat (limited to 'src/commands.rs')
-rw-r--r--src/commands.rs323
1 files changed, 323 insertions, 0 deletions
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(())
+ }
+ }
+}