From 1d72e59c8232f4c9e3c9c83469e68417d5885d8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Tue, 3 Mar 2026 22:01:23 +0100 Subject: [PATCH] add more mailbox useful commands --- src/imap/mailbox/command/close.rs | 34 ++++++ src/imap/mailbox/command/list.rs | 49 +++++---- src/imap/mailbox/command/mod.rs | 23 +++- src/imap/mailbox/command/rename.rs | 45 ++++++++ src/imap/mailbox/command/status.rs | 140 ++++++++++++++++++++++++ src/imap/mailbox/command/subscribe.rs | 38 +++++++ src/imap/mailbox/command/unselect.rs | 51 +++++++++ src/imap/mailbox/command/unsubscribe.rs | 38 +++++++ 8 files changed, 398 insertions(+), 20 deletions(-) create mode 100644 src/imap/mailbox/command/close.rs create mode 100644 src/imap/mailbox/command/rename.rs create mode 100644 src/imap/mailbox/command/status.rs create mode 100644 src/imap/mailbox/command/subscribe.rs create mode 100644 src/imap/mailbox/command/unselect.rs create mode 100644 src/imap/mailbox/command/unsubscribe.rs diff --git a/src/imap/mailbox/command/close.rs b/src/imap/mailbox/command/close.rs new file mode 100644 index 00000000..45e140e4 --- /dev/null +++ b/src/imap/mailbox/command/close.rs @@ -0,0 +1,34 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::coroutines::close::*; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{config::ImapConfig, imap::stream}; + +/// Close a mailbox. +/// +/// This command first selects the given mailbox, then closes it. +/// CLOSE permanently removes all messages with the \Deleted flag +/// and returns to the authenticated state. +#[derive(Debug, Parser)] +pub struct CloseMailboxCommand; + +impl CloseMailboxCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + let (context, mut stream) = stream::connect(config)?; + + let mut arg = None; + let mut close_coroutine = ImapClose::new(context); + + loop { + match close_coroutine.resume(arg.take()) { + ImapCloseResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapCloseResult::Ok { .. } => break, + ImapCloseResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Mailbox successfully closed")) + } +} diff --git a/src/imap/mailbox/command/list.rs b/src/imap/mailbox/command/list.rs index c6b97fa9..4524490c 100644 --- a/src/imap/mailbox/command/list.rs +++ b/src/imap/mailbox/command/list.rs @@ -4,41 +4,52 @@ use anyhow::{bail, Result}; use clap::Parser; use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; use crossterm::style::Color; -use io_imap::coroutines::list::*; +use io_imap::coroutines::{list::*, lsub::*}; use io_stream::runtimes::std::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::{Serialize, Serializer}; use crate::{config::ImapConfig, imap::stream}; -/// List all mailboxes. +/// List mailboxes. /// -/// This command allows you to list all exsting mailboxes from your -/// IMAP account. +/// This command allows you to list mailboxes from your IMAP account. +/// By default, only subscribed mailboxes are listed. Use --all to +/// list all mailboxes. #[derive(Debug, Parser)] pub struct ListMailboxesCommand { - // /// The maximum width the table should not exceed. - // /// - // /// This argument will force the table not to exceed the given - // /// width, in pixels. Columns may shrink with ellipsis in order to - // /// fit the width. - // #[arg(long = "max-width", short = 'w')] - // #[arg(name = "table_max_width", value_name = "PIXELS")] - // pub table_max_width: Option, + /// List all mailboxes, not just subscribed ones. + #[arg(short = 'A', long)] + pub all: bool, } impl ListMailboxesCommand { pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { let (context, mut stream) = stream::connect(config)?; - let mut arg = None; - let mut coroutine = ImapList::new(context, "".try_into().unwrap(), "*".try_into().unwrap()); + let mailboxes = if self.all { + let mut arg = None; + let mut coroutine = + ImapList::new(context, "".try_into().unwrap(), "*".try_into().unwrap()); - let mailboxes = loop { - match coroutine.resume(arg.take()) { - ImapListResult::Io(io) => arg = Some(handle(&mut stream, io)?), - ImapListResult::Ok { mailboxes, .. } => break mailboxes, - ImapListResult::Err { err, .. } => bail!(err), + loop { + match coroutine.resume(arg.take()) { + ImapListResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapListResult::Ok { mailboxes, .. } => break mailboxes, + ImapListResult::Err { err, .. } => bail!(err), + } + } + } else { + let mut arg = None; + let mut coroutine = + ImapLsub::new(context, "".try_into().unwrap(), "*".try_into().unwrap()); + + loop { + match coroutine.resume(arg.take()) { + ImapLsubResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapLsubResult::Ok { mailboxes, .. } => break mailboxes, + ImapLsubResult::Err { err, .. } => bail!(err), + } } }; diff --git a/src/imap/mailbox/command/mod.rs b/src/imap/mailbox/command/mod.rs index 1fa09c9c..c4127bc7 100644 --- a/src/imap/mailbox/command/mod.rs +++ b/src/imap/mailbox/command/mod.rs @@ -1,8 +1,14 @@ +pub mod close; pub mod create; pub mod delete; pub mod expunge; pub mod list; pub mod purge; +pub mod rename; +pub mod status; +pub mod subscribe; +pub mod unselect; +pub mod unsubscribe; use anyhow::Result; use clap::Subcommand; @@ -11,8 +17,11 @@ use pimalaya_toolbox::terminal::printer::Printer; use crate::{ config::ImapConfig, imap::mailbox::command::{ - create::CreateMailboxCommand, delete::DeleteMailboxCommand, + close::CloseMailboxCommand, create::CreateMailboxCommand, delete::DeleteMailboxCommand, expunge::ExpungeMailboxCommand, list::ListMailboxesCommand, purge::PurgeMailboxCommand, + rename::RenameMailboxCommand, status::StatusMailboxCommand, + subscribe::SubscribeMailboxCommand, unselect::UnselectMailboxCommand, + unsubscribe::UnsubscribeMailboxCommand, }, }; @@ -22,6 +31,7 @@ use crate::{ /// manage them. #[derive(Debug, Subcommand)] pub enum MailboxCommand { + Close(CloseMailboxCommand), #[command(alias = "add", alias = "new")] Create(CreateMailboxCommand), #[command(alias = "remove", alias = "rm")] @@ -29,16 +39,27 @@ pub enum MailboxCommand { Expunge(ExpungeMailboxCommand), List(ListMailboxesCommand), Purge(PurgeMailboxCommand), + Rename(RenameMailboxCommand), + Status(StatusMailboxCommand), + Subscribe(SubscribeMailboxCommand), + Unselect(UnselectMailboxCommand), + Unsubscribe(UnsubscribeMailboxCommand), } impl MailboxCommand { pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { match self { + Self::Close(cmd) => cmd.execute(printer, config), Self::Create(cmd) => cmd.execute(printer, config), Self::Delete(cmd) => cmd.execute(printer, config), Self::Expunge(cmd) => cmd.execute(printer, config), Self::List(cmd) => cmd.execute(printer, config), Self::Purge(cmd) => cmd.execute(printer, config), + Self::Rename(cmd) => cmd.execute(printer, config), + Self::Status(cmd) => cmd.execute(printer, config), + Self::Subscribe(cmd) => cmd.execute(printer, config), + Self::Unselect(cmd) => cmd.execute(printer, config), + Self::Unsubscribe(cmd) => cmd.execute(printer, config), } } } diff --git a/src/imap/mailbox/command/rename.rs b/src/imap/mailbox/command/rename.rs new file mode 100644 index 00000000..4c3b8dbd --- /dev/null +++ b/src/imap/mailbox/command/rename.rs @@ -0,0 +1,45 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::coroutines::rename::*; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + config::ImapConfig, + imap::mailbox::arg::name::{MailboxNameArg, TargetMailboxNameArg}, + imap::stream, +}; + +/// Rename a mailbox. +/// +/// This command renames an existing mailbox to a new name. +#[derive(Debug, Parser)] +pub struct RenameMailboxCommand { + #[command(flatten)] + pub from: MailboxNameArg, + + #[command(flatten)] + pub to: TargetMailboxNameArg, +} + +impl RenameMailboxCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + let (context, mut stream) = stream::connect(config)?; + + let from = self.from.name.try_into()?; + let to = self.to.name.try_into()?; + + let mut arg = None; + let mut coroutine = ImapRename::new(context, from, to); + + loop { + match coroutine.resume(arg.take()) { + ImapRenameResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapRenameResult::Ok { .. } => break, + ImapRenameResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Mailbox successfully renamed")) + } +} diff --git a/src/imap/mailbox/command/status.rs b/src/imap/mailbox/command/status.rs new file mode 100644 index 00000000..d2fbf905 --- /dev/null +++ b/src/imap/mailbox/command/status.rs @@ -0,0 +1,140 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use io_imap::coroutines::status::*; +use io_imap::types::status::{StatusDataItem, StatusDataItemName}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::{Serialize, Serializer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameArg, imap::stream}; + +/// Get the status of a mailbox. +/// +/// This command displays status information about a mailbox, +/// including message counts and UID values. +#[derive(Debug, Parser)] +pub struct StatusMailboxCommand { + #[command(flatten)] + pub mailbox: MailboxNameArg, +} + +impl StatusMailboxCommand { + 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()?; + let item_names = vec![ + StatusDataItemName::Messages, + StatusDataItemName::Recent, + StatusDataItemName::Unseen, + StatusDataItemName::UidNext, + StatusDataItemName::UidValidity, + ]; + + let mut arg = None; + let mut coroutine = ImapStatus::new(context, mailbox, item_names); + + let items = loop { + match coroutine.resume(arg.take()) { + ImapStatusResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapStatusResult::Ok { items, .. } => break items, + ImapStatusResult::Err { err, .. } => bail!(err), + } + }; + + let table = MailboxStatusTable::from(items); + + printer.out(table)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct MailboxStatus { + pub messages: Option, + pub recent: Option, + pub unseen: Option, + pub uidnext: Option, + pub uidvalidity: Option, +} + +impl From> for MailboxStatus { + fn from(items: Vec) -> Self { + let mut status = MailboxStatus { + messages: None, + recent: None, + unseen: None, + uidnext: None, + uidvalidity: None, + }; + + for item in items { + match item { + StatusDataItem::Messages(n) => status.messages = Some(n), + StatusDataItem::Recent(n) => status.recent = Some(n), + StatusDataItem::Unseen(n) => status.unseen = Some(n), + StatusDataItem::UidNext(n) => status.uidnext = Some(n.get()), + StatusDataItem::UidValidity(n) => status.uidvalidity = Some(n.get()), + _ => {} + } + } + + status + } +} + +pub struct MailboxStatusTable { + status: MailboxStatus, +} + +impl From> for MailboxStatusTable { + fn from(items: Vec) -> Self { + Self { + status: MailboxStatus::from(items), + } + } +} + +impl fmt::Display for MailboxStatusTable { + 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("ATTRIBUTE"), Cell::new("VALUE")])); + + if let Some(messages) = self.status.messages { + table.add_row(Row::from([Cell::new("Messages"), Cell::new(messages)])); + } + if let Some(recent) = self.status.recent { + table.add_row(Row::from([Cell::new("Recent"), Cell::new(recent)])); + } + if let Some(unseen) = self.status.unseen { + table.add_row(Row::from([Cell::new("Unseen"), Cell::new(unseen)])); + } + if let Some(uidnext) = self.status.uidnext { + table.add_row(Row::from([Cell::new("UIDNext"), Cell::new(uidnext)])); + } + if let Some(uidvalidity) = self.status.uidvalidity { + table.add_row(Row::from([ + Cell::new("UIDValidity"), + Cell::new(uidvalidity), + ])); + } + + writeln!(f)?; + write!(f, "{table}")?; + writeln!(f)?; + Ok(()) + } +} + +impl Serialize for MailboxStatusTable { + fn serialize(&self, serializer: S) -> Result { + self.status.serialize(serializer) + } +} diff --git a/src/imap/mailbox/command/subscribe.rs b/src/imap/mailbox/command/subscribe.rs new file mode 100644 index 00000000..888a4960 --- /dev/null +++ b/src/imap/mailbox/command/subscribe.rs @@ -0,0 +1,38 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::coroutines::subscribe::*; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameArg, imap::stream}; + +/// Subscribe to the given mailbox. +/// +/// This command subscribes to a mailbox, making it appear in the +/// list of subscribed mailboxes. +#[derive(Debug, Parser)] +pub struct SubscribeMailboxCommand { + #[command(flatten)] + pub mailbox: MailboxNameArg, +} + +impl SubscribeMailboxCommand { + 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()?; + + let mut arg = None; + let mut coroutine = ImapSubscribe::new(context, mailbox); + + loop { + match coroutine.resume(arg.take()) { + ImapSubscribeResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapSubscribeResult::Ok { .. } => break, + ImapSubscribeResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Mailbox successfully subscribed")) + } +} diff --git a/src/imap/mailbox/command/unselect.rs b/src/imap/mailbox/command/unselect.rs new file mode 100644 index 00000000..235706c9 --- /dev/null +++ b/src/imap/mailbox/command/unselect.rs @@ -0,0 +1,51 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::coroutines::{select::*, unselect::*}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameArg, imap::stream}; + +/// Unselect a mailbox. +/// +/// This command first selects the given mailbox, then unselects it. +/// Unlike CLOSE, UNSELECT does not expunge deleted messages. +#[derive(Debug, Parser)] +pub struct UnselectMailboxCommand { + #[command(flatten)] + pub mailbox: MailboxNameArg, +} + +impl UnselectMailboxCommand { + 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()?; + + // First SELECT the mailbox + let mut arg = None; + let mut select_coroutine = ImapSelect::new(context, mailbox); + + let context = loop { + match select_coroutine.resume(arg.take()) { + ImapSelectResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapSelectResult::Ok { context, .. } => break context, + ImapSelectResult::Err { err, .. } => bail!(err), + } + }; + + // Then UNSELECT + let mut arg = None; + let mut unselect_coroutine = ImapUnselect::new(context); + + loop { + match unselect_coroutine.resume(arg.take()) { + ImapUnselectResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapUnselectResult::Ok { .. } => break, + ImapUnselectResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Mailbox successfully unselected")) + } +} diff --git a/src/imap/mailbox/command/unsubscribe.rs b/src/imap/mailbox/command/unsubscribe.rs new file mode 100644 index 00000000..2df1d585 --- /dev/null +++ b/src/imap/mailbox/command/unsubscribe.rs @@ -0,0 +1,38 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::coroutines::unsubscribe::*; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameArg, imap::stream}; + +/// Unsubscribe from the given mailbox. +/// +/// This command unsubscribes from a mailbox, removing it from the +/// list of subscribed mailboxes. +#[derive(Debug, Parser)] +pub struct UnsubscribeMailboxCommand { + #[command(flatten)] + pub mailbox: MailboxNameArg, +} + +impl UnsubscribeMailboxCommand { + 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()?; + + let mut arg = None; + let mut coroutine = ImapUnsubscribe::new(context, mailbox); + + loop { + match coroutine.resume(arg.take()) { + ImapUnsubscribeResult::Io(io) => arg = Some(handle(&mut stream, io)?), + ImapUnsubscribeResult::Ok { .. } => break, + ImapUnsubscribeResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Mailbox successfully unsubscribed")) + } +}