feat: init jmap support

This commit is contained in:
Clément DOUIN
2026-03-25 00:26:43 +01:00
parent 7a581b33b4
commit c720e6e36b
49 changed files with 2918 additions and 138 deletions
Generated
+101 -137
View File
@@ -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",
+6 -1
View File
@@ -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"
+18
View File
@@ -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)?;
+55
View File
@@ -54,8 +54,12 @@ pub struct AccountConfig {
pub table_preset: Option<String>,
pub table_arrangement: Option<TableArrangementConfig>,
#[allow(unused)]
pub imap: Option<ImapConfig>,
pub jmap: Option<JmapConfig>,
#[allow(unused)]
pub maildir: Option<MaildirConfig>,
#[allow(unused)]
pub smtp: Option<SmtpConfig>,
}
@@ -79,6 +83,7 @@ impl From<TableArrangementConfig> 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<SaslConfig> 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<JmapAuthConfig> for pimalaya_toolbox::stream::jmap::JmapAuth {
type Error = pimalaya_toolbox::secret::SecretError;
fn try_from(config: JmapAuthConfig) -> Result<Self, Self::Error> {
match config {
JmapAuthConfig::Bearer { token } => Ok(Self::Bearer(token.get()?)),
JmapAuthConfig::Basic { username, password } => Ok(Self::Basic {
username,
password: password.get()?,
}),
}
}
}
+16
View File
@@ -0,0 +1,16 @@
use anyhow::Result;
use pimalaya_toolbox::stream::jmap::JmapSession;
use crate::{account::Account, config::JmapConfig};
pub type JmapAccount = Account<JmapConfig>;
impl JmapAccount {
pub fn new_jmap_session(&self) -> Result<JmapSession> {
JmapSession::new(
self.backend.url.clone(),
self.backend.tls.clone().try_into()?,
self.backend.auth.clone().try_into()?,
)
}
}
+55
View File
@@ -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),
}
}
}
+41
View File
@@ -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),
}
}
}
+76
View File
@@ -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<String>,
/// 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<String>,
}
impl CopyEmailCommand {
pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> {
let mut jmap = account.new_jmap_session()?;
let mailbox_ids: HashMap<String, bool> =
self.mailbox_id.iter().map(|m| (m.clone(), true)).collect();
let emails: HashMap<String, EmailCopy> = 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 &not_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"))
}
}
+50
View File
@@ -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<String>,
}
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 &not_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"))
}
}
+83
View File
@@ -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<String>,
/// 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(())
}
}
+82
View File
@@ -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<String>,
/// Keywords to set on the imported email (e.g. `$seen`).
#[arg(long, value_name = "KEYWORD", num_args = 0..)]
pub keyword: Vec<String>,
/// Override the `receivedAt` time (RFC 3339).
#[arg(long, value_name = "DATE")]
pub received_at: Option<String>,
}
impl ImportEmailCommand {
pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> {
let mut jmap = account.new_jmap_session()?;
let mailbox_ids: HashMap<String, bool> =
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"))
}
}
+8
View File
@@ -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;
+68
View File
@@ -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<String>,
}
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 &not_found {
printer.log(format!("Blob `{id}` not found."))?;
}
for id in &not_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(())
}
}
+281
View File
@@ -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<String>,
/// Filter by received-before date (RFC 3339, e.g. 2024-01-01T00:00:00Z).
#[arg(long, value_name = "DATE")]
pub before: Option<String>,
/// Filter by received-after date (RFC 3339, e.g. 2024-01-01T00:00:00Z).
#[arg(long, value_name = "DATE")]
pub after: Option<String>,
/// Filter by minimum size in bytes.
#[arg(long, value_name = "BYTES")]
pub min_size: Option<u64>,
/// Filter by maximum size in bytes.
#[arg(long, value_name = "BYTES")]
pub max_size: Option<u64>,
/// Filter to emails that have this keyword set.
#[arg(long, value_name = "KEYWORD")]
pub has_keyword: Option<String>,
/// Filter to emails that do not have this keyword set.
#[arg(long, value_name = "KEYWORD")]
pub not_keyword: Option<String>,
/// 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<String>,
/// Filter by From header (substring match).
#[arg(long, value_name = "TEXT")]
pub from: Option<String>,
/// Filter by To header (substring match).
#[arg(long, value_name = "TEXT")]
pub to: Option<String>,
/// Filter by Subject header (substring match).
#[arg(long, value_name = "TEXT")]
pub subject: Option<String>,
/// Filter by email body (substring match).
#[arg(long, value_name = "TEXT")]
pub body: Option<String>,
/// 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::<Vec<_>>()
.join(", ")
}
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct EmailsTable {
#[serde(skip)]
pub preset: String,
#[serde(skip)]
pub arrangement: ContentArrangement,
pub emails: Vec<Email>,
}
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<SortArg> 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,
}
}
}
+103
View File
@@ -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<String>,
/// Add keyword(s) to the email(s).
#[arg(long, value_name = "KEYWORD", num_args = 0..)]
pub add_keyword: Vec<String>,
/// Remove keyword(s) from the email(s).
#[arg(long, value_name = "KEYWORD", num_args = 0..)]
pub remove_keyword: Vec<String>,
/// Replace all keywords atomically (no fetch required).
#[arg(long, value_name = "KEYWORD", num_args = 0..)]
pub keywords: Option<Vec<String>>,
/// Add email(s) to a mailbox.
#[arg(long, value_name = "MAILBOX-ID", num_args = 1..)]
pub add_mailbox: Vec<String>,
/// Remove email(s) from a mailbox.
#[arg(long, value_name = "MAILBOX-ID", num_args = 1..)]
pub remove_mailbox: Vec<String>,
/// Replace all mailbox memberships atomically.
#[arg(long, value_name = "MAILBOX-ID", num_args = 0..)]
pub mailboxes: Option<Vec<String>>,
}
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<String, bool> = 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<String, bool> = 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 &not_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"))
}
}
+39
View File
@@ -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),
}
}
}
+69
View File
@@ -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<String>,
/// HTML signature to append to outgoing emails.
#[arg(long)]
pub html_signature: Option<String>,
}
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"))
}
}
+52
View File
@@ -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<String>,
}
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 &not_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"))
}
}
+88
View File
@@ -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<Vec<String>>,
}
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 &not_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<Identity>,
}
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}")
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod command;
pub mod create;
pub mod delete;
pub mod get;
pub mod update;
+69
View File
@@ -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<String>,
/// New plaintext signature.
#[arg(long)]
pub text_signature: Option<String>,
/// New HTML signature.
#[arg(long)]
pub html_signature: Option<String>,
}
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"))
}
}
+63
View File
@@ -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<String>,
/// The keyword(s) to add.
#[arg(long, short, num_args = 1..)]
pub keyword: Vec<String>,
}
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 &not_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()
))
}
}
+26
View File
@@ -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),
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod add;
pub mod command;
pub mod remove;
pub mod set;
+61
View File
@@ -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<String>,
/// The keyword(s) to remove.
#[arg(long, short, num_args = 1..)]
pub keyword: Vec<String>,
}
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 &not_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()
))
}
}
+62
View File
@@ -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<String>,
/// Keywords to set (replaces all existing keywords).
#[arg(long, short, num_args = 1..)]
pub keyword: Vec<String>,
}
impl SetKeywordsCommand {
pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> {
let mut jmap = account.new_jmap_session()?;
let keywords: HashMap<String, bool> =
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 &not_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()))
}
}
+36
View File
@@ -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),
}
}
}
+75
View File
@@ -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<String>,
/// 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"))
}
}
+58
View File
@@ -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<String>,
/// 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"))
}
}
+48
View File
@@ -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<String>,
}
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)
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod command;
pub mod create;
pub mod destroy;
pub mod get;
pub mod query;
pub mod update;
+238
View File
@@ -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<String>,
/// Filter by role [possible values: inbox, archive, drafts,
/// flagged, important, junk, sent, subscribed, trash].
#[arg(long, value_name = "ROLE")]
pub role: Option<RoleArg>,
/// Filter by substring name match.
#[arg(long, value_name = "NAME")]
pub name: Option<String>,
/// 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<Mailbox>,
}
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<Self, Self::Err> {
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<RoleArg> 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<SortArg> for MailboxSortProperty {
fn from(arg: SortArg) -> Self {
match arg {
SortArg::Name => MailboxSortProperty::Name,
SortArg::SortOrder => MailboxSortProperty::SortOrder,
SortArg::ParentId => MailboxSortProperty::ParentId,
}
}
}
+99
View File
@@ -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<String>,
/// New parent mailbox ID.
#[arg(long, value_name = "ID")]
pub parent_id: Option<String>,
/// New role.
#[arg(long, value_name = "ROLE")]
pub role: Option<RoleArg>,
/// New sort order.
#[arg(long, value_name = "N")]
pub sort_order: Option<u32>,
/// 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"))
}
}
+9
View File
@@ -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;
+139
View File
@@ -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<String>,
/// 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<String>,
}
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::<Vec<_>>()
.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, "<serialization error: {e}>"),
}
}
}
+94
View File
@@ -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<String>,
}
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<String, serde_json::Value> = 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()
)))
}
}
+38
View File
@@ -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),
}
}
}
+91
View File
@@ -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<String>,
/// Override the RCPT TO addresses (uses `To`, `Cc`, `Bcc` if omitted).
#[arg(long, value_name = "ADDRESS")]
pub rcpt_to: Vec<String>,
}
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))
}
}
+51
View File
@@ -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<String>,
}
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 &not_found {
printer.log(format!("Submission `{id}` not found."))?;
}
printer.out(serde_json::to_value(&submissions)?)
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod cancel;
pub mod command;
pub mod create;
pub mod get;
pub mod query;
+123
View File
@@ -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<String>,
/// Filter by sent-before date (RFC 3339).
#[arg(long, value_name = "DATE")]
pub before: Option<String>,
/// Filter by sent-after date (RFC 3339).
#[arg(long, value_name = "DATE")]
pub after: Option<String>,
/// 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<EmailSubmission>,
}
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}")
}
}
+20
View File
@@ -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),
}
}
}
+49
View File
@@ -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<String>,
}
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 &not_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(())
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod command;
pub mod get;
+26
View File
@@ -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),
}
}
}
+40
View File
@@ -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."),
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod command;
pub mod get;
pub mod set;
+85
View File
@@ -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<String>,
/// Active until date (RFC 3339).
#[arg(long, value_name = "DATE")]
pub to_date: Option<String>,
/// Subject line for the auto-reply.
#[arg(long, value_name = "TEXT")]
pub subject: Option<String>,
/// Plaintext body for the auto-reply.
#[arg(long, value_name = "TEXT")]
pub text_body: Option<String>,
/// HTML body for the auto-reply.
#[arg(long, value_name = "TEXT")]
pub html_body: Option<String>,
}
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."))
}
}
+2
View File
@@ -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")]