mirror of
https://github.com/pimalaya/himalaya.git
synced 2026-06-16 20:57:53 +08:00
add envelope commands
This commit is contained in:
+8
-1
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(", ")
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod command;
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod command;
|
||||
pub mod envelope;
|
||||
pub mod flag;
|
||||
pub mod mailbox;
|
||||
pub mod stream;
|
||||
|
||||
Reference in New Issue
Block a user