add envelope commands

This commit is contained in:
Clément DOUIN
2026-03-03 23:26:46 +01:00
parent f993bb58c9
commit 4992c256ca
11 changed files with 1242 additions and 1 deletions
+8 -1
View File
@@ -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),
}
+186
View File
@@ -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<MessageDataItem<'static>>) -> 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<IString>, 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.detail.serialize(serializer)
}
}
+227
View File
@@ -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<EnvelopeEntry>,
uid_mode: bool,
}
impl EnvelopesTable {
pub fn new(
data: std::collections::HashMap<std::num::NonZeroU32, Vec1<MessageDataItem<'static>>>,
uid_mode: bool,
) -> Self {
let mut entries: Vec<EnvelopeEntry> = 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<IString>, 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.entries.serialize(serializer)
}
}
use io_imap::types::envelope::Address;
pub fn format_address(addr: &Address<'_>) -> String {
// NString wraps Option<IString>, 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::<Vec<_>>()
.join(", ")
}
+43
View File
@@ -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),
}
}
}
+272
View File
@@ -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<Vec1<SearchKey<'static>>> {
let mut keys: Vec<SearchKey<'static>> = 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<SearchKey<'static>> {
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<NaiveDate> {
// 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<SearchResult>,
uid_mode: bool,
}
impl SearchResultsTable {
pub fn new(ids: Vec<std::num::NonZeroU32>, 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.results.serialize(serializer)
}
}
+169
View File
@@ -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<SortKey> {
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<SortResult>,
uid_mode: bool,
}
impl SortResultsTable {
pub fn new(ids: Vec<std::num::NonZeroU32>, 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.results.serialize(serializer)
}
}
+333
View File
@@ -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<ThreadingAlgorithm<'static>> {
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<NonZeroU32> {
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<NonZeroU32>) {
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<HashMap<u32, String>> {
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::<Vec<_>>()
.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<u32, String> = 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<IString>, 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<Thread>,
subjects: HashMap<u32, String>,
#[allow(dead_code)]
uid_mode: bool,
}
impl ThreadResultsTable {
pub fn new(threads: Vec<Thread>, subjects: HashMap<u32, String>, uid_mode: bool) -> Self {
Self {
threads,
subjects,
uid_mode,
}
}
fn build_entries(&self) -> Vec<ThreadEntry> {
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<ThreadEntry>,
) {
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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.build_entries().serialize(serializer)
}
}
+1
View File
@@ -0,0 +1 @@
pub mod command;
+1
View File
@@ -1,4 +1,5 @@
pub mod command;
pub mod envelope;
pub mod flag;
pub mod mailbox;
pub mod stream;