feat: add missing m2dir commands

This commit is contained in:
Clément DOUIN
2026-05-31 20:38:47 +02:00
parent a28f95f9db
commit 1ddc3e48d5
21 changed files with 1187 additions and 6 deletions
+2
View File
@@ -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/<id>.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 <PATH>` flag on `messages add` is gone (positional path replaces it).
+1 -1
View File
@@ -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"]
+25
View File
@@ -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<String>,
}
+18 -5
View File
@@ -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),
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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),
}
}
}
+86
View File
@@ -0,0 +1,86 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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(())
}
}
+146
View File
@@ -0,0 +1,146 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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<EnvelopesTableEntry>,
}
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,
}
+20
View File
@@ -0,0 +1,20 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
pub mod cli;
pub mod get;
pub mod list;
+57
View File
@@ -0,0 +1,57 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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<String>,
}
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"))
}
}
+51
View File
@@ -0,0 +1,51 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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/<id>.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),
}
}
}
+85
View File
@@ -0,0 +1,85 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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/<id>.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<String>,
}
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}")
}
}
+22
View File
@@ -0,0 +1,22 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
pub mod add;
pub mod cli;
pub mod list;
pub mod remove;
pub mod set;
+55
View File
@@ -0,0 +1,55 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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<String>,
}
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"))
}
}
+55
View File
@@ -0,0 +1,55 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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<String>,
}
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"))
}
}
+51
View File
@@ -0,0 +1,51 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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),
}
}
}
+174
View File
@@ -0,0 +1,174 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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 <DIR>` (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<PathBuf>,
/// 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<PathBuf>,
}
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())
}
}
+71
View File
@@ -0,0 +1,71 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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(())
}
}
+22
View File
@@ -0,0 +1,22 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
pub mod cli;
pub mod export;
pub mod get;
pub mod read;
pub mod save;
+114
View File
@@ -0,0 +1,114 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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(())
}
}
+85
View File
@@ -0,0 +1,85 @@
// This file is part of Himalaya, a CLI to manage emails.
//
// Copyright (C) 2022-2026 soywod <pimalaya.org@posteo.net>
//
// 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 <https://www.gnu.org/licenses/>.
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/<id>.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<String>,
#[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}")
}
}
+3
View File
@@ -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;