diff --git a/Cargo.lock b/Cargo.lock index fc8fc7b1..bb00693d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,21 +26,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse 0.2.7", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - [[package]] name = "anstream" version = "1.0.0" @@ -48,7 +33,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", - "anstyle-parse 1.0.0", + "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -62,15 +47,6 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - [[package]] name = "anstyle-parse" version = "1.0.0" @@ -123,9 +99,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -133,9 +109,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -257,7 +233,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream 1.0.0", + "anstream", "anstyle", "clap_lex", "strsim", @@ -443,9 +419,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -453,11 +429,11 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ - "anstream 0.6.21", + "anstream", "anstyle", "env_filter", "jiff", @@ -634,6 +610,7 @@ dependencies = [ "gethostname", "io-fs", "io-imap", + "io-jmap", "io-maildir", "io-process", "io-smtp", @@ -646,11 +623,28 @@ dependencies = [ "rfc2047-decoder", "secrecy", "serde", + "serde_json", "shellexpand", "uds_windows", "url", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.1.1" @@ -802,16 +796,26 @@ dependencies = [ [[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-http" +version = "0.0.3" +dependencies = [ + "http", + "httparse", + "io-stream", + "log", + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "io-imap" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-imap?branch=io#35186c5ea0f6c535a528029e22ebb6a02bff8473" dependencies = [ "imap-codec", "io-stream", @@ -821,10 +825,24 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "io-jmap" +version = "0.0.1" +dependencies = [ + "http", + "io-http", + "io-stream", + "log", + "secrecy", + "serde", + "serde_json", + "thiserror 2.0.18", + "url", +] + [[package]] name = "io-maildir" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-maildir#105acf1b5e2e005c0afea4ecb27ebe2ae49d3e4f" dependencies = [ "gethostname", "io-fs", @@ -851,7 +869,6 @@ dependencies = [ [[package]] name = "io-smtp" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-smtp#3a74933b9c6e98723c31c6f8a68b366657ccb395" dependencies = [ "io-stream", "log", @@ -898,9 +915,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" @@ -935,7 +952,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -944,9 +961,31 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] [[package]] name = "jobserver" @@ -994,9 +1033,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] @@ -1259,9 +1298,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pimalaya-toolbox" version = "0.0.4" -source = "git+https://github.com/pimalaya/toolbox#64babb1b4358dc57910d815fa91f0dbae9423a9c" dependencies = [ "anyhow", + "base64", "clap", "clap_complete", "clap_mangen", @@ -1270,6 +1309,7 @@ dependencies = [ "gethostname", "git2", "io-imap", + "io-jmap", "io-process", "io-smtp", "io-stream", @@ -1367,9 +1407,9 @@ dependencies = [ [[package]] name = "quoted_printable" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" [[package]] name = "r-efi" @@ -1592,9 +1632,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -1756,7 +1796,6 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smtp-codec" version = "0.2.0" -source = "git+https://github.com/pimalaya/smtp-codec#baeb185ad0a63d43c48b05f7f7a4d9e81b291332" dependencies = [ "abnf-core", "base64", @@ -1768,7 +1807,6 @@ dependencies = [ [[package]] name = "smtp-types" version = "0.2.0" -source = "git+https://github.com/pimalaya/smtp-codec#baeb185ad0a63d43c48b05f7f7a4d9e81b291332" dependencies = [ "base64", "bounded-static", @@ -1844,12 +1882,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1968,9 +2006,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" [[package]] name = "unicode-width" @@ -2218,15 +2256,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2260,30 +2289,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -2296,12 +2308,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -2314,12 +2320,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -2332,24 +2332,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -2362,12 +2350,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -2380,12 +2362,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -2398,12 +2374,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -2416,12 +2386,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -2550,18 +2514,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 45d7327b..a6146c72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,10 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["imap", "smtp", "maildir", "rustls-ring"] +default = ["imap", "maildir", "smtp", "rustls-ring"] imap = ["dep:io-imap", "dep:rfc2047-decoder", "pimalaya-toolbox/imap"] +jmap = ["dep:io-jmap", "dep:serde_json", "pimalaya-toolbox/jmap"] smtp = ["dep:io-smtp", "pimalaya-toolbox/smtp"] maildir = ["dep:convert_case", "dep:io-fs", "dep:io-maildir", "dep:mime_guess"] @@ -41,6 +42,7 @@ dirs = "6" gethostname = "1" 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-jmap = { 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 } @@ -53,6 +55,7 @@ pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["c rfc2047-decoder = { version = "1", optional = true } secrecy = "0.10" serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", optional = true } shellexpand = "3.1" url = { version = "2.2", features = ["serde"] } @@ -62,6 +65,8 @@ 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-jmap.git = "https://github.com/pimalaya/io-jmap" +io-http.git = "https://github.com/pimalaya/io-http" 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" diff --git a/src/cli.rs b/src/cli.rs index 959e2d30..64edb663 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 = "jmap")] +use crate::jmap::command::JmapCommand; #[cfg(feature = "maildir")] use crate::maildir::command::MaildirCommand; #[cfg(feature = "smtp")] @@ -61,6 +63,9 @@ pub enum BackendCommand { #[cfg(feature = "imap")] #[command(subcommand)] Imap(ImapCommand), + #[cfg(feature = "jmap")] + #[command(subcommand)] + Jmap(JmapCommand), #[cfg(feature = "maildir")] #[command(subcommand)] Maildir(MaildirCommand), @@ -93,6 +98,19 @@ impl BackendCommand { cmd.execute(printer, account) } + #[cfg(feature = "jmap")] + Self::Jmap(cmd) => { + let config = Config::from_paths_or_default(config_paths)?; + let (account_name, mut account_config) = config.get_account(account_name)?; + + let Some(jmap_config) = account_config.jmap.take() else { + bail!("JMAP config is missing for account `{account_name}`") + }; + + let account = Account::new(config, account_config, jmap_config)?; + + cmd.execute(printer, account) + } #[cfg(feature = "maildir")] Self::Maildir(cmd) => { let config = Config::from_paths_or_default(config_paths)?; diff --git a/src/config.rs b/src/config.rs index 04078de4..0357c418 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,8 +54,12 @@ pub struct AccountConfig { pub table_preset: Option, pub table_arrangement: Option, + #[allow(unused)] pub imap: Option, + pub jmap: Option, + #[allow(unused)] pub maildir: Option, + #[allow(unused)] pub smtp: Option, } @@ -79,6 +83,7 @@ impl From for ContentArrangement { } /// IMAP configuration. +#[allow(unused)] #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct ImapConfig { @@ -92,6 +97,7 @@ pub struct ImapConfig { } /// Maildir configuration. +#[allow(unused)] #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct MaildirConfig { @@ -99,6 +105,7 @@ pub struct MaildirConfig { } /// SMTP configuration. +#[allow(unused)] #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct SmtpConfig { @@ -255,3 +262,51 @@ impl TryFrom for Sasl { }) } } + +/// JMAP configuration. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct JmapConfig { + /// The HTTPS base URL of the JMAP server. + /// + /// Must use the `https://` or `jmap://` scheme. Session discovery + /// (`GET /.well-known/jmap`) is performed automatically on connection. + pub url: Url, + + /// TLS configuration. + #[serde(default)] + pub tls: TlsConfig, + + /// Authentication configuration. + pub auth: JmapAuthConfig, +} + +/// JMAP authentication configuration. +// https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml#authschemes +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub enum JmapAuthConfig { + /// Bearer token (OAuth 2.0 access token). + Bearer { token: Secret }, + /// HTTP Basic authentication (username + password). + Basic { + #[serde(deserialize_with = "shell_expanded_string")] + username: String, + password: Secret, + }, +} + +#[cfg(feature = "jmap")] +impl TryFrom for pimalaya_toolbox::stream::jmap::JmapAuth { + type Error = pimalaya_toolbox::secret::SecretError; + + fn try_from(config: JmapAuthConfig) -> Result { + match config { + JmapAuthConfig::Bearer { token } => Ok(Self::Bearer(token.get()?)), + JmapAuthConfig::Basic { username, password } => Ok(Self::Basic { + username, + password: password.get()?, + }), + } + } +} diff --git a/src/jmap/account.rs b/src/jmap/account.rs new file mode 100644 index 00000000..0cc8c6fe --- /dev/null +++ b/src/jmap/account.rs @@ -0,0 +1,16 @@ +use anyhow::Result; +use pimalaya_toolbox::stream::jmap::JmapSession; + +use crate::{account::Account, config::JmapConfig}; + +pub type JmapAccount = Account; + +impl JmapAccount { + pub fn new_jmap_session(&self) -> Result { + JmapSession::new( + self.backend.url.clone(), + self.backend.tls.clone().try_into()?, + self.backend.auth.clone().try_into()?, + ) + } +} diff --git a/src/jmap/command.rs b/src/jmap/command.rs new file mode 100644 index 00000000..5b016636 --- /dev/null +++ b/src/jmap/command.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{ + account::JmapAccount, email::command::JmapEmailCommand, identity::command::IdentityCommand, + mailbox::command::JmapMailboxCommand, query::QueryCommand, + submission::command::SubmissionCommand, thread::command::ThreadCommand, + vacation::command::VacationCommand, +}; + +/// JMAP CLI (requires the `jmap` cargo feature). +/// +/// This command gives you access to the JMAP CLI API, and allows you +/// to manage JMAP mailboxes, threads, emails, identities, submissions +/// and vacation responses. +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab-case")] +pub enum JmapCommand { + #[command(subcommand)] + #[command(visible_aliases = ["mbox"])] + Mailboxes(JmapMailboxCommand), + + #[command(subcommand)] + #[command(visible_aliases = ["msg"])] + Emails(JmapEmailCommand), + + #[command(subcommand)] + Threads(ThreadCommand), + #[command(subcommand)] + #[command(aliases = ["identities"])] + Identity(IdentityCommand), + #[command(subcommand)] + #[command(aliases = ["submissions", "submit"])] + Submission(SubmissionCommand), + #[command(subcommand)] + #[command(alias = "vacation-response")] + Vacation(VacationCommand), + Query(QueryCommand), +} + +impl JmapCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + match self { + Self::Mailboxes(cmd) => cmd.execute(printer, account), + Self::Emails(cmd) => cmd.execute(printer, account), + + Self::Threads(cmd) => cmd.execute(printer, account), + Self::Identity(cmd) => cmd.execute(printer, account), + Self::Submission(cmd) => cmd.execute(printer, account), + Self::Vacation(cmd) => cmd.execute(printer, account), + Self::Query(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/jmap/email/command.rs b/src/jmap/email/command.rs new file mode 100644 index 00000000..41a9b560 --- /dev/null +++ b/src/jmap/email/command.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{ + account::JmapAccount, + email::{ + copy::CopyEmailCommand, delete::DeleteEmailCommand, get::JmapEmailGetCommand, + import::ImportEmailCommand, parse::ParseEmailCommand, query::JmapEmailQueryCommand, + update::JmapEmailUpdateCommand, + }, +}; + +/// Manage JMAP emails. +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab-case")] +pub enum JmapEmailCommand { + Get(JmapEmailGetCommand), + Query(JmapEmailQueryCommand), + #[command(alias = "edit")] + Update(JmapEmailUpdateCommand), + #[command(aliases = ["remove", "rm"])] + Delete(DeleteEmailCommand), + Copy(CopyEmailCommand), + Import(ImportEmailCommand), + Parse(ParseEmailCommand), +} + +impl JmapEmailCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + match self { + Self::Get(cmd) => cmd.execute(printer, account), + Self::Query(cmd) => cmd.execute(printer, account), + Self::Update(cmd) => cmd.execute(printer, account), + Self::Delete(cmd) => cmd.execute(printer, account), + Self::Copy(cmd) => cmd.execute(printer, account), + Self::Import(cmd) => cmd.execute(printer, account), + Self::Parse(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/jmap/email/copy.rs b/src/jmap/email/copy.rs new file mode 100644 index 00000000..74555337 --- /dev/null +++ b/src/jmap/email/copy.rs @@ -0,0 +1,76 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::email_copy::{CopyJmapEmails, CopyJmapEmailsResult}, + types::email::EmailCopy, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Copy JMAP emails from another account (Email/copy). +#[derive(Debug, Parser)] +pub struct CopyEmailCommand { + /// Email ID(s) to copy. + #[arg(value_name = "EMAIL-ID", required = true, num_args = 1..)] + pub ids: Vec, + + /// Source account ID to copy from. + #[arg(long, value_name = "ACCOUNT-ID")] + pub from_account: String, + + /// Destination mailbox ID(s) to place copies in. + #[arg(long, value_name = "MAILBOX-ID", num_args = 0..)] + pub mailbox_id: Vec, +} + +impl CopyEmailCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mailbox_ids: HashMap = + self.mailbox_id.iter().map(|m| (m.clone(), true)).collect(); + + let emails: HashMap = self + .ids + .iter() + .map(|id| { + ( + id.clone(), + EmailCopy { + id: id.clone(), + mailbox_ids: mailbox_ids.clone(), + keywords: None, + received_at: None, + }, + ) + }) + .collect(); + + let mut coroutine = CopyJmapEmails::new(jmap.context, self.from_account.clone(), emails)?; + let mut arg = None; + + let not_created = loop { + match coroutine.resume(arg.take()) { + CopyJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + CopyJmapEmailsResult::Ok { not_created, .. } => break not_created, + CopyJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for (id, err) in ¬_created { + let mut ctx = anyhow!("Failed to copy email `{id}`"); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Email(s) successfully copied")) + } +} diff --git a/src/jmap/email/delete.rs b/src/jmap/email/delete.rs new file mode 100644 index 00000000..9b0a0252 --- /dev/null +++ b/src/jmap/email/delete.rs @@ -0,0 +1,50 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::coroutines::email_set::{EmailSetArgs, SetJmapEmails, SetJmapEmailsResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Delete JMAP emails (Email/set destroy). +#[derive(Debug, Parser)] +pub struct DeleteEmailCommand { + /// Email ID(s) to delete. + #[arg(value_name = "ID", required = true, num_args = 1..)] + pub ids: Vec, +} + +impl DeleteEmailCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut args = EmailSetArgs::default(); + + for id in self.ids { + args.destroy(id); + } + + let mut coroutine = SetJmapEmails::new(jmap.context, args)?; + let mut arg = None; + + let not_destroyed = loop { + match coroutine.resume(arg.take()) { + SetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapEmailsResult::Ok { not_destroyed, .. } => break not_destroyed, + SetJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for (id, err) in ¬_destroyed { + let mut ctx = anyhow!("Failed to delete email `{id}`"); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Email(s) successfully deleted")) + } +} diff --git a/src/jmap/email/get.rs b/src/jmap/email/get.rs new file mode 100644 index 00000000..81e0bac6 --- /dev/null +++ b/src/jmap/email/get.rs @@ -0,0 +1,83 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::coroutines::email_get::{GetJmapEmails, GetJmapEmailsResult}; +use io_stream::runtimes::std::handle; +use log::warn; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Get a JMAP email by ID (Email/get). +/// +/// Downloads and displays the full message content including body. +#[derive(Debug, Parser)] +pub struct JmapEmailGetCommand { + /// The email ID(s) to retrieve. + #[arg(value_name = "ID", required = true)] + pub ids: Vec, + + /// Output raw RFC 5322 message headers. + #[arg(long, short)] + pub raw: bool, +} + +impl JmapEmailGetCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut coroutine = + GetJmapEmails::new(jmap.context, self.ids.clone(), None, true, true, None)?; + let mut arg = None; + + let (emails, not_found) = loop { + match coroutine.resume(arg.take()) { + GetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + GetJmapEmailsResult::Ok { + emails, not_found, .. + } => break (emails, not_found), + GetJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for id in not_found { + warn!("email `{id}` not found"); + } + + for email in emails { + if self.raw { + if let Some(headers) = &email.headers { + for h in headers { + printer.log(format!("{}: {}", h.name, h.value))?; + } + } + printer.log("")?; + } + + if let Some(body_values) = &email.body_values { + if let Some(text_parts) = &email.text_body { + for part in text_parts { + if let Some(part_id) = &part.part_id { + if let Some(body_value) = body_values.get(part_id) { + printer.out(&body_value.value)?; + continue; + } + } + } + } + + if let Some(html_parts) = &email.html_body { + for part in html_parts { + if let Some(part_id) = &part.part_id { + if let Some(body_value) = body_values.get(part_id) { + printer.out(&body_value.value)?; + continue; + } + } + } + } + } + } + + Ok(()) + } +} diff --git a/src/jmap/email/import.rs b/src/jmap/email/import.rs new file mode 100644 index 00000000..5cc099ca --- /dev/null +++ b/src/jmap/email/import.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::email_import::{ImportJmapEmail, ImportJmapEmailResult}, + types::email::EmailImport, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Import an RFC 5322 message blob into a mailbox (Email/import). +/// +/// The blob must already be uploaded to the JMAP server. +#[derive(Debug, Parser)] +pub struct ImportEmailCommand { + /// Blob ID of the RFC 5322 message to import. + #[arg(value_name = "BLOB-ID")] + pub blob_id: String, + + /// Mailbox ID(s) to place the imported email in. + #[arg(long, value_name = "MAILBOX-ID", num_args = 0..)] + pub mailbox_id: Vec, + + /// Keywords to set on the imported email (e.g. `$seen`). + #[arg(long, value_name = "KEYWORD", num_args = 0..)] + pub keyword: Vec, + + /// Override the `receivedAt` time (RFC 3339). + #[arg(long, value_name = "DATE")] + pub received_at: Option, +} + +impl ImportEmailCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mailbox_ids: HashMap = + self.mailbox_id.iter().map(|m| (m.clone(), true)).collect(); + + let keywords = if self.keyword.is_empty() { + None + } else { + Some(self.keyword.iter().map(|kw| (kw.clone(), true)).collect()) + }; + + let import = EmailImport { + blob_id: self.blob_id.clone(), + mailbox_ids, + keywords, + received_at: self.received_at, + }; + + let mut emails = HashMap::new(); + emails.insert(self.blob_id.clone(), import); + + let mut coroutine = ImportJmapEmail::new(jmap.context, emails)?; + let mut arg = None; + + let not_created = loop { + match coroutine.resume(arg.take()) { + ImportJmapEmailResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + ImportJmapEmailResult::Ok { not_created, .. } => break not_created, + ImportJmapEmailResult::Err { err, .. } => bail!(err), + } + }; + + if let Some(err) = not_created.get(&self.blob_id) { + let mut ctx = anyhow!("Failed to import email from blob `{}`", self.blob_id); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Email successfully imported from blob")) + } +} diff --git a/src/jmap/email/mod.rs b/src/jmap/email/mod.rs new file mode 100644 index 00000000..9054925f --- /dev/null +++ b/src/jmap/email/mod.rs @@ -0,0 +1,8 @@ +pub mod command; +pub mod copy; +pub mod delete; +pub mod get; +pub mod import; +pub mod parse; +pub mod query; +pub mod update; diff --git a/src/jmap/email/parse.rs b/src/jmap/email/parse.rs new file mode 100644 index 00000000..148c9624 --- /dev/null +++ b/src/jmap/email/parse.rs @@ -0,0 +1,68 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::coroutines::email_parse::{ParseJmapEmails, ParseJmapEmailsResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Parse RFC 5322 message blobs without storing them (Email/parse). +/// +/// Useful for reading attached .eml files or message blobs that are +/// not yet stored as Email objects. +#[derive(Debug, Parser)] +pub struct ParseEmailCommand { + /// Blob ID(s) to parse as RFC 5322 messages. + #[arg(value_name = "BLOB-ID", required = true, num_args = 1..)] + pub blob_ids: Vec, +} + +impl ParseEmailCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut coroutine = ParseJmapEmails::new(jmap.context, self.blob_ids.clone(), None)?; + let mut arg = None; + + let (parsed, not_parsable, not_found) = loop { + match coroutine.resume(arg.take()) { + ParseJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + ParseJmapEmailsResult::Ok { + context, + parsed, + not_parsable, + not_found, + .. + } => { + jmap.context = context; + break (parsed, not_parsable, not_found); + } + ParseJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for id in ¬_found { + printer.log(format!("Blob `{id}` not found."))?; + } + + for id in ¬_parsable { + printer.log(format!("Blob `{id}` is not a valid RFC 5322 message."))?; + } + + for (_blob_id, email) in parsed { + if let Some(body_values) = &email.body_values { + if let Some(text_parts) = &email.text_body { + for part in text_parts { + if let Some(part_id) = &part.part_id { + if let Some(body_value) = body_values.get(part_id) { + printer.out(&body_value.value)?; + } + } + } + } + } + } + + Ok(()) + } +} diff --git a/src/jmap/email/query.rs b/src/jmap/email/query.rs new file mode 100644 index 00000000..016f82c4 --- /dev/null +++ b/src/jmap/email/query.rs @@ -0,0 +1,281 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use clap::{Parser, ValueEnum}; +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use io_jmap::{ + coroutines::email_query::{QueryJmapEmails, QueryJmapEmailsResult}, + types::email::{Email, EmailAddress, EmailComparator, EmailFilter, EmailSortProperty}, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::jmap::account::JmapAccount; + +/// Query JMAP emails (Email/query + Email/get). +/// +/// Lists, filters and sorts email envelopes. +#[derive(Debug, Parser)] +pub struct JmapEmailQueryCommand { + /// Filter by mailbox ID. + #[arg(long, short, value_name = "MAILBOX-ID")] + pub mailbox: Option, + + /// Filter by received-before date (RFC 3339, e.g. 2024-01-01T00:00:00Z). + #[arg(long, value_name = "DATE")] + pub before: Option, + + /// Filter by received-after date (RFC 3339, e.g. 2024-01-01T00:00:00Z). + #[arg(long, value_name = "DATE")] + pub after: Option, + + /// Filter by minimum size in bytes. + #[arg(long, value_name = "BYTES")] + pub min_size: Option, + + /// Filter by maximum size in bytes. + #[arg(long, value_name = "BYTES")] + pub max_size: Option, + + /// Filter to emails that have this keyword set. + #[arg(long, value_name = "KEYWORD")] + pub has_keyword: Option, + + /// Filter to emails that do not have this keyword set. + #[arg(long, value_name = "KEYWORD")] + pub not_keyword: Option, + + /// Filter to emails that have at least one attachment. + #[arg(long)] + pub has_attachment: bool, + + /// Full-text search across all headers and body. + #[arg(long, value_name = "TEXT")] + pub text: Option, + + /// Filter by From header (substring match). + #[arg(long, value_name = "TEXT")] + pub from: Option, + + /// Filter by To header (substring match). + #[arg(long, value_name = "TEXT")] + pub to: Option, + + /// Filter by Subject header (substring match). + #[arg(long, value_name = "TEXT")] + pub subject: Option, + + /// Filter by email body (substring match). + #[arg(long, value_name = "TEXT")] + pub body: Option, + + /// Sort by property. + #[arg(long, value_name = "PROP", default_value_t)] + pub sort: SortArg, + + /// Sort in descending order. + #[arg(long, default_value_t)] + pub desc: bool, + + /// Number of emails to display per page. + #[arg(long, short = 's', value_name = "N", default_value = "10")] + pub page_size: u64, + + /// Page index, starting from 1. + #[arg(long, short, value_name = "N", default_value = "1")] + pub page: u64, +} + +impl JmapEmailQueryCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let filter = { + let f = EmailFilter { + in_mailbox: self.mailbox, + before: self.before, + after: self.after, + min_size: self.min_size, + max_size: self.max_size, + has_keyword: self.has_keyword, + not_keyword: self.not_keyword, + has_attachment: if self.has_attachment { + Some(true) + } else { + None + }, + text: self.text, + from: self.from, + to: self.to, + subject: self.subject, + body: self.body, + ..Default::default() + }; + + let has_one_filter = f.in_mailbox.is_some() + || f.before.is_some() + || f.after.is_some() + || f.min_size.is_some() + || f.max_size.is_some() + || f.has_keyword.is_some() + || f.not_keyword.is_some() + || f.has_attachment.is_some() + || f.text.is_some() + || f.from.is_some() + || f.to.is_some() + || f.subject.is_some() + || f.body.is_some(); + + if has_one_filter { + Some(f) + } else { + None + } + }; + + let sort = Some(vec![EmailComparator { + property: self.sort.into(), + is_ascending: Some(!self.desc), + collation: None, + keyword: None, + }]); + + let mut arg = None; + let mut coroutine = QueryJmapEmails::new( + jmap.context, + filter, + sort, + Some(self.page.saturating_sub(1) * self.page_size), + Some(self.page_size), + None, + )?; + + let emails = loop { + match coroutine.resume(arg.take()) { + QueryJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + QueryJmapEmailsResult::Ok { emails, .. } => break emails, + QueryJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + let table = EmailsTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + emails, + }; + + printer.out(table) + } +} + +fn format_addresses(addrs: &[EmailAddress]) -> String { + addrs + .iter() + .map(|a| { + if let Some(name) = &a.name { + if !name.is_empty() { + return name.clone(); + } + } + a.email.clone() + }) + .collect::>() + .join(", ") +} + +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct EmailsTable { + #[serde(skip)] + pub preset: String, + #[serde(skip)] + pub arrangement: ContentArrangement, + pub emails: Vec, +} + +impl fmt::Display for EmailsTable { + 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("FLAGS"), + Cell::new("SUBJECT"), + Cell::new("FROM"), + Cell::new("DATE"), + ])); + + for e in &self.emails { + let mut flags = String::new(); + let kw = e.keywords.as_ref(); + if !kw.and_then(|k| k.get("$seen")).copied().unwrap_or(false) { + flags.push('U'); + } + if kw.and_then(|k| k.get("$flagged")).copied().unwrap_or(false) { + flags.push('F'); + } + if e.has_attachment.unwrap_or(false) { + flags.push('A'); + } + + let mut row = Row::new(); + row.max_height(1); + row.add_cell(Cell::new(e.id.as_deref().unwrap_or(""))); + row.add_cell(Cell::new(&flags)); + row.add_cell(Cell::new(e.subject.as_deref().unwrap_or(""))); + row.add_cell(Cell::new(format_addresses( + e.from.as_deref().unwrap_or(&[]), + ))); + row.add_cell(Cell::new(e.received_at.as_deref().unwrap_or(""))); + table.add_row(row); + } + + writeln!(f)?; + writeln!(f, "{table}") + } +} + +#[derive(Clone, Debug, Default, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum SortArg { + #[default] + ReceivedAt, + SentAt, + Size, + From, + To, + Subject, + HasAttachment, +} + +impl fmt::Display for SortArg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ReceivedAt => write!(f, "received-at"), + Self::SentAt => write!(f, "sent-at"), + Self::Size => write!(f, "size"), + Self::From => write!(f, "from"), + Self::To => write!(f, "to"), + Self::Subject => write!(f, "subject"), + Self::HasAttachment => write!(f, "has-attachment"), + } + } +} + +impl From for EmailSortProperty { + fn from(arg: SortArg) -> Self { + match arg { + SortArg::ReceivedAt => EmailSortProperty::ReceivedAt, + SortArg::SentAt => EmailSortProperty::SentAt, + SortArg::Size => EmailSortProperty::Size, + SortArg::From => EmailSortProperty::From, + SortArg::To => EmailSortProperty::To, + SortArg::Subject => EmailSortProperty::Subject, + SortArg::HasAttachment => EmailSortProperty::HasAttachment, + } + } +} diff --git a/src/jmap/email/update.rs b/src/jmap/email/update.rs new file mode 100644 index 00000000..57b25c2f --- /dev/null +++ b/src/jmap/email/update.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::coroutines::email_set::{EmailSetArgs, SetJmapEmails, SetJmapEmailsResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Update JMAP emails via patch operations (Email/set). +#[derive(Debug, Parser)] +pub struct JmapEmailUpdateCommand { + /// Email ID(s) to update. + #[arg(value_name = "EMAIL_ID", required = true, num_args = 1..)] + pub ids: Vec, + + /// Add keyword(s) to the email(s). + #[arg(long, value_name = "KEYWORD", num_args = 0..)] + pub add_keyword: Vec, + + /// Remove keyword(s) from the email(s). + #[arg(long, value_name = "KEYWORD", num_args = 0..)] + pub remove_keyword: Vec, + + /// Replace all keywords atomically (no fetch required). + #[arg(long, value_name = "KEYWORD", num_args = 0..)] + pub keywords: Option>, + + /// Add email(s) to a mailbox. + #[arg(long, value_name = "MAILBOX-ID", num_args = 1..)] + pub add_mailbox: Vec, + + /// Remove email(s) from a mailbox. + #[arg(long, value_name = "MAILBOX-ID", num_args = 1..)] + pub remove_mailbox: Vec, + + /// Replace all mailbox memberships atomically. + #[arg(long, value_name = "MAILBOX-ID", num_args = 0..)] + pub mailboxes: Option>, +} + +impl JmapEmailUpdateCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + let mut args = EmailSetArgs::default(); + + for id in &self.ids { + for kw in &self.add_keyword { + args.set_keyword(id.clone(), kw.clone()); + } + + for kw in &self.remove_keyword { + args.unset_keyword(id.clone(), kw.clone()); + } + + if let Some(kws) = &self.keywords { + let map: HashMap = kws.iter().map(|kw| (kw.clone(), true)).collect(); + args.replace_keywords(id.clone(), map); + } + + for mbox in &self.add_mailbox { + args.add_to_mailbox(id.clone(), mbox.clone()); + } + + for mbox in &self.remove_mailbox { + args.remove_from_mailbox(id.clone(), mbox.clone()); + } + + if let Some(mboxes) = &self.mailboxes { + let map: HashMap = mboxes.iter().map(|m| (m.clone(), true)).collect(); + args.replace_mailbox_ids(id.clone(), map); + } + } + + let mut coroutine = SetJmapEmails::new(jmap.context, args)?; + let mut arg = None; + + let not_updated = loop { + match coroutine.resume(arg.take()) { + SetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapEmailsResult::Ok { not_updated, .. } => break not_updated, + SetJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for (id, err) in ¬_updated { + let mut ctx = anyhow!("Failed to update email `{id}`"); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + if !err.properties.is_empty() { + ctx = anyhow!("Invalid properties: {}", err.properties.join(", ")).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Email(s) successfully updated")) + } +} diff --git a/src/jmap/identity/command.rs b/src/jmap/identity/command.rs new file mode 100644 index 00000000..abc19696 --- /dev/null +++ b/src/jmap/identity/command.rs @@ -0,0 +1,39 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{ + account::JmapAccount, + identity::{ + create::CreateIdentityCommand, delete::DeleteIdentityCommand, get::GetIdentityCommand, + update::UpdateIdentityCommand, + }, +}; + +/// Manage JMAP sender identities. +#[derive(Debug, Subcommand)] +pub enum IdentityCommand { + /// Fetch identities (Identity/get). + #[command(aliases = ["lst", "list"])] + Get(GetIdentityCommand), + /// Create a new identity (Identity/set). + #[command(aliases = ["add", "new"])] + Create(CreateIdentityCommand), + /// Update an existing identity (Identity/set). + #[command(alias = "edit")] + Update(UpdateIdentityCommand), + /// Delete an identity (Identity/set). + #[command(aliases = ["remove", "rm"])] + Delete(DeleteIdentityCommand), +} + +impl IdentityCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + match self { + Self::Get(cmd) => cmd.execute(printer, account), + Self::Create(cmd) => cmd.execute(printer, account), + Self::Update(cmd) => cmd.execute(printer, account), + Self::Delete(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/jmap/identity/create.rs b/src/jmap/identity/create.rs new file mode 100644 index 00000000..cf036797 --- /dev/null +++ b/src/jmap/identity/create.rs @@ -0,0 +1,69 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::identity_set::{IdentitySetArgs, SetJmapIdentities, SetJmapIdentitiesResult}, + types::identity::IdentityCreate, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Create a JMAP sender identity (Identity/set). +#[derive(Debug, Parser)] +pub struct CreateIdentityCommand { + /// Display name for the sender. + pub name: String, + + /// Email address for the sender. + pub email: String, + + /// Plaintext signature to append to outgoing emails. + #[arg(long)] + pub text_signature: Option, + + /// HTML signature to append to outgoing emails. + #[arg(long)] + pub html_signature: Option, +} + +impl CreateIdentityCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let identity = IdentityCreate { + name: self.name.clone(), + email: self.email.clone(), + reply_to: None, + bcc: None, + text_signature: self.text_signature, + html_signature: self.html_signature, + }; + + let mut args = IdentitySetArgs::default(); + args.create(self.email.clone(), identity); + + let mut coroutine = SetJmapIdentities::new(jmap.context, args)?; + let mut arg = None; + + let not_created = loop { + match coroutine.resume(arg.take()) { + SetJmapIdentitiesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapIdentitiesResult::Ok { not_created, .. } => break not_created, + SetJmapIdentitiesResult::Err { err, .. } => bail!(err), + } + }; + + if let Some(err) = not_created.get(&self.email) { + let mut ctx = anyhow!("Failed to create identity `{}`", self.email); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Identity successfully created")) + } +} diff --git a/src/jmap/identity/delete.rs b/src/jmap/identity/delete.rs new file mode 100644 index 00000000..563d9a6d --- /dev/null +++ b/src/jmap/identity/delete.rs @@ -0,0 +1,52 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::coroutines::identity_set::{ + IdentitySetArgs, SetJmapIdentities, SetJmapIdentitiesResult, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Delete a JMAP sender identity (Identity/set). +#[derive(Debug, Parser)] +pub struct DeleteIdentityCommand { + /// Identity ID(s) to delete. + #[arg(value_name = "ID", required = true)] + pub ids: Vec, +} + +impl DeleteIdentityCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut args = IdentitySetArgs::default(); + + for id in self.ids { + args.destroy(id); + } + + let mut coroutine = SetJmapIdentities::new(jmap.context, args)?; + let mut arg = None; + + let not_destroyed = loop { + match coroutine.resume(arg.take()) { + SetJmapIdentitiesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapIdentitiesResult::Ok { not_destroyed, .. } => break not_destroyed, + SetJmapIdentitiesResult::Err { err, .. } => bail!(err), + } + }; + + for (id, err) in ¬_destroyed { + let mut ctx = anyhow!("Failed to delete identity `{id}`"); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Identity successfully deleted")) + } +} diff --git a/src/jmap/identity/get.rs b/src/jmap/identity/get.rs new file mode 100644 index 00000000..83e0358b --- /dev/null +++ b/src/jmap/identity/get.rs @@ -0,0 +1,88 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{Cell, Row, Table}; +use io_jmap::{ + coroutines::identity_get::{GetJmapIdentities, GetJmapIdentitiesResult}, + types::identity::Identity, +}; +use io_stream::runtimes::std::handle; +use log::warn; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::jmap::account::JmapAccount; + +/// Get JMAP identities (Identity/get). +/// +/// Lists sender identities available for sending email. Pass no IDs to +/// list all identities. +#[derive(Debug, Parser)] +pub struct GetIdentityCommand { + /// Identity ID(s) to retrieve (omit to get all). + #[arg(value_name = "ID")] + pub ids: Option>, +} + +impl GetIdentityCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut coroutine = GetJmapIdentities::new(jmap.context, self.ids)?; + let mut arg = None; + + let (identities, not_found) = loop { + match coroutine.resume(arg.take()) { + GetJmapIdentitiesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + GetJmapIdentitiesResult::Ok { + identities, + not_found, + .. + } => break (identities, not_found), + GetJmapIdentitiesResult::Err { err, .. } => bail!(err), + } + }; + + for id in ¬_found { + warn!("identity `{id}` not found"); + } + + let table = IdentitiesTable { + preset: account.table_preset, + identities, + }; + + printer.out(table) + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct IdentitiesTable { + #[serde(skip)] + pub preset: String, + pub identities: Vec, +} + +impl fmt::Display for IdentitiesTable { + 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("ID"), + Cell::new("NAME"), + Cell::new("EMAIL"), + ])) + .add_rows( + self.identities.iter().map(|i| { + Row::from([Cell::new(&i.id), Cell::new(&i.name), Cell::new(&i.email)]) + }), + ); + + writeln!(f)?; + writeln!(f, "{table}") + } +} diff --git a/src/jmap/identity/mod.rs b/src/jmap/identity/mod.rs new file mode 100644 index 00000000..632abbb5 --- /dev/null +++ b/src/jmap/identity/mod.rs @@ -0,0 +1,5 @@ +pub mod command; +pub mod create; +pub mod delete; +pub mod get; +pub mod update; diff --git a/src/jmap/identity/update.rs b/src/jmap/identity/update.rs new file mode 100644 index 00000000..45f10a6e --- /dev/null +++ b/src/jmap/identity/update.rs @@ -0,0 +1,69 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::identity_set::{IdentitySetArgs, SetJmapIdentities, SetJmapIdentitiesResult}, + types::identity::IdentityUpdate, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Update a JMAP sender identity (Identity/set). +#[derive(Debug, Parser)] +pub struct UpdateIdentityCommand { + /// Identity ID to update. + pub id: String, + + /// New display name. + #[arg(long)] + pub name: Option, + + /// New plaintext signature. + #[arg(long)] + pub text_signature: Option, + + /// New HTML signature. + #[arg(long)] + pub html_signature: Option, +} + +impl UpdateIdentityCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let patch = IdentityUpdate { + name: self.name, + reply_to: None, + bcc: None, + text_signature: self.text_signature, + html_signature: self.html_signature, + }; + + let mut args = IdentitySetArgs::default(); + args.update(self.id.clone(), patch); + + let mut coroutine = SetJmapIdentities::new(jmap.context, args)?; + let mut arg = None; + + let not_updated = loop { + match coroutine.resume(arg.take()) { + SetJmapIdentitiesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapIdentitiesResult::Ok { not_updated, .. } => break not_updated, + SetJmapIdentitiesResult::Err { err, .. } => bail!(err), + } + }; + + if let Some(err) = not_updated.get(&self.id) { + let mut ctx = anyhow!("Failed to update identity `{}`", self.id); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Identity successfully updated")) + } +} diff --git a/src/jmap/keyword/add.rs b/src/jmap/keyword/add.rs new file mode 100644 index 00000000..14b02f91 --- /dev/null +++ b/src/jmap/keyword/add.rs @@ -0,0 +1,63 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::coroutines::email_set::{EmailSetArgs, SetJmapEmails, SetJmapEmailsResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Add keywords to JMAP emails. +/// +/// Standard JMAP keywords: `$seen`, `$flagged`, `$answered`, `$draft`. +#[derive(Debug, Parser)] +pub struct AddKeywordCommand { + /// Email ID(s) to add the keyword(s) to. + #[arg(value_name = "EMAIL_ID", num_args = 1..)] + pub ids: Vec, + + /// The keyword(s) to add. + #[arg(long, short, num_args = 1..)] + pub keyword: Vec, +} + +impl AddKeywordCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut args = EmailSetArgs::default(); + + for id in &self.ids { + for kw in &self.keyword { + args.set_keyword(id.clone(), kw.clone()); + } + } + + let mut coroutine = SetJmapEmails::new(jmap.context, args)?; + let mut arg = None; + + let not_updated = loop { + match coroutine.resume(arg.take()) { + SetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapEmailsResult::Ok { not_updated, .. } => break not_updated, + SetJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for (id, err) in ¬_updated { + let mut ctx = anyhow!("failed to add keyword to email `{id}`"); + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + if !err.properties.is_empty() { + ctx = anyhow!("invalid properties: {}", err.properties.join(", ")).context(ctx); + } + bail!(ctx); + } + + printer.log(format!( + "Keyword(s) `{}` added to {} email(s).", + self.keyword.join(", "), + self.ids.len() + )) + } +} diff --git a/src/jmap/keyword/command.rs b/src/jmap/keyword/command.rs new file mode 100644 index 00000000..e015aea1 --- /dev/null +++ b/src/jmap/keyword/command.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{ + account::JmapAccount, + keyword::{add::AddKeywordCommand, remove::RemoveKeywordCommand, set::SetKeywordsCommand}, +}; + +/// Manage JMAP email keywords (flags). +#[derive(Debug, Subcommand)] +pub enum KeywordCommand { + Add(AddKeywordCommand), + Remove(RemoveKeywordCommand), + Set(SetKeywordsCommand), +} + +impl KeywordCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + match self { + Self::Add(cmd) => cmd.execute(printer, account), + Self::Remove(cmd) => cmd.execute(printer, account), + Self::Set(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/jmap/keyword/mod.rs b/src/jmap/keyword/mod.rs new file mode 100644 index 00000000..3d56c5ee --- /dev/null +++ b/src/jmap/keyword/mod.rs @@ -0,0 +1,4 @@ +pub mod add; +pub mod command; +pub mod remove; +pub mod set; diff --git a/src/jmap/keyword/remove.rs b/src/jmap/keyword/remove.rs new file mode 100644 index 00000000..50bf7a42 --- /dev/null +++ b/src/jmap/keyword/remove.rs @@ -0,0 +1,61 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::coroutines::email_set::{EmailSetArgs, SetJmapEmails, SetJmapEmailsResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Remove keywords from JMAP emails. +#[derive(Debug, Parser)] +pub struct RemoveKeywordCommand { + /// Email ID(s) to remove the keyword(s) from. + #[arg(value_name = "EMAIL_ID", num_args = 1..)] + pub ids: Vec, + + /// The keyword(s) to remove. + #[arg(long, short, num_args = 1..)] + pub keyword: Vec, +} + +impl RemoveKeywordCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut args = EmailSetArgs::default(); + + for id in &self.ids { + for kw in &self.keyword { + args.unset_keyword(id.clone(), kw.clone()); + } + } + + let mut coroutine = SetJmapEmails::new(jmap.context, args)?; + let mut arg = None; + + let not_updated = loop { + match coroutine.resume(arg.take()) { + SetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapEmailsResult::Ok { not_updated, .. } => break not_updated, + SetJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for (id, err) in ¬_updated { + let mut ctx = anyhow!("failed to remove keyword from email `{id}`"); + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + if !err.properties.is_empty() { + ctx = anyhow!("invalid properties: {}", err.properties.join(", ")).context(ctx); + } + bail!(ctx); + } + + printer.log(format!( + "Keyword(s) `{}` removed from {} email(s).", + self.keyword.join(", "), + self.ids.len() + )) + } +} diff --git a/src/jmap/keyword/set.rs b/src/jmap/keyword/set.rs new file mode 100644 index 00000000..1edf314a --- /dev/null +++ b/src/jmap/keyword/set.rs @@ -0,0 +1,62 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::coroutines::email_set::{EmailSetArgs, SetJmapEmails, SetJmapEmailsResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Replace all keywords on JMAP emails. +/// +/// Replaces the entire set of keywords atomically — no need to know +/// the current keywords first. +#[derive(Debug, Parser)] +pub struct SetKeywordsCommand { + /// Email ID(s) to update. + #[arg(value_name = "EMAIL_ID", num_args = 1..)] + pub ids: Vec, + + /// Keywords to set (replaces all existing keywords). + #[arg(long, short, num_args = 1..)] + pub keyword: Vec, +} + +impl SetKeywordsCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let keywords: HashMap = + self.keyword.iter().map(|kw| (kw.clone(), true)).collect(); + + let mut args = EmailSetArgs::default(); + for id in &self.ids { + args.replace_keywords(id.clone(), keywords.clone()); + } + + let mut coroutine = SetJmapEmails::new(jmap.context, args)?; + let mut arg = None; + + let not_updated = loop { + match coroutine.resume(arg.take()) { + SetJmapEmailsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapEmailsResult::Ok { not_updated, .. } => break not_updated, + SetJmapEmailsResult::Err { err, .. } => bail!(err), + } + }; + + for (id, err) in ¬_updated { + let mut ctx = anyhow!("failed to set keywords on email `{id}`"); + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + if !err.properties.is_empty() { + ctx = anyhow!("invalid properties: {}", err.properties.join(", ")).context(ctx); + } + bail!(ctx); + } + + printer.log(format!("Keywords set on {} email(s).", self.ids.len())) + } +} diff --git a/src/jmap/mailbox/command.rs b/src/jmap/mailbox/command.rs new file mode 100644 index 00000000..b1f94164 --- /dev/null +++ b/src/jmap/mailbox/command.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{ + account::JmapAccount, + mailbox::{ + create::JmapMailboxCreateCommand, destroy::JmapMailboxDestroyCommand, + get::JmapMailboxGetCommand, query::JmapMailboxQueryCommand, + update::JmapMailboxUpdateCommand, + }, +}; + +/// Manage JMAP mailboxes. +#[derive(Debug, Subcommand)] +pub enum JmapMailboxCommand { + Get(JmapMailboxGetCommand), + Query(JmapMailboxQueryCommand), + #[command(visible_aliases = ["add", "new"])] + Create(JmapMailboxCreateCommand), + Update(JmapMailboxUpdateCommand), + #[command(visible_aliases = ["delete", "del", "remove", "rm"])] + Destroy(JmapMailboxDestroyCommand), +} + +impl JmapMailboxCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + match self { + Self::Get(cmd) => cmd.execute(printer, account), + Self::Query(cmd) => cmd.execute(printer, account), + Self::Create(cmd) => cmd.execute(printer, account), + Self::Update(cmd) => cmd.execute(printer, account), + Self::Destroy(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/jmap/mailbox/create.rs b/src/jmap/mailbox/create.rs new file mode 100644 index 00000000..19357935 --- /dev/null +++ b/src/jmap/mailbox/create.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::mailbox_set::{MailboxSetArgs, SetJmapMailboxes, SetJmapMailboxesResult}, + types::mailbox::MailboxCreate, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Create a JMAP mailbox. +#[derive(Debug, Parser)] +pub struct JmapMailboxCreateCommand { + /// The name of the new mailbox. + #[arg(value_name = "NAME")] + pub name: String, + + /// Attach the new mailbox to the parent mailbox matching the + /// given identifier. + #[arg(long, value_name = "ID")] + pub parent_id: Option, + + /// Should subscribe to the new mailbox. + #[arg(long, value_name = "NAME")] + pub subscribe: bool, +} + +impl JmapMailboxCreateCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let new_mailbox = MailboxCreate { + name: Some(self.name.clone()), + parent_id: self.parent_id, + is_subscribed: if self.subscribe { Some(true) } else { None }, + ..Default::default() + }; + + let mut create = HashMap::new(); + create.insert(self.name.clone(), new_mailbox); + + let mut args = MailboxSetArgs::default(); + args.create = Some(create); + + let mut coroutine = SetJmapMailboxes::new(jmap.context, args)?; + let mut arg = None; + + let not_created = loop { + match coroutine.resume(arg.take()) { + SetJmapMailboxesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapMailboxesResult::Ok { not_created, .. } => break not_created, + SetJmapMailboxesResult::Err { err, .. } => bail!(err), + } + }; + + if let Some(err) = not_created.get(&self.name) { + let mut ctx = anyhow!("Create JMAP mailbox `{}` error", self.name); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + if !err.properties.is_empty() { + ctx = anyhow!("Invalid properties: {}", err.properties.join(", ")).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Mailbox successfully created")) + } +} diff --git a/src/jmap/mailbox/destroy.rs b/src/jmap/mailbox/destroy.rs new file mode 100644 index 00000000..5165909d --- /dev/null +++ b/src/jmap/mailbox/destroy.rs @@ -0,0 +1,58 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::coroutines::mailbox_set::{MailboxSetArgs, SetJmapMailboxes, SetJmapMailboxesResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Delete a JMAP mailbox. +#[derive(Debug, Parser)] +pub struct JmapMailboxDestroyCommand { + /// The ID of the mailbox to delete. + #[arg(value_name = "ID", required = true, num_args = 1..)] + pub ids: Vec, + + /// Destroy all emails in the mailbox when deleting. + #[arg(long, default_value_t)] + pub purge: bool, +} + +impl JmapMailboxDestroyCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut args = MailboxSetArgs::default(); + args.destroy = Some(self.ids.clone()); + args.on_destroy_remove_emails = if self.purge { Some(true) } else { None }; + + let mut arg = None; + let mut coroutine = SetJmapMailboxes::new(jmap.context, args)?; + + let not_destroyed = loop { + match coroutine.resume(arg.take()) { + SetJmapMailboxesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapMailboxesResult::Ok { not_destroyed, .. } => break not_destroyed, + SetJmapMailboxesResult::Err { err, .. } => bail!(err), + } + }; + + for ref id in self.ids { + if let Some(err) = not_destroyed.get(id) { + let mut ctx = anyhow!("Update JMAP mailbox `{id}` error"); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + if !err.properties.is_empty() { + ctx = anyhow!("Invalid properties: {}", err.properties.join(", ")).context(ctx); + } + + bail!(ctx); + } + } + + printer.out(Message::new("Mailbox successfully deleted")) + } +} diff --git a/src/jmap/mailbox/get.rs b/src/jmap/mailbox/get.rs new file mode 100644 index 00000000..20445337 --- /dev/null +++ b/src/jmap/mailbox/get.rs @@ -0,0 +1,48 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::coroutines::mailbox_get::{GetJmapMailboxes, GetJmapMailboxesResult}; +use io_stream::runtimes::std::handle; +use log::warn; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{account::JmapAccount, mailbox::query::MailboxesTable}; + +/// Get JMAP mailboxes by ID (Mailbox/get). +#[derive(Debug, Parser)] +pub struct JmapMailboxGetCommand { + /// Mailbox ID(s) to retrieve. + #[arg(value_name = "ID", required = true)] + pub ids: Vec, +} + +impl JmapMailboxGetCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut coroutine = GetJmapMailboxes::new(jmap.context, Some(self.ids.clone()), None)?; + let mut arg = None; + + let (mailboxes, not_found) = loop { + match coroutine.resume(arg.take()) { + GetJmapMailboxesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + GetJmapMailboxesResult::Ok { + mailboxes, + not_found, + .. + } => break (mailboxes, not_found), + GetJmapMailboxesResult::Err { err, .. } => bail!(err), + } + }; + + for id in not_found { + warn!("mailbox `{id}` not found"); + } + + let table = MailboxesTable { + preset: account.table_preset, + mailboxes, + }; + + printer.out(table) + } +} diff --git a/src/jmap/mailbox/mod.rs b/src/jmap/mailbox/mod.rs new file mode 100644 index 00000000..49a87e08 --- /dev/null +++ b/src/jmap/mailbox/mod.rs @@ -0,0 +1,6 @@ +pub mod command; +pub mod create; +pub mod destroy; +pub mod get; +pub mod query; +pub mod update; diff --git a/src/jmap/mailbox/query.rs b/src/jmap/mailbox/query.rs new file mode 100644 index 00000000..656baa87 --- /dev/null +++ b/src/jmap/mailbox/query.rs @@ -0,0 +1,238 @@ +use std::{convert::Infallible, fmt, str::FromStr}; + +use anyhow::{bail, Result}; +use clap::{Parser, ValueEnum}; +use comfy_table::{Cell, Row, Table}; +use io_jmap::{ + coroutines::mailbox_query::{QueryJmapMailboxes, QueryJmapMailboxesResult}, + types::mailbox::{ + Mailbox, MailboxFilter, MailboxRole, MailboxSortComparator, MailboxSortProperty, + }, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::jmap::account::JmapAccount; + +/// Query JMAP mailboxes (Mailbox/query + Mailbox/get). +/// +/// Lists, filters and sorts mailboxes. +#[derive(Debug, Parser)] +pub struct JmapMailboxQueryCommand { + /// Filter by parent mailbox identifier. + #[arg(long, value_name = "ID")] + pub parent_id: Option, + + /// Filter by role [possible values: inbox, archive, drafts, + /// flagged, important, junk, sent, subscribed, trash]. + #[arg(long, value_name = "ROLE")] + pub role: Option, + + /// Filter by substring name match. + #[arg(long, value_name = "NAME")] + pub name: Option, + + /// List all mailboxes, not just subscribed ones. + #[arg(long, default_value_t)] + pub all: bool, + + /// Only return mailboxes that have a role. + #[arg(long, default_value_t)] + pub has_any_role: bool, + + /// Sort by property. + #[arg(long, value_name = "PROP", default_value_t)] + pub sort: SortArg, + + /// Sort in descending order. + #[arg(long, default_value_t)] + pub desc: bool, + + /// Number of mailboxes to display per page. + #[arg(long, short = 's', value_name = "N", default_value = "10")] + pub page_size: u64, + + /// Page index, starting from 1. + #[arg(long, short, value_name = "N", default_value = "1")] + pub page: u64, +} + +impl JmapMailboxQueryCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let filter = { + let f = MailboxFilter { + parent_id: self.parent_id, + role: self.role.map(Into::into), + name: self.name, + is_subscribed: if self.all { None } else { Some(true) }, + has_any_role: if self.has_any_role { Some(true) } else { None }, + }; + + let has_one_filter = f.parent_id.is_some() + || f.role.is_some() + || f.name.is_some() + || f.is_subscribed.is_some() + || f.has_any_role.is_some(); + + if has_one_filter { + Some(f) + } else { + None + } + }; + + let sort = Some(vec![MailboxSortComparator { + property: self.sort.into(), + is_ascending: Some(!self.desc), + }]); + + let mut arg = None; + let mut coroutine = QueryJmapMailboxes::new( + jmap.context, + filter, + sort, + Some(self.page.saturating_sub(1) * self.page_size), + Some(self.page_size), + None, + )?; + + let mailboxes = loop { + match coroutine.resume(arg.take()) { + QueryJmapMailboxesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + QueryJmapMailboxesResult::Ok { mailboxes, .. } => break mailboxes, + QueryJmapMailboxesResult::Err { err, .. } => bail!(err), + } + }; + + let table = MailboxesTable { + preset: account.table_preset, + mailboxes, + }; + + printer.out(table) + } +} + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(transparent)] +pub struct MailboxesTable { + #[serde(skip)] + pub preset: String, + pub mailboxes: Vec, +} + +impl fmt::Display for MailboxesTable { + 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("ID"), + Cell::new("NAME"), + Cell::new("ROLE"), + Cell::new("TOTAL"), + Cell::new("UNREAD"), + Cell::new("SUBSCRIBED"), + ])) + .add_rows(self.mailboxes.iter().map(|r| { + let mut row = Row::new(); + row.max_height(1) + .add_cell(Cell::new(r.id.as_deref().unwrap_or("Unknown"))) + .add_cell(Cell::new(r.name.as_deref().unwrap_or("Unknown"))) + .add_cell(match r.role.as_ref() { + Some(r) => Cell::new(r.to_string()), + None => Cell::new(""), + }) + .add_cell(Cell::new(r.total_emails)) + .add_cell(Cell::new(r.unread_emails)) + .add_cell(Cell::new(if r.is_subscribed { "yes" } else { "" })); + row + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} + +#[derive(Clone, Debug)] +pub enum RoleArg { + Inbox, + Archive, + Drafts, + Flagged, + Important, + Junk, + Sent, + Subscribed, + Trash, + Other(String), +} + +impl FromStr for RoleArg { + type Err = Infallible; + + fn from_str(s: &str) -> Result { + match s { + "inbox" => Ok(Self::Inbox), + "archive" => Ok(Self::Archive), + "drafts" => Ok(Self::Drafts), + "flagged" => Ok(Self::Flagged), + "important" => Ok(Self::Important), + "junk" => Ok(Self::Junk), + "sent" => Ok(Self::Sent), + "subscribed" => Ok(Self::Subscribed), + "trash" => Ok(Self::Trash), + other => Ok(Self::Other(other.to_owned())), + } + } +} + +impl From for MailboxRole { + fn from(arg: RoleArg) -> Self { + match arg { + RoleArg::Inbox => MailboxRole::Inbox, + RoleArg::Archive => MailboxRole::Archive, + RoleArg::Drafts => MailboxRole::Drafts, + RoleArg::Flagged => MailboxRole::Flagged, + RoleArg::Important => MailboxRole::Important, + RoleArg::Junk => MailboxRole::Junk, + RoleArg::Sent => MailboxRole::Sent, + RoleArg::Subscribed => MailboxRole::Subscribed, + RoleArg::Trash => MailboxRole::Trash, + RoleArg::Other(s) => MailboxRole::Other(s), + } + } +} + +#[derive(Clone, Debug, Default, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum SortArg { + Name, + #[default] + SortOrder, + ParentId, +} + +impl fmt::Display for SortArg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Name => write!(f, "name"), + Self::SortOrder => write!(f, "sort-order"), + Self::ParentId => write!(f, "parent-id"), + } + } +} + +impl From for MailboxSortProperty { + fn from(arg: SortArg) -> Self { + match arg { + SortArg::Name => MailboxSortProperty::Name, + SortArg::SortOrder => MailboxSortProperty::SortOrder, + SortArg::ParentId => MailboxSortProperty::ParentId, + } + } +} diff --git a/src/jmap/mailbox/update.rs b/src/jmap/mailbox/update.rs new file mode 100644 index 00000000..ff7f8767 --- /dev/null +++ b/src/jmap/mailbox/update.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::mailbox_set::{MailboxSetArgs, SetJmapMailboxes, SetJmapMailboxesResult}, + types::mailbox::MailboxUpdate, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::{account::JmapAccount, mailbox::query::RoleArg}; + +/// Update a JMAP mailbox. +#[derive(Debug, Parser)] +pub struct JmapMailboxUpdateCommand { + /// The ID of the mailbox to update. + #[arg(value_name = "ID")] + pub id: String, + + /// New display name. + #[arg(long)] + pub name: Option, + + /// New parent mailbox ID. + #[arg(long, value_name = "ID")] + pub parent_id: Option, + + /// New role. + #[arg(long, value_name = "ROLE")] + pub role: Option, + + /// New sort order. + #[arg(long, value_name = "N")] + pub sort_order: Option, + + /// Subscribe to the mailbox. + #[arg(long, conflicts_with = "unsubscribe")] + pub subscribe: bool, + + /// Unsubscribe from the mailbox. + #[arg(long, conflicts_with = "subscribe")] + pub unsubscribe: bool, +} + +impl JmapMailboxUpdateCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let is_subscribed = if self.subscribe { + Some(true) + } else if self.unsubscribe { + Some(false) + } else { + None + }; + + let patch = MailboxUpdate { + name: self.name, + parent_id: self.parent_id, + role: self.role.map(Into::into), + sort_order: self.sort_order, + is_subscribed, + }; + + let mut update = HashMap::new(); + update.insert(self.id.clone(), patch); + + let mut args = MailboxSetArgs::default(); + args.update = Some(update); + + let mut arg = None; + let mut coroutine = SetJmapMailboxes::new(jmap.context, args)?; + + let not_updated = loop { + match coroutine.resume(arg.take()) { + SetJmapMailboxesResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SetJmapMailboxesResult::Ok { not_updated, .. } => break not_updated, + SetJmapMailboxesResult::Err { err, .. } => bail!(err), + } + }; + + if let Some(err) = not_updated.get(&self.id) { + let mut ctx = anyhow!("Update JMAP mailbox `{}` error", self.id); + + if let Some(desc) = &err.description { + ctx = anyhow!(desc.clone()).context(ctx); + } + + if !err.properties.is_empty() { + ctx = anyhow!("Invalid properties: {}", err.properties.join(", ")).context(ctx); + } + + bail!(ctx); + } + + printer.out(Message::new("Mailbox successfully updated")) + } +} diff --git a/src/jmap/mod.rs b/src/jmap/mod.rs new file mode 100644 index 00000000..90f97f0e --- /dev/null +++ b/src/jmap/mod.rs @@ -0,0 +1,9 @@ +pub mod account; +pub mod command; +pub mod email; +pub mod identity; +pub mod mailbox; +pub mod query; +pub mod submission; +pub mod thread; +pub mod vacation; diff --git a/src/jmap/query.rs b/src/jmap/query.rs new file mode 100644 index 00000000..9268512a --- /dev/null +++ b/src/jmap/query.rs @@ -0,0 +1,139 @@ +use std::{ + fmt, + io::{stdin, BufRead}, +}; + +use anyhow::{bail, Context, Result}; +use clap::Parser; +use io_jmap::coroutines::send::{JmapRequest, SendJmapRequest, SendJmapRequestResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::jmap::account::JmapAccount; + +/// Send a raw JMAP method-calls array and print the response. +/// +/// METHOD_CALLS must be a JSON array of JMAP method call tuples: +/// +/// '[["Mailbox/query", {"filter": {"role": "inbox"}}, "c0"]]' +/// +/// The `accountId` field is injected into each call's arguments +/// automatically if not already present. Pass `-` or omit to read +/// from stdin. +#[derive(Debug, Parser)] +pub struct QueryCommand { + /// Extra capability URNs to declare (core and mail are always included). + #[arg(long = "using", value_name = "URN")] + pub using: Vec, + + /// The JMAP methodCalls JSON array (or omit / pass `-` to read stdin). + #[arg(trailing_var_arg = true)] + #[arg(name = "method-calls", value_name = "METHOD_CALLS")] + pub method_calls: Vec, +} + +impl QueryCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let raw = if self.method_calls.is_empty() + || self.method_calls.first().map(|s| s.as_str()) == Some("-") + { + stdin() + .lock() + .lines() + .map_while(Result::ok) + .collect::>() + .join("\n") + } else { + self.method_calls.join(" ") + }; + + let calls_value: serde_json::Value = + serde_json::from_str(&raw).context("METHOD_CALLS is not valid JSON")?; + + let serde_json::Value::Array(calls_arr) = calls_value else { + bail!("METHOD_CALLS must be a JSON array"); + }; + + let account_id = jmap.context.account_id.clone().unwrap_or_default(); + + // Parse and inject accountId into each call's args. + let mut method_calls = Vec::with_capacity(calls_arr.len()); + for (i, call) in calls_arr.into_iter().enumerate() { + let serde_json::Value::Array(mut tuple) = call else { + bail!("method call #{i} must be a JSON array [name, args, callId]"); + }; + if tuple.len() != 3 { + bail!("method call #{i} must have exactly 3 elements [name, args, callId]"); + } + let call_id = match tuple.remove(2) { + serde_json::Value::String(s) => s, + v => bail!("method call #{i} callId must be a string, got {v}"), + }; + let mut args = tuple.remove(1); + let name = match tuple.remove(0) { + serde_json::Value::String(s) => s, + v => bail!("method call #{i} name must be a string, got {v}"), + }; + // Inject accountId if the args object doesn't already have it. + if let serde_json::Value::Object(ref mut map) = args { + map.entry("accountId") + .or_insert_with(|| serde_json::Value::String(account_id.clone())); + } + method_calls.push((name, args, call_id)); + } + + let mut using = vec![ + io_jmap::types::session::capabilities::CORE.to_string(), + io_jmap::types::session::capabilities::MAIL.to_string(), + ]; + for extra in self.using { + if !using.contains(&extra) { + using.push(extra); + } + } + + let api_url = jmap + .context + .api_url() + .cloned() + .unwrap_or_else(|| "http://localhost".parse().unwrap()); + + let request = JmapRequest { + using, + method_calls, + created_ids: None, + }; + + let mut coroutine = SendJmapRequest::new(jmap.context, &api_url, request)?; + let mut arg = None; + + let response = loop { + match coroutine.resume(arg.take()) { + SendJmapRequestResult::Ok { context, response, .. } => { + jmap.context = context; + break response; + } + SendJmapRequestResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SendJmapRequestResult::Err { err, .. } => return Err(err.into()), + } + }; + + printer.out(RawResponse(response.method_responses)) + } +} + +/// Wraps the raw method_responses for display. +#[derive(Serialize)] +struct RawResponse(Vec<(String, serde_json::Value, String)>); + +impl fmt::Display for RawResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match serde_json::to_string_pretty(&self.0) { + Ok(s) => write!(f, "{s}"), + Err(e) => write!(f, ""), + } + } +} diff --git a/src/jmap/submission/cancel.rs b/src/jmap/submission/cancel.rs new file mode 100644 index 00000000..41936533 --- /dev/null +++ b/src/jmap/submission/cancel.rs @@ -0,0 +1,94 @@ +use anyhow::{anyhow, bail, Result}; +use clap::Parser; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Cancel (undo) a pending JMAP email submission (EmailSubmission/set). +/// +/// Only submissions with `undoStatus: "pending"` can be canceled. +/// The server may reject this if the message has already been sent. +#[derive(Debug, Parser)] +pub struct CancelSubmissionCommand { + /// Submission ID(s) to cancel. + #[arg(value_name = "SUBMISSION_ID", num_args = 1..)] + pub ids: Vec, +} + +impl CancelSubmissionCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + // EmailSubmission/set update: set undoStatus to "canceled" + let update: std::collections::HashMap = self + .ids + .iter() + .map(|id| { + ( + id.clone(), + serde_json::json!({ "undoStatus": "canceled" }), + ) + }) + .collect(); + + let args = serde_json::json!({ + "update": update + }); + + // Use the raw query approach via SubmitJmapEmail isn't suitable here; + // we need a direct EmailSubmission/set update. Use the query command + // pattern with a raw request instead. + // + // For now, build the request directly via the send coroutine. + use io_jmap::{ + coroutines::send::{JmapBatch, SendJmapRequest, SendJmapRequestResult}, + types::session::capabilities, + }; + + let account_id = jmap.context.account_id.clone().unwrap_or_default(); + let api_url = jmap + .context + .api_url() + .cloned() + .unwrap_or_else(|| "http://localhost".parse().unwrap()); + + let mut json_args = args.clone(); + json_args["accountId"] = serde_json::json!(account_id); + + let mut batch = JmapBatch::new(); + batch.add("EmailSubmission/set", json_args); + let request = batch.into_request(vec![ + capabilities::CORE.into(), + capabilities::MAIL.into(), + capabilities::SUBMISSION.into(), + ]); + + let mut send = SendJmapRequest::new(jmap.context, &api_url, request) + .map_err(|e| anyhow!("{e}"))?; + let mut arg = None; + + loop { + match send.resume(arg.take()) { + SendJmapRequestResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SendJmapRequestResult::Ok { context, response, .. } => { + jmap.context = context; + if let Some((name, args, _)) = + response.method_responses.into_iter().next() + { + if name == "error" { + bail!("EmailSubmission/set error: {args}"); + } + } + break; + } + SendJmapRequestResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new(format!( + "{} submission(s) canceled.", + self.ids.len() + ))) + } +} diff --git a/src/jmap/submission/command.rs b/src/jmap/submission/command.rs new file mode 100644 index 00000000..86924e0f --- /dev/null +++ b/src/jmap/submission/command.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{ + account::JmapAccount, + submission::{ + cancel::CancelSubmissionCommand, create::CreateSubmissionCommand, + get::GetSubmissionCommand, query::QuerySubmissionCommand, + }, +}; + +/// Manage JMAP email submissions. +#[derive(Debug, Subcommand)] +#[command(rename_all = "kebab-case")] +pub enum SubmissionCommand { + /// Fetch submissions by ID (EmailSubmission/get). + Get(GetSubmissionCommand), + /// Query and list submissions (EmailSubmission/query + EmailSubmission/get). + #[command(aliases = ["lst", "list"])] + Query(QuerySubmissionCommand), + /// Submit a draft email for sending (EmailSubmission/set). + #[command(aliases = ["send", "submit"])] + Create(CreateSubmissionCommand), + /// Cancel a pending submission (EmailSubmission/set). + Cancel(CancelSubmissionCommand), +} + +impl SubmissionCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + match self { + Self::Get(cmd) => cmd.execute(printer, account), + Self::Query(cmd) => cmd.execute(printer, account), + Self::Create(cmd) => cmd.execute(printer, account), + Self::Cancel(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/jmap/submission/create.rs b/src/jmap/submission/create.rs new file mode 100644 index 00000000..992d7005 --- /dev/null +++ b/src/jmap/submission/create.rs @@ -0,0 +1,91 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::email_submission_set::{SubmitJmapEmail, SubmitJmapEmailResult}, + types::email_submission::{EmailAddressWithParameters, EmailSubmissionCreate, Envelope}, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Submit a JMAP email for sending (EmailSubmission/set). +/// +/// The email must already exist as a draft in the JMAP account. +/// This is the JMAP equivalent of SMTP message submission. +#[derive(Debug, Parser)] +pub struct CreateSubmissionCommand { + /// The ID of the draft email to send. + #[arg(value_name = "EMAIL_ID")] + pub email_id: String, + + /// The identity ID to send as (from `identity get`). + #[arg(long, value_name = "IDENTITY_ID")] + pub identity_id: String, + + /// Override the MAIL FROM address (uses `From` header if omitted). + #[arg(long, value_name = "ADDRESS")] + pub mail_from: Option, + + /// Override the RCPT TO addresses (uses `To`, `Cc`, `Bcc` if omitted). + #[arg(long, value_name = "ADDRESS")] + pub rcpt_to: Vec, +} + +impl CreateSubmissionCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let envelope = if let Some(mail_from_addr) = self.mail_from { + let rcpt_to = self + .rcpt_to + .into_iter() + .map(|addr| EmailAddressWithParameters { email: addr, parameters: None }) + .collect(); + Some(Envelope { + mail_from: EmailAddressWithParameters { + email: mail_from_addr, + parameters: None, + }, + rcpt_to, + }) + } else { + None + }; + + let submission = EmailSubmissionCreate { + identity_id: self.identity_id, + email_id: self.email_id.clone(), + envelope, + }; + + let mut submissions = std::collections::HashMap::new(); + submissions.insert("send-1".to_string(), submission); + + let mut coroutine = SubmitJmapEmail::new(jmap.context, submissions)?; + let mut arg = None; + + loop { + match coroutine.resume(arg.take()) { + SubmitJmapEmailResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + SubmitJmapEmailResult::Ok { context, not_created, .. } => { + jmap.context = context; + + if let Some(err) = not_created.get("send-1") { + bail!( + "failed to send email `{}`: {} — {}", + self.email_id, + err.error_type, + err.description.as_deref().unwrap_or("no description") + ); + } + + break; + } + SubmitJmapEmailResult::Err { err, .. } => bail!(err), + } + } + + printer.log(format!("Email `{}` successfully sent.", self.email_id)) + } +} diff --git a/src/jmap/submission/get.rs b/src/jmap/submission/get.rs new file mode 100644 index 00000000..77ee5947 --- /dev/null +++ b/src/jmap/submission/get.rs @@ -0,0 +1,51 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::coroutines::email_submission_get::{ + GetJmapEmailSubmissions, GetJmapEmailSubmissionsResult, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Get JMAP email submissions by ID (EmailSubmission/get). +#[derive(Debug, Parser)] +pub struct GetSubmissionCommand { + /// Submission ID(s) to retrieve. + #[arg(value_name = "SUBMISSION_ID", num_args = 1..)] + pub ids: Vec, +} + +impl GetSubmissionCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut coroutine = + GetJmapEmailSubmissions::new(jmap.context, Some(self.ids.clone()))?; + let mut arg = None; + + let (submissions, not_found) = loop { + match coroutine.resume(arg.take()) { + GetJmapEmailSubmissionsResult::Io(io) => { + arg = Some(handle(&mut jmap.stream, io)?) + } + GetJmapEmailSubmissionsResult::Ok { + context, + submissions, + not_found, + .. + } => { + jmap.context = context; + break (submissions, not_found); + } + GetJmapEmailSubmissionsResult::Err { err, .. } => bail!(err), + } + }; + + for id in ¬_found { + printer.log(format!("Submission `{id}` not found."))?; + } + + printer.out(serde_json::to_value(&submissions)?) + } +} diff --git a/src/jmap/submission/mod.rs b/src/jmap/submission/mod.rs new file mode 100644 index 00000000..ad776e78 --- /dev/null +++ b/src/jmap/submission/mod.rs @@ -0,0 +1,5 @@ +pub mod cancel; +pub mod command; +pub mod create; +pub mod get; +pub mod query; diff --git a/src/jmap/submission/query.rs b/src/jmap/submission/query.rs new file mode 100644 index 00000000..288c6a98 --- /dev/null +++ b/src/jmap/submission/query.rs @@ -0,0 +1,123 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{Cell, Row, Table}; +use io_jmap::{ + coroutines::email_submission_query::{ + QueryJmapEmailSubmissions, QueryJmapEmailSubmissionsResult, + }, + types::email_submission::EmailSubmission, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; +use serde::Serialize; + +use crate::jmap::account::JmapAccount; + +/// Query JMAP email submissions (EmailSubmission/query + EmailSubmission/get). +#[derive(Debug, Parser)] +pub struct QuerySubmissionCommand { + /// Filter by undo status (`pending`, `final`, `canceled`). + #[arg(long, value_name = "STATUS")] + pub undo_status: Option, + + /// Filter by sent-before date (RFC 3339). + #[arg(long, value_name = "DATE")] + pub before: Option, + + /// Filter by sent-after date (RFC 3339). + #[arg(long, value_name = "DATE")] + pub after: Option, + + /// Number of submissions to display per page. + #[arg(long, short = 's', value_name = "N", default_value = "10")] + pub page_size: u64, + + /// Page index, starting from 1. + #[arg(long, short, value_name = "N", default_value = "1")] + pub page: u64, +} + +impl QuerySubmissionCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let filter = { + use io_jmap::types::email_submission::EmailSubmissionFilter; + let f = EmailSubmissionFilter { + undo_status: self.undo_status, + before: self.before, + after: self.after, + ..Default::default() + }; + let has_one = f.undo_status.is_some() || f.before.is_some() || f.after.is_some(); + if has_one { Some(f) } else { None } + }; + + let mut coroutine = QueryJmapEmailSubmissions::new( + jmap.context, + filter, + None, + Some(self.page.saturating_sub(1) * self.page_size), + Some(self.page_size), + )?; + let mut arg = None; + + let submissions = loop { + match coroutine.resume(arg.take()) { + QueryJmapEmailSubmissionsResult::Io(io) => { + arg = Some(handle(&mut jmap.stream, io)?) + } + QueryJmapEmailSubmissionsResult::Ok { context, submissions, .. } => { + jmap.context = context; + break submissions; + } + QueryJmapEmailSubmissionsResult::Err { err, .. } => bail!(err), + } + }; + + let table = SubmissionsTable { + preset: account.table_preset, + submissions, + }; + + printer.out(table) + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct SubmissionsTable { + #[serde(skip)] + pub preset: String, + pub submissions: Vec, +} + +impl fmt::Display for SubmissionsTable { + 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("ID"), + Cell::new("EMAIL-ID"), + Cell::new("IDENTITY-ID"), + Cell::new("STATUS"), + Cell::new("SENT-AT"), + ])) + .add_rows(self.submissions.iter().map(|s| { + Row::from([ + Cell::new(s.id.as_deref().unwrap_or("")), + Cell::new(&s.email_id), + Cell::new(&s.identity_id), + Cell::new(s.undo_status.as_deref().unwrap_or("")), + Cell::new(s.send_at.as_deref().unwrap_or("")), + ]) + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} diff --git a/src/jmap/thread/command.rs b/src/jmap/thread/command.rs new file mode 100644 index 00000000..2e9cfdbf --- /dev/null +++ b/src/jmap/thread/command.rs @@ -0,0 +1,20 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{account::JmapAccount, thread::get::GetThreadCommand}; + +/// Manage JMAP threads. +#[derive(Debug, Subcommand)] +pub enum ThreadCommand { + /// Fetch threads by ID (Thread/get). + Get(GetThreadCommand), +} + +impl ThreadCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + match self { + Self::Get(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/jmap/thread/get.rs b/src/jmap/thread/get.rs new file mode 100644 index 00000000..b53cc71f --- /dev/null +++ b/src/jmap/thread/get.rs @@ -0,0 +1,49 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::coroutines::thread_get::{GetJmapThreads, GetJmapThreadsResult}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Get JMAP threads by ID (Thread/get). +/// +/// Each thread contains an ordered list of email IDs in the thread. +#[derive(Debug, Parser)] +pub struct GetThreadCommand { + /// Thread ID(s) to retrieve. + #[arg(value_name = "ID", required = true)] + pub ids: Vec, +} + +impl GetThreadCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut coroutine = GetJmapThreads::new(jmap.context, self.ids.clone())?; + let mut arg = None; + + let (threads, not_found) = loop { + match coroutine.resume(arg.take()) { + GetJmapThreadsResult::Io(io) => arg = Some(handle(&mut jmap.stream, io)?), + GetJmapThreadsResult::Ok { + threads, not_found, .. + } => break (threads, not_found), + GetJmapThreadsResult::Err { err, .. } => bail!(err), + } + }; + + for id in ¬_found { + printer.log(format!("Thread `{id}` not found."))?; + } + + for thread in threads { + printer.out(serde_json::json!({ + "id": thread.id, + "emailIds": thread.email_ids, + }))?; + } + + Ok(()) + } +} diff --git a/src/jmap/thread/mod.rs b/src/jmap/thread/mod.rs new file mode 100644 index 00000000..60680179 --- /dev/null +++ b/src/jmap/thread/mod.rs @@ -0,0 +1,2 @@ +pub mod command; +pub mod get; diff --git a/src/jmap/vacation/command.rs b/src/jmap/vacation/command.rs new file mode 100644 index 00000000..14b0bbd4 --- /dev/null +++ b/src/jmap/vacation/command.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::{ + account::JmapAccount, + vacation::{get::GetVacationCommand, set::SetVacationCommand}, +}; + +/// Manage JMAP vacation response. +#[derive(Debug, Subcommand)] +pub enum VacationCommand { + /// Get the vacation response (VacationResponse/get). + Get(GetVacationCommand), + /// Update the vacation response (VacationResponse/set). + Set(SetVacationCommand), +} + +impl VacationCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + match self { + Self::Get(cmd) => cmd.execute(printer, account), + Self::Set(cmd) => cmd.execute(printer, account), + } + } +} diff --git a/src/jmap/vacation/get.rs b/src/jmap/vacation/get.rs new file mode 100644 index 00000000..9fe4d6eb --- /dev/null +++ b/src/jmap/vacation/get.rs @@ -0,0 +1,40 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::coroutines::vacation_response_get::{ + GetJmapVacationResponse, GetJmapVacationResponseResult, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::jmap::account::JmapAccount; + +/// Get the JMAP vacation response (VacationResponse/get). +#[derive(Debug, Parser)] +pub struct GetVacationCommand; + +impl GetVacationCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let mut coroutine = GetJmapVacationResponse::new(jmap.context)?; + let mut arg = None; + + let vacation = loop { + match coroutine.resume(arg.take()) { + GetJmapVacationResponseResult::Io(io) => { + arg = Some(handle(&mut jmap.stream, io)?) + } + GetJmapVacationResponseResult::Ok { context, vacation_response, .. } => { + jmap.context = context; + break vacation_response; + } + GetJmapVacationResponseResult::Err { err, .. } => bail!(err), + } + }; + + match vacation { + Some(v) => printer.out(serde_json::to_value(&v)?), + None => printer.log("No vacation response configured."), + } + } +} diff --git a/src/jmap/vacation/mod.rs b/src/jmap/vacation/mod.rs new file mode 100644 index 00000000..a6c8df6f --- /dev/null +++ b/src/jmap/vacation/mod.rs @@ -0,0 +1,3 @@ +pub mod command; +pub mod get; +pub mod set; diff --git a/src/jmap/vacation/set.rs b/src/jmap/vacation/set.rs new file mode 100644 index 00000000..fcab72a1 --- /dev/null +++ b/src/jmap/vacation/set.rs @@ -0,0 +1,85 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use io_jmap::{ + coroutines::vacation_response_set::{ + SetJmapVacationResponse, SetJmapVacationResponseResult, + }, + types::vacation_response::VacationResponseUpdate, +}; +use io_stream::runtimes::std::handle; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::jmap::account::JmapAccount; + +/// Update the JMAP vacation response (VacationResponse/set). +#[derive(Debug, Parser)] +pub struct SetVacationCommand { + /// Enable the vacation response. + #[arg(long, conflicts_with = "disable")] + pub enable: bool, + + /// Disable the vacation response. + #[arg(long, conflicts_with = "enable")] + pub disable: bool, + + /// Active from date (RFC 3339). + #[arg(long, value_name = "DATE")] + pub from_date: Option, + + /// Active until date (RFC 3339). + #[arg(long, value_name = "DATE")] + pub to_date: Option, + + /// Subject line for the auto-reply. + #[arg(long, value_name = "TEXT")] + pub subject: Option, + + /// Plaintext body for the auto-reply. + #[arg(long, value_name = "TEXT")] + pub text_body: Option, + + /// HTML body for the auto-reply. + #[arg(long, value_name = "TEXT")] + pub html_body: Option, +} + +impl SetVacationCommand { + pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + let mut jmap = account.new_jmap_session()?; + + let is_enabled = if self.enable { + Some(true) + } else if self.disable { + Some(false) + } else { + None + }; + + let patch = VacationResponseUpdate { + is_enabled, + from_date: self.from_date, + to_date: self.to_date, + subject: self.subject, + text_body: self.text_body, + html_body: self.html_body, + }; + + let mut coroutine = SetJmapVacationResponse::new(jmap.context, patch)?; + let mut arg = None; + + loop { + match coroutine.resume(arg.take()) { + SetJmapVacationResponseResult::Io(io) => { + arg = Some(handle(&mut jmap.stream, io)?) + } + SetJmapVacationResponseResult::Ok { context, .. } => { + jmap.context = context; + break; + } + SetJmapVacationResponseResult::Err { err, .. } => bail!(err), + } + } + + printer.out(Message::new("Vacation response updated.")) + } +} diff --git a/src/main.rs b/src/main.rs index b1a5feff..913d9dfc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ mod cli; mod config; #[cfg(feature = "imap")] mod imap; +#[cfg(feature = "jmap")] +mod jmap; #[cfg(feature = "maildir")] mod maildir; #[cfg(feature = "smtp")]