From 4992c256ca2120fa92f4e3f8e5485a349a8085e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Tue, 3 Mar 2026 23:26:46 +0100 Subject: [PATCH] add envelope commands --- Cargo.lock | 1 + Cargo.toml | 1 + src/imap/command.rs | 9 +- src/imap/envelope/command/get.rs | 186 ++++++++++++++++ src/imap/envelope/command/list.rs | 227 +++++++++++++++++++ src/imap/envelope/command/mod.rs | 43 ++++ src/imap/envelope/command/search.rs | 272 +++++++++++++++++++++++ src/imap/envelope/command/sort.rs | 169 ++++++++++++++ src/imap/envelope/command/thread.rs | 333 ++++++++++++++++++++++++++++ src/imap/envelope/mod.rs | 1 + src/imap/mod.rs | 1 + 11 files changed, 1242 insertions(+), 1 deletion(-) create mode 100644 src/imap/envelope/command/get.rs create mode 100644 src/imap/envelope/command/list.rs create mode 100644 src/imap/envelope/command/mod.rs create mode 100644 src/imap/envelope/command/search.rs create mode 100644 src/imap/envelope/command/sort.rs create mode 100644 src/imap/envelope/command/thread.rs create mode 100644 src/imap/envelope/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 5db1f3e0..723c9459 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -583,6 +583,7 @@ version = "1.2.0" dependencies = [ "anyhow", "ariadne", + "chrono", "clap", "comfy-table", "crossterm 0.27.0", diff --git a/Cargo.toml b/Cargo.toml index 35be5a0f..a0d1e768 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["b [dependencies] anyhow = "1" ariadne = { version = "0.2", features = ["auto-color"] } +chrono = { version = "0.4", default-features = false } clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } comfy-table = "7" crossterm = { version = "0.27", features = ["serde"] } diff --git a/src/imap/command.rs b/src/imap/command.rs index 63c8ad9d..b1fd9fc9 100644 --- a/src/imap/command.rs +++ b/src/imap/command.rs @@ -4,7 +4,10 @@ use pimalaya_toolbox::terminal::printer::Printer; use crate::{ config::ImapConfig, - imap::{flag::command::FlagCommand, mailbox::command::MailboxCommand}, + imap::{ + envelope::command::EnvelopeCommand, flag::command::FlagCommand, + mailbox::command::MailboxCommand, + }, }; /// IMAP CLI (requires `imap` cargo feature). @@ -15,6 +18,9 @@ use crate::{ #[derive(Debug, Subcommand)] #[command(rename_all = "lowercase")] pub enum ImapCommand { + #[command(subcommand)] + #[command(aliases = ["envelope", "env"])] + Envelopes(EnvelopeCommand), #[command(subcommand)] Flags(FlagCommand), #[command(subcommand)] @@ -25,6 +31,7 @@ pub enum ImapCommand { impl ImapCommand { pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { match self { + Self::Envelopes(cmd) => cmd.execute(printer, config), Self::Flags(cmd) => cmd.execute(printer, config), Self::Mailboxes(cmd) => cmd.execute(printer, config), } diff --git a/src/imap/envelope/command/get.rs b/src/imap/envelope/command/get.rs new file mode 100644 index 00000000..00cd267c --- /dev/null +++ b/src/imap/envelope/command/get.rs @@ -0,0 +1,186 @@ +use std::{fmt, num::NonZeroU32}; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use io_imap::{ + coroutines::{fetch::*, select::*}, + types::{ + core::Vec1, + fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::{Serialize, Serializer}; + +use crate::{ + config::ImapConfig, + imap::{ + envelope::command::list::format_addresses, + mailbox::arg::name::MailboxNameOptionalFlag, + stream, + }, +}; + +/// Get a single message envelope. +/// +/// This command displays detailed envelope information for a specific +/// message, including all header fields like date, subject, from, to, +/// cc, bcc, reply-to, message-id, and in-reply-to. +#[derive(Debug, Parser)] +pub struct GetEnvelopeCommand { + #[command(flatten)] + pub mailbox: MailboxNameOptionalFlag, + + /// The message sequence number or UID. + #[arg(name = "id", value_name = "ID")] + pub id: u32, + + /// Use UID FETCH instead of FETCH. + #[arg(long)] + pub uid: bool, +} + +impl GetEnvelopeCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + let (context, mut stream) = stream::connect(config)?; + + let mailbox = self.mailbox.name.try_into()?; + + // SELECT mailbox + let mut arg = None; + let mut coroutine = ImapSelect::new(context, mailbox); + + let context = loop { + match coroutine.resume(arg.take()) { + ImapSelectResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapSelectResult::Ok { context, .. } => break context, + ImapSelectResult::Err { err, .. } => bail!(err), + } + }; + + // FETCH envelope + let id = NonZeroU32::new(self.id).ok_or_else(|| anyhow::anyhow!("ID must be non-zero"))?; + + let item_names = MacroOrMessageDataItemNames::MessageDataItemNames(vec![ + MessageDataItemName::Envelope, + ]); + + let mut arg = None; + let mut coroutine = ImapFetchFirst::new(context, id, item_names, self.uid); + + let items = loop { + match coroutine.resume(arg.take()) { + ImapFetchFirstResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapFetchFirstResult::Ok { items, .. } => break items, + ImapFetchFirstResult::Err { err, .. } => bail!(err), + } + }; + + let table = EnvelopeDetailTable::new(items); + + printer.out(table)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct EnvelopeDetail { + pub date: String, + pub subject: String, + pub message_id: String, + pub in_reply_to: String, + pub from: String, + pub sender: String, + pub reply_to: String, + pub to: String, + pub cc: String, + pub bcc: String, +} + +pub struct EnvelopeDetailTable { + detail: EnvelopeDetail, +} + +impl EnvelopeDetailTable { + pub fn new(items: Vec1>) -> Self { + let mut detail = EnvelopeDetail { + date: String::new(), + subject: String::new(), + message_id: String::new(), + in_reply_to: String::new(), + from: String::new(), + sender: String::new(), + reply_to: String::new(), + to: String::new(), + cc: String::new(), + bcc: String::new(), + }; + + for item in items.into_iter() { + if let MessageDataItem::Envelope(env) = item { + // NString wraps Option, access via .0 + if let Some(d) = &env.date.0 { + detail.date = String::from_utf8_lossy(d.as_ref()).to_string(); + } + if let Some(s) = &env.subject.0 { + detail.subject = String::from_utf8_lossy(s.as_ref()).to_string(); + } + if let Some(m) = &env.message_id.0 { + detail.message_id = String::from_utf8_lossy(m.as_ref()).to_string(); + } + if let Some(r) = &env.in_reply_to.0 { + detail.in_reply_to = String::from_utf8_lossy(r.as_ref()).to_string(); + } + detail.from = format_addresses(&env.from); + detail.sender = format_addresses(&env.sender); + detail.reply_to = format_addresses(&env.reply_to); + detail.to = format_addresses(&env.to); + detail.cc = format_addresses(&env.cc); + detail.bcc = format_addresses(&env.bcc); + } + } + + Self { detail } + } +} + +impl fmt::Display for EnvelopeDetailTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + table + .load_preset(presets::ASCII_MARKDOWN) + .set_content_arrangement(ContentArrangement::DynamicFullWidth) + .set_header(Row::from([Cell::new("FIELD"), Cell::new("VALUE")])); + + let fields = [ + ("Date", &self.detail.date), + ("Subject", &self.detail.subject), + ("Message-ID", &self.detail.message_id), + ("From", &self.detail.from), + ("Sender", &self.detail.sender), + ("To", &self.detail.to), + ("Cc", &self.detail.cc), + ("Bcc", &self.detail.bcc), + ("Reply-To", &self.detail.reply_to), + ("In-Reply-To", &self.detail.in_reply_to), + ]; + + for (name, value) in fields { + table.add_row(Row::from([Cell::new(name), Cell::new(value)])); + } + + writeln!(f)?; + write!(f, "{table}")?; + writeln!(f)?; + Ok(()) + } +} + +impl Serialize for EnvelopeDetailTable { + fn serialize(&self, serializer: S) -> Result { + self.detail.serialize(serializer) + } +} diff --git a/src/imap/envelope/command/list.rs b/src/imap/envelope/command/list.rs new file mode 100644 index 00000000..2f8467c8 --- /dev/null +++ b/src/imap/envelope/command/list.rs @@ -0,0 +1,227 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use io_imap::{ + coroutines::{fetch::*, select::*}, + types::{ + core::Vec1, + fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, + sequence::SequenceSet, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::{Serialize, Serializer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameOptionalArg, imap::stream}; + +/// List message envelopes in a mailbox. +/// +/// This command displays envelopes for messages in the specified +/// mailbox. You can specify a sequence set to limit which messages +/// are fetched. +#[derive(Debug, Parser)] +pub struct ListEnvelopesCommand { + #[command(flatten)] + pub mailbox: MailboxNameOptionalArg, + + /// The sequence set of messages (default: "1:*" for all). + #[arg(short, long, default_value = "1:*")] + pub sequence: String, + + /// Use UID FETCH instead of FETCH. + #[arg(long)] + pub uid: bool, +} + +impl ListEnvelopesCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + let (context, mut stream) = stream::connect(config)?; + + let mailbox = self.mailbox.name.try_into()?; + + // SELECT mailbox + let mut arg = None; + let mut coroutine = ImapSelect::new(context, mailbox); + + let context = loop { + match coroutine.resume(arg.take()) { + ImapSelectResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapSelectResult::Ok { context, .. } => break context, + ImapSelectResult::Err { err, .. } => bail!(err), + } + }; + + // Parse sequence set + let sequence_set: SequenceSet = self.sequence.parse()?; + + // FETCH envelopes + let item_names = MacroOrMessageDataItemNames::MessageDataItemNames(vec![ + MessageDataItemName::Envelope, + ]); + + let mut arg = None; + let mut coroutine = ImapFetch::new(context, sequence_set, item_names, self.uid); + + let data = loop { + match coroutine.resume(arg.take()) { + ImapFetchResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapFetchResult::Ok { data, .. } => break data, + ImapFetchResult::Err { err, .. } => bail!(err), + } + }; + + let table = EnvelopesTable::new(data, self.uid); + + printer.out(table)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct EnvelopeEntry { + pub id: u32, + pub date: String, + pub from: String, + pub subject: String, +} + +pub struct EnvelopesTable { + entries: Vec, + uid_mode: bool, +} + +impl EnvelopesTable { + pub fn new( + data: std::collections::HashMap>>, + uid_mode: bool, + ) -> Self { + let mut entries: Vec = data + .into_iter() + .map(|(seq, items)| { + let mut id = seq.get(); + let mut date = String::new(); + let mut from = String::new(); + let mut subject = String::new(); + + for item in items.into_iter() { + match item { + MessageDataItem::Uid(uid) => { + if uid_mode { + id = uid.get(); + } + } + MessageDataItem::Envelope(env) => { + // NString wraps Option, access via .0 + if let Some(d) = &env.date.0 { + date = String::from_utf8_lossy(d.as_ref()).to_string(); + } + if let Some(s) = &env.subject.0 { + subject = String::from_utf8_lossy(s.as_ref()).to_string(); + } + from = format_addresses(&env.from); + } + _ => {} + } + } + + EnvelopeEntry { + id, + date, + from, + subject, + } + }) + .collect(); + + entries.sort_by_key(|e| e.id); + + Self { + entries, + uid_mode, + } + } +} + +impl fmt::Display for EnvelopesTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + let id_header = if self.uid_mode { "UID" } else { "SEQ" }; + + table + .load_preset(presets::ASCII_MARKDOWN) + .set_content_arrangement(ContentArrangement::DynamicFullWidth) + .set_header(Row::from([ + Cell::new(id_header), + Cell::new("DATE"), + Cell::new("FROM"), + Cell::new("SUBJECT"), + ])); + + for entry in &self.entries { + let mut row = Row::new(); + row.max_height(1); + row.add_cell(Cell::new(entry.id)); + row.add_cell(Cell::new(&entry.date)); + row.add_cell(Cell::new(&entry.from)); + row.add_cell(Cell::new(&entry.subject)); + table.add_row(row); + } + + writeln!(f)?; + write!(f, "{table}")?; + writeln!(f)?; + Ok(()) + } +} + +impl Serialize for EnvelopesTable { + fn serialize(&self, serializer: S) -> Result { + self.entries.serialize(serializer) + } +} + +use io_imap::types::envelope::Address; + +pub fn format_address(addr: &Address<'_>) -> String { + // NString wraps Option, access via .0 + let mailbox = addr + .mailbox + .0 + .as_ref() + .map(|m| String::from_utf8_lossy(m.as_ref()).to_string()) + .unwrap_or_default(); + let host = addr + .host + .0 + .as_ref() + .map(|h| String::from_utf8_lossy(h.as_ref()).to_string()) + .unwrap_or_default(); + let name = addr + .name + .0 + .as_ref() + .map(|n| String::from_utf8_lossy(n.as_ref()).to_string()); + + let email = if !mailbox.is_empty() && !host.is_empty() { + format!("{mailbox}@{host}") + } else { + mailbox + }; + + match name { + Some(n) if !n.is_empty() => format!("{n} <{email}>"), + _ => email, + } +} + +pub fn format_addresses(addrs: &[Address<'_>]) -> String { + addrs + .iter() + .map(format_address) + .collect::>() + .join(", ") +} diff --git a/src/imap/envelope/command/mod.rs b/src/imap/envelope/command/mod.rs new file mode 100644 index 00000000..ceedf415 --- /dev/null +++ b/src/imap/envelope/command/mod.rs @@ -0,0 +1,43 @@ +pub mod get; +pub mod list; +pub mod search; +pub mod sort; +pub mod thread; + +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + config::ImapConfig, + imap::envelope::command::{ + get::GetEnvelopeCommand, list::ListEnvelopesCommand, search::SearchEnvelopesCommand, + sort::SortEnvelopesCommand, thread::ThreadEnvelopesCommand, + }, +}; + +/// Manage message envelopes. +/// +/// An envelope contains header information about a message such as +/// date, subject, from, to, cc, bcc, etc. This subcommand allows you +/// to list, get, search, sort, and thread envelopes. +#[derive(Debug, Subcommand)] +pub enum EnvelopeCommand { + List(ListEnvelopesCommand), + Get(GetEnvelopeCommand), + Search(SearchEnvelopesCommand), + Sort(SortEnvelopesCommand), + Thread(ThreadEnvelopesCommand), +} + +impl EnvelopeCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, config), + Self::Get(cmd) => cmd.execute(printer, config), + Self::Search(cmd) => cmd.execute(printer, config), + Self::Sort(cmd) => cmd.execute(printer, config), + Self::Thread(cmd) => cmd.execute(printer, config), + } + } +} diff --git a/src/imap/envelope/command/search.rs b/src/imap/envelope/command/search.rs new file mode 100644 index 00000000..c8f0b650 --- /dev/null +++ b/src/imap/envelope/command/search.rs @@ -0,0 +1,272 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use io_imap::{ + coroutines::{search::*, select::*}, + types::{ + core::{AString, Vec1}, + datetime::NaiveDate, + search::SearchKey, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::{Serialize, Serializer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameOptionalArg, imap::stream}; + +/// Search messages by criteria. +/// +/// This command searches for messages matching the given criteria and +/// returns a list of matching sequence numbers or UIDs. +/// +/// Query syntax (multiple terms are ANDed together): +/// - from:alice - messages from "alice" +/// - to:bob - messages to "bob" +/// - cc:charlie - messages CC'd to "charlie" +/// - bcc:dave - messages BCC'd to "dave" +/// - subject:hello - messages with "hello" in subject +/// - body:keyword - messages with "keyword" in body +/// - text:keyword - messages with "keyword" in header or body +/// - seen - messages that have been read +/// - unseen - messages that have not been read +/// - flagged - messages that are flagged +/// - answered - messages that have been answered +/// - deleted - messages marked for deletion +/// - draft - draft messages +/// - before:2024-01-15 - messages before date +/// - since:2024-01-01 - messages since date +/// - on:2024-01-10 - messages on date +/// - larger:1000 - messages larger than 1000 bytes +/// - smaller:5000 - messages smaller than 5000 bytes +/// - all - all messages +#[derive(Debug, Parser)] +pub struct SearchEnvelopesCommand { + #[command(flatten)] + pub mailbox: MailboxNameOptionalArg, + + /// Search query (e.g., "from:alice unseen"). + #[arg(name = "query", value_name = "QUERY", default_value = "all")] + pub query: String, + + /// Use UID SEARCH instead of SEARCH. + #[arg(long)] + pub uid: bool, +} + +impl SearchEnvelopesCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + let (context, mut stream) = stream::connect(config)?; + + let mailbox = self.mailbox.name.try_into()?; + + // SELECT mailbox + let mut arg = None; + let mut coroutine = ImapSelect::new(context, mailbox); + + let context = loop { + match coroutine.resume(arg.take()) { + ImapSelectResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapSelectResult::Ok { context, .. } => break context, + ImapSelectResult::Err { err, .. } => bail!(err), + } + }; + + // Parse query into search criteria + let criteria = parse_query(&self.query)?; + + // SEARCH + let mut arg = None; + let mut coroutine = ImapSearch::new(context, criteria, self.uid); + + let ids = loop { + match coroutine.resume(arg.take()) { + ImapSearchResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapSearchResult::Ok { ids, .. } => break ids, + ImapSearchResult::Err { err, .. } => bail!(err), + } + }; + + let table = SearchResultsTable::new(ids, self.uid); + + printer.out(table)?; + Ok(()) + } +} + +/// Parse a query string into search criteria. +/// +/// Multiple terms are ANDed together. +pub fn parse_query(query: &str) -> Result>> { + let mut keys: Vec> = Vec::new(); + + for term in query.split_whitespace() { + let key = parse_term(term)?; + keys.push(key); + } + + if keys.is_empty() { + keys.push(SearchKey::All); + } + + Ok(Vec1::unvalidated(keys)) +} + +fn parse_term(term: &str) -> Result> { + let term_lower = term.to_lowercase(); + + // Simple flag keywords + match term_lower.as_str() { + "all" => return Ok(SearchKey::All), + "seen" => return Ok(SearchKey::Seen), + "unseen" => return Ok(SearchKey::Unseen), + "flagged" => return Ok(SearchKey::Flagged), + "unflagged" => return Ok(SearchKey::Unflagged), + "answered" => return Ok(SearchKey::Answered), + "unanswered" => return Ok(SearchKey::Unanswered), + "deleted" => return Ok(SearchKey::Deleted), + "undeleted" => return Ok(SearchKey::Undeleted), + "draft" => return Ok(SearchKey::Draft), + "undraft" => return Ok(SearchKey::Undraft), + "new" => return Ok(SearchKey::New), + "old" => return Ok(SearchKey::Old), + "recent" => return Ok(SearchKey::Recent), + _ => {} + } + + // Key:value patterns + if let Some((key, value)) = term.split_once(':') { + let key_lower = key.to_lowercase(); + let value_str = value.to_string(); + + match key_lower.as_str() { + "from" => { + let astring = AString::try_from(value_str)?; + return Ok(SearchKey::From(astring)); + } + "to" => { + let astring = AString::try_from(value_str)?; + return Ok(SearchKey::To(astring)); + } + "cc" => { + let astring = AString::try_from(value_str)?; + return Ok(SearchKey::Cc(astring)); + } + "bcc" => { + let astring = AString::try_from(value_str)?; + return Ok(SearchKey::Bcc(astring)); + } + "subject" => { + let astring = AString::try_from(value_str)?; + return Ok(SearchKey::Subject(astring)); + } + "body" => { + let astring = AString::try_from(value_str)?; + return Ok(SearchKey::Body(astring)); + } + "text" => { + let astring = AString::try_from(value_str)?; + return Ok(SearchKey::Text(astring)); + } + "before" => { + let date = parse_date(value)?; + return Ok(SearchKey::Before(date)); + } + "since" => { + let date = parse_date(value)?; + return Ok(SearchKey::Since(date)); + } + "on" => { + let date = parse_date(value)?; + return Ok(SearchKey::On(date)); + } + "larger" => { + let size: u32 = value.parse()?; + return Ok(SearchKey::Larger(size)); + } + "smaller" => { + let size: u32 = value.parse()?; + return Ok(SearchKey::Smaller(size)); + } + _ => {} + } + } + + bail!("Unknown search term: {term}") +} + +fn parse_date(s: &str) -> Result { + // Parse YYYY-MM-DD format + let parts: Vec<&str> = s.split('-').collect(); + if parts.len() != 3 { + bail!("Invalid date format '{s}'. Expected YYYY-MM-DD"); + } + + let year: i32 = parts[0] + .parse() + .map_err(|_| anyhow::anyhow!("Invalid year in date '{s}'"))?; + let month: u32 = parts[1] + .parse() + .map_err(|_| anyhow::anyhow!("Invalid month in date '{s}'"))?; + let day: u32 = parts[2] + .parse() + .map_err(|_| anyhow::anyhow!("Invalid day in date '{s}'"))?; + + // Create chrono::NaiveDate first + let chrono_date = chrono::NaiveDate::from_ymd_opt(year, month, day) + .ok_or_else(|| anyhow::anyhow!("Invalid date '{s}'"))?; + + // Convert to imap-types NaiveDate + NaiveDate::try_from(chrono_date).map_err(|e| anyhow::anyhow!("Invalid date '{s}': {e}")) +} + +#[derive(Clone, Debug, Serialize)] +pub struct SearchResult { + pub id: u32, +} + +pub struct SearchResultsTable { + results: Vec, + uid_mode: bool, +} + +impl SearchResultsTable { + pub fn new(ids: Vec, uid_mode: bool) -> Self { + let results = ids + .into_iter() + .map(|id| SearchResult { id: id.get() }) + .collect(); + Self { results, uid_mode } + } +} + +impl fmt::Display for SearchResultsTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + let id_header = if self.uid_mode { "UID" } else { "SEQ" }; + + table + .load_preset(presets::ASCII_MARKDOWN) + .set_content_arrangement(ContentArrangement::DynamicFullWidth) + .set_header(Row::from([Cell::new(id_header)])); + + for result in &self.results { + table.add_row(Row::from([Cell::new(result.id)])); + } + + writeln!(f)?; + write!(f, "{table}")?; + writeln!(f)?; + writeln!(f, "Found {} message(s)", self.results.len())?; + Ok(()) + } +} + +impl Serialize for SearchResultsTable { + fn serialize(&self, serializer: S) -> Result { + self.results.serialize(serializer) + } +} diff --git a/src/imap/envelope/command/sort.rs b/src/imap/envelope/command/sort.rs new file mode 100644 index 00000000..56f9dbf9 --- /dev/null +++ b/src/imap/envelope/command/sort.rs @@ -0,0 +1,169 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use io_imap::{ + coroutines::{select::*, sort::*}, + types::{ + core::Vec1, + extensions::sort::{SortCriterion, SortKey}, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::{Serialize, Serializer}; + +use crate::{ + config::ImapConfig, + imap::{ + envelope::command::search::parse_query, mailbox::arg::name::MailboxNameOptionalArg, stream, + }, +}; + +/// Sort messages by criteria. +/// +/// This command searches for messages matching the given query and +/// returns them sorted by the specified criteria. Requires the SORT +/// IMAP extension. +/// +/// Sort criteria: +/// - date - sort by Date header +/// - arrival - sort by internal date (arrival time) +/// - from - sort by From header +/// - to - sort by To header +/// - cc - sort by Cc header +/// - subject - sort by Subject header +/// - size - sort by message size +#[derive(Debug, Parser)] +pub struct SortEnvelopesCommand { + #[command(flatten)] + pub mailbox: MailboxNameOptionalArg, + + /// Sort criteria (e.g., "date", "from", "subject", "size"). + #[arg(short = 'S', long, default_value = "date")] + pub sort: String, + + /// Reverse sort order. + #[arg(short, long)] + pub reverse: bool, + + /// Search query (same syntax as search command). + #[arg(name = "query", value_name = "QUERY", default_value = "all")] + pub query: String, + + /// Use UID SORT instead of SORT. + #[arg(long)] + pub uid: bool, +} + +impl SortEnvelopesCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + let (context, mut stream) = stream::connect(config)?; + + let mailbox = self.mailbox.name.try_into()?; + + // SELECT mailbox + let mut arg = None; + let mut coroutine = ImapSelect::new(context, mailbox); + + let context = loop { + match coroutine.resume(arg.take()) { + ImapSelectResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapSelectResult::Ok { context, .. } => break context, + ImapSelectResult::Err { err, .. } => bail!(err), + } + }; + + // Parse sort criteria + let sort_key = parse_sort_key(&self.sort)?; + let sort_criteria = Vec1::unvalidated(vec![SortCriterion { + reverse: self.reverse, + key: sort_key, + }]); + + // Parse search criteria + let search_criteria = parse_query(&self.query)?; + + // SORT + let mut arg = None; + let mut coroutine = ImapSort::new(context, sort_criteria, search_criteria, self.uid); + + let ids = loop { + match coroutine.resume(arg.take()) { + ImapSortResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapSortResult::Ok { ids, .. } => break ids, + ImapSortResult::Err { err, .. } => bail!(err), + } + }; + + let table = SortResultsTable::new(ids, self.uid); + + printer.out(table)?; + Ok(()) + } +} + +fn parse_sort_key(s: &str) -> Result { + match s.to_lowercase().as_str() { + "date" => Ok(SortKey::Date), + "arrival" => Ok(SortKey::Arrival), + "from" => Ok(SortKey::From), + "to" => Ok(SortKey::To), + "cc" => Ok(SortKey::Cc), + "subject" => Ok(SortKey::Subject), + "size" => Ok(SortKey::Size), + _ => bail!( + "Unknown sort key: {s}. Valid options: date, arrival, from, to, cc, subject, size" + ), + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct SortResult { + pub id: u32, +} + +pub struct SortResultsTable { + results: Vec, + uid_mode: bool, +} + +impl SortResultsTable { + pub fn new(ids: Vec, uid_mode: bool) -> Self { + let results = ids + .into_iter() + .map(|id| SortResult { id: id.get() }) + .collect(); + Self { results, uid_mode } + } +} + +impl fmt::Display for SortResultsTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + let id_header = if self.uid_mode { "UID" } else { "SEQ" }; + + table + .load_preset(presets::ASCII_MARKDOWN) + .set_content_arrangement(ContentArrangement::DynamicFullWidth) + .set_header(Row::from([Cell::new(id_header)])); + + for result in &self.results { + table.add_row(Row::from([Cell::new(result.id)])); + } + + writeln!(f)?; + write!(f, "{table}")?; + writeln!(f)?; + writeln!(f, "Found {} message(s)", self.results.len())?; + Ok(()) + } +} + +impl Serialize for SortResultsTable { + fn serialize(&self, serializer: S) -> Result { + self.results.serialize(serializer) + } +} diff --git a/src/imap/envelope/command/thread.rs b/src/imap/envelope/command/thread.rs new file mode 100644 index 00000000..688008c1 --- /dev/null +++ b/src/imap/envelope/command/thread.rs @@ -0,0 +1,333 @@ +use std::{collections::HashMap, fmt, num::NonZeroU32}; + +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::{ + coroutines::{fetch::*, select::*, thread::*}, + types::{ + extensions::thread::{Thread, ThreadingAlgorithm}, + fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, + sequence::SequenceSet, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::{Serialize, Serializer}; + +use crate::{ + config::ImapConfig, + imap::{ + envelope::command::search::parse_query, mailbox::arg::name::MailboxNameOptionalArg, stream, + }, +}; + +/// Thread messages by algorithm. +/// +/// This command groups messages into conversation threads using the +/// specified threading algorithm. Requires the THREAD IMAP extension. +/// +/// Threading algorithms: +/// - references (default) - uses References and In-Reply-To headers +/// - orderedsubject - groups by normalized subject +#[derive(Debug, Parser)] +pub struct ThreadEnvelopesCommand { + #[command(flatten)] + pub mailbox: MailboxNameOptionalArg, + + /// Threading algorithm (orderedsubject or references). + #[arg(short = 'A', long, default_value = "references")] + pub algorithm: String, + + /// Search query (same syntax as search command). + #[arg(name = "query", value_name = "QUERY", default_value = "all")] + pub query: String, + + /// Use UID THREAD instead of THREAD. + #[arg(long)] + pub uid: bool, +} + +impl ThreadEnvelopesCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + let (context, mut stream) = stream::connect(config)?; + + let mailbox = self.mailbox.name.try_into()?; + + // SELECT mailbox + let mut arg = None; + let mut coroutine = ImapSelect::new(context, mailbox); + + let context = loop { + match coroutine.resume(arg.take()) { + ImapSelectResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapSelectResult::Ok { context, .. } => break context, + ImapSelectResult::Err { err, .. } => bail!(err), + } + }; + + // Parse threading algorithm + let algorithm = parse_algorithm(&self.algorithm)?; + + // Parse search criteria + let search_criteria = parse_query(&self.query)?; + + // THREAD + let mut arg = None; + let mut coroutine = ImapThread::new(context, algorithm, search_criteria, self.uid); + + let (context, threads) = loop { + match coroutine.resume(arg.take()) { + ImapThreadResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapThreadResult::Ok { context, threads, .. } => break (context, threads), + ImapThreadResult::Err { err, .. } => bail!(err), + } + }; + + // Collect all message IDs from threads to fetch subjects + let all_ids = collect_thread_ids(&threads); + + // Fetch subjects for all messages in threads + let subjects = if !all_ids.is_empty() { + fetch_subjects(&mut stream, context, &all_ids, self.uid)? + } else { + HashMap::new() + }; + + let table = ThreadResultsTable::new(threads, subjects, self.uid); + + printer.out(table)?; + Ok(()) + } +} + +fn parse_algorithm(s: &str) -> Result> { + match s.to_lowercase().as_str() { + "references" => Ok(ThreadingAlgorithm::References), + "orderedsubject" => Ok(ThreadingAlgorithm::OrderedSubject), + _ => bail!( + "Unknown threading algorithm: {s}. Valid options: references, orderedsubject" + ), + } +} + +fn collect_thread_ids(threads: &[Thread]) -> Vec { + let mut ids = Vec::new(); + for thread in threads { + collect_thread_ids_recursive(thread, &mut ids); + } + ids +} + +fn collect_thread_ids_recursive(thread: &Thread, ids: &mut Vec) { + match thread { + Thread::Members { prefix, answers } => { + // Vec1 can be converted to a slice via as_ref() + ids.extend(prefix.as_ref().iter().copied()); + if let Some(nested) = answers { + // Vec2 can also be converted to a slice via as_ref() + for t in nested.as_ref().iter() { + collect_thread_ids_recursive(t, ids); + } + } + } + Thread::Nested { answers } => { + for t in answers.as_ref().iter() { + collect_thread_ids_recursive(t, ids); + } + } + } +} + +fn fetch_subjects( + stream: &mut stream::Stream, + context: io_imap::context::ImapContext, + ids: &[NonZeroU32], + uid: bool, +) -> Result> { + if ids.is_empty() { + return Ok(HashMap::new()); + } + + // Build sequence set from IDs + let seq_set_str = ids + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(","); + let sequence_set: SequenceSet = seq_set_str.parse()?; + + let item_names = MacroOrMessageDataItemNames::MessageDataItemNames(vec![ + MessageDataItemName::Envelope, + MessageDataItemName::Uid, + ]); + + let mut arg = None; + let mut coroutine = ImapFetch::new(context, sequence_set, item_names, uid); + + let data = loop { + match coroutine.resume(arg.take()) { + ImapFetchResult::Io { io } => arg = Some(handle(&mut *stream, io)?), + ImapFetchResult::Ok { data, .. } => break data, + ImapFetchResult::Err { err, .. } => bail!(err), + } + }; + + let mut subjects: HashMap = HashMap::new(); + + for (seq, items) in data { + let mut id = seq.get(); + let mut subject = String::new(); + + for item in items.into_iter() { + match item { + MessageDataItem::Uid(uid_val) => { + if uid { + id = uid_val.get(); + } + } + MessageDataItem::Envelope(env) => { + // NString wraps Option, access via .0 + if let Some(s) = &env.subject.0 { + subject = String::from_utf8_lossy(s.as_ref()).to_string(); + } + } + _ => {} + } + } + + subjects.insert(id, subject); + } + + Ok(subjects) +} + +#[derive(Clone, Debug, Serialize)] +pub struct ThreadEntry { + pub id: u32, + pub subject: String, + pub depth: usize, +} + +pub struct ThreadResultsTable { + threads: Vec, + subjects: HashMap, + #[allow(dead_code)] + uid_mode: bool, +} + +impl ThreadResultsTable { + pub fn new(threads: Vec, subjects: HashMap, uid_mode: bool) -> Self { + Self { + threads, + subjects, + uid_mode, + } + } + + fn build_entries(&self) -> Vec { + let mut entries = Vec::new(); + for thread in &self.threads { + self.build_entries_recursive(thread, 0, &mut entries); + } + entries + } + + fn build_entries_recursive( + &self, + thread: &Thread, + depth: usize, + entries: &mut Vec, + ) { + match thread { + Thread::Members { prefix, answers } => { + for (i, id) in prefix.as_ref().iter().enumerate() { + let id_val: u32 = id.get(); + let subject = self.subjects.get(&id_val).cloned().unwrap_or_default(); + entries.push(ThreadEntry { + id: id_val, + subject, + depth: depth + i, + }); + } + if let Some(nested) = answers { + for t in nested.as_ref().iter() { + self.build_entries_recursive(t, depth + prefix.as_ref().len(), entries); + } + } + } + Thread::Nested { answers } => { + for t in answers.as_ref().iter() { + self.build_entries_recursive(t, depth, entries); + } + } + } + } +} + +impl fmt::Display for ThreadResultsTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.threads.is_empty() { + writeln!(f)?; + writeln!(f, "No threads found")?; + return Ok(()); + } + + let mut thread_num = 0; + + writeln!(f)?; + + for thread in &self.threads { + thread_num += 1; + writeln!(f, "Thread {thread_num}:")?; + self.display_thread(f, thread, 1)?; + writeln!(f)?; + } + + writeln!(f, "Found {} thread(s)", self.threads.len())?; + Ok(()) + } +} + +impl ThreadResultsTable { + fn display_thread( + &self, + f: &mut fmt::Formatter<'_>, + thread: &Thread, + depth: usize, + ) -> fmt::Result { + let indent = " ".repeat(depth); + + match thread { + Thread::Members { prefix, answers } => { + for (i, id) in prefix.as_ref().iter().enumerate() { + let id_val: u32 = id.get(); + let subject = self.subjects.get(&id_val).cloned().unwrap_or_default(); + let connector = if i == 0 && depth > 0 { + "\u{2514}\u{2500}" + } else { + " " + }; + writeln!(f, "{indent}{connector} {id_val}: {subject}")?; + } + if let Some(nested) = answers { + for t in nested.as_ref().iter() { + self.display_thread(f, t, depth + 1)?; + } + } + } + Thread::Nested { answers } => { + for t in answers.as_ref().iter() { + self.display_thread(f, t, depth)?; + } + } + } + + Ok(()) + } +} + +impl Serialize for ThreadResultsTable { + fn serialize(&self, serializer: S) -> Result { + self.build_entries().serialize(serializer) + } +} diff --git a/src/imap/envelope/mod.rs b/src/imap/envelope/mod.rs new file mode 100644 index 00000000..9fe79612 --- /dev/null +++ b/src/imap/envelope/mod.rs @@ -0,0 +1 @@ +pub mod command; diff --git a/src/imap/mod.rs b/src/imap/mod.rs index 67d5e3bd..e5aefca5 100644 --- a/src/imap/mod.rs +++ b/src/imap/mod.rs @@ -1,4 +1,5 @@ pub mod command; +pub mod envelope; pub mod flag; pub mod mailbox; pub mod stream;