From 1ddc3e48d55e3b6f614a7c88d3847de7d90fb6f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Sun, 31 May 2026 20:38:47 +0200 Subject: [PATCH] feat: add missing m2dir commands --- CHANGELOG.md | 2 + Cargo.toml | 2 +- src/m2dir/arg.rs | 25 ++++++ src/m2dir/cli.rs | 23 +++-- src/m2dir/envelope/cli.rs | 44 +++++++++ src/m2dir/envelope/get.rs | 86 ++++++++++++++++++ src/m2dir/envelope/list.rs | 146 ++++++++++++++++++++++++++++++ src/m2dir/envelope/mod.rs | 20 +++++ src/m2dir/flag/add.rs | 57 ++++++++++++ src/m2dir/flag/cli.rs | 51 +++++++++++ src/m2dir/flag/list.rs | 85 ++++++++++++++++++ src/m2dir/flag/mod.rs | 22 +++++ src/m2dir/flag/remove.rs | 55 ++++++++++++ src/m2dir/flag/set.rs | 55 ++++++++++++ src/m2dir/message/cli.rs | 51 +++++++++++ src/m2dir/message/export.rs | 174 ++++++++++++++++++++++++++++++++++++ src/m2dir/message/get.rs | 71 +++++++++++++++ src/m2dir/message/mod.rs | 22 +++++ src/m2dir/message/read.rs | 114 +++++++++++++++++++++++ src/m2dir/message/save.rs | 85 ++++++++++++++++++ src/m2dir/mod.rs | 3 + 21 files changed, 1187 insertions(+), 6 deletions(-) create mode 100644 src/m2dir/envelope/cli.rs create mode 100644 src/m2dir/envelope/get.rs create mode 100644 src/m2dir/envelope/list.rs create mode 100644 src/m2dir/envelope/mod.rs create mode 100644 src/m2dir/flag/add.rs create mode 100644 src/m2dir/flag/cli.rs create mode 100644 src/m2dir/flag/list.rs create mode 100644 src/m2dir/flag/mod.rs create mode 100644 src/m2dir/flag/remove.rs create mode 100644 src/m2dir/flag/set.rs create mode 100644 src/m2dir/message/cli.rs create mode 100644 src/m2dir/message/export.rs create mode 100644 src/m2dir/message/get.rs create mode 100644 src/m2dir/message/mod.rs create mode 100644 src/m2dir/message/read.rs create mode 100644 src/m2dir/message/save.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7a6000..068141e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Restored the RFC 2971 `ID`-after-auth quirk under the new shape `imap.id.{auto, fields}`. Set `imap.id.auto = true` to chain an `ID` exchange straight after IMAP authentication (required by mail.qq.com, fastmail). `imap.id.fields` is a `{ name = bool, … }` map: missing keys are not transmitted, `false` sends `NIL`, `true` sends himalaya's canned value for the well-known keys (`name`, `version`, `vendor`, `support-url`) or `NIL` (with a warning) for any other key. Replaces the v1.2.0 `imap.extensions.id.send-after-auth` flag dropped during the v2 migration. +- Brought the `m2dir` backend to feature parity with `maildir` at the CLI level: `m2dir messages {save, get, read, export}`, `m2dir flags {list, add, set, remove}`, `m2dir envelopes {get, list}`. Flags are free-form UTF-8 strings persisted in the `.meta/.flags` metadata file. Still missing relative to `maildir`: mailbox `rename`, message `copy` and `move` (need io-m2dir lib support first). + ### Changed - Unified raw-message input across `messages add`, `messages send`, `imap message save`, `maildir message save`, `jmap email import` and `smtp message send` behind a single `MessageArg` (ported from `mml::cli::args::MessageArg`). Every command now accepts the same three forms: a positional file path, a positional inline raw message (with `\r` / `\n` literals normalized to `\r\n`), or stdin when piped. The legacy `--file ` flag on `messages add` is gone (positional path replaces it). diff --git a/Cargo.toml b/Cargo.toml index f7ed0f6b..b1e42d10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ imap = ["dep:io-imap", "dep:mail-parser", "dep:rfc2047-decoder", "io-email/imap" jmap = ["dep:base64", "dep:io-jmap", "dep:mail-parser", "dep:serde_json", "io-email/jmap", "io-jmap/client"] smtp = ["dep:io-smtp", "dep:mail-parser", "io-email/smtp"] maildir = ["dep:convert_case", "dep:io-maildir", "dep:mail-parser", "dep:mime_guess", "io-email/maildir", "io-maildir/client"] -m2dir = ["dep:io-m2dir", "dep:mail-parser", "io-email/m2dir", "io-m2dir/client"] +m2dir = ["dep:convert_case", "dep:io-m2dir", "dep:mail-parser", "dep:mime_guess", "io-email/m2dir", "io-m2dir/client"] native-tls = ["pimalaya-stream/native-tls", "pimconf/native-tls", "io-email/native-tls", "io-imap?/native-tls", "io-jmap?/native-tls", "io-smtp?/native-tls"] rustls-aws = ["pimalaya-stream/rustls-aws", "pimconf/rustls-aws", "io-email/rustls-aws", "io-imap?/rustls-aws", "io-jmap?/rustls-aws", "io-smtp?/rustls-aws"] rustls-ring = ["pimalaya-stream/rustls-ring", "pimconf/rustls-ring", "io-email/rustls-ring", "io-imap?/rustls-ring", "io-jmap?/rustls-ring", "io-smtp?/rustls-ring"] diff --git a/src/m2dir/arg.rs b/src/m2dir/arg.rs index 0401ea24..26178bdc 100644 --- a/src/m2dir/arg.rs +++ b/src/m2dir/arg.rs @@ -17,9 +17,34 @@ use clap::Parser; +const INBOX: &str = "Inbox"; + #[derive(Debug, Parser)] pub struct M2dirNameArg { /// Name of the m2dir folder, relative to the m2store root. #[arg(name = "m2dir_name", value_name = "NAME")] pub inner: String, } + +#[derive(Debug, Parser)] +pub struct M2dirNameFlag { + /// Name of the m2dir folder, relative to the m2store root. + #[arg(name = "m2dir_source_name", long = "m2dir", short = 'm')] + #[arg(value_name = "NAME", default_value = INBOX)] + pub inner: String, +} + +#[derive(Debug, Parser)] +pub struct MessageIdArg { + /// Identifier of the message. + #[arg(name = "message_id", value_name = "ID")] + pub inner: String, +} + +#[derive(Debug, Parser)] +pub struct MessageIdsArg { + /// Identifier(s) of message(s). + #[arg(name = "message_ids", value_name = "ID")] + #[arg(num_args = 1..)] + pub inner: Vec, +} diff --git a/src/m2dir/cli.rs b/src/m2dir/cli.rs index 9c8ca115..5f8a391a 100644 --- a/src/m2dir/cli.rs +++ b/src/m2dir/cli.rs @@ -21,21 +21,30 @@ use pimalaya_cli::printer::Printer; use crate::m2dir::{ client::M2dirClient, create::M2dirMailboxCreateCommand, delete::M2dirMailboxDeleteCommand, - list::M2dirMailboxListCommand, + envelope::cli::M2dirEnvelopeCommand, flag::cli::M2dirFlagCommand, + list::M2dirMailboxListCommand, message::cli::M2dirMessageCommand, }; /// m2dir CLI. /// -/// Protocol-specific entry point for the m2dir backend. Wider -/// operations (envelopes, flags, messages) go through the shared -/// commands `himalaya envelopes`, `himalaya flags`, `himalaya -/// messages` with `--backend m2dir`. +/// Protocol-specific entry point for the m2dir backend. Mailbox and +/// per-folder operations (messages, flags, envelopes) live here; +/// cross-backend shared commands also dispatch here when +/// `--backend m2dir` is passed. #[derive(Debug, Subcommand)] #[command(rename_all = "kebab-case")] pub enum M2dirCommand { Create(M2dirMailboxCreateCommand), Delete(M2dirMailboxDeleteCommand), List(M2dirMailboxListCommand), + + #[command(subcommand)] + #[command(aliases = ["msgs", "msg"])] + Messages(M2dirMessageCommand), + #[command(subcommand)] + Flags(M2dirFlagCommand), + #[command(subcommand)] + Envelopes(M2dirEnvelopeCommand), } impl M2dirCommand { @@ -44,6 +53,10 @@ impl M2dirCommand { Self::Create(cmd) => cmd.execute(printer, client), Self::Delete(cmd) => cmd.execute(printer, client), Self::List(cmd) => cmd.execute(printer, client), + + Self::Messages(cmd) => cmd.execute(printer, client), + Self::Flags(cmd) => cmd.execute(printer, client), + Self::Envelopes(cmd) => cmd.execute(printer, client), } } } diff --git a/src/m2dir/envelope/cli.rs b/src/m2dir/envelope/cli.rs new file mode 100644 index 00000000..abe0b2aa --- /dev/null +++ b/src/m2dir/envelope/cli.rs @@ -0,0 +1,44 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::m2dir::{ + client::M2dirClient, + envelope::{get::M2dirEnvelopeGetCommand, list::M2dirEnvelopeListCommand}, +}; + +/// Manage M2DIR envelopes. +/// +/// An envelope contains header information about a message such as +/// date, subject, from, to, cc, bcc, etc. +#[derive(Debug, Subcommand)] +pub enum M2dirEnvelopeCommand { + Get(M2dirEnvelopeGetCommand), + List(M2dirEnvelopeListCommand), +} + +impl M2dirEnvelopeCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + match self { + Self::Get(cmd) => cmd.execute(printer, client), + Self::List(cmd) => cmd.execute(printer, client), + } + } +} diff --git a/src/m2dir/envelope/get.rs b/src/m2dir/envelope/get.rs new file mode 100644 index 00000000..2766f778 --- /dev/null +++ b/src/m2dir/envelope/get.rs @@ -0,0 +1,86 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::fmt; + +use anyhow::{Result, bail}; +use clap::Parser; +use comfy_table::{Cell, Row, Table}; +use mail_parser::{Header, MessageParser}; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::m2dir::{ + arg::{M2dirNameFlag, MessageIdArg}, + client::M2dirClient, +}; + +/// Get a single M2DIR envelope. +/// +/// Resolves the message identified by `ID` inside the given m2dir +/// folder, parses its headers, and prints them. +#[derive(Debug, Parser)] +pub struct M2dirEnvelopeGetCommand { + #[command(flatten)] + pub m2dir: M2dirNameFlag, + #[command(flatten)] + pub id: MessageIdArg, +} + +impl M2dirEnvelopeGetCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + let (entry, bytes) = client.get(m2dir, &self.id.inner)?; + + let Some(parsed) = MessageParser::new().parse_headers(&bytes) else { + let path = entry.path(); + bail!("Invalid MIME message at {path}"); + }; + + let table = EnvelopeTable { + preset: client.account.table_preset().to_string(), + headers: parsed.headers(), + }; + + printer.out(table) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct EnvelopeTable<'a> { + #[serde(skip)] + pub preset: String, + pub headers: &'a [Header<'a>], +} + +impl fmt::Display for EnvelopeTable<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + table + .load_preset(&self.preset) + .set_header(Row::from([Cell::new("HEADER"), Cell::new("VALUE")])); + + for header in self.headers { + writeln!(f, "{}: {:?}", header.name.as_str(), header.value)?; + } + + Ok(()) + } +} diff --git a/src/m2dir/envelope/list.rs b/src/m2dir/envelope/list.rs new file mode 100644 index 00000000..f2860fe2 --- /dev/null +++ b/src/m2dir/envelope/list.rs @@ -0,0 +1,146 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::fmt; + +use anyhow::Result; +use clap::Parser; +use comfy_table::{Cell, Color, ContentArrangement, Row, Table}; +use mail_parser::MessageParser; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::m2dir::{arg::M2dirNameFlag, client::M2dirClient}; + +/// List M2DIR envelopes from the given mailbox. +/// +/// Streams every entry under the m2dir, parses each header block +/// and renders the result sorted by date. +#[derive(Debug, Parser)] +pub struct M2dirEnvelopeListCommand { + #[command(flatten)] + pub m2dir: M2dirNameFlag, +} + +impl M2dirEnvelopeListCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + + let entries = client.list_entries(m2dir.clone())?; + let messages = client.read_entries_par(&m2dir, &entries)?; + + let parser = MessageParser::new(); + let mut envelopes = Vec::with_capacity(messages.len()); + + for full in messages { + let Some(parsed) = parser.parse_headers(full.contents()) else { + continue; + }; + + envelopes.push(EnvelopesTableEntry { + id: full.entry().id().to_owned(), + subject: parsed.subject().unwrap_or("").to_owned(), + from: parsed + .from() + .and_then(|a| a.first()) + .and_then(|addr| addr.name().or(addr.address())) + .map(str::to_owned) + .unwrap_or_default(), + date: parsed + .date() + .map(|date| date.to_rfc822()) + .unwrap_or_default(), + }); + } + + envelopes.sort_by(|a, b| a.date.cmp(&b.date)); + + let table = EnvelopesTable { + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), + colors: EnvelopeColors { + id: client.account.envelopes_list_table_id_color(), + subject: client.account.envelopes_list_table_subject_color(), + from: client.account.envelopes_list_table_from_color(), + date: client.account.envelopes_list_table_date_color(), + }, + envelopes, + }; + + printer.out(table) + } +} + +#[derive(Clone, Copy, Debug)] +struct EnvelopeColors { + id: Color, + subject: Color, + from: Color, + date: Color, +} + +#[derive(Clone, Debug, Serialize)] +pub struct EnvelopesTable { + #[serde(skip)] + preset: String, + #[serde(skip)] + arrangement: ContentArrangement, + #[serde(skip)] + colors: EnvelopeColors, + envelopes: Vec, +} + +impl fmt::Display for EnvelopesTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + table + .load_preset(&self.preset) + .set_content_arrangement(self.arrangement.clone()) + .set_header(Row::from([ + Cell::new("ID"), + Cell::new("SUBJECT"), + Cell::new("FROM"), + Cell::new("DATE"), + ])); + + for entry in &self.envelopes { + let mut row = Row::new(); + + row.max_height(1) + .add_cell(Cell::new(&entry.id).fg(self.colors.id)) + .add_cell(Cell::new(&entry.subject).fg(self.colors.subject)) + .add_cell(Cell::new(&entry.from).fg(self.colors.from)) + .add_cell(Cell::new(&entry.date).fg(self.colors.date)); + + table.add_row(row); + } + + writeln!(f)?; + writeln!(f, "{table}") + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct EnvelopesTableEntry { + pub id: String, + pub subject: String, + pub from: String, + pub date: String, +} diff --git a/src/m2dir/envelope/mod.rs b/src/m2dir/envelope/mod.rs new file mode 100644 index 00000000..5994d66c --- /dev/null +++ b/src/m2dir/envelope/mod.rs @@ -0,0 +1,20 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pub mod cli; +pub mod get; +pub mod list; diff --git a/src/m2dir/flag/add.rs b/src/m2dir/flag/add.rs new file mode 100644 index 00000000..be5fa21a --- /dev/null +++ b/src/m2dir/flag/add.rs @@ -0,0 +1,57 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use anyhow::Result; +use clap::Parser; +use io_m2dir::flag::M2dirFlags; +use pimalaya_cli::printer::{Message, Printer}; + +use crate::m2dir::{ + arg::{M2dirNameFlag, MessageIdsArg}, + client::M2dirClient, +}; + +/// Add M2DIR flag(s) to message(s). +/// +/// Each flag is an arbitrary UTF-8 string (e.g. `$seen`, `custom`). +#[derive(Debug, Parser)] +pub struct M2dirFlagAddCommand { + #[command(flatten)] + pub ids: MessageIdsArg, + + #[command(flatten)] + pub m2dir: M2dirNameFlag, + + /// Flag(s) to add to the message. + #[arg(long = "flag", short = 'f', num_args = 1..)] + pub flags: Vec, +} + +impl M2dirFlagAddCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + let flags = M2dirFlags::from_iter(self.flags.iter().map(String::as_str)); + + for id in self.ids.inner { + client.add_flags(&m2dir, &id, flags.clone())?; + } + + printer.out(Message::new("M2dir flag(s) successfully added")) + } +} diff --git a/src/m2dir/flag/cli.rs b/src/m2dir/flag/cli.rs new file mode 100644 index 00000000..826a6825 --- /dev/null +++ b/src/m2dir/flag/cli.rs @@ -0,0 +1,51 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::m2dir::{ + client::M2dirClient, + flag::{ + add::M2dirFlagAddCommand, list::M2dirFlagListCommand, remove::M2dirFlagRemoveCommand, + set::M2dirFlagSetCommand, + }, +}; + +/// Manage M2DIR flags. +/// +/// A flag is a free-form UTF-8 string stored in the +/// `.meta/.flags` metadata file alongside the message. +#[derive(Debug, Subcommand)] +pub enum M2dirFlagCommand { + List(M2dirFlagListCommand), + Add(M2dirFlagAddCommand), + Set(M2dirFlagSetCommand), + Remove(M2dirFlagRemoveCommand), +} + +impl M2dirFlagCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, client), + Self::Add(cmd) => cmd.execute(printer, client), + Self::Set(cmd) => cmd.execute(printer, client), + Self::Remove(cmd) => cmd.execute(printer, client), + } + } +} diff --git a/src/m2dir/flag/list.rs b/src/m2dir/flag/list.rs new file mode 100644 index 00000000..d4b3d52c --- /dev/null +++ b/src/m2dir/flag/list.rs @@ -0,0 +1,85 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::fmt; + +use anyhow::Result; +use clap::Parser; +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::m2dir::{ + arg::{M2dirNameFlag, MessageIdArg}, + client::M2dirClient, +}; + +/// List flags set on an m2dir message. +/// +/// Reads the `.meta/.flags` metadata file and prints one flag per +/// row. Returns an empty table when the file is missing. +#[derive(Debug, Parser)] +pub struct M2dirFlagListCommand { + #[command(flatten)] + pub m2dir: M2dirNameFlag, + #[command(flatten)] + pub id: MessageIdArg, +} + +impl M2dirFlagListCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + let flags = client.read_flags(&m2dir, &self.id.inner)?; + + let table = FlagsTable { + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), + flags: flags.iter().map(str::to_owned).collect(), + }; + + printer.out(table) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct FlagsTable { + #[serde(skip_serializing)] + preset: String, + #[serde(skip_serializing)] + arrangement: ContentArrangement, + flags: Vec, +} + +impl fmt::Display for FlagsTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + table + .load_preset(&self.preset) + .set_content_arrangement(self.arrangement.clone()) + .set_header(Row::from([Cell::new("FLAG")])); + + for flag in &self.flags { + table.add_row(Row::from([Cell::new(flag)])); + } + + writeln!(f)?; + writeln!(f, "{table}") + } +} diff --git a/src/m2dir/flag/mod.rs b/src/m2dir/flag/mod.rs new file mode 100644 index 00000000..e3492b36 --- /dev/null +++ b/src/m2dir/flag/mod.rs @@ -0,0 +1,22 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pub mod add; +pub mod cli; +pub mod list; +pub mod remove; +pub mod set; diff --git a/src/m2dir/flag/remove.rs b/src/m2dir/flag/remove.rs new file mode 100644 index 00000000..81828655 --- /dev/null +++ b/src/m2dir/flag/remove.rs @@ -0,0 +1,55 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use anyhow::Result; +use clap::Parser; +use io_m2dir::flag::M2dirFlags; +use pimalaya_cli::printer::{Message, Printer}; + +use crate::m2dir::{ + arg::{M2dirNameFlag, MessageIdsArg}, + client::M2dirClient, +}; + +/// Remove M2DIR flag(s) from message(s). +#[derive(Debug, Parser)] +pub struct M2dirFlagRemoveCommand { + #[command(flatten)] + pub ids: MessageIdsArg, + + #[command(flatten)] + pub m2dir: M2dirNameFlag, + + /// Flag(s) to remove from the message. + #[arg(long = "flag", short = 'f', num_args = 1..)] + pub flags: Vec, +} + +impl M2dirFlagRemoveCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + let flags = M2dirFlags::from_iter(self.flags.iter().map(String::as_str)); + + for id in self.ids.inner { + client.remove_flags(&m2dir, &id, flags.clone())?; + } + + printer.out(Message::new("M2dir flag(s) successfully removed")) + } +} diff --git a/src/m2dir/flag/set.rs b/src/m2dir/flag/set.rs new file mode 100644 index 00000000..8f31570c --- /dev/null +++ b/src/m2dir/flag/set.rs @@ -0,0 +1,55 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use anyhow::Result; +use clap::Parser; +use io_m2dir::flag::M2dirFlags; +use pimalaya_cli::printer::{Message, Printer}; + +use crate::m2dir::{ + arg::{M2dirNameFlag, MessageIdsArg}, + client::M2dirClient, +}; + +/// Set M2DIR flag(s) on message(s) (replaces any existing flags). +#[derive(Debug, Parser)] +pub struct M2dirFlagSetCommand { + #[command(flatten)] + pub ids: MessageIdsArg, + + #[command(flatten)] + pub m2dir: M2dirNameFlag, + + /// Flag(s) to set on the message. + #[arg(long = "flag", short = 'f', num_args = 1..)] + pub flags: Vec, +} + +impl M2dirFlagSetCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + let flags = M2dirFlags::from_iter(self.flags.iter().map(String::as_str)); + + for id in self.ids.inner { + client.set_flags(&m2dir, &id, flags.clone())?; + } + + printer.out(Message::new("M2dir flag(s) successfully changed")) + } +} diff --git a/src/m2dir/message/cli.rs b/src/m2dir/message/cli.rs new file mode 100644 index 00000000..9c1c7109 --- /dev/null +++ b/src/m2dir/message/cli.rs @@ -0,0 +1,51 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::m2dir::{ + client::M2dirClient, + message::{ + export::M2dirMessageExportCommand, get::M2dirMessageGetCommand, + read::M2dirMessageReadCommand, save::M2dirMessageSaveCommand, + }, +}; + +/// Manage M2DIR messages. +/// +/// A message is a complete email including headers and body. This +/// subcommand allows you to save, get, read and export messages. +#[derive(Debug, Subcommand)] +pub enum M2dirMessageCommand { + Save(M2dirMessageSaveCommand), + Get(M2dirMessageGetCommand), + Read(M2dirMessageReadCommand), + Export(M2dirMessageExportCommand), +} + +impl M2dirMessageCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + match self { + Self::Save(cmd) => cmd.execute(printer, client), + Self::Get(cmd) => cmd.execute(printer, client), + Self::Read(cmd) => cmd.execute(printer, client), + Self::Export(cmd) => cmd.execute(printer, client), + } + } +} diff --git a/src/m2dir/message/export.rs b/src/m2dir/message/export.rs new file mode 100644 index 00000000..2a5577cc --- /dev/null +++ b/src/m2dir/message/export.rs @@ -0,0 +1,174 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{fmt, fs, path::PathBuf}; + +use anyhow::{Result, bail}; +use clap::{Parser, ValueEnum}; +use convert_case::ccase; +use mail_parser::{MessageParser, MimeHeaders}; +use mime_guess::{get_mime_extensions_str, mime::OCTET_STREAM}; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::m2dir::{ + arg::{M2dirNameFlag, MessageIdArg}, + client::M2dirClient, +}; + +/// Export an m2dir message. +/// +/// Two output modes are supported: +/// - raw: write the raw RFC 5322 bytes to stdout +/// - parts: explode every MIME part into a separate file under +/// `--directory ` (defaults to the message id) +#[derive(Debug, Parser)] +pub struct M2dirMessageExportCommand { + #[command(flatten)] + pub m2dir: M2dirNameFlag, + #[command(flatten)] + pub id: MessageIdArg, + + /// Type of the export. + #[arg(long, short, value_enum, default_value_t)] + pub r#type: ExportType, + + /// Output directory (for parts type). + #[arg(long, short, value_name = "DIR")] + pub directory: Option, + + /// Open exported HTML parts in the default application. + #[arg(long, short)] + pub open: bool, +} + +impl M2dirMessageExportCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + let (entry, bytes) = client.get(m2dir, &self.id.inner)?; + + match self.r#type { + ExportType::Raw => { + let contents = String::from_utf8(bytes)?; + printer.out(ExportRaw { contents })?; + } + ExportType::Parts => { + let Some(parsed) = MessageParser::new().parse(&bytes) else { + let path = entry.path(); + bail!("Invalid MIME message at {path}"); + }; + + let dir = match self.directory { + Some(dir) => dir, + None => PathBuf::from(self.id.inner), + }; + + fs::create_dir_all(&dir)?; + + let mut parts = Vec::new(); + + for (i, part) in parsed.parts.iter().enumerate() { + let cr = part.content_type().map(|ct| match &ct.c_subtype { + Some(sub) => format!("{}/{}", ct.c_type, sub), + None => ct.c_type.to_string(), + }); + + if let Some(ref ct) = cr { + if ct.starts_with("multipart/") { + continue; + } + } + + let filename = match part.attachment_name() { + Some(name) => ccase!(kebab, name), + None => { + let ext = match cr.as_deref().unwrap_or(OCTET_STREAM.as_str()) { + "text/plain" => Some(&"txt"), + "text/html" => Some(&"html"), + ct => get_mime_extensions_str(ct).and_then(|ext| ext.first()), + }; + + match ext { + Some(ext) => format!("part_{i}.{ext}"), + None => format!("part_{i}"), + } + } + }; + + let path = dir.join(&filename); + let contents = part.contents(); + fs::write(&path, contents)?; + parts.push(path); + } + + if self.open { + for path in &parts { + if let Some(ext) = path.extension() { + if ext == "html" { + open::that(path)?; + } + } + } + } + + printer.out(ExportParts { parts })?; + } + }; + + Ok(()) + } +} + +/// Export type for message export. +#[derive(Clone, Debug, Default, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum ExportType { + #[default] + /// Output raw RFC822 message to stdout. + Raw, + /// Export all MIME parts to separate files. + Parts, +} + +#[derive(Serialize)] +struct ExportRaw { + contents: String, +} + +impl fmt::Display for ExportRaw { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.contents) + } +} + +#[derive(Serialize)] +struct ExportParts { + parts: Vec, +} + +impl fmt::Display for ExportParts { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for path in &self.parts { + writeln!(f, " - {}", path.display())?; + } + + writeln!(f)?; + write!(f, "Exported {} part(s)", self.parts.len()) + } +} diff --git a/src/m2dir/message/get.rs b/src/m2dir/message/get.rs new file mode 100644 index 00000000..2e11f4ec --- /dev/null +++ b/src/m2dir/message/get.rs @@ -0,0 +1,71 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::fmt; + +use anyhow::{Result, bail}; +use clap::Parser; +use mail_parser::{Message, MessageParser}; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::m2dir::{ + arg::{M2dirNameFlag, MessageIdArg}, + client::M2dirClient, +}; + +/// Get headers of an m2dir message. +/// +/// Resolves the message identified by `ID` inside the given m2dir +/// folder, parses its headers and prints them. +#[derive(Debug, Parser)] +pub struct M2dirMessageGetCommand { + #[command(flatten)] + pub m2dir: M2dirNameFlag, + #[command(flatten)] + pub id: MessageIdArg, +} + +impl M2dirMessageGetCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + let (entry, bytes) = client.get(m2dir, &self.id.inner)?; + + let Some(parsed) = MessageParser::new().parse_headers(&bytes) else { + let path = entry.path(); + bail!("Invalid MIME message at {path}"); + }; + + printer.out(MessageView(parsed)) + } +} + +#[derive(Serialize)] +#[serde(transparent)] +pub struct MessageView<'a>(Message<'a>); + +impl fmt::Display for MessageView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for header in self.0.headers() { + writeln!(f, "{}: {:?}", header.name(), header.value())?; + } + + Ok(()) + } +} diff --git a/src/m2dir/message/mod.rs b/src/m2dir/message/mod.rs new file mode 100644 index 00000000..b5215815 --- /dev/null +++ b/src/m2dir/message/mod.rs @@ -0,0 +1,22 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pub mod cli; +pub mod export; +pub mod get; +pub mod read; +pub mod save; diff --git a/src/m2dir/message/read.rs b/src/m2dir/message/read.rs new file mode 100644 index 00000000..508a041e --- /dev/null +++ b/src/m2dir/message/read.rs @@ -0,0 +1,114 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::fmt; + +use anyhow::{Result, bail}; +use clap::Parser; +use mail_parser::{Message, MessageParser}; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::m2dir::{ + arg::{M2dirNameFlag, MessageIdArg}, + client::M2dirClient, +}; + +/// Read m2dir message content. +/// +/// Fetches a message and displays its text content. By default it +/// shows plain text content; use `--html` to show HTML. +#[derive(Debug, Parser)] +pub struct M2dirMessageReadCommand { + #[command(flatten)] + pub m2dir: M2dirNameFlag, + #[command(flatten)] + pub id: MessageIdArg, + + /// Show HTML content instead of plain text. + #[arg(long)] + pub html: bool, + /// Terminal width for text wrapping. + #[arg(long, short, default_value = "80")] + pub width: usize, +} + +impl M2dirMessageReadCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + let (entry, bytes) = client.get(m2dir, &self.id.inner)?; + + let Some(parsed) = MessageParser::new().parse(&bytes) else { + let path = entry.path(); + bail!("Invalid MIME message at {path}"); + }; + + if self.html { + printer.out(MessageHtmlView { message: parsed }) + } else { + printer.out(MessagePlainView { message: parsed }) + } + } +} + +#[derive(Serialize)] +#[serde(transparent)] +pub struct MessagePlainView<'a> { + message: Message<'a>, +} + +impl fmt::Display for MessagePlainView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, part) in self.message.text_bodies().enumerate() { + if i > 0 { + writeln!(f)?; + writeln!(f)?; + } + + if let Some(contents) = part.text_contents() { + write!(f, "{}", contents.trim_end())?; + } + } + + Ok(()) + } +} + +#[derive(Serialize)] +#[serde(transparent)] +pub struct MessageHtmlView<'a> { + message: Message<'a>, +} + +impl fmt::Display for MessageHtmlView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, part) in self.message.html_bodies().enumerate() { + if i > 0 { + writeln!(f)?; + writeln!(f)?; + } + + if let Some(contents) = part.text_contents() { + write!(f, "{}", contents.trim_end())?; + } + } + + Ok(()) + } +} diff --git a/src/m2dir/message/save.rs b/src/m2dir/message/save.rs new file mode 100644 index 00000000..4fc7b530 --- /dev/null +++ b/src/m2dir/message/save.rs @@ -0,0 +1,85 @@ +// This file is part of Himalaya, a CLI to manage emails. +// +// Copyright (C) 2022-2026 soywod +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) any +// later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +// details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::fmt; + +use anyhow::Result; +use clap::Parser; +use io_m2dir::flag::M2dirFlags; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::{ + m2dir::{arg::M2dirNameFlag, client::M2dirClient}, + shared::messages::arg::MessageArg, +}; + +/// Save a message to an m2dir folder. +/// +/// Appends a message to the specified m2dir. The message can be +/// passed as a positional file path, an inline raw string, or piped +/// via stdin (see [`MessageArg`] for resolution order). When flags +/// are passed, they are written to the `.meta/.flags` file +/// alongside the message. +#[derive(Debug, Parser)] +pub struct M2dirMessageSaveCommand { + #[command(flatten)] + pub m2dir: M2dirNameFlag, + + /// Flag(s) to write to the new message's `.flags` metadata file. + /// Each flag is an arbitrary UTF-8 string (e.g. `$seen`, `custom`). + #[arg(long = "flag", short = 'f', num_args = 0..)] + pub flags: Vec, + + #[command(flatten)] + pub message: MessageArg, +} + +impl M2dirMessageSaveCommand { + pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> { + let store = client.open_store()?; + let path = store.resolve_folder_path(&self.m2dir.inner)?; + let m2dir = client.open_m2dir(path)?; + + let msg = self.message.parse()?; + let entry = client.store(m2dir.clone(), msg.into_bytes())?; + + if !self.flags.is_empty() { + let flags = M2dirFlags::from_iter(self.flags.iter().map(String::as_str)); + client.set_flags(&m2dir, entry.id(), flags)?; + } + + printer.out(StoredMessage { + id: entry.id().to_owned(), + path: entry.path().as_str().to_owned(), + }) + } +} + +#[derive(Serialize)] +pub struct StoredMessage { + id: String, + path: String, +} + +impl fmt::Display for StoredMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let id = &self.id; + let path = &self.path; + write!(f, "Message `{id}` successfully saved to {path}") + } +} diff --git a/src/m2dir/mod.rs b/src/m2dir/mod.rs index af3313be..f1e0e2df 100644 --- a/src/m2dir/mod.rs +++ b/src/m2dir/mod.rs @@ -20,4 +20,7 @@ pub mod cli; pub mod client; pub mod create; pub mod delete; +pub mod envelope; +pub mod flag; pub mod list; +pub mod message;