diff --git a/Cargo.lock b/Cargo.lock index 476a6042..61b41483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,12 @@ dependencies = [ "syn", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" @@ -328,7 +334,16 @@ checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" dependencies = [ "crossterm", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", ] [[package]] @@ -513,16 +528,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "gethostname" version = "1.1.0" @@ -599,6 +604,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashify" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "heck" version = "0.5.0" @@ -613,15 +629,18 @@ dependencies = [ "chrono", "clap", "comfy-table", + "convert_case", "dirs", "gethostname", - "html2text", + "io-fs", "io-imap", + "io-maildir", "io-process", "io-smtp", "io-stream", "log", "mail-parser", + "mime_guess", "open", "pimalaya-toolbox", "rfc2047-decoder", @@ -632,33 +651,6 @@ dependencies = [ "url", ] -[[package]] -name = "html2text" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "042a9677c258ac2952dd026bb0cd21972f00f644a5a38f5a215cb22cdaf6834e" -dependencies = [ - "html5ever", - "markup5ever", - "tendril", - "thiserror 1.0.69", - "unicode-width 0.1.13", -] - -[[package]] -name = "html5ever" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" -dependencies = [ - "log", - "mac", - "markup5ever", - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "icu_collections" version = "2.1.1" @@ -807,6 +799,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "io-fs" +version = "0.0.1" +source = "git+https://github.com/pimalaya/io-fs#6c2305c52fdd5ec9ed05e902183fdf2942ea0590" +dependencies = [ + "log", + "thiserror 2.0.18", +] + [[package]] name = "io-imap" version = "0.0.1" @@ -820,6 +821,20 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "io-maildir" +version = "0.0.1" +source = "git+https://github.com/pimalaya/io-maildir#e490e6ed98c2f5781b3e00319b61a4af7af15b1c" +dependencies = [ + "gethostname", + "io-fs", + "log", + "mail-parser", + "memchr", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "io-process" version = "0.0.2" @@ -943,6 +958,16 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1021,33 +1046,14 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mail-parser" -version = "0.9.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c3b9e5d8b17faf573330bbc43b37d6e918c0a3bf8a88e7d0a220ebc84af9fc" +checksum = "f82a3d6522697593ba4c683e0a6ee5a40fee93bc1a525e3cc6eeb3da11fd8897" dependencies = [ - "encoding_rs", -] - -[[package]] -name = "markup5ever" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" -dependencies = [ - "log", - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", + "hashify", + "serde", ] [[package]] @@ -1065,6 +1071,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1088,12 +1110,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nom" version = "7.1.3" @@ -1240,44 +1256,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "pimalaya-toolbox" version = "0.0.4" @@ -1324,9 +1302,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -1349,12 +1327,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "prettyplease" version = "0.2.37" @@ -1630,6 +1602,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "same-file" version = "1.0.6" @@ -1769,12 +1747,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "smallvec" version = "1.15.1" @@ -1823,31 +1795,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - [[package]] name = "strsim" version = "0.11.1" @@ -1895,17 +1842,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "terminal_size" version = "0.4.3" @@ -2009,15 +1945,21 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2030,12 +1972,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-width" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" - [[package]] name = "unicode-width" version = "0.2.2" @@ -2067,12 +2003,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2085,6 +2015,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2125,6 +2066,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index 39fae759..f0e4e4a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,11 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["imap", "smtp", "rustls-ring"] +default = ["imap", "smtp", "maildir", "rustls-ring"] imap = ["dep:io-imap", "pimalaya-toolbox/imap"] smtp = ["dep:io-smtp", "pimalaya-toolbox/smtp"] +maildir = ["dep:convert_case", "dep:io-fs", "dep:io-maildir", "dep:mime_guess"] native-tls = ["pimalaya-toolbox/native-tls"] rustls-aws = ["pimalaya-toolbox/rustls-aws"] @@ -35,15 +36,18 @@ anyhow = "1" chrono = { version = "0.4", default-features = false } clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } comfy-table = "7" +convert_case = { version = "0.11", optional = true } dirs = "6" gethostname = "1" -html2text = "0.12" +io-fs = { version = "0.0.1", default-features = false, features = ["std"], optional = true } io-imap = { version = "0.0.1", default-features = false, optional = true } +io-maildir = { version = "0.0.1", default-features = false, features = ["serde"], optional = true } io-process = { version = "0.0.2", default-features = false } io-smtp = { version = "0.0.1", default-features = false, features = ["ext_auth", "starttls"], optional = true } io-stream = { version = "0.0.2", default-features = false, features = ["std"] } log = "0.4" -mail-parser = "0.9" +mail-parser = "0.11" +mime_guess = { version = "2", optional = true } open = "5" pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["config", "terminal", "secret"] } rfc2047-decoder = "1" @@ -56,7 +60,9 @@ url = { version = "2.2", features = ["serde"] } uds_windows = "1" [patch.crates-io] +io-fs.git = "https://github.com/pimalaya/io-fs" io-imap = { git = "https://github.com/pimalaya/io-imap", branch = "io" } +io-maildir.git = "https://github.com/pimalaya/io-maildir" io-smtp.git = "https://github.com/pimalaya/io-smtp" pimalaya-toolbox.git = "https://github.com/pimalaya/toolbox" smtp-codec.git = "https://github.com/pimalaya/smtp-codec" diff --git a/src/cli.rs b/src/cli.rs index a9c1b809..959e2d30 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -17,6 +17,8 @@ use pimalaya_toolbox::{ #[cfg(feature = "imap")] use crate::imap::command::ImapCommand; +#[cfg(feature = "maildir")] +use crate::maildir::command::MaildirCommand; #[cfg(feature = "smtp")] use crate::smtp::command::SmtpCommand; use crate::{account::Account, config::Config}; @@ -59,6 +61,9 @@ pub enum BackendCommand { #[cfg(feature = "imap")] #[command(subcommand)] Imap(ImapCommand), + #[cfg(feature = "maildir")] + #[command(subcommand)] + Maildir(MaildirCommand), #[cfg(feature = "smtp")] #[command(subcommand)] Smtp(SmtpCommand), @@ -88,6 +93,19 @@ impl BackendCommand { cmd.execute(printer, account) } + #[cfg(feature = "maildir")] + Self::Maildir(cmd) => { + let config = Config::from_paths_or_default(config_paths)?; + let (account_name, mut account_config) = config.get_account(account_name)?; + + let Some(maildir_config) = account_config.maildir.take() else { + bail!("Maildir config is missing for account `{account_name}`") + }; + + let account = Account::new(config, account_config, maildir_config)?; + + cmd.execute(printer, account) + } #[cfg(feature = "smtp")] Self::Smtp(cmd) => { let config = Config::from_paths_or_default(config_paths)?; diff --git a/src/config.rs b/src/config.rs index 0aa5d7ef..04078de4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -55,6 +55,7 @@ pub struct AccountConfig { pub table_arrangement: Option, pub imap: Option, + pub maildir: Option, pub smtp: Option, } @@ -90,6 +91,13 @@ pub struct ImapConfig { pub sasl: SaslConfig, } +/// Maildir configuration. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct MaildirConfig { + pub root: PathBuf, +} + /// SMTP configuration. #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] diff --git a/src/imap/command.rs b/src/imap/command.rs index 2a0352fe..3545b10b 100644 --- a/src/imap/command.rs +++ b/src/imap/command.rs @@ -12,7 +12,7 @@ use crate::imap::{ /// This command gives you access to the IMAP CLI API, and allows you /// to manage IMAP mailboxes, envelopes, flags, messages etc. #[derive(Debug, Subcommand)] -#[command(rename_all = "lowercase")] +#[command(rename_all = "kebab-case")] pub enum ImapCommand { Id(IdCommand), diff --git a/src/imap/message/command.rs b/src/imap/message/command.rs index 682cb906..53a8ff9f 100644 --- a/src/imap/message/command.rs +++ b/src/imap/message/command.rs @@ -6,7 +6,7 @@ use crate::imap::{ account::ImapAccount, message::{ copy::CopyMessageCommand, export::ExportMessageCommand, get::GetMessageCommand, - r#move::MoveMessageCommand, read::ReadMessageCommand, save::SaveMessageCommand, + r#move::MoveMessagesCommand, read::ReadMessageCommand, save::SaveMessageCommand, }, }; @@ -22,7 +22,7 @@ pub enum MessageCommand { Read(ReadMessageCommand), Export(ExportMessageCommand), Copy(CopyMessageCommand), - Move(MoveMessageCommand), + Move(MoveMessagesCommand), } impl MessageCommand { diff --git a/src/imap/message/move.rs b/src/imap/message/move.rs index 2d3a08df..a098caba 100644 --- a/src/imap/message/move.rs +++ b/src/imap/message/move.rs @@ -18,7 +18,7 @@ use crate::imap::{ /// from the source mailbox to the destination mailbox. Requires the /// MOVE IMAP extension. #[derive(Debug, Parser)] -pub struct MoveMessageCommand { +pub struct MoveMessagesCommand { #[command(flatten)] pub mailbox_name: MailboxNameOptionalFlag, #[command(flatten)] @@ -35,7 +35,7 @@ pub struct MoveMessageCommand { pub seq: bool, } -impl MoveMessageCommand { +impl MoveMessagesCommand { pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; diff --git a/src/imap/message/read.rs b/src/imap/message/read.rs index 9a477546..85ad7e71 100644 --- a/src/imap/message/read.rs +++ b/src/imap/message/read.rs @@ -1,13 +1,13 @@ use std::{fmt, num::NonZeroU32}; -use anyhow::{anyhow, bail, Result}; +use anyhow::{bail, Result}; use clap::Parser; use io_imap::{ coroutines::{fetch::*, select::*}, types::fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, }; use io_stream::runtimes::std::handle; -use mail_parser::MessageParser; +use mail_parser::{Message, MessageParser}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -99,47 +99,59 @@ impl ReadMessageCommand { }; let Some(message) = MessageParser::new().parse(&raw) else { - bail!("Invalid message"); + bail!("Invalid MIME message"); }; - let content = if self.html { - message - .body_html(0) - .map(|s| s.to_string()) - .ok_or_else(|| anyhow!("No HTML content found"))? + if self.html { + printer.out(MessageHtmlView { message }) } else { - if let Some(text) = message.body_text(0) { - text.to_string() - } else if let Some(html) = message.body_html(0) { - html2text::from_read(html.as_bytes(), self.width) - } else { - bail!("No text or HTML content found"); - } - }; - - let output = MessageContent { content }; - printer.out(output) + printer.out(MessagePlainView { message }) + } } } -#[derive(Clone, Debug)] -pub struct MessageContent { - pub content: String, +#[derive(Serialize)] +#[serde(transparent)] +pub struct MessagePlainView<'a> { + message: Message<'a>, } -impl fmt::Display for MessageContent { +impl fmt::Display for MessagePlainView<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f)?; - write!(f, "{}", self.content)?; - if !self.content.ends_with('\n') { - writeln!(f)?; + 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(()) } } -impl Serialize for MessageContent { - fn serialize(&self, serializer: S) -> Result { - self.content.serialize(serializer) +#[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/maildir/account.rs b/src/maildir/account.rs new file mode 100644 index 00000000..0d8ec5ec --- /dev/null +++ b/src/maildir/account.rs @@ -0,0 +1,3 @@ +use crate::{account::Account, config::MaildirConfig}; + +pub type MaildirAccount = Account; diff --git a/src/maildir/arg.rs b/src/maildir/arg.rs new file mode 100644 index 00000000..8e21c01a --- /dev/null +++ b/src/maildir/arg.rs @@ -0,0 +1,66 @@ +use clap::Parser; + +const INBOX: &str = "INBOX"; + +/// The optional maildir name argument parser. +#[derive(Debug, Parser)] +pub struct MaildirNameOptionalArg { + /// The name of the maildir. + #[arg(name = "maildir_name", value_name = "MAILDIR", default_value = INBOX)] + pub inner: String, +} + +impl Default for MaildirNameOptionalArg { + fn default() -> Self { + Self { + inner: INBOX.into(), + } + } +} + +/// The optional maildir name flag parser. +#[derive(Debug, Parser)] +pub struct MaildirPathOptionalFlag { + /// The name of the maildir. + #[arg(long = "maildir", short = 'm')] + #[arg(name = "maildir_name", value_name = "NAME", default_value = INBOX)] + pub inner: String, +} + +impl Default for MaildirPathOptionalFlag { + fn default() -> Self { + Self { + inner: INBOX.into(), + } + } +} + +#[derive(Debug, Parser)] +pub struct MaildirNoSelectFlag { + /// Do not select the given maildir before performing the current + /// action. + /// + /// This argument is useful when stateful IMAP sessions are used, + /// for example with Sirup CLI: + /// + /// https://github.com/pimalaya/sirup + #[arg(long = "no-select", default_value_t)] + #[arg(name = "maildir_no_select")] + pub inner: bool, +} + +/// The required maildir name argument parser. +#[derive(Debug, Parser)] +pub struct MaildirNameArg { + /// The name of the maildir. + #[arg(name = "maildir_name", value_name = "MAILDIR")] + pub inner: String, +} + +/// The target maildir name argument parser. +#[derive(Debug, Clone, Parser)] +pub struct TargetMaildirNameArg { + /// The name of the target maildir. + #[arg(name = "target_maildir_name", value_name = "TARGET")] + pub inner: String, +} diff --git a/src/maildir/command.rs b/src/maildir/command.rs new file mode 100644 index 00000000..b1c339a8 --- /dev/null +++ b/src/maildir/command.rs @@ -0,0 +1,25 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::maildir::{account::MaildirAccount, message::command::MessageCommand}; + +/// MAILDIR CLI (requires the `maildir` cargo feature). +/// +/// This command gives you access to the MAILDIR CLI API, and allows you +/// to manage MAILDIR mailboxes, envelopes, flags, messages etc. +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab-case")] +pub enum MaildirCommand { + #[command(subcommand)] + #[command(aliases = ["msgs", "msg"])] + Messages(MessageCommand), +} + +impl MaildirCommand { + pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + match self { + Self::Messages(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/maildir/message/command.rs b/src/maildir/message/command.rs new file mode 100644 index 00000000..0ef5e087 --- /dev/null +++ b/src/maildir/message/command.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::maildir::{ + account::MaildirAccount, + message::{ + copy::CopyMessagesCommand, export::ExportMessageCommand, get::GetMessageCommand, + r#move::MoveMessagesCommand, read::ReadMessageCommand, save::SaveMessageCommand, + }, +}; + +/// Manage MAILDIR messages. +/// +/// A message is a complete email including headers and body. This +/// subcommand allows you to save, get, read, export, copy, and move +/// messages. +#[derive(Debug, Subcommand)] +pub enum MessageCommand { + Save(SaveMessageCommand), + Get(GetMessageCommand), + Read(ReadMessageCommand), + Export(ExportMessageCommand), + Copy(CopyMessagesCommand), + Move(MoveMessagesCommand), +} + +impl MessageCommand { + pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + match self { + Self::Save(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, account), + Self::Read(cmd) => cmd.execute(printer, account), + Self::Export(cmd) => cmd.execute(printer, account), + Self::Copy(cmd) => cmd.execute(printer, account), + Self::Move(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/maildir/message/copy.rs b/src/maildir/message/copy.rs new file mode 100644 index 00000000..afca601e --- /dev/null +++ b/src/maildir/message/copy.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use clap::{Parser, ValueEnum}; +use io_fs::runtimes::std::handle; +use io_maildir::{ + coroutines::copy_message::*, + maildir::{Maildir, MaildirSubdir}, +}; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::maildir::account::MaildirAccount; + +/// Copy Maildir message to the given mailbox. +/// +/// This command copies message(s) identified by the given sequence +/// set from the source mailbox to the destination mailbox. +#[derive(Debug, Parser)] +pub struct CopyMessagesCommand { + /// Path to the source Maildir, where messages are copied from. + #[arg(long = "source", short = 's')] + #[arg(value_name = "PATH", default_value = "Inbox")] + pub maildir_source_path: PathBuf, + + /// Path to the target Maildir, where messages are copied into. + #[arg(long = "target", short = 't')] + #[arg(value_name = "PATH")] + pub maildir_target_path: PathBuf, + /// Subdir of the target Maildir. + #[arg(long = "subdir", value_name = "NAME", value_enum)] + pub maildir_target_subdir: MaildirSubdirArg, + + /// Id(s) of message(s) to copy. + #[arg(value_name = "ID", num_args = 1..)] + pub ids: Vec, +} + +impl CopyMessagesCommand { + pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + let maildir_source = match Maildir::try_from(self.maildir_source_path.clone()) { + Ok(maildir) => maildir, + Err(_) => Maildir::try_from(account.backend.root.join(self.maildir_source_path))?, + }; + + let maildir_target = match Maildir::try_from(self.maildir_target_path.clone()) { + Ok(maildir) => maildir, + Err(_) => Maildir::try_from(account.backend.root.join(self.maildir_target_path))?, + }; + + for id in self.ids { + let mut arg = None; + let mut coroutine = CopyMaildirMessage::new( + maildir_source.clone(), + maildir_target.clone(), + self.maildir_target_subdir.clone().into(), + id, + ); + + loop { + match coroutine.resume(arg.take()) { + CopyMaildirMessageResult::Io(io) => arg = Some(handle(io)?), + CopyMaildirMessageResult::Ok => break, + CopyMaildirMessageResult::Err(err) => bail!(err), + } + } + } + + printer.out(Message::new("Message(s) successfully copied")) + } +} + +#[derive(Clone, Debug, ValueEnum)] +pub enum MaildirSubdirArg { + Cur, + New, + Tmp, +} + +impl From for MaildirSubdir { + fn from(value: MaildirSubdirArg) -> Self { + match value { + MaildirSubdirArg::Cur => MaildirSubdir::Cur, + MaildirSubdirArg::New => MaildirSubdir::New, + MaildirSubdirArg::Tmp => MaildirSubdir::Tmp, + } + } +} diff --git a/src/maildir/message/export.rs b/src/maildir/message/export.rs new file mode 100644 index 00000000..2d35cda1 --- /dev/null +++ b/src/maildir/message/export.rs @@ -0,0 +1,165 @@ +use std::{fmt, fs, path::PathBuf}; + +use anyhow::{bail, Result}; +use clap::{Parser, ValueEnum}; +use convert_case::ccase; +use io_fs::runtimes::std::handle; +use io_maildir::{coroutines::get_message::*, maildir::Maildir, types::MimeHeaders}; +use mime_guess::{get_mime_extensions_str, mime::OCTET_STREAM}; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::maildir::account::MaildirAccount; + +/// 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, +} + +/// Export a message. +/// +/// This command exports a message in various formats: +/// - raw: Output raw RFC822 message to stdout +/// - eml: Save as .eml file +/// - parts: Export all MIME parts to separate files +#[derive(Debug, Parser)] +pub struct ExportMessageCommand { + /// Path to the Maildir containing the message looked for. + #[arg(long = "maildir", short)] + #[arg(value_name = "PATH", default_value = "Inbox")] + pub maildir_path: PathBuf, + + /// Id of message to export. + #[arg()] + pub id: String, + + /// Type of the export. + #[arg(long, short, value_enum, default_value_t)] + pub r#type: ExportType, + + /// Output directory (for eml and parts types). + #[arg(long, short, value_name = "DIR")] + pub directory: Option, + + /// Open exported content in default application, when applicable. + #[arg(long, short)] + pub open: bool, +} + +impl ExportMessageCommand { + pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + let maildir = match Maildir::try_from(self.maildir_path.clone()) { + Ok(maildir) => maildir, + Err(_) => Maildir::try_from(account.backend.root.join(self.maildir_path))?, + }; + + let mut arg = None; + let mut coroutine = GetMaildirMessage::new(maildir, &self.id); + + let msg = loop { + match coroutine.resume(arg.take()) { + GetMaildirMessageResult::Io(io) => arg = Some(handle(io)?), + GetMaildirMessageResult::Ok(msg) => break msg, + GetMaildirMessageResult::Err(err) => bail!(err), + }; + }; + + match self.r#type { + ExportType::Raw => { + let contents = String::from_utf8(msg.into())?; + printer.out(ExportRaw { contents })?; + } + ExportType::Parts => { + let Some(msg) = msg.parsed() else { + bail!("Invalid MIME message at {}", msg.path().display()); + }; + + let dir = self.directory.unwrap_or_else(|| PathBuf::from(self.id)); + fs::create_dir_all(&dir)?; + + let mut parts = Vec::new(); + + for (i, part) in msg.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(()) + } +} + +#[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/maildir/message/get.rs b/src/maildir/message/get.rs new file mode 100644 index 00000000..76a4b3ee --- /dev/null +++ b/src/maildir/message/get.rs @@ -0,0 +1,88 @@ +use std::{fmt, path::PathBuf}; + +use anyhow::{bail, Result}; +use clap::Parser; +use io_fs::runtimes::std::handle; +use io_maildir::{ + coroutines::get_message::*, + maildir::Maildir, + types::{Message, PartType}, +}; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::maildir::account::MaildirAccount; + +/// Get Maildir message to the given mailbox. +/// +/// This command copies message(s) identified by the given sequence +/// set from the source mailbox to the destination mailbox. +#[derive(Debug, Parser)] +pub struct GetMessageCommand { + /// Path to the Maildir containing the message looked for. + #[arg(long, short, value_name = "PATH")] + #[arg(default_value = "Inbox")] + pub maildir: PathBuf, + + /// Id of message to get. + #[arg(value_name = "ID")] + pub id: String, +} + +impl GetMessageCommand { + pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + let maildir = match Maildir::try_from(self.maildir.clone()) { + Ok(maildir) => maildir, + Err(_) => Maildir::try_from(account.backend.root.join(self.maildir))?, + }; + + let mut arg = None; + let mut coroutine = GetMaildirMessage::new(maildir, &self.id); + + let msg = loop { + match coroutine.resume(arg.take()) { + GetMaildirMessageResult::Io(io) => arg = Some(handle(io)?), + GetMaildirMessageResult::Ok(msg) => break msg, + GetMaildirMessageResult::Err(err) => bail!(err), + }; + }; + + let Some(msg) = msg.parsed() else { + bail!("Invalid MIME message at {}", msg.path().display()); + }; + + printer.out(MessageView(msg)) + } +} + +#[derive(Serialize)] +#[serde(transparent)] +pub struct MessageView<'a>(Message<'a>); + +impl fmt::Display for MessageView<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let parts_len = self.0.parts.len(); + for (i, p) in self.0.parts.iter().enumerate() { + writeln!(f, "---")?; + writeln!(f, "Part {}/{parts_len}:", i + 1)?; + writeln!(f)?; + + for h in p.headers() { + writeln!(f, "{}: {:?}", h.name.as_str(), h.value)?; + } + + writeln!(f)?; + + match &p.body { + PartType::Text(p) => writeln!(f, "{p}")?, + PartType::Html(p) => writeln!(f, "{p}")?, + PartType::Binary(p) => writeln!(f, "({} bytes)", p.len())?, + PartType::InlineBinary(p) => writeln!(f, "({} inline bytes)", p.len())?, + PartType::Multipart(_) => continue, + PartType::Message(m) => write!(f, "{}", MessageView(m.clone()))?, + } + } + + Ok(()) + } +} diff --git a/src/maildir/message/mod.rs b/src/maildir/message/mod.rs new file mode 100644 index 00000000..91cc5818 --- /dev/null +++ b/src/maildir/message/mod.rs @@ -0,0 +1,7 @@ +pub mod command; +pub mod copy; +pub mod export; +pub mod get; +pub mod r#move; +pub mod read; +pub mod save; diff --git a/src/maildir/message/move.rs b/src/maildir/message/move.rs new file mode 100644 index 00000000..aa79a2cf --- /dev/null +++ b/src/maildir/message/move.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; + +use anyhow::{bail, Result}; +use clap::{Parser, ValueEnum}; +use io_fs::runtimes::std::handle; +use io_maildir::{ + coroutines::move_message::*, + maildir::{Maildir, MaildirSubdir}, +}; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::maildir::account::MaildirAccount; + +/// Move Maildir message to the given mailbox. +/// +/// This command copies message(s) identified by the given sequence +/// set from the source mailbox to the destination mailbox. +#[derive(Debug, Parser)] +pub struct MoveMessagesCommand { + /// Path to the source Maildir, where messages are copied from. + #[arg(long = "source", short = 's')] + #[arg(value_name = "PATH", default_value = "Inbox")] + pub maildir_source_path: PathBuf, + + /// Path to the target Maildir, where messages are copied into. + #[arg(long = "target", short = 't')] + #[arg(value_name = "PATH")] + pub maildir_target_path: PathBuf, + /// Subdir of the target Maildir. + #[arg(long = "subdir", value_name = "NAME", value_enum)] + pub maildir_target_subdir: MaildirSubdirArg, + + /// Id(s) of message(s) to move. + #[arg(value_name = "ID", num_args = 1..)] + pub ids: Vec, +} + +impl MoveMessagesCommand { + pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + let maildir_source = match Maildir::try_from(self.maildir_source_path.clone()) { + Ok(maildir) => maildir, + Err(_) => Maildir::try_from(account.backend.root.join(self.maildir_source_path))?, + }; + + let maildir_target = match Maildir::try_from(self.maildir_target_path.clone()) { + Ok(maildir) => maildir, + Err(_) => Maildir::try_from(account.backend.root.join(self.maildir_target_path))?, + }; + + for id in self.ids { + let mut arg = None; + let mut coroutine = MoveMaildirMessage::new( + maildir_source.clone(), + maildir_target.clone(), + self.maildir_target_subdir.clone().into(), + id, + ); + + loop { + match coroutine.resume(arg.take()) { + MoveMaildirMessageResult::Io(io) => arg = Some(handle(io)?), + MoveMaildirMessageResult::Ok => break, + MoveMaildirMessageResult::Err(err) => bail!(err), + } + } + } + + printer.out(Message::new("Message(s) successfully copied")) + } +} + +#[derive(Clone, Debug, ValueEnum)] +pub enum MaildirSubdirArg { + Cur, + New, + Tmp, +} + +impl From for MaildirSubdir { + fn from(value: MaildirSubdirArg) -> Self { + match value { + MaildirSubdirArg::Cur => MaildirSubdir::Cur, + MaildirSubdirArg::New => MaildirSubdir::New, + MaildirSubdirArg::Tmp => MaildirSubdir::Tmp, + } + } +} diff --git a/src/maildir/message/read.rs b/src/maildir/message/read.rs new file mode 100644 index 00000000..2e498d56 --- /dev/null +++ b/src/maildir/message/read.rs @@ -0,0 +1,110 @@ +use std::{fmt, path::PathBuf}; + +use anyhow::{bail, Result}; +use clap::Parser; +use io_fs::runtimes::std::handle; +use io_maildir::{coroutines::get_message::*, maildir::Maildir, types::Message}; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::maildir::account::MaildirAccount; + +/// Read message content. +/// +/// This command 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 ReadMessageCommand { + /// Path to the Maildir containing the message looked for. + #[arg(long, short, value_name = "PATH")] + #[arg(default_value = "Inbox")] + pub maildir: PathBuf, + + /// Id of message to read. + #[arg()] + pub id: String, + + /// 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 ReadMessageCommand { + pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + let maildir = match Maildir::try_from(self.maildir.clone()) { + Ok(maildir) => maildir, + Err(_) => Maildir::try_from(account.backend.root.join(self.maildir))?, + }; + + let mut arg = None; + let mut coroutine = GetMaildirMessage::new(maildir, &self.id); + + let message = loop { + match coroutine.resume(arg.take()) { + GetMaildirMessageResult::Io(io) => arg = Some(handle(io)?), + GetMaildirMessageResult::Ok(msg) => break msg, + GetMaildirMessageResult::Err(err) => bail!(err), + }; + }; + + let Some(message) = message.parsed() else { + bail!("Invalid MIME message at {}", message.path().display()); + }; + + if self.html { + printer.out(MessageHtmlView { message }) + } else { + printer.out(MessagePlainView { message }) + } + } +} + +#[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/maildir/message/save.rs b/src/maildir/message/save.rs new file mode 100644 index 00000000..1286bc5b --- /dev/null +++ b/src/maildir/message/save.rs @@ -0,0 +1,138 @@ +use std::{ + fmt, + io::{stdin, BufRead, IsTerminal}, + path::PathBuf, +}; + +use anyhow::{bail, Result}; +use clap::{Parser, ValueEnum}; +use io_fs::runtimes::std::handle; +use io_maildir::{ + coroutines::store_message::*, + flag::Flag, + maildir::{Maildir, MaildirSubdir}, +}; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::maildir::account::MaildirAccount; + +/// Save a message to a mailbox. +/// +/// This command appends a message to the specified mailbox. The +/// message is read from stdin in RFC 5322 format (raw email). +#[derive(Debug, Parser)] +pub struct SaveMessageCommand { + /// Path to the Maildir to save message into. + #[arg(long, short, value_name = "PATH")] + #[arg(default_value = "Inbox")] + pub maildir: PathBuf, + + /// The subdirectory of the Maildir + #[arg(long, short, value_name = "NAME", value_enum)] + #[arg(default_value = "new")] + pub subdir: MaildirSubdirArg, + + /// The flags to add to the message. + #[arg(long = "flag", short, num_args = 0..)] + pub flags: Vec, + + /// The raw message, including headers and body. + #[arg(trailing_var_arg = true)] + #[arg(name = "message", value_name = "MESSAGE")] + pub message: Vec, +} + +impl SaveMessageCommand { + pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + let maildir = match Maildir::try_from(self.maildir.clone()) { + Ok(maildir) => maildir, + Err(_) => Maildir::try_from(account.backend.root.join(self.maildir))?, + }; + + let msg = if stdin().is_terminal() || printer.is_json() { + self.message + .join(" ") + .replace('\r', "") + .replace('\n', "\r\n") + } else { + stdin() + .lock() + .lines() + .map_while(Result::ok) + .collect::>() + .join("\r\n") + }; + + let flags = self.flags.into_iter().map(Into::into).into(); + + let mut arg = None; + let mut coroutine = + StoreMaildirMessage::new(maildir, self.subdir.into(), flags, msg.into_bytes()); + + let out = loop { + match coroutine.resume(arg.take()) { + StoreMaildirMessageResult::Io(io) => arg = Some(handle(io)?), + StoreMaildirMessageResult::Ok { id, path } => break StoredMessage { id, path }, + StoreMaildirMessageResult::Err(err) => bail!(err), + } + }; + + printer.out(out) + } +} + +#[derive(Clone, Debug, ValueEnum)] +pub enum MaildirSubdirArg { + Cur, + New, + Tmp, +} + +impl From for MaildirSubdir { + fn from(value: MaildirSubdirArg) -> Self { + match value { + MaildirSubdirArg::Cur => MaildirSubdir::Cur, + MaildirSubdirArg::New => MaildirSubdir::New, + MaildirSubdirArg::Tmp => MaildirSubdir::Tmp, + } + } +} + +#[derive(Clone, Debug, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum FlagArg { + Passed, + Replied, + Seen, + Trashed, + Draft, + Flagged, +} + +impl From for Flag { + fn from(flag: FlagArg) -> Self { + match flag { + FlagArg::Passed => Flag::Passed, + FlagArg::Replied => Flag::Replied, + FlagArg::Seen => Flag::Seen, + FlagArg::Trashed => Flag::Trashed, + FlagArg::Draft => Flag::Draft, + FlagArg::Flagged => Flag::Flagged, + } + } +} + +#[derive(Serialize)] +pub struct StoredMessage { + id: String, + path: PathBuf, +} + +impl fmt::Display for StoredMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let id = &self.id; + let path = self.path.display(); + write!(f, "Message `{id}` successfully saved to {path}") + } +} diff --git a/src/maildir/mod.rs b/src/maildir/mod.rs new file mode 100644 index 00000000..53f35944 --- /dev/null +++ b/src/maildir/mod.rs @@ -0,0 +1,4 @@ +pub mod account; +pub mod arg; +pub mod command; +pub mod message; diff --git a/src/main.rs b/src/main.rs index 6706c5b0..b1a5feff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ mod cli; mod config; #[cfg(feature = "imap")] mod imap; +#[cfg(feature = "maildir")] +mod maildir; #[cfg(feature = "smtp")] mod smtp; diff --git a/src/smtp/command.rs b/src/smtp/command.rs index d9306736..b2bc13f2 100644 --- a/src/smtp/command.rs +++ b/src/smtp/command.rs @@ -10,7 +10,7 @@ use crate::smtp::{account::SmtpAccount, message::command::MessageCommand}; /// you to manage SMTP mailboxes: list mailboxes, read messages, /// add flags etc. #[derive(Debug, Subcommand)] -#[command(rename_all = "lowercase")] +#[command(rename_all = "kebab-case")] pub enum SmtpCommand { #[command(subcommand)] #[command(aliases = ["msgs", "msg"])]