From f993bb58c93e7f8923779d91ddacec987dd62ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Tue, 3 Mar 2026 22:49:43 +0100 Subject: [PATCH] add flag commands --- src/imap/command.rs | 8 ++- src/imap/flag/command/add.rs | 80 +++++++++++++++++++++ src/imap/flag/command/list.rs | 119 ++++++++++++++++++++++++++++++++ src/imap/flag/command/mod.rs | 40 +++++++++++ src/imap/flag/command/remove.rs | 80 +++++++++++++++++++++ src/imap/flag/command/set.rs | 80 +++++++++++++++++++++ src/imap/flag/mod.rs | 1 + src/imap/mod.rs | 1 + 8 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 src/imap/flag/command/add.rs create mode 100644 src/imap/flag/command/list.rs create mode 100644 src/imap/flag/command/mod.rs create mode 100644 src/imap/flag/command/remove.rs create mode 100644 src/imap/flag/command/set.rs create mode 100644 src/imap/flag/mod.rs diff --git a/src/imap/command.rs b/src/imap/command.rs index 7b77dd2a..63c8ad9d 100644 --- a/src/imap/command.rs +++ b/src/imap/command.rs @@ -2,7 +2,10 @@ use anyhow::Result; use clap::Subcommand; use pimalaya_toolbox::terminal::printer::Printer; -use crate::{config::ImapConfig, imap::mailbox::command::MailboxCommand}; +use crate::{ + config::ImapConfig, + imap::{flag::command::FlagCommand, mailbox::command::MailboxCommand}, +}; /// IMAP CLI (requires `imap` cargo feature). /// @@ -12,6 +15,8 @@ use crate::{config::ImapConfig, imap::mailbox::command::MailboxCommand}; #[derive(Debug, Subcommand)] #[command(rename_all = "lowercase")] pub enum ImapCommand { + #[command(subcommand)] + Flags(FlagCommand), #[command(subcommand)] #[command(aliases = ["mboxes", "mbox"])] Mailboxes(MailboxCommand), @@ -20,6 +25,7 @@ pub enum ImapCommand { impl ImapCommand { pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { match self { + Self::Flags(cmd) => cmd.execute(printer, config), Self::Mailboxes(cmd) => cmd.execute(printer, config), } } diff --git a/src/imap/flag/command/add.rs b/src/imap/flag/command/add.rs new file mode 100644 index 00000000..5980c5b1 --- /dev/null +++ b/src/imap/flag/command/add.rs @@ -0,0 +1,80 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::{ + coroutines::{select::*, store::*}, + types::{ + flag::{Flag, StoreType}, + IntoStatic, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameOptionalFlag, imap::stream}; + +/// Add flags to messages. +/// +/// This command adds the specified flags to messages identified by +/// the given sequence set. +#[derive(Debug, Parser)] +pub struct AddFlagsCommand { + #[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, + + /// The flags to add (e.g., "\\Seen", "\\Flagged"). + #[arg(short, long, required = true, num_args = 1..)] + pub flags: Vec, + + /// Use UID STORE instead of STORE. + #[arg(long)] + pub uid: bool, +} + +impl AddFlagsCommand { + 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 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 flags + let flags: Vec> = self + .flags + .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.uid); + + loop { + match coroutine.resume(arg.take()) { + ImapStoreSilentResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapStoreSilentResult::Ok { .. } => break, + ImapStoreSilentResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Flag(s) successfully added")) + } +} diff --git a/src/imap/flag/command/list.rs b/src/imap/flag/command/list.rs new file mode 100644 index 00000000..091c5479 --- /dev/null +++ b/src/imap/flag/command/list.rs @@ -0,0 +1,119 @@ +use std::{collections::BTreeMap, fmt}; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{presets, Cell, ContentArrangement, Row, Table}; +use io_imap::{ + coroutines::select::*, + types::flag::{Flag, FlagPerm}, +}; +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 available flags for a mailbox. +/// +/// This command displays the flags and permanent flags that are +/// available in the given mailbox. These flags come from the SELECT +/// response. +#[derive(Debug, Parser)] +pub struct ListFlagsCommand { + #[command(flatten)] + pub mailbox: MailboxNameOptionalArg, +} + +impl ListFlagsCommand { + 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 = ImapSelect::new(context, mailbox); + + let (flags, permanent_flags) = loop { + match coroutine.resume(arg.take()) { + ImapSelectResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapSelectResult::Ok { data, .. } => { + break ( + data.flags.unwrap_or_default(), + data.permanent_flags.unwrap_or_default(), + ) + } + ImapSelectResult::Err { err, .. } => bail!(err), + } + }; + + let table = FlagsTable { flags, permanent_flags }; + + printer.out(table)?; + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct FlagEntry { + pub name: String, + pub permanent: bool, +} + +pub struct FlagsTable { + flags: Vec>, + permanent_flags: Vec>, +} + +impl FlagsTable { + fn build_entries(&self) -> Vec { + 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(), + FlagPerm::Asterisk => "\\*".to_string(), + }; + entries.insert(name, true); + } + + entries + .into_iter() + .map(|(name, permanent)| FlagEntry { name, permanent }) + .collect() + } +} + +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) + .set_header(Row::from([Cell::new("FLAG"), Cell::new("PERMANENT")])); + + for entry in self.build_entries() { + table.add_row(Row::from([ + Cell::new(&entry.name), + Cell::new(if entry.permanent { "true" } else { "" }), + ])); + } + + writeln!(f)?; + write!(f, "{table}")?; + writeln!(f)?; + Ok(()) + } +} + +impl Serialize for FlagsTable { + fn serialize(&self, serializer: S) -> Result { + self.build_entries().serialize(serializer) + } +} diff --git a/src/imap/flag/command/mod.rs b/src/imap/flag/command/mod.rs new file mode 100644 index 00000000..271b22af --- /dev/null +++ b/src/imap/flag/command/mod.rs @@ -0,0 +1,40 @@ +pub mod add; +pub mod list; +pub mod remove; +pub mod set; + +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + config::ImapConfig, + imap::flag::command::{ + add::AddFlagsCommand, list::ListFlagsCommand, remove::RemoveFlagsCommand, + set::SetFlagsCommand, + }, +}; + +/// Manage message 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. +#[derive(Debug, Subcommand)] +pub enum FlagCommand { + List(ListFlagsCommand), + Add(AddFlagsCommand), + Set(SetFlagsCommand), + Remove(RemoveFlagsCommand), +} + +impl FlagCommand { + pub fn execute(self, printer: &mut impl Printer, config: ImapConfig) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, config), + Self::Add(cmd) => cmd.execute(printer, config), + Self::Set(cmd) => cmd.execute(printer, config), + Self::Remove(cmd) => cmd.execute(printer, config), + } + } +} diff --git a/src/imap/flag/command/remove.rs b/src/imap/flag/command/remove.rs new file mode 100644 index 00000000..7bf4c834 --- /dev/null +++ b/src/imap/flag/command/remove.rs @@ -0,0 +1,80 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::{ + coroutines::{select::*, store::*}, + types::{ + flag::{Flag, StoreType}, + IntoStatic, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameOptionalFlag, imap::stream}; + +/// Remove flags from messages. +/// +/// This command removes the specified flags from messages identified +/// by the given sequence set. +#[derive(Debug, Parser)] +pub struct RemoveFlagsCommand { + #[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, + + /// The flags to remove (e.g., "\\Seen", "\\Flagged"). + #[arg(short, long, required = true, num_args = 1..)] + pub flags: Vec, + + /// Use UID STORE instead of STORE. + #[arg(long)] + pub uid: bool, +} + +impl RemoveFlagsCommand { + 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 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 flags + let flags: Vec> = self + .flags + .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.uid); + + loop { + match coroutine.resume(arg.take()) { + ImapStoreSilentResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapStoreSilentResult::Ok { .. } => break, + ImapStoreSilentResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Flag(s) successfully removed")) + } +} diff --git a/src/imap/flag/command/set.rs b/src/imap/flag/command/set.rs new file mode 100644 index 00000000..d4a56d1e --- /dev/null +++ b/src/imap/flag/command/set.rs @@ -0,0 +1,80 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_imap::{ + coroutines::{select::*, store::*}, + types::{ + flag::{Flag, StoreType}, + IntoStatic, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{config::ImapConfig, imap::mailbox::arg::name::MailboxNameOptionalFlag, imap::stream}; + +/// Set flags on messages (replacing existing flags). +/// +/// This command replaces all existing flags on messages identified by +/// the given sequence set with the specified flags. +#[derive(Debug, Parser)] +pub struct SetFlagsCommand { + #[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, + + /// The flags to set (e.g., "\\Seen", "\\Flagged"). + #[arg(short, long, required = true, num_args = 1..)] + pub flags: Vec, + + /// Use UID STORE instead of STORE. + #[arg(long)] + pub uid: bool, +} + +impl SetFlagsCommand { + 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 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 flags + let flags: Vec> = self + .flags + .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.uid); + + loop { + match coroutine.resume(arg.take()) { + ImapStoreSilentResult::Io { io } => arg = Some(handle(&mut stream, io)?), + ImapStoreSilentResult::Ok { .. } => break, + ImapStoreSilentResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Flag(s) successfully set")) + } +} diff --git a/src/imap/flag/mod.rs b/src/imap/flag/mod.rs new file mode 100644 index 00000000..9fe79612 --- /dev/null +++ b/src/imap/flag/mod.rs @@ -0,0 +1 @@ +pub mod command; diff --git a/src/imap/mod.rs b/src/imap/mod.rs index b2c40526..67d5e3bd 100644 --- a/src/imap/mod.rs +++ b/src/imap/mod.rs @@ -1,3 +1,4 @@ pub mod command; +pub mod flag; pub mod mailbox; pub mod stream;