refactor: improve maildir, bring m2dir support

This commit is contained in:
Clément DOUIN
2026-05-24 01:37:14 +02:00
parent c490bd5a27
commit 68ff784c24
36 changed files with 510 additions and 117 deletions
Generated
+30 -20
View File
@@ -127,9 +127,9 @@ dependencies = [
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
@@ -207,9 +207,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.20.2"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
@@ -814,6 +814,7 @@ dependencies = [
"io-email",
"io-imap",
"io-jmap",
"io-m2dir",
"io-maildir",
"io-smtp",
"log",
@@ -1061,12 +1062,13 @@ dependencies = [
[[package]]
name = "io-email"
version = "0.0.1"
source = "git+https://github.com/pimalaya/io-email#4dc69352fc31cae441611ce638f42fbab207e98b"
source = "git+https://github.com/pimalaya/io-email#6c898bbc8faced492e974fff27dd4da0f46f4827"
dependencies = [
"chrono",
"chumsky",
"io-imap",
"io-jmap",
"io-m2dir",
"io-maildir",
"io-smtp",
"log",
@@ -1083,7 +1085,7 @@ dependencies = [
[[package]]
name = "io-http"
version = "0.0.3"
source = "git+https://github.com/pimalaya/io-http#812d8a30891631e49c70722555eefecaa0033458"
source = "git+https://github.com/pimalaya/io-http#1e71383b97e823f9440ea2d7c2af378bcd3b5933"
dependencies = [
"anyhow",
"base64",
@@ -1099,7 +1101,7 @@ dependencies = [
[[package]]
name = "io-imap"
version = "0.0.1"
source = "git+https://github.com/pimalaya/io-imap#f627d6ac1e5b04d293aaff3863eac4c6bfe1a16d"
source = "git+https://github.com/pimalaya/io-imap#0a6a3ef4761b12b29cd5b9178b0cb7889d84ccbc"
dependencies = [
"anyhow",
"base64",
@@ -1114,7 +1116,7 @@ dependencies = [
[[package]]
name = "io-jmap"
version = "0.0.1"
source = "git+https://github.com/pimalaya/io-jmap#a53855723683e0c08269ebaabfd530efceeadb5d"
source = "git+https://github.com/pimalaya/io-jmap#740ac4f314d4de3b4c6b53eba61722787f01f24c"
dependencies = [
"anyhow",
"io-http",
@@ -1127,15 +1129,23 @@ dependencies = [
"url",
]
[[package]]
name = "io-m2dir"
version = "0.0.1"
source = "git+https://github.com/pimalaya/io-m2dir#69541e3b50bde6bdcc00135d5ec2bb388471ee5c"
dependencies = [
"log",
"thiserror",
]
[[package]]
name = "io-maildir"
version = "0.0.1"
source = "git+https://github.com/pimalaya/io-maildir#d6756d8bb19c8f9dcc61e10ec580200e6ed308a7"
source = "git+https://github.com/pimalaya/io-maildir#ac75cf8359ce828e5702f1c912e52bb9cfb66c6d"
dependencies = [
"gethostname",
"log",
"mail-parser",
"memchr",
"thiserror",
]
@@ -1272,9 +1282,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.98"
version = "0.3.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
dependencies = [
"cfg-if",
"futures-util",
@@ -2523,9 +2533,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.121"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"cfg-if",
"once_cell",
@@ -2536,9 +2546,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2546,9 +2556,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.121"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2559,9 +2569,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.121"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"unicode-ident",
]
+4 -1
View File
@@ -17,11 +17,12 @@ all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[features]
default = ["imap", "smtp", "jmap", "maildir", "rustls-ring"]
default = ["imap", "smtp", "jmap", "maildir", "m2dir", "rustls-ring"]
imap = ["dep:io-imap", "dep:mail-parser", "dep:rfc2047-decoder", "io-email/imap", "io-imap/client"]
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"]
native-tls = ["pimalaya-stream/native-tls", "io-discovery/native-tls", "io-imap?/native-tls", "io-jmap?/native-tls", "io-smtp?/native-tls"]
rustls-aws = ["pimalaya-stream/rustls-aws", "io-discovery/rustls-aws", "io-imap?/rustls-aws", "io-jmap?/rustls-aws", "io-smtp?/rustls-aws"]
rustls-ring = ["pimalaya-stream/rustls-ring", "io-discovery/rustls-ring", "io-imap?/rustls-ring", "io-jmap?/rustls-ring", "io-smtp?/rustls-ring"]
@@ -51,6 +52,7 @@ ariadne = "0.6"
io-email = { version = "0.0.1", default-features = false, features = ["serde", "client", "search"] }
io-imap = { version = "0.0.1", default-features = false, optional = true }
io-jmap = { version = "0.0.1", default-features = false, optional = true }
io-m2dir = { version = "0.0.1", default-features = false, optional = true }
io-maildir = { version = "0.0.1", default-features = false, optional = true }
io-smtp = { version = "0.0.1", default-features = false, optional = true }
log = "0.4"
@@ -87,6 +89,7 @@ io-email.git = "https://github.com/pimalaya/io-email"
io-http.git = "https://github.com/pimalaya/io-http"
io-imap.git = "https://github.com/pimalaya/io-imap"
io-jmap.git = "https://github.com/pimalaya/io-jmap"
io-m2dir.git = "https://github.com/pimalaya/io-m2dir"
io-maildir.git = "https://github.com/pimalaya/io-maildir"
io-smtp.git = "https://github.com/pimalaya/io-smtp"
pimalaya-cli.git = "https://github.com/pimalaya/cli"
+4 -1
View File
@@ -470,7 +470,10 @@ mod tests {
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
let config = Config {
mailbox: MailboxConfig { alias },
mailbox: MailboxConfig {
alias,
..MailboxConfig::default()
},
..Config::default()
};
Account::from(config)
+9 -1
View File
@@ -28,7 +28,7 @@ use clap::Parser;
/// (config missing, or the operation has no arm for that backend).
///
/// The protocol-specific subcommands (`imap`, `jmap`, `maildir`,
/// `smtp`) ignore this arg entirely.
/// `m2dir`, `smtp`) ignore this arg entirely.
#[derive(Clone, Copy, Debug, Default, Parser, PartialEq, Eq)]
pub enum Backend {
#[default]
@@ -36,6 +36,7 @@ pub enum Backend {
Imap,
Jmap,
Maildir,
M2dir,
Smtp,
}
@@ -56,6 +57,11 @@ impl Backend {
matches!(self, Self::Auto | Self::Maildir)
}
/// Whether the m2dir arm of a shared command is allowed to run.
pub fn allows_m2dir(self) -> bool {
matches!(self, Self::Auto | Self::M2dir)
}
/// Whether the SMTP arm of a shared command is allowed to run.
pub fn allows_smtp(self) -> bool {
matches!(self, Self::Auto | Self::Smtp)
@@ -71,6 +77,7 @@ impl FromStr for Backend {
"imap" => Ok(Self::Imap),
"jmap" => Ok(Self::Jmap),
"maildir" => Ok(Self::Maildir),
"m2dir" => Ok(Self::M2dir),
"smtp" => Ok(Self::Smtp),
backend => bail!("Invalid backend {backend}"),
}
@@ -84,6 +91,7 @@ impl fmt::Display for Backend {
Self::Imap => write!(f, "imap"),
Self::Jmap => write!(f, "jmap"),
Self::Maildir => write!(f, "maildir"),
Self::M2dir => write!(f, "m2dir"),
Self::Smtp => write!(f, "smtp"),
}
}
+10
View File
@@ -34,6 +34,8 @@ use pimalaya_config::toml::TomlConfig;
use crate::imap::{cli::ImapCommand, client::build_imap_client};
#[cfg(feature = "jmap")]
use crate::jmap::{cli::JmapCommand, client::build_jmap_client};
#[cfg(feature = "m2dir")]
use crate::m2dir::{cli::M2dirCommand, client::build_m2dir_client};
#[cfg(feature = "maildir")]
use crate::maildir::{cli::MaildirCommand, client::build_maildir_client};
#[cfg(feature = "smtp")]
@@ -119,6 +121,9 @@ pub enum HimalayaCommand {
#[cfg(feature = "maildir")]
#[command(subcommand)]
Maildir(MaildirCommand),
#[cfg(feature = "m2dir")]
#[command(subcommand)]
M2dir(M2dirCommand),
#[cfg(feature = "smtp")]
#[command(subcommand)]
Smtp(SmtpCommand),
@@ -206,6 +211,11 @@ impl HimalayaCommand {
let client = build_maildir_client(config_paths, account_name)?;
cmd.execute(printer, client)
}
#[cfg(feature = "m2dir")]
Self::M2dir(cmd) => {
let client = build_m2dir_client(config_paths, account_name)?;
cmd.execute(printer, client)
}
#[cfg(feature = "smtp")]
Self::Smtp(cmd) => {
let client = build_smtp_client(config_paths, account_name)?;
+11 -1
View File
@@ -107,7 +107,7 @@ impl Config {
/// `deny_unknown_fields` is omitted so per-account TUI-only fields
/// (`email`, `display-name`, `signature`, `signature-delim`) coexist
/// in the same `[accounts.<name>]` block when the file is shared.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct AccountConfig {
#[serde(default)]
@@ -133,6 +133,8 @@ pub struct AccountConfig {
#[allow(unused)]
pub maildir: Option<MaildirConfig>,
#[allow(unused)]
pub m2dir: Option<M2dirConfig>,
#[allow(unused)]
pub smtp: Option<SmtpConfig>,
}
@@ -426,6 +428,14 @@ pub struct MaildirConfig {
pub root: PathBuf,
}
/// m2dir configuration.
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct M2dirConfig {
pub root: PathBuf,
}
/// SMTP configuration.
#[allow(unused)]
#[derive(Clone, Debug, Deserialize, Serialize)]
+5 -2
View File
@@ -17,7 +17,10 @@
use anyhow::{Result, anyhow};
use clap::Parser;
use io_jmap::{client::JmapClientStd, rfc8621::capabilities::MAIL};
use io_jmap::{
client::JmapClientStd,
rfc8621::{capabilities::MAIL, email::EmailProperty},
};
use pimalaya_cli::printer::{Message, Printer};
use pimalaya_stream::tls::Tls;
use url::Url;
@@ -36,7 +39,7 @@ pub struct JmapEmailExportCommand {
impl JmapEmailExportCommand {
pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> {
let properties = Some(vec!["id".to_owned(), "blobId".to_owned()]);
let properties = Some(vec![EmailProperty::Id, EmailProperty::BlobId]);
let output = client.email_get(vec![self.id.clone()], properties, false, false, 0)?;
let session = client.session().expect("session loaded by new_jmap_client");
+25
View File
@@ -0,0 +1,25 @@
// 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 clap::Parser;
#[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,
}
+49
View File
@@ -0,0 +1,49 @@
// 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, create::M2dirMailboxCreateCommand, delete::M2dirMailboxDeleteCommand,
list::M2dirMailboxListCommand,
};
/// 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`.
#[derive(Debug, Subcommand)]
#[command(rename_all = "kebab-case")]
pub enum M2dirCommand {
Create(M2dirMailboxCreateCommand),
Delete(M2dirMailboxDeleteCommand),
List(M2dirMailboxListCommand),
}
impl M2dirCommand {
pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> {
match self {
Self::Create(cmd) => cmd.execute(printer, client),
Self::Delete(cmd) => cmd.execute(printer, client),
Self::List(cmd) => cmd.execute(printer, client),
}
}
}
+77
View File
@@ -0,0 +1,77 @@
// 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/>.
//! Himalaya wrapper around [`io_m2dir::client::M2dirClient`] bundling
//! the merged [`Account`] alongside the m2dir client.
use std::{
ops::{Deref, DerefMut},
path::PathBuf,
};
use anyhow::{Result, anyhow};
use io_m2dir::client::M2dirClient as Inner;
use pimalaya_config::toml::TomlConfig;
use crate::{account::context::Account, cli::load_or_wizard, config::M2dirConfig};
pub struct M2dirClient {
inner: Inner,
pub account: Account,
}
impl M2dirClient {
/// Builds an [`M2dirClient`] rooted at the configured m2store
/// path.
pub fn new(config: M2dirConfig, account: Account) -> Self {
let inner = Inner::new(config.root.to_string_lossy().into_owned());
Self { inner, account }
}
}
impl Deref for M2dirClient {
type Target = Inner;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for M2dirClient {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
/// Loads the configuration, picks the active account, builds the
/// merged [`Account`] then opens the m2dir client. Bails when the
/// account has no `[m2dir]` block.
pub fn build_m2dir_client(
config_paths: &[PathBuf],
account_name: Option<&str>,
) -> Result<M2dirClient> {
let mut config = load_or_wizard(config_paths)?;
let (name, mut ac) = config
.take_account(account_name)?
.ok_or_else(|| anyhow!("Cannot find account"))?;
let m2dir_config = ac
.m2dir
.take()
.ok_or_else(|| anyhow!("m2dir config is missing for account `{name}`"))?;
let account = Account::from(config).merge(Account::from(ac));
Ok(M2dirClient::new(m2dir_config, account))
}
+40
View File
@@ -0,0 +1,40 @@
// 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 pimalaya_cli::printer::{Message, Printer};
use crate::m2dir::{arg::M2dirNameArg, client::M2dirClient};
/// Create the given m2dir folder.
///
/// Initialises the m2store at the client root if needed, then creates
/// the m2dir folder named after `name` (relative to the store root).
#[derive(Debug, Parser)]
pub struct M2dirMailboxCreateCommand {
#[command(flatten)]
pub m2dir_name: M2dirNameArg,
}
impl M2dirMailboxCreateCommand {
pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> {
client.init_store()?;
client.create_mailbox(&self.m2dir_name.inner)?;
printer.out(Message::new("m2dir folder successfully created"))
}
}
+38
View File
@@ -0,0 +1,38 @@
// 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 pimalaya_cli::printer::{Message, Printer};
use crate::m2dir::{arg::M2dirNameArg, client::M2dirClient};
/// Delete the given m2dir folder.
#[derive(Debug, Parser)]
pub struct M2dirMailboxDeleteCommand {
#[command(flatten)]
pub m2dir_name: M2dirNameArg,
}
impl M2dirMailboxDeleteCommand {
pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> {
let store = client.open_store()?;
let path = store.resolve_folder_path(&self.m2dir_name.inner)?;
client.delete_mailbox(path)?;
printer.out(Message::new("m2dir folder successfully deleted"))
}
}
+97
View File
@@ -0,0 +1,97 @@
// 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, Row, Table};
use io_m2dir::m2dir::M2dir;
use pimalaya_cli::printer::Printer;
use serde::Serialize;
use crate::m2dir::client::M2dirClient;
/// List m2dir folders found under the store root.
#[derive(Debug, Parser)]
pub struct M2dirMailboxListCommand;
impl M2dirMailboxListCommand {
pub fn execute(self, printer: &mut impl Printer, client: M2dirClient) -> Result<()> {
let m2dirs = client.list_mailboxes()?;
let table = M2dirsTable {
preset: client.account.table_preset().to_string(),
name_color: client.account.mailboxes_list_table_name_color(),
rows: m2dirs.into_iter().map(From::from).collect(),
};
printer.out(table)
}
}
#[derive(Clone, Debug, Serialize)]
pub struct M2dirsTable {
#[serde(skip)]
pub preset: String,
#[serde(skip)]
pub name_color: Color,
#[serde(rename = "m2dirs")]
pub rows: Vec<M2dirRow>,
}
impl fmt::Display for M2dirsTable {
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("NAME"), Cell::new("PATH")]))
.add_rows(self.rows.iter().map(|m| {
let mut row = Row::new();
row.max_height(1)
.add_cell(Cell::new(&m.name).fg(self.name_color))
.add_cell(Cell::new(&m.path));
row
}));
writeln!(f)?;
write!(f, "{table}")?;
writeln!(f)?;
Ok(())
}
}
#[derive(Clone, Debug, Serialize)]
pub struct M2dirRow {
pub name: String,
pub path: String,
}
impl From<M2dir> for M2dirRow {
fn from(m2dir: M2dir) -> Self {
let name = m2dir
.path()
.file_name()
.map(str::to_owned)
.unwrap_or_default();
let path = m2dir.path().as_str().to_owned();
Self { name, path }
}
}
+23
View File
@@ -0,0 +1,23 @@
// 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 arg;
pub mod cli;
pub mod client;
pub mod create;
pub mod delete;
pub mod list;
+14 -3
View File
@@ -24,11 +24,11 @@
use std::{
ops::{Deref, DerefMut},
path::PathBuf,
path::{Path, PathBuf},
};
use anyhow::{Result, anyhow};
use io_maildir::client::MaildirClient as Inner;
use io_maildir::{client::MaildirClient as Inner, maildir::Maildir};
use pimalaya_config::toml::TomlConfig;
use crate::{account::context::Account, cli::load_or_wizard, config::MaildirConfig};
@@ -47,13 +47,24 @@ impl MaildirClient {
/// path.
pub fn new(config: MaildirConfig, account: Account) -> Self {
let root = config.root.clone();
let inner = Inner::new(config.root);
let inner = Inner::new(root.to_string_lossy().into_owned());
Self {
inner,
account,
root,
}
}
/// Resolves a maildir CLI argument: tries `path` as-is first, then
/// falls back to `self.root.join(path)`. Both attempts go through
/// [`io_maildir::client::MaildirClient::load_maildir`] so the
/// `cur` / `new` / `tmp` markers are validated.
pub fn resolve_maildir(&self, path: &Path) -> Result<Maildir> {
if let Ok(maildir) = self.load_maildir(path.to_string_lossy().into_owned()) {
return Ok(maildir);
}
Ok(self.load_maildir(self.root.join(path).to_string_lossy().into_owned())?)
}
}
impl Deref for MaildirClient {
+5 -1
View File
@@ -33,7 +33,11 @@ pub struct MaildirMailboxCreateCommand {
impl MaildirMailboxCreateCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let path = client.root.join(&self.maildir_name.inner);
let path = client
.root
.join(&self.maildir_name.inner)
.to_string_lossy()
.into_owned();
client.create_maildir(path)?;
printer.out(Message::new("Maildir successfully created"))
+5 -1
View File
@@ -33,7 +33,11 @@ pub struct MaildirMailboxDeleteCommand {
impl MaildirMailboxDeleteCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let path = client.root.join(&self.maildir_path.inner);
let path = client
.root
.join(&self.maildir_path.inner)
.to_string_lossy()
.into_owned();
client.delete_maildir(path)?;
printer.out(Message::new("Maildir successfully deleted"))
+3 -7
View File
@@ -20,7 +20,6 @@ use std::fmt;
use anyhow::{Result, bail};
use clap::Parser;
use comfy_table::{Cell, Row, Table};
use io_maildir::maildir::Maildir;
use mail_parser::Header;
use pimalaya_cli::printer::Printer;
use serde::Serialize;
@@ -45,17 +44,14 @@ pub struct MaildirEnvelopeGetCommand {
impl MaildirEnvelopeGetCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let message = client.get(maildir, &self.id.inner)?;
let path = message.path().to_owned();
let path = message.path().clone();
let Some(parsed) = message.headers() else {
bail!("Invalid MIME message at {}", path.display());
bail!("Invalid MIME message at {path}");
};
let table = EnvelopeTable {
+1 -5
View File
@@ -20,7 +20,6 @@ use std::fmt;
use anyhow::Result;
use clap::Parser;
use comfy_table::{Cell, Color, ContentArrangement, Row, Table};
use io_maildir::maildir::Maildir;
use pimalaya_cli::printer::Printer;
use serde::Serialize;
@@ -39,10 +38,7 @@ pub struct MaildirEnvelopeListCommand {
impl MaildirEnvelopeListCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let messages = client.list_messages(maildir)?;
+2 -5
View File
@@ -17,7 +17,7 @@
use anyhow::Result;
use clap::Parser;
use io_maildir::{flag::Flags, maildir::Maildir};
use io_maildir::flag::Flags;
use pimalaya_cli::printer::{Message, Printer};
use crate::maildir::{
@@ -44,10 +44,7 @@ pub struct MaildirFlagAddCommand {
impl MaildirFlagAddCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let flags = Flags::from_iter(self.flags.into_iter().map(Into::into));
+2 -5
View File
@@ -17,7 +17,7 @@
use anyhow::Result;
use clap::Parser;
use io_maildir::{flag::Flags, maildir::Maildir};
use io_maildir::flag::Flags;
use pimalaya_cli::printer::{Message, Printer};
use crate::maildir::{
@@ -44,10 +44,7 @@ pub struct MaildirFlagRemoveCommand {
impl MaildirFlagRemoveCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let flags = Flags::from_iter(self.flags.into_iter().map(Into::into));
+2 -5
View File
@@ -17,7 +17,7 @@
use anyhow::Result;
use clap::Parser;
use io_maildir::{flag::Flags, maildir::Maildir};
use io_maildir::flag::Flags;
use pimalaya_cli::printer::{Message, Printer};
use crate::maildir::{
@@ -44,10 +44,7 @@ pub struct MaildirFlagSetCommand {
impl MaildirFlagSetCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let flags = Flags::from_iter(self.flags.into_iter().map(Into::into));
+1 -1
View File
@@ -92,7 +92,7 @@ impl From<Maildir> for MaildirRow {
fn from(maildir: Maildir) -> Self {
Self {
name: maildir.name().unwrap_or("Unknown").to_owned(),
path: maildir.as_ref().to_owned(),
path: PathBuf::from(maildir.path().as_str()),
}
}
}
+2 -10
View File
@@ -17,7 +17,6 @@
use anyhow::Result;
use clap::Parser;
use io_maildir::maildir::Maildir;
use pimalaya_cli::printer::{Message, Printer};
use crate::maildir::{
@@ -45,15 +44,8 @@ pub struct MaildirMessageCopyCommand {
impl MaildirMessageCopyCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let source = match Maildir::try_from(self.source.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.source.inner))?,
};
let target = match Maildir::try_from(self.target.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.target.inner))?,
};
let source = client.resolve_maildir(&self.source.inner)?;
let target = client.resolve_maildir(&self.target.inner)?;
for id in self.ids.inner {
client.copy(
+3 -7
View File
@@ -20,7 +20,6 @@ use std::{fmt, fs, path::PathBuf};
use anyhow::{Result, bail};
use clap::{Parser, ValueEnum};
use convert_case::ccase;
use io_maildir::maildir::Maildir;
use mail_parser::MimeHeaders;
use mime_guess::{get_mime_extensions_str, mime::OCTET_STREAM};
use pimalaya_cli::printer::Printer;
@@ -59,10 +58,7 @@ pub struct MaildirMessageExportCommand {
impl MaildirMessageExportCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let msg = client.get(maildir, &self.id.inner)?;
@@ -72,10 +68,10 @@ impl MaildirMessageExportCommand {
printer.out(ExportRaw { contents })?;
}
ExportType::Parts => {
let path = msg.path().to_owned();
let path = msg.path().clone();
let Some(parsed) = msg.parsed() else {
bail!("Invalid MIME message at {}", path.display());
bail!("Invalid MIME message at {path}");
};
let dir = match self.directory {
+3 -7
View File
@@ -19,7 +19,6 @@ use std::fmt;
use anyhow::{Result, bail};
use clap::Parser;
use io_maildir::maildir::Maildir;
use mail_parser::Message;
use pimalaya_cli::printer::Printer;
use serde::Serialize;
@@ -43,17 +42,14 @@ pub struct MaildirMessageGetCommand {
impl MaildirMessageGetCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let msg = client.get(maildir, &self.id.inner)?;
let path = msg.path().to_owned();
let path = msg.path().clone();
let Some(parsed) = msg.headers() else {
bail!("Invalid MIME message at {}", path.display());
bail!("Invalid MIME message at {path}");
};
printer.out(MessageView(parsed))
+2 -10
View File
@@ -17,7 +17,6 @@
use anyhow::Result;
use clap::Parser;
use io_maildir::maildir::Maildir;
use pimalaya_cli::printer::{Message, Printer};
use crate::maildir::{
@@ -45,15 +44,8 @@ pub struct MaildirMessageMoveCommand {
impl MaildirMessageMoveCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let source = match Maildir::try_from(self.source.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.source.inner))?,
};
let target = match Maildir::try_from(self.target.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.target.inner))?,
};
let source = client.resolve_maildir(&self.source.inner)?;
let target = client.resolve_maildir(&self.target.inner)?;
for id in self.ids.inner {
client.r#move(
+3 -7
View File
@@ -19,7 +19,6 @@ use std::fmt;
use anyhow::{Result, bail};
use clap::Parser;
use io_maildir::maildir::Maildir;
use mail_parser::Message;
use pimalaya_cli::printer::Printer;
use serde::Serialize;
@@ -50,17 +49,14 @@ pub struct MaildirMessageReadCommand {
impl MaildirMessageReadCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let message = client.get(maildir, &self.id.inner)?;
let path = message.path().to_owned();
let path = message.path().clone();
let Some(parsed) = message.parsed() else {
bail!("Invalid MIME message at {}", path.display());
bail!("Invalid MIME message at {path}");
};
if self.html {
+3 -5
View File
@@ -23,7 +23,7 @@ use std::{
use anyhow::Result;
use clap::Parser;
use io_maildir::{flag::Flags, maildir::Maildir};
use io_maildir::flag::Flags;
use pimalaya_cli::printer::Printer;
use serde::Serialize;
@@ -59,10 +59,7 @@ pub struct MaildirMessageSaveCommand {
impl MaildirMessageSaveCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let maildir = match Maildir::try_from(self.maildir.inner.clone()) {
Ok(maildir) => maildir,
Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?,
};
let maildir = client.resolve_maildir(&self.maildir.inner)?;
let msg = if stdin().is_terminal() || printer.is_json() {
self.message
@@ -81,6 +78,7 @@ impl MaildirMessageSaveCommand {
let flags = Flags::from_iter(self.flags.into_iter().map(Into::into));
let (id, path) = client.store(maildir, self.subdir.into(), flags, msg.into_bytes())?;
let path = PathBuf::from(path.into_string());
printer.out(StoredMessage { id, path })
}
+5 -1
View File
@@ -38,7 +38,11 @@ pub struct MaildirMailboxRenameCommand {
impl MaildirMailboxRenameCommand {
pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> {
let path = client.root.join(&self.maildir_path.inner);
let path = client
.root
.join(&self.maildir_path.inner)
.to_string_lossy()
.into_owned();
client.rename_maildir(path, self.maildir_name.inner)?;
printer.out(Message::new("Maildir successfully renamed"))
+2
View File
@@ -23,6 +23,8 @@ mod config;
mod imap;
#[cfg(feature = "jmap")]
mod jmap;
#[cfg(feature = "m2dir")]
mod m2dir;
#[cfg(feature = "maildir")]
mod maildir;
mod shared;
+13 -1
View File
@@ -101,13 +101,25 @@ impl EmailClient {
if let Some(maildir_config) = account_config.maildir.take() {
use io_maildir::client::MaildirClient;
let client = MaildirClient::new(maildir_config.root);
let client = MaildirClient::new(maildir_config.root.to_string_lossy().into_owned());
inner = inner.with_maildir(client);
configured = true;
}
}
#[cfg(feature = "m2dir")]
if !configured && backend.allows_m2dir() {
if let Some(m2dir_config) = account_config.m2dir.take() {
use io_m2dir::client::M2dirClient;
let client = M2dirClient::new(m2dir_config.root.to_string_lossy().into_owned());
inner = inner.with_m2dir(client);
configured = true;
}
}
if !configured {
bail!("no backend matching `{backend}` is configured for this account");
}
+3 -3
View File
@@ -229,17 +229,17 @@ impl fmt::Display for Envelopes {
/// (v1.2.0 defaults: `*`, `R`, `!`).
pub(super) fn format_flags(flags: &BTreeSet<Flag>, chars: &FlagChars) -> String {
let mut out = String::with_capacity(3);
out.push(if flags.contains(&Flag::Seen) {
out.push(if flags.iter().any(Flag::is_seen) {
' '
} else {
chars.unseen
});
out.push(if flags.contains(&Flag::Answered) {
out.push(if flags.iter().any(Flag::is_answered) {
chars.replied
} else {
' '
});
out.push(if flags.contains(&Flag::Flagged) {
out.push(if flags.iter().any(Flag::is_flagged) {
chars.flagged
} else {
' '
+9 -7
View File
@@ -87,14 +87,16 @@ impl From<&FlagArg> for io_maildir::flag::Flag {
impl From<&FlagArg> for io_email::flag::Flag {
fn from(flag: &FlagArg) -> Self {
use io_email::flag::Flag;
use io_email::flag::{Flag, IanaFlag};
match flag {
FlagArg::Seen => Flag::Seen,
FlagArg::Answered => Flag::Answered,
FlagArg::Flagged => Flag::Flagged,
FlagArg::Draft => Flag::Draft,
}
let iana = match flag {
FlagArg::Seen => IanaFlag::Seen,
FlagArg::Answered => IanaFlag::Answered,
FlagArg::Flagged => IanaFlag::Flagged,
FlagArg::Draft => IanaFlag::Draft,
};
Flag::from_iana(iana)
}
}
+2
View File
@@ -193,6 +193,7 @@ fn build_account_from_discovery(
imap: None,
jmap: Some(jmap_to_config(jmap)?),
maildir: None,
m2dir: None,
smtp: None,
})
} else {
@@ -209,6 +210,7 @@ fn build_account_from_discovery(
imap: Some(imap_to_config(imap)?),
jmap: None,
maildir: None,
m2dir: None,
smtp: Some(smtp_to_config(smtp)?),
})
}
+3
View File
@@ -111,6 +111,7 @@ pub fn edit_account(target: &Path, mut config: Config, account_name: &str) -> Re
.map(|a| a.attachment.clone())
.unwrap_or_default();
let maildir = existing.as_ref().and_then(|a| a.maildir.clone());
let m2dir = existing.as_ref().and_then(|a| a.m2dir.clone());
let account = if jmap_defaults.is_some() {
let jmap = jmap_wizard::run(account_name, local_part, domain, jmap_defaults.as_ref())?;
@@ -124,6 +125,7 @@ pub fn edit_account(target: &Path, mut config: Config, account_name: &str) -> Re
imap: None,
jmap: Some(jmap_to_config(jmap)?),
maildir,
m2dir,
smtp: None,
}
} else {
@@ -139,6 +141,7 @@ pub fn edit_account(target: &Path, mut config: Config, account_name: &str) -> Re
imap: Some(imap_to_config(imap)?),
jmap: None,
maildir,
m2dir,
smtp: Some(smtp_to_config(smtp)?),
}
};