From 0ad22c863092953f02adc055f9a45fc92785c1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Mon, 9 Mar 2026 22:21:20 +0100 Subject: [PATCH] clean implem part 1 --- Cargo.lock | 61 ++++++++ Cargo.toml | 2 +- src/account.rs | 9 +- src/config.rs | 22 +++ src/imap/command.rs | 19 +-- src/imap/envelope/command.rs | 8 +- src/imap/envelope/get.rs | 263 +++++++++++++++++++++-------------- src/imap/envelope/list.rs | 225 +++++++++++++++--------------- src/imap/envelope/search.rs | 151 ++++++++++---------- src/imap/envelope/thread.rs | 57 ++++---- src/imap/flag/add.rs | 50 +++---- src/imap/flag/command.rs | 5 +- src/imap/flag/list.rs | 84 ++++++----- src/imap/flag/remove.rs | 48 ++++--- src/imap/flag/set.rs | 46 +++--- src/imap/mailbox/arg.rs | 15 +- src/imap/mailbox/expunge.rs | 19 ++- src/imap/mailbox/purge.rs | 19 ++- src/imap/message/command.rs | 13 +- src/imap/message/copy.rs | 47 ++++--- src/imap/message/delete.rs | 86 ------------ src/imap/message/get.rs | 59 ++++---- src/imap/message/mod.rs | 1 - src/imap/message/move.rs | 43 +++--- src/imap/message/read.rs | 60 ++++---- src/imap/message/save.rs | 81 ++++++----- src/imap/stream.rs | 74 +++++----- 27 files changed, 820 insertions(+), 747 deletions(-) delete mode 100644 src/imap/message/delete.rs diff --git a/Cargo.lock b/Cargo.lock index e4566407..0bce0cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,7 @@ version = "7.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ + "crossterm", "unicode-segmentation", "unicode-width 0.2.2", ] @@ -322,6 +323,29 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "dirs" version = "6.0.0" @@ -354,6 +378,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -942,6 +975,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -2092,6 +2131,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2101,6 +2156,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 20fc7a07..12d5ef29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["b anyhow = "1" chrono = { version = "0.4", default-features = false } clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } -comfy-table = { version = "7", default-features = false } +comfy-table = "7" dirs = "6" gethostname = "1" html2text = "0.12" diff --git a/src/account.rs b/src/account.rs index 2240ce40..1e30f0ff 100644 --- a/src/account.rs +++ b/src/account.rs @@ -2,14 +2,16 @@ use std::{env::temp_dir, path::PathBuf}; use crate::config::{AccountConfig, Config}; use anyhow::Result; -use comfy_table::presets; +use comfy_table::{presets, ContentArrangement}; use dirs::download_dir; #[derive(Debug)] pub struct Account { pub backend: B, pub downloads_dir: PathBuf, + pub table_preset: String, + pub table_arrangement: ContentArrangement, } impl Account { @@ -36,6 +38,11 @@ impl Account { .table_preset .or(account_config.table_preset) .unwrap_or(presets::UTF8_FULL_CONDENSED.to_string()), + table_arrangement: config + .table_arrangement + .or(account_config.table_arrangement) + .unwrap_or_default() + .into(), }) } } diff --git a/src/config.rs b/src/config.rs index 323f5636..55b94be0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, fmt, path::PathBuf, process::Command}; use anyhow::{bail, Result}; +use comfy_table::ContentArrangement; use pimalaya_toolbox::config::TomlConfig; use secrecy::SecretString; use serde::{de::Visitor, Deserialize, Deserializer}; @@ -14,6 +15,7 @@ use url::Url; pub struct Config { pub downloads_dir: Option, pub table_preset: Option, + pub table_arrangement: Option, pub accounts: HashMap, } @@ -47,11 +49,31 @@ pub struct AccountConfig { pub downloads_dir: Option, pub table_preset: Option, + pub table_arrangement: Option, pub imap: Option, pub smtp: Option, } +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub enum TableArrangementConfig { + #[default] + Dynamic, + DynamicFullWidth, + Disabled, +} + +impl From for ContentArrangement { + fn from(arrangement: TableArrangementConfig) -> Self { + match arrangement { + TableArrangementConfig::Dynamic => ContentArrangement::Dynamic, + TableArrangementConfig::DynamicFullWidth => ContentArrangement::DynamicFullWidth, + TableArrangementConfig::Disabled => ContentArrangement::Disabled, + } + } +} + /// IMAP configuration. #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] diff --git a/src/imap/command.rs b/src/imap/command.rs index bd552f18..64470939 100644 --- a/src/imap/command.rs +++ b/src/imap/command.rs @@ -7,33 +7,34 @@ use crate::imap::{ id::IdCommand, mailbox::command::MailboxCommand, message::command::MessageCommand, }; -/// IMAP CLI (requires `imap` cargo feature). +/// IMAP CLI (requires the `imap` cargo feature). /// -/// This command gives you access to the IMAP CLI API, and allows -/// you to manage IMAP mailboxes: list mailboxes, read messages, -/// add flags etc. +/// This command gives you access to the IMAP CLI API, and allows you +/// to manage IMAP mailboxes, envelopes, flags, messages etc. #[derive(Debug, Subcommand)] #[command(rename_all = "lowercase")] pub enum ImapCommand { + Id(IdCommand), + + #[command(subcommand)] + #[command(aliases = ["mboxes", "mbox"])] + Mailboxes(MailboxCommand), #[command(subcommand)] Envelopes(EnvelopeCommand), #[command(subcommand)] Flags(FlagCommand), #[command(subcommand)] - #[command(aliases = ["mboxes", "mbox"])] - Mailboxes(MailboxCommand), - #[command(subcommand)] #[command(aliases = ["msgs", "msg"])] Messages(MessageCommand), - Id(IdCommand), } impl ImapCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { match self { + Self::Id(cmd) => cmd.exec(printer, account), + Self::Envelopes(cmd) => cmd.exec(printer, account), Self::Flags(cmd) => cmd.exec(printer, account), - Self::Id(cmd) => cmd.exec(printer, account), Self::Mailboxes(cmd) => cmd.exec(printer, account), Self::Messages(cmd) => cmd.exec(printer, account), } diff --git a/src/imap/envelope/command.rs b/src/imap/envelope/command.rs index e4bc5cae..a24d8299 100644 --- a/src/imap/envelope/command.rs +++ b/src/imap/envelope/command.rs @@ -10,15 +10,15 @@ use crate::imap::{ }, }; -/// Manage message envelopes. +/// Manage IMAP 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. +/// to get, list, search, sort, and thread envelopes. #[derive(Debug, Subcommand)] pub enum EnvelopeCommand { - List(ListEnvelopesCommand), Get(GetEnvelopeCommand), + List(ListEnvelopesCommand), Search(SearchEnvelopesCommand), Sort(SortEnvelopesCommand), Thread(ThreadEnvelopesCommand), @@ -27,8 +27,8 @@ pub enum EnvelopeCommand { impl EnvelopeCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { match self { - Self::List(cmd) => cmd.exec(printer, account), Self::Get(cmd) => cmd.exec(printer, account), + Self::List(cmd) => cmd.exec(printer, account), Self::Search(cmd) => cmd.exec(printer, account), Self::Sort(cmd) => cmd.exec(printer, account), Self::Thread(cmd) => cmd.exec(printer, account), diff --git a/src/imap/envelope/get.rs b/src/imap/envelope/get.rs index 9518f456..9e793486 100644 --- a/src/imap/envelope/get.rs +++ b/src/imap/envelope/get.rs @@ -2,7 +2,7 @@ use std::{fmt, num::NonZeroU32}; use anyhow::{bail, Result}; use clap::Parser; -use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, Row, Table}; use io_imap::{ coroutines::{fetch::*, select::*}, types::{ @@ -12,16 +12,16 @@ use io_imap::{ }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; -use serde::{Serialize, Serializer}; +use serde::Serialize; use crate::imap::{ account::ImapAccount, - envelope::list::{decode_mime, format_addresses}, - mailbox::arg::MailboxNameOptionalFlag, + envelope::list::{decode_mime, format_address}, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag}, stream, }; -/// Get a single message envelope. +/// Get a single IMAP envelope. /// /// This command displays detailed envelope information for a specific /// message, including all header fields like date, subject, from, to, @@ -30,11 +30,12 @@ use crate::imap::{ pub struct GetEnvelopeCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// The message UID (or sequence number with --seq). #[arg(name = "id", value_name = "ID")] pub id: u32, - /// Use sequence numbers instead of UIDs. #[arg(long)] pub seq: bool, @@ -42,25 +43,27 @@ pub struct GetEnvelopeCommand { impl GetEnvelopeCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // SELECT mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } + 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), + } + }; + } + + let Some(id) = NonZeroU32::new(self.id) else { + bail!("ID must be non-zero"); }; - // 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]); @@ -75,109 +78,153 @@ impl GetEnvelopeCommand { } }; - let table = EnvelopeDetailTable::new(items); + let table = EnvelopeTable { + preset: account.table_preset, + envelope: items.into(), + }; - printer.out(table)?; - Ok(()) + printer.out(table) } } #[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 EnvelopeTable { + #[serde(skip)] + pub preset: String, + pub envelope: EnvelopeTableItems, } -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 = decode_mime(&String::from_utf8_lossy(s.as_ref())); - } - 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 { +impl fmt::Display for EnvelopeTable { 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")])); + .load_preset(&self.preset) + .set_header(Row::from([Cell::new("HEADER"), 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), - ]; + table.add_row(Row::from([ + Cell::new("Message ID"), + match &self.envelope.message_id { + Some(id) => Cell::new(id), + None => Cell::new(""), + }, + ])); - for (name, value) in fields { - table.add_row(Row::from([Cell::new(name), Cell::new(value)])); + table.add_row(Row::from([ + Cell::new("In Reply To"), + match &self.envelope.in_reply_to { + Some(id) => Cell::new(id), + None => Cell::new(""), + }, + ])); + + table.add_row(Row::from([ + Cell::new("Date"), + match &self.envelope.date { + Some(date) => Cell::new(date), + None => Cell::new(""), + }, + ])); + + table.add_row(Row::from([ + Cell::new("Subject"), + match &self.envelope.subject { + Some(subject) => Cell::new(subject), + None => Cell::new(""), + }, + ])); + + table.add_row(Row::from([ + Cell::new("Sender"), + Cell::new(self.envelope.sender.join(", ")), + ])); + + table.add_row(Row::from([ + Cell::new("From"), + Cell::new(self.envelope.from.join(", ")), + ])); + + table.add_row(Row::from([ + Cell::new("Reply To"), + Cell::new(self.envelope.reply_to.join(", ")), + ])); + + table.add_row(Row::from([ + Cell::new("To"), + Cell::new(self.envelope.to.join(", ")), + ])); + + table.add_row(Row::from([ + Cell::new("Cc"), + Cell::new(self.envelope.cc.join(", ")), + ])); + + table.add_row(Row::from([ + Cell::new("Bcc"), + Cell::new(self.envelope.bcc.join(", ")), + ])); + + writeln!(f)?; + writeln!(f, "{table}") + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct EnvelopeTableItems { + pub date: Option, + pub subject: Option, + pub message_id: Option, + pub in_reply_to: Option, + + pub from: Vec, + pub sender: Vec, + pub reply_to: Vec, + pub to: Vec, + pub cc: Vec, + pub bcc: Vec, +} + +impl From>> for EnvelopeTableItems { + fn from(items: Vec1>) -> Self { + let mut table = EnvelopeTableItems::default(); + + for item in items.into_iter() { + if let MessageDataItem::Envelope(env) = item { + if let Some(d) = &env.date.into_option() { + table + .date + .replace(String::from_utf8_lossy(d.as_ref()).to_string()); + } + + if let Some(s) = &env.subject.into_option() { + table + .subject + .replace(decode_mime(&String::from_utf8_lossy(s.as_ref()))); + } + + if let Some(m) = &env.message_id.into_option() { + table + .message_id + .replace(String::from_utf8_lossy(m.as_ref()).to_string()); + } + + if let Some(r) = &env.in_reply_to.into_option() { + table + .in_reply_to + .replace(String::from_utf8_lossy(r.as_ref()).to_string()); + } + + table.from.extend(env.from.iter().map(format_address)); + table.sender.extend(env.sender.iter().map(format_address)); + table + .reply_to + .extend(env.reply_to.iter().map(format_address)); + table.to.extend(env.to.iter().map(format_address)); + table.cc.extend(env.cc.iter().map(format_address)); + table.bcc.extend(env.bcc.iter().map(format_address)); + } } - writeln!(f)?; - write!(f, "{table}")?; - writeln!(f)?; - Ok(()) - } -} - -impl Serialize for EnvelopeDetailTable { - fn serialize(&self, serializer: S) -> Result { - self.detail.serialize(serializer) + table } } diff --git a/src/imap/envelope/list.rs b/src/imap/envelope/list.rs index 87b22dda..08977d71 100644 --- a/src/imap/envelope/list.rs +++ b/src/imap/envelope/list.rs @@ -1,8 +1,8 @@ -use std::fmt; +use std::{collections::HashMap, fmt, num::NonZeroU32}; use anyhow::{bail, Result}; use clap::Parser; -use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, ContentArrangement, Row, Table}; use io_imap::{ coroutines::{fetch::*, select::*}, types::{ @@ -16,23 +16,15 @@ use io_stream::runtimes::std::handle; use log::debug; use pimalaya_toolbox::terminal::printer::Printer; use rfc2047_decoder::{Decoder, RecoverStrategy}; -use serde::{Serialize, Serializer}; +use serde::Serialize; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalArg, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalArg, MailboxSelectFlag}, + stream, +}; -/// Decode RFC 2047 MIME-encoded string, falling back to original on error. -pub fn decode_mime(s: &str) -> String { - let decoder = Decoder::new().too_long_encoded_word_strategy(RecoverStrategy::Decode); - match decoder.decode(s.as_bytes()) { - Ok(s) => s, - Err(err) => { - debug!("cannot decode rfc2047 string `{s}`: {err}"); - s.to_string() - } - } -} - -/// List message envelopes in a mailbox. +/// List IMAP envelopes from the given mailbox. /// /// This command displays envelopes for messages in the specified /// mailbox. You can specify a sequence set to limit which messages @@ -41,11 +33,12 @@ pub fn decode_mime(s: &str) -> String { pub struct ListEnvelopesCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalArg, + #[command(flatten)] + pub select: MailboxSelectFlag, - /// The sequence set of messages (default: "1:*" for all). + /// The sequence set of envelopes. #[arg(short, long, default_value = "1:*")] pub sequence: String, - /// Use sequence numbers instead of UIDs. #[arg(long)] pub seq: bool, @@ -53,26 +46,24 @@ pub struct ListEnvelopesCommand { impl ListEnvelopesCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // SELECT mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } - }; + 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]); @@ -87,75 +78,27 @@ impl ListEnvelopesCommand { } }; - let table = EnvelopesTable::new(data, !self.seq); + let table = EnvelopesTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + envelopes: map_envelopes_table_entries(!self.seq, data), + uid_mode: !self.seq, + }; - printer.out(table)?; - Ok(()) + printer.out(table) } } #[derive(Clone, Debug, Serialize)] -pub struct EnvelopeEntry { - pub id: u32, - pub date: String, - pub from: String, - pub subject: String, -} - pub struct EnvelopesTable { - entries: Vec, + #[serde(skip)] + preset: String, + #[serde(skip)] + arrangement: ContentArrangement, + envelopes: 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 = decode_mime(String::from_utf8_lossy(s.as_ref()).as_ref()); - } - from = format_addresses_short(&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(); @@ -163,35 +106,94 @@ impl fmt::Display for EnvelopesTable { let id_header = if self.uid_mode { "UID" } else { "SEQ" }; table - .load_preset(presets::ASCII_FULL) - .set_content_arrangement(ContentArrangement::DynamicFullWidth) + .load_preset(&self.preset) + .set_content_arrangement(self.arrangement.clone()) .set_header(Row::from([ Cell::new(id_header), - Cell::new("DATE"), - Cell::new("FROM"), - Cell::new("SUBJECT"), + Cell::new("Subject"), + Cell::new("From"), + Cell::new("Date"), ])); - for entry in &self.entries { + for entry in &self.envelopes { 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)); + row.add_cell(Cell::new(&entry.from)); + row.add_cell(Cell::new(&entry.date)); table.add_row(row); } writeln!(f)?; - write!(f, "{table}")?; - writeln!(f)?; - Ok(()) + writeln!(f, "{table}") } } -impl Serialize for EnvelopesTable { - fn serialize(&self, serializer: S) -> Result { - self.entries.serialize(serializer) +#[derive(Clone, Debug, Serialize)] +pub struct EnvelopesTableEntry { + pub id: u32, + pub date: String, + pub from: String, + pub subject: String, +} + +fn map_envelopes_table_entries( + uid_mode: bool, + data: HashMap>>, +) -> Vec { + 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 = decode_mime(String::from_utf8_lossy(s.as_ref()).as_ref()); + } + from = format_addresses_short(&env.from); + } + _ => {} + } + } + + EnvelopesTableEntry { + id, + date, + from, + subject, + } + }) + .collect(); + + entries.sort_by_key(|e| e.id); + entries.reverse(); + entries +} + +/// Decode RFC 2047 MIME-encoded string, falling back to original on error. +pub fn decode_mime(s: &str) -> String { + let decoder = Decoder::new().too_long_encoded_word_strategy(RecoverStrategy::Decode); + match decoder.decode(s.as_bytes()) { + Ok(s) => s, + Err(err) => { + debug!("cannot decode rfc2047 string `{s}`: {err}"); + s.to_string() + } } } @@ -250,12 +252,3 @@ pub fn format_addresses_short(addrs: &[Address<'_>]) -> String { .collect::>() .join(", ") } - -/// Full addresses formatter for detailed view. -pub fn format_addresses(addrs: &[Address<'_>]) -> String { - addrs - .iter() - .map(format_address) - .collect::>() - .join(", ") -} diff --git a/src/imap/envelope/search.rs b/src/imap/envelope/search.rs index 721194d3..03589926 100644 --- a/src/imap/envelope/search.rs +++ b/src/imap/envelope/search.rs @@ -1,8 +1,8 @@ use std::fmt; -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use clap::Parser; -use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, ContentArrangement, Row, Table}; use io_imap::{ coroutines::{search::*, select::*}, types::{ @@ -13,11 +13,15 @@ use io_imap::{ }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; -use serde::{Serialize, Serializer}; +use serde::Serialize; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalArg, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag}, + stream, +}; -/// Search messages by criteria. +/// Search IMAP messages by criteria. /// /// This command searches for messages matching the given criteria and /// returns a list of matching sequence numbers or UIDs. @@ -45,7 +49,9 @@ use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalArg, st #[derive(Debug, Parser)] pub struct SearchEnvelopesCommand { #[command(flatten)] - pub mailbox: MailboxNameOptionalArg, + pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// Search query (e.g., "from:alice unseen"). #[arg(name = "query", value_name = "QUERY", default_value = "all")] @@ -58,26 +64,25 @@ pub struct SearchEnvelopesCommand { impl SearchEnvelopesCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // SELECT mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } - }; + 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.seq); @@ -89,9 +94,54 @@ impl SearchEnvelopesCommand { } }; - let table = SearchResultsTable::new(ids, !self.seq); + let table = SearchTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + ids: ids + .into_iter() + .map(|id| SearchResult { id: id.get() }) + .collect(), + uid_mode: !self.seq, + }; - printer.out(table)?; + printer.out(table) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct SearchResult { + pub id: u32, +} + +#[derive(Clone, Debug, Serialize)] +pub struct SearchTable { + #[serde(skip)] + preset: String, + #[serde(skip)] + arrangement: ContentArrangement, + uid_mode: bool, + ids: Vec, +} + +impl fmt::Display for SearchTable { + 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(&self.preset) + .set_content_arrangement(self.arrangement.clone()) + .set_header(Row::from([Cell::new(id_header)])); + + for result in &self.ids { + table.add_row(Row::from([Cell::new(result.id)])); + } + + writeln!(f)?; + write!(f, "{table}")?; + writeln!(f)?; + writeln!(f, "Found {} message(s)", self.ids.len())?; Ok(()) } } @@ -206,67 +256,18 @@ fn parse_date(s: &str) -> Result { let year: i32 = parts[0] .parse() - .map_err(|_| anyhow::anyhow!("Invalid year in date '{s}'"))?; + .map_err(|_| anyhow!("Invalid year in date '{s}'"))?; let month: u32 = parts[1] .parse() - .map_err(|_| anyhow::anyhow!("Invalid month in date '{s}'"))?; + .map_err(|_| anyhow!("Invalid month in date '{s}'"))?; let day: u32 = parts[2] .parse() - .map_err(|_| anyhow::anyhow!("Invalid day in date '{s}'"))?; + .map_err(|_| 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}'"))?; + .ok_or_else(|| 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) - } + NaiveDate::try_from(chrono_date).map_err(|e| anyhow!("Invalid date '{s}': {e}")) } diff --git a/src/imap/envelope/thread.rs b/src/imap/envelope/thread.rs index fcbfcfa8..9892e8ce 100644 --- a/src/imap/envelope/thread.rs +++ b/src/imap/envelope/thread.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, fmt, num::NonZeroU32}; use anyhow::{bail, Result}; use clap::Parser; use io_imap::{ + context::ImapContext, coroutines::{fetch::*, select::*, thread::*}, types::{ extensions::thread::{Thread, ThreadingAlgorithm}, @@ -17,11 +18,11 @@ use serde::{Serialize, Serializer}; use crate::imap::{ account::ImapAccount, envelope::{list::decode_mime, search::parse_query}, - mailbox::arg::MailboxNameOptionalArg, - stream, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag}, + stream::{self, Stream}, }; -/// Thread messages by algorithm. +/// Thread IMAP messages by algorithm. /// /// This command groups messages into conversation threads using the /// specified threading algorithm. Requires the THREAD IMAP extension. @@ -32,7 +33,9 @@ use crate::imap::{ #[derive(Debug, Parser)] pub struct ThreadEnvelopesCommand { #[command(flatten)] - pub mailbox: MailboxNameOptionalArg, + pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// Threading algorithm (orderedsubject or references). #[arg(short = 'A', long, default_value = "references")] @@ -49,29 +52,26 @@ pub struct ThreadEnvelopesCommand { impl ThreadEnvelopesCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // SELECT mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } - }; + 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.seq); @@ -95,10 +95,9 @@ impl ThreadEnvelopesCommand { HashMap::new() }; - let table = ThreadResultsTable::new(threads, subjects, !self.seq); + let table = ThreadResultsTable::new(threads, subjects); - printer.out(table)?; - Ok(()) + printer.out(table) } } @@ -139,8 +138,8 @@ fn collect_thread_ids_recursive(thread: &Thread, ids: &mut Vec) { } fn fetch_subjects( - stream: &mut stream::Stream, - context: io_imap::context::ImapContext, + stream: &mut Stream, + context: ImapContext, ids: &[NonZeroU32], uid: bool, ) -> Result> { @@ -211,17 +210,11 @@ pub struct ThreadEntry { 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, - } + pub fn new(threads: Vec, subjects: HashMap) -> Self { + Self { threads, subjects } } fn build_entries(&self) -> Vec { diff --git a/src/imap/flag/add.rs b/src/imap/flag/add.rs index 1a619146..aef16db4 100644 --- a/src/imap/flag/add.rs +++ b/src/imap/flag/add.rs @@ -10,24 +10,29 @@ use io_imap::{ use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag}, + stream, +}; -/// Add flags to messages. +/// Add IMAP flag(s) to message(s). /// -/// This command adds the specified flags to messages identified by -/// the given sequence set. +/// This command adds the given flags to messages identified by the +/// given sequence set. #[derive(Debug, Parser)] pub struct AddFlagsCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// The sequence set of messages (e.g., "1", "1,2,3", "1:*"). - #[arg(name = "sequence_set", value_name = "SEQUENCE")] + #[arg(value_name = "SEQUENCE")] pub sequence_set: String, - /// The flags to add (e.g., "\\Seen", "\\Flagged"). #[arg(short, long, required = true, num_args = 1..)] - pub flags: Vec, + pub flag: Vec, /// Use sequence numbers instead of UIDs. #[arg(long)] @@ -36,33 +41,30 @@ pub struct AddFlagsCommand { impl AddFlagsCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // First, SELECT the mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } - }; + 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 flags + let sequence_set = self.sequence_set.as_str().try_into()?; let flags: Vec> = self - .flags + .flag .iter() .map(|f| Flag::try_from(f.as_str()).map(|flag| flag.into_static())) .collect::>()?; - // Parse sequence set - let sequence_set = self.sequence_set.as_str().try_into()?; - - // Store flags let mut arg = None; let mut coroutine = ImapStoreSilent::new(context, sequence_set, StoreType::Add, flags, !self.seq); diff --git a/src/imap/flag/command.rs b/src/imap/flag/command.rs index ef91f5ae..2b5bd61b 100644 --- a/src/imap/flag/command.rs +++ b/src/imap/flag/command.rs @@ -10,11 +10,10 @@ use crate::imap::{ }, }; -/// Manage message flags. +/// Manage IMAP flags. /// /// A flag is a label attached to a message. This subcommand allows -/// you to manage them: list available flags, add flags to messages, -/// remove flags from messages, etc. +/// you to manage them. #[derive(Debug, Subcommand)] pub enum FlagCommand { List(ListFlagsCommand), diff --git a/src/imap/flag/list.rs b/src/imap/flag/list.rs index 212a1f9d..c758d60a 100644 --- a/src/imap/flag/list.rs +++ b/src/imap/flag/list.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, fmt}; use anyhow::{bail, Result}; use clap::Parser; -use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use comfy_table::{Cell, ContentArrangement, Row, Table}; use io_imap::{ coroutines::select::*, types::flag::{Flag, FlagPerm}, @@ -11,9 +11,9 @@ use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::{Serialize, Serializer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalArg, stream}; +use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg, stream}; -/// List available flags for a mailbox. +/// List available IMAP flags for the given mailbox. /// /// This command displays the flags and permanent flags that are /// available in the given mailbox. These flags come from the SELECT @@ -21,7 +21,7 @@ use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalArg, st #[derive(Debug, Parser)] pub struct ListFlagsCommand { #[command(flatten)] - pub mailbox: MailboxNameOptionalArg, + pub mailbox: MailboxNameArg, } impl ListFlagsCommand { @@ -47,36 +47,36 @@ impl ListFlagsCommand { }; let table = FlagsTable { + preset: account.table_preset, + arrangement: account.table_arrangement, flags, permanent_flags, }; - printer.out(table)?; - Ok(()) + printer.out(table) } } #[derive(Clone, Debug, Serialize)] -pub struct FlagEntry { - pub name: String, - pub permanent: bool, +pub struct FlagsTable<'a> { + #[serde(skip_serializing)] + preset: String, + #[serde(skip_serializing)] + arrangement: ContentArrangement, + #[serde(serialize_with = "serialize_flags")] + flags: Vec>, + #[serde(serialize_with = "serialize_permanent_flags")] + permanent_flags: Vec>, } -pub struct FlagsTable { - flags: Vec>, - permanent_flags: Vec>, -} - -impl FlagsTable { - fn build_entries(&self) -> Vec { +impl FlagsTable<'_> { + fn build_entries(&self) -> Vec<(String, bool)> { let mut entries: BTreeMap = BTreeMap::new(); - // Add flags for flag in &self.flags { entries.entry(flag.to_string()).or_insert(false); } - // Mark permanent flags for flag in &self.permanent_flags { let name = match flag { FlagPerm::Flag(f) => f.to_string(), @@ -85,38 +85,52 @@ impl FlagsTable { entries.insert(name, true); } - entries - .into_iter() - .map(|(name, permanent)| FlagEntry { name, permanent }) - .collect() + entries.into_iter().collect() } } -impl fmt::Display for FlagsTable { +impl fmt::Display for FlagsTable<'_> { 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) + .load_preset(&self.preset) + .set_content_arrangement(self.arrangement.clone()) .set_header(Row::from([Cell::new("FLAG"), Cell::new("PERMANENT")])); - for entry in self.build_entries() { + for (flag, perm) in self.build_entries() { table.add_row(Row::from([ - Cell::new(&entry.name), - Cell::new(if entry.permanent { "true" } else { "" }), + Cell::new(&flag), + Cell::new(if perm { "true" } else { "" }), ])); } writeln!(f)?; - write!(f, "{table}")?; - writeln!(f)?; - Ok(()) + writeln!(f, "{table}") } } -impl Serialize for FlagsTable { - fn serialize(&self, serializer: S) -> Result { - self.build_entries().serialize(serializer) - } +pub fn serialize_flags( + flags: &Vec>, + serializer: S, +) -> Result { + flags + .iter() + .map(|f| f.to_string()) + .collect::>() + .serialize(serializer) +} + +fn serialize_permanent_flags( + flags: &Vec>, + serializer: S, +) -> Result { + flags + .iter() + .map(|f| match f { + FlagPerm::Flag(f) => f.to_string(), + FlagPerm::Asterisk => "\\*".to_string(), + }) + .collect::>() + .serialize(serializer) } diff --git a/src/imap/flag/remove.rs b/src/imap/flag/remove.rs index d71f6699..096dd09f 100644 --- a/src/imap/flag/remove.rs +++ b/src/imap/flag/remove.rs @@ -10,24 +10,29 @@ use io_imap::{ use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag}, + stream, +}; -/// Remove flags from messages. +/// Remove IMAP flag(s) from message(s). /// -/// This command removes the specified flags from messages identified -/// by the given sequence set. +/// This command removes the specified flag(s) from message(s) +/// identified by the given sequence set. #[derive(Debug, Parser)] pub struct RemoveFlagsCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// The sequence set of messages (e.g., "1", "1,2,3", "1:*"). #[arg(name = "sequence_set", value_name = "SEQUENCE")] pub sequence_set: String, - /// The flags to remove (e.g., "\\Seen", "\\Flagged"). #[arg(short, long, required = true, num_args = 1..)] - pub flags: Vec, + pub flag: Vec, /// Use sequence numbers instead of UIDs. #[arg(long)] @@ -36,33 +41,30 @@ pub struct RemoveFlagsCommand { impl RemoveFlagsCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // First, SELECT the mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } - }; + 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 flags + let sequence_set = self.sequence_set.as_str().try_into()?; let flags: Vec> = self - .flags + .flag .iter() .map(|f| Flag::try_from(f.as_str()).map(|flag| flag.into_static())) .collect::>()?; - // Parse sequence set - let sequence_set = self.sequence_set.as_str().try_into()?; - - // Store flags let mut arg = None; let mut coroutine = ImapStoreSilent::new(context, sequence_set, StoreType::Remove, flags, !self.seq); diff --git a/src/imap/flag/set.rs b/src/imap/flag/set.rs index cb3e2a4e..9c3c0688 100644 --- a/src/imap/flag/set.rs +++ b/src/imap/flag/set.rs @@ -10,9 +10,13 @@ use io_imap::{ use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag}, + stream, +}; -/// Set flags on messages (replacing existing flags). +/// Set IMAP flag(s) on message(s), replacing any existing flags. /// /// This command replaces all existing flags on messages identified by /// the given sequence set with the specified flags. @@ -20,14 +24,15 @@ use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, s pub struct SetFlagsCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// The sequence set of messages (e.g., "1", "1,2,3", "1:*"). #[arg(name = "sequence_set", value_name = "SEQUENCE")] pub sequence_set: String, - /// The flags to set (e.g., "\\Seen", "\\Flagged"). #[arg(short, long, required = true, num_args = 1..)] - pub flags: Vec, + pub flag: Vec, /// Use sequence numbers instead of UIDs. #[arg(long)] @@ -36,33 +41,30 @@ pub struct SetFlagsCommand { impl SetFlagsCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // First, SELECT the mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } - }; + 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 flags + let sequence_set = self.sequence_set.as_str().try_into()?; let flags: Vec> = self - .flags + .flag .iter() .map(|f| Flag::try_from(f.as_str()).map(|flag| flag.into_static())) .collect::>()?; - // Parse sequence set - let sequence_set = self.sequence_set.as_str().try_into()?; - - // Store flags let mut arg = None; let mut coroutine = ImapStoreSilent::new(context, sequence_set, StoreType::Replace, flags, !self.seq); @@ -75,6 +77,6 @@ impl SetFlagsCommand { } } - printer.out(Message::new("Flag(s) successfully set")) + printer.out(Message::new("Flag(s) successfully replaced")) } } diff --git a/src/imap/mailbox/arg.rs b/src/imap/mailbox/arg.rs index 0fb8ecb4..0a800084 100644 --- a/src/imap/mailbox/arg.rs +++ b/src/imap/mailbox/arg.rs @@ -35,6 +35,19 @@ impl Default for MailboxNameOptionalArg { } } +#[derive(Debug, Parser)] +pub struct MailboxSelectFlag { + /// Select the given mailbox before performing the current action. + /// + /// This argument can be omitted when stateful IMAP sessions are + /// used, for example with: + /// + /// https://github.com/pimalaya/sirup + #[arg(long = "select", default_value_t)] + #[arg(name = "mailbox_select")] + pub r#true: bool, +} + /// The required mailbox name argument parser. #[derive(Debug, Parser)] pub struct MailboxNameArg { @@ -53,7 +66,7 @@ pub struct SourceMailboxNameOptionalFlag { } /// The target mailbox name argument parser. -#[derive(Debug, Parser)] +#[derive(Debug, Clone, Parser)] pub struct TargetMailboxNameArg { /// The name of the target mailbox. #[arg(name = "target_mailbox_name", value_name = "TARGET")] diff --git a/src/imap/mailbox/expunge.rs b/src/imap/mailbox/expunge.rs index 92eb828c..a37dae9a 100644 --- a/src/imap/mailbox/expunge.rs +++ b/src/imap/mailbox/expunge.rs @@ -4,7 +4,11 @@ use io_imap::coroutines::{expunge::*, select::*}; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameArg, MailboxSelectFlag}, + stream, +}; /// Expunge the given mailbox. /// @@ -14,15 +18,8 @@ use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg, stream}; pub struct ExpungeMailboxCommand { #[command(flatten)] pub mailbox: MailboxNameArg, - - /// Select the given mailbox before expunging it. - /// - /// This argument can be omitted when stateful IMAP sessions are - /// used, for example with: - /// - /// https://github.com/pimalaya/sirup - #[arg(short, long, default_value_t)] - pub select: bool, + #[command(flatten)] + pub select: MailboxSelectFlag, } impl ExpungeMailboxCommand { @@ -31,7 +28,7 @@ impl ExpungeMailboxCommand { let mailbox = self.mailbox.name.try_into()?; - if self.select { + if self.select.r#true { let mut arg = None; let mut coroutine = ImapSelect::new(context, mailbox); diff --git a/src/imap/mailbox/purge.rs b/src/imap/mailbox/purge.rs index 034039a5..c1194faa 100644 --- a/src/imap/mailbox/purge.rs +++ b/src/imap/mailbox/purge.rs @@ -7,7 +7,11 @@ use io_imap::{ use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameArg, MailboxSelectFlag}, + stream, +}; /// Shortcut for marking as deleted all envelopes then expunging the /// given mailbox. @@ -18,15 +22,8 @@ use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg, stream}; pub struct PurgeMailboxCommand { #[command(flatten)] pub mailbox: MailboxNameArg, - - /// Select the given mailbox before purging it. - /// - /// This argument can be omitted when stateful IMAP sessions are - /// used, for example with: - /// - /// https://github.com/pimalaya/sirup - #[arg(short, long, default_value_t)] - pub select: bool, + #[command(flatten)] + pub select: MailboxSelectFlag, } impl PurgeMailboxCommand { @@ -35,7 +32,7 @@ impl PurgeMailboxCommand { let mailbox = self.mailbox.name.try_into()?; - if self.select { + if self.select.r#true { let mut arg = None; let mut coroutine = ImapSelect::new(context, mailbox); diff --git a/src/imap/message/command.rs b/src/imap/message/command.rs index 00350979..20a6950b 100644 --- a/src/imap/message/command.rs +++ b/src/imap/message/command.rs @@ -5,17 +5,16 @@ use pimalaya_toolbox::terminal::printer::Printer; use crate::imap::{ account::ImapAccount, message::{ - copy::CopyMessageCommand, delete::DeleteMessageCommand, export::ExportMessageCommand, - get::GetMessageCommand, r#move::MoveMessageCommand, read::ReadMessageCommand, - save::SaveMessageCommand, + copy::CopyMessageCommand, export::ExportMessageCommand, get::GetMessageCommand, + r#move::MoveMessageCommand, read::ReadMessageCommand, save::SaveMessageCommand, }, }; -/// Manage messages. +/// Manage IMAP messages. /// /// A message is a complete email including headers and body. This -/// subcommand allows you to save, get, read, export, copy, move, and -/// delete messages. +/// subcommand allows you to save, get, read, export, copy, and move +/// messages. #[derive(Debug, Subcommand)] pub enum MessageCommand { Save(SaveMessageCommand), @@ -24,7 +23,6 @@ pub enum MessageCommand { Export(ExportMessageCommand), Copy(CopyMessageCommand), Move(MoveMessageCommand), - Delete(DeleteMessageCommand), } impl MessageCommand { @@ -36,7 +34,6 @@ impl MessageCommand { Self::Export(cmd) => cmd.exec(printer, account), Self::Copy(cmd) => cmd.exec(printer, account), Self::Move(cmd) => cmd.exec(printer, account), - Self::Delete(cmd) => cmd.exec(printer, account), } } } diff --git a/src/imap/message/copy.rs b/src/imap/message/copy.rs index ab7bfc75..0c008e84 100644 --- a/src/imap/message/copy.rs +++ b/src/imap/message/copy.rs @@ -7,24 +7,28 @@ use io_imap::{ use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag, TargetMailboxNameArg}, + stream, +}; -/// Copy messages to another mailbox. +/// Copy IMAP message(s) to the given mailbox. /// -/// This command copies messages identified by the given sequence set -/// from the source mailbox to the destination mailbox. +/// This command copies message(s) identified by the given sequence +/// set from the source mailbox to the destination mailbox. #[derive(Debug, Parser)] pub struct CopyMessageCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// The sequence set of messages (e.g., "1", "1,2,3", "1:*"). #[arg(name = "sequence_set", value_name = "SEQUENCE")] pub sequence_set: String, - - /// The destination mailbox. - #[arg(name = "destination", value_name = "DESTINATION")] - pub destination: String, + #[command(flatten)] + pub destination: TargetMailboxNameArg, /// Use sequence numbers instead of UIDs. #[arg(long)] @@ -33,27 +37,26 @@ pub struct CopyMessageCommand { impl CopyMessageCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // SELECT mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } - }; + 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 and destination let sequence_set = self.sequence_set.as_str().try_into()?; - let destination: Mailbox<'static> = self.destination.try_into()?; + let destination: Mailbox = self.destination.name.try_into()?; - // COPY let mut arg = None; let mut coroutine = ImapCopy::new(context, sequence_set, destination, !self.seq); diff --git a/src/imap/message/delete.rs b/src/imap/message/delete.rs deleted file mode 100644 index ad0d0d72..00000000 --- a/src/imap/message/delete.rs +++ /dev/null @@ -1,86 +0,0 @@ -use anyhow::{bail, Result}; -use clap::Parser; -use io_imap::{ - coroutines::{expunge::*, select::*, store::*}, - types::flag::{Flag, StoreType}, -}; -use io_stream::runtimes::std::handle; -use pimalaya_toolbox::terminal::printer::{Message, Printer}; - -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, stream}; - -/// Delete messages from a mailbox. -/// -/// This command marks messages as deleted and expunges them from the -/// mailbox. The messages are permanently removed. -#[derive(Debug, Parser)] -pub struct DeleteMessageCommand { - #[command(flatten)] - pub mailbox: MailboxNameOptionalFlag, - - /// The sequence set of messages (e.g., "1", "1,2,3", "1:*"). - #[arg(name = "sequence_set", value_name = "SEQUENCE")] - pub sequence_set: String, - - /// Use sequence numbers instead of UIDs. - #[arg(long)] - pub seq: bool, -} - -impl DeleteMessageCommand { - pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; - - 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 = self.sequence_set.as_str().try_into()?; - - // STORE +FLAGS \Deleted - let mut arg = None; - let mut coroutine = ImapStoreSilent::new( - context, - sequence_set, - StoreType::Add, - vec![Flag::Deleted], - !self.seq, - ); - - let context = loop { - match coroutine.resume(arg.take()) { - ImapStoreSilentResult::Io { io } => arg = Some(handle(&mut stream, io)?), - ImapStoreSilentResult::Ok { context, .. } => break context, - ImapStoreSilentResult::Err { err, .. } => bail!(err), - } - }; - - // EXPUNGE - let mut arg = None; - let mut coroutine = ImapExpunge::new(context); - - let expunged = loop { - match coroutine.resume(arg.take()) { - ImapExpungeResult::Io { io } => arg = Some(handle(&mut stream, io)?), - ImapExpungeResult::Ok { expunged, .. } => break expunged, - ImapExpungeResult::Err { err, .. } => bail!(err), - } - }; - - printer.out(Message::new(format!( - "{} message(s) successfully deleted", - expunged.len() - ))) - } -} diff --git a/src/imap/message/get.rs b/src/imap/message/get.rs index c4cbd476..24fa955c 100644 --- a/src/imap/message/get.rs +++ b/src/imap/message/get.rs @@ -8,11 +8,15 @@ use io_imap::{ types::fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, }; use io_stream::runtimes::std::handle; -use mail_parser::{Addr, Address, ContentType, MessageParser, MimeHeaders}; +use mail_parser::{Addr, Address, ContentType, Message, MessageParser, MimeHeaders}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag}, + stream, +}; /// Get a message and display its structure. /// @@ -22,11 +26,11 @@ use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, s pub struct GetMessageCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// The message UID (or sequence number with --seq). - #[arg(name = "id", value_name = "ID")] pub id: u32, - /// Use sequence numbers instead of UIDs. #[arg(long)] pub seq: bool, @@ -34,24 +38,25 @@ pub struct GetMessageCommand { impl GetMessageCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; 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), - } + let Some(id) = NonZeroU32::new(self.id) else { + bail!("ID must be non-zero"); }; - // FETCH with BODY.PEEK[] to avoid marking as read - let id = NonZeroU32::new(self.id).ok_or_else(|| anyhow::anyhow!("ID must be non-zero"))?; + if self.select.r#true { + let mut arg = None; + let mut coroutine = ImapSelect::new(context, mailbox); + + 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), + } + }; + } let item_names = MacroOrMessageDataItemNames::MessageDataItemNames(vec![MessageDataItemName::BodyExt { @@ -71,7 +76,6 @@ impl GetMessageCommand { } }; - // Extract raw message bytes let mut raw_message: Option> = None; for item in items.into_iter() { if let MessageDataItem::BodyExt { data, .. } = item { @@ -81,17 +85,16 @@ impl GetMessageCommand { } } - let raw = raw_message.ok_or_else(|| anyhow::anyhow!("No message data returned"))?; + let Some(raw) = raw_message else { + bail!("No message found"); + }; - // Parse message using mail-parser - let message = MessageParser::default() - .parse(&raw) - .ok_or_else(|| anyhow::anyhow!("Failed to parse message"))?; + let Some(message) = MessageParser::new().parse(&raw) else { + bail!("Invalid message"); + }; let structure = MessageStructure::from_parsed(&message); - - printer.out(structure)?; - Ok(()) + printer.out(structure) } } @@ -124,7 +127,7 @@ pub struct MessageStructure { } impl MessageStructure { - pub fn from_parsed(message: &mail_parser::Message<'_>) -> Self { + pub fn from_parsed(message: &Message<'_>) -> Self { // Extract headers let headers = MessageHeaders { date: message.date().map(|d| d.to_rfc3339()), diff --git a/src/imap/message/mod.rs b/src/imap/message/mod.rs index 222509ad..91cc5818 100644 --- a/src/imap/message/mod.rs +++ b/src/imap/message/mod.rs @@ -1,6 +1,5 @@ pub mod command; pub mod copy; -pub mod delete; pub mod export; pub mod get; pub mod r#move; diff --git a/src/imap/message/move.rs b/src/imap/message/move.rs index a794cb1d..ec49ec8f 100644 --- a/src/imap/message/move.rs +++ b/src/imap/message/move.rs @@ -7,9 +7,13 @@ use io_imap::{ use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag, TargetMailboxNameArg}, + stream, +}; -/// Move messages to another mailbox. +/// Move message(s) to the given mailbox. /// /// This command moves messages identified by the given sequence set /// from the source mailbox to the destination mailbox. Requires the @@ -18,14 +22,14 @@ use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, s pub struct MoveMessageCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// The sequence set of messages (e.g., "1", "1,2,3", "1:*"). #[arg(name = "sequence_set", value_name = "SEQUENCE")] pub sequence_set: String, - - /// The destination mailbox. - #[arg(name = "destination", value_name = "DESTINATION")] - pub destination: String, + #[command(flatten)] + pub destination: TargetMailboxNameArg, /// Use sequence numbers instead of UIDs. #[arg(long)] @@ -34,27 +38,26 @@ pub struct MoveMessageCommand { impl MoveMessageCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // SELECT mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } - }; + 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 and destination let sequence_set = self.sequence_set.as_str().try_into()?; - let destination: Mailbox<'static> = self.destination.try_into()?; + let destination: Mailbox<'static> = self.destination.name.try_into()?; - // MOVE let mut arg = None; let mut coroutine = ImapMove::new(context, sequence_set, destination, !self.seq); diff --git a/src/imap/message/read.rs b/src/imap/message/read.rs index af46f7b8..1e0c9a4a 100644 --- a/src/imap/message/read.rs +++ b/src/imap/message/read.rs @@ -1,6 +1,6 @@ use std::{fmt, num::NonZeroU32}; -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use clap::Parser; use io_imap::{ coroutines::{fetch::*, select::*}, @@ -11,7 +11,11 @@ use mail_parser::MessageParser; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, stream}; +use crate::imap::{ + account::ImapAccount, + mailbox::arg::{MailboxNameOptionalFlag, MailboxSelectFlag}, + stream, +}; /// Read message content. /// @@ -21,6 +25,8 @@ use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag, s pub struct ReadMessageCommand { #[command(flatten)] pub mailbox: MailboxNameOptionalFlag, + #[command(flatten)] + pub select: MailboxSelectFlag, /// The message UID (or sequence number with --seq). #[arg(name = "id", value_name = "ID")] @@ -41,25 +47,27 @@ pub struct ReadMessageCommand { impl ReadMessageCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (context, mut stream) = stream::connect(account.backend)?; + let (mut context, mut stream) = stream::connect(account.backend)?; let mailbox = self.mailbox.name.try_into()?; - // SELECT mailbox - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox); + if self.select.r#true { + 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), - } + 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), + } + }; + } + + let Some(id) = NonZeroU32::new(self.id) else { + bail!("ID must be non-zero"); }; - // FETCH with BODY.PEEK[] to avoid marking as read - let id = NonZeroU32::new(self.id).ok_or_else(|| anyhow::anyhow!("ID must be non-zero"))?; - let item_names = MacroOrMessageDataItemNames::MessageDataItemNames(vec![MessageDataItemName::BodyExt { section: None, @@ -78,8 +86,8 @@ impl ReadMessageCommand { } }; - // Extract raw message bytes let mut raw_message: Option> = None; + for item in items.into_iter() { if let MessageDataItem::BodyExt { data, .. } = item { if let Some(data) = data.0 { @@ -88,25 +96,23 @@ impl ReadMessageCommand { } } - let raw = raw_message.ok_or_else(|| anyhow::anyhow!("No message data returned"))?; + let Some(raw) = raw_message else { + bail!("No message found"); + }; - // Parse message using mail-parser - let message = MessageParser::default() - .parse(&raw) - .ok_or_else(|| anyhow::anyhow!("Failed to parse message"))?; + let Some(message) = MessageParser::new().parse(&raw) else { + bail!("Invalid message"); + }; let content = if self.html { - // Get HTML content message .body_html(0) .map(|s| s.to_string()) - .ok_or_else(|| anyhow::anyhow!("No HTML content found"))? + .ok_or_else(|| anyhow!("No HTML content found"))? } else { - // Get plain text, or convert HTML to text if let Some(text) = message.body_text(0) { text.to_string() } else if let Some(html) = message.body_html(0) { - // Convert HTML to text html2text::from_read(html.as_bytes(), self.width) } else { bail!("No text or HTML content found"); @@ -114,9 +120,7 @@ impl ReadMessageCommand { }; let output = MessageContent { content }; - - printer.out(output)?; - Ok(()) + printer.out(output) } } diff --git a/src/imap/message/save.rs b/src/imap/message/save.rs index 9f984edf..faadf454 100644 --- a/src/imap/message/save.rs +++ b/src/imap/message/save.rs @@ -1,18 +1,18 @@ -use std::io::{stdin, Read}; +use std::io::{stdin, BufRead, IsTerminal}; use anyhow::{bail, Result}; use clap::Parser; use io_imap::{ - coroutines::{ - append::*, - select::{ImapSelect, ImapSelectResult}, + coroutines::append::*, + types::{ + core::Literal, extensions::binary::LiteralOrLiteral8, flag::Flag, mailbox::Mailbox, + IntoStatic, }, - types::{core::Literal, extensions::binary::LiteralOrLiteral8, mailbox::Mailbox}, }; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, stream}; +use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg, stream}; /// Save a message to a mailbox. /// @@ -20,51 +20,50 @@ use crate::imap::{account::ImapAccount, stream}; /// message is read from stdin in RFC 5322 format (raw email). #[derive(Debug, Parser)] pub struct SaveMessageCommand { - /// The mailbox to save the message to. - #[arg(name = "mailbox", value_name = "MAILBOX")] - pub mailbox: String, + #[command(flatten)] + pub mailbox: MailboxNameArg, - /// Select the given mailbox before saving message into it. - /// - /// This argument can be omitted when stateful IMAP sessions are - /// used, for example with: - /// - /// https://github.com/pimalaya/sirup - #[arg(short, long, default_value_t)] - pub select: bool, + /// The flags to add to the message. + #[arg(short, long, num_args = 0..)] + pub flag: Vec, + + /// The raw message, including headers and body. + #[arg(trailing_var_arg = true)] + #[arg(name = "message", value_name = "MESSAGE")] + pub message: Vec, } impl SaveMessageCommand { pub fn exec(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let (mut context, mut stream) = stream::connect(account.backend)?; + let (context, mut stream) = stream::connect(account.backend)?; - // Read message from stdin - let mut message = Vec::new(); - stdin().read_to_end(&mut message)?; + let mailbox: Mailbox<'static> = self.mailbox.name.try_into()?; - if message.is_empty() { - bail!("No message provided on stdin"); - } + let message = if stdin().is_terminal() || printer.is_json() { + self.message + .join(" ") + .replace('\r', "") + .replace('\n', "\r\n") + } else { + stdin() + .lock() + .lines() + .map_while(Result::ok) + .collect::>() + .join("\r\n") + }; + let message = Literal::try_from(message)?; + let message = LiteralOrLiteral8::Literal(message); - let mailbox: Mailbox<'static> = self.mailbox.try_into()?; - let literal = Literal::try_from(message)?; - let message = LiteralOrLiteral8::Literal(literal); - - if self.select { - let mut arg = None; - let mut coroutine = ImapSelect::new(context, mailbox.clone()); - - 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), - } - }; - } + let flags: Vec<_> = self + .flag + .iter() + .map(String::as_str) + .map(|f| Flag::try_from(f).map(IntoStatic::into_static)) + .collect::>()?; let mut arg = None; - let mut coroutine = ImapAppend::new(context, mailbox, vec![], None, message); + let mut coroutine = ImapAppend::new(context, mailbox, flags, None, message); loop { match coroutine.resume(arg.take()) { diff --git a/src/imap/stream.rs b/src/imap/stream.rs index 12800165..2b5853f8 100644 --- a/src/imap/stream.rs +++ b/src/imap/stream.rs @@ -43,43 +43,6 @@ pub enum Stream { NativeTls(native_tls::TlsStream), } -impl Read for Stream { - fn read(&mut self, buf: &mut [u8]) -> io::Result { - match self { - Self::Tcp(s) => s.read(buf), - Self::Unix(s) => s.read(buf), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Self::Rustls(s) => s.read(buf), - #[cfg(feature = "native-tls")] - Self::NativeTls(s) => s.read(buf), - } - } -} - -impl Write for Stream { - fn write(&mut self, buf: &[u8]) -> io::Result { - match self { - Self::Tcp(s) => s.write(buf), - Self::Unix(s) => s.write(buf), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Self::Rustls(s) => s.write(buf), - #[cfg(feature = "native-tls")] - Self::NativeTls(s) => s.write(buf), - } - } - - fn flush(&mut self) -> io::Result<()> { - match self { - Self::Tcp(s) => s.flush(), - Self::Unix(s) => s.flush(), - #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] - Self::Rustls(s) => s.flush(), - #[cfg(feature = "native-tls")] - Self::NativeTls(s) => s.flush(), - } - } -} - pub fn connect(mut config: ImapConfig) -> Result<(ImapContext, Stream)> { info!("connecting to IMAP server using {}", config.url); @@ -376,3 +339,40 @@ pub fn connect(mut config: ImapConfig) -> Result<(ImapContext, Stream)> { Ok((context, stream)) } + +impl Read for Stream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + match self { + Self::Tcp(s) => s.read(buf), + Self::Unix(s) => s.read(buf), + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Self::Rustls(s) => s.read(buf), + #[cfg(feature = "native-tls")] + Self::NativeTls(s) => s.read(buf), + } + } +} + +impl Write for Stream { + fn write(&mut self, buf: &[u8]) -> io::Result { + match self { + Self::Tcp(s) => s.write(buf), + Self::Unix(s) => s.write(buf), + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Self::Rustls(s) => s.write(buf), + #[cfg(feature = "native-tls")] + Self::NativeTls(s) => s.write(buf), + } + } + + fn flush(&mut self) -> io::Result<()> { + match self { + Self::Tcp(s) => s.flush(), + Self::Unix(s) => s.flush(), + #[cfg(any(feature = "rustls-aws", feature = "rustls-ring"))] + Self::Rustls(s) => s.flush(), + #[cfg(feature = "native-tls")] + Self::NativeTls(s) => s.flush(), + } + } +}