From c32047d4def5642fc0c1b6a705c758891137c1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Fri, 1 May 2026 02:25:34 +0200 Subject: [PATCH] rewrite lib without io-socket nor io-fs, and with io-email --- Cargo.lock | 258 ++++++++------ Cargo.toml | 19 +- config.sample.toml | 41 +++ src/attachments/command.rs | 35 ++ src/attachments/download.rs | 153 ++++++++ src/attachments/list.rs | 107 ++++++ src/attachments/mod.rs | 4 + src/attachments/table.rs | 68 ++++ src/cli.rs | 134 ++++++- src/config.rs | 11 + src/envelopes/command.rs | 34 ++ src/envelopes/list.rs | 221 ++++++++++++ src/envelopes/mod.rs | 3 + src/envelopes/table.rs | 59 ++++ src/flags/add.rs | 194 +++++++++++ src/flags/arg.rs | 81 +++++ src/flags/command.rs | 38 ++ src/flags/delete.rs | 194 +++++++++++ src/flags/mod.rs | 5 + src/flags/set.rs | 194 +++++++++++ src/imap/envelope/get.rs | 41 ++- src/imap/envelope/list.rs | 57 ++- src/imap/envelope/search.rs | 40 ++- src/imap/envelope/sort.rs | 38 +- src/imap/envelope/thread.rs | 56 ++- src/imap/flag/add.rs | 37 +- src/imap/flag/list.rs | 25 +- src/imap/flag/remove.rs | 37 +- src/imap/flag/set.rs | 37 +- src/imap/id.rs | 23 +- src/imap/mailbox/close.rs | 21 +- src/imap/mailbox/create.rs | 21 +- src/imap/mailbox/delete.rs | 21 +- src/imap/mailbox/expunge.rs | 37 +- src/imap/mailbox/list.rs | 40 ++- src/imap/mailbox/purge.rs | 52 ++- src/imap/mailbox/rename.rs | 21 +- src/imap/mailbox/select.rs | 21 +- src/imap/mailbox/status.rs | 24 +- src/imap/mailbox/subscribe.rs | 21 +- src/imap/mailbox/unselect.rs | 22 +- src/imap/mailbox/unsubscribe.rs | 21 +- src/imap/message/copy.rs | 35 +- src/imap/message/export.rs | 37 +- src/imap/message/get.rs | 41 ++- src/imap/message/move.rs | 35 +- src/imap/message/read.rs | 41 ++- src/imap/message/save.rs | 21 +- src/jmap/email/copy.rs | 47 +-- src/jmap/email/delete.rs | 36 +- src/jmap/email/export.rs | 58 +++- src/jmap/email/get.rs | 21 +- src/jmap/email/import.rs | 71 ++-- src/jmap/email/parse.rs | 23 +- src/jmap/email/query.rs | 28 +- src/jmap/email/read.rs | 25 +- src/jmap/email/update.rs | 44 +-- src/jmap/error.rs | 230 ++++++++++++ src/jmap/identity/create.rs | 43 ++- src/jmap/identity/delete.rs | 38 +- src/jmap/identity/get.rs | 28 +- src/jmap/identity/update.rs | 43 ++- src/jmap/mailbox/create.rs | 46 +-- src/jmap/mailbox/destroy.rs | 38 +- src/jmap/mailbox/get.rs | 21 +- src/jmap/mailbox/query.rs | 32 +- src/jmap/mailbox/update.rs | 46 +-- src/jmap/mod.rs | 1 + src/jmap/query.rs | 30 +- src/jmap/submission/cancel.rs | 38 +- src/jmap/submission/create.rs | 54 ++- src/jmap/submission/get.rs | 23 +- src/jmap/submission/query.rs | 32 +- src/jmap/thread/get.rs | 28 +- src/jmap/vacation/get.rs | 34 +- src/jmap/vacation/set.rs | 31 +- src/mailboxes/command.rs | 34 ++ src/mailboxes/list.rs | 164 +++++++++ src/mailboxes/mod.rs | 3 + src/mailboxes/table.rs | 45 +++ src/maildir/create.rs | 18 +- src/maildir/delete.rs | 18 +- src/maildir/envelope/get.rs | 30 +- src/maildir/envelope/list.rs | 36 +- src/maildir/flag/add.rs | 22 +- src/maildir/flag/remove.rs | 24 +- src/maildir/flag/set.rs | 22 +- src/maildir/list.rs | 18 +- src/maildir/message/copy.rs | 25 +- src/maildir/message/export.rs | 31 +- src/maildir/message/get.rs | 31 +- src/maildir/message/move.rs | 25 +- src/maildir/message/read.rs | 33 +- src/maildir/message/save.rs | 27 +- src/maildir/mod.rs | 1 + src/maildir/rename.rs | 17 +- src/maildir/runtime.rs | 93 +++++ src/main.rs | 9 +- src/messages/add.rs | 203 +++++++++++ src/messages/command.rs | 49 +++ src/messages/compose.rs | 599 ++++++++++++++++++++++++++++++++ src/messages/copy.rs | 173 +++++++++ src/messages/fetch.rs | 143 ++++++++ src/messages/get.rs | 255 ++++++++++++++ src/messages/mod.rs | 9 + src/messages/mv.rs | 174 ++++++++++ src/messages/send.rs | 257 ++++++++++++++ src/smtp/message/send.rs | 21 +- tests/common/jmap.rs | 2 +- 109 files changed, 5664 insertions(+), 912 deletions(-) create mode 100644 src/attachments/command.rs create mode 100644 src/attachments/download.rs create mode 100644 src/attachments/list.rs create mode 100644 src/attachments/mod.rs create mode 100644 src/attachments/table.rs create mode 100644 src/envelopes/command.rs create mode 100644 src/envelopes/list.rs create mode 100644 src/envelopes/mod.rs create mode 100644 src/envelopes/table.rs create mode 100644 src/flags/add.rs create mode 100644 src/flags/arg.rs create mode 100644 src/flags/command.rs create mode 100644 src/flags/delete.rs create mode 100644 src/flags/mod.rs create mode 100644 src/flags/set.rs create mode 100644 src/jmap/error.rs create mode 100644 src/mailboxes/command.rs create mode 100644 src/mailboxes/list.rs create mode 100644 src/mailboxes/mod.rs create mode 100644 src/mailboxes/table.rs create mode 100644 src/maildir/runtime.rs create mode 100644 src/messages/add.rs create mode 100644 src/messages/command.rs create mode 100644 src/messages/compose.rs create mode 100644 src/messages/copy.rs create mode 100644 src/messages/fetch.rs create mode 100644 src/messages/get.rs create mode 100644 src/messages/mod.rs create mode 100644 src/messages/mv.rs create mode 100644 src/messages/send.rs diff --git a/Cargo.lock b/Cargo.lock index 928246ed..c8150cff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" dependencies = [ "anstyle", "bstr", @@ -114,9 +114,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", "zeroize", @@ -124,9 +124,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" dependencies = [ "cc", "cmake", @@ -142,9 +142,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bounded-static" @@ -182,9 +182,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -244,9 +244,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -267,18 +267,18 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.2" +version = "4.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" +checksum = "660c0520455b1013b9bcb0393d5f643d7e4454fb69c915b8d6d2aa0e9a45acc3" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -535,6 +535,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "gethostname" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +dependencies = [ + "libc", + "windows-targets 0.48.5", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -640,15 +650,15 @@ dependencies = [ "comfy-table", "convert_case", "dirs", - "gethostname", - "io-fs", + "gethostname 1.1.0", + "io-email", "io-imap", "io-jmap", "io-maildir", "io-process", "io-smtp", - "io-socket", "log", + "mail-builder", "mail-parser", "mime_guess", "open", @@ -770,9 +780,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -819,22 +829,28 @@ dependencies = [ ] [[package]] -name = "io-fs" -version = "0.0.2" -source = "git+https://github.com/pimalaya/io-fs#96cfd6cf4c058dec430a993b893ecaa6bcf40901" +name = "io-email" +version = "0.0.1" dependencies = [ + "io-imap", + "io-jmap", + "io-maildir", + "io-smtp", "log", + "mail-parser", + "secrecy", + "serde", "thiserror", + "url", ] [[package]] name = "io-http" version = "0.0.3" -source = "git+https://github.com/pimalaya/io-http#898a841b009ba133ac5242a8456fc3138bf05311" +source = "git+https://github.com/pimalaya/io-http#c299cf8bc7512507a0e108561e10eaf4194de1a5" dependencies = [ "base64", "httparse", - "io-socket", "log", "memchr", "secrecy", @@ -845,10 +861,9 @@ dependencies = [ [[package]] name = "io-imap" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-imap?branch=io#f6a8ce517740d2922b310203658cd494b759d70f" +source = "git+https://github.com/pimalaya/io-imap?branch=io#573fd8bacf2e834a3f1be0c58af7cda9240b7c2d" dependencies = [ "imap-codec", - "io-socket", "log", "secrecy", "thiserror", @@ -857,10 +872,9 @@ dependencies = [ [[package]] name = "io-jmap" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-jmap#facc39bd8d18fbe5957e948980aded750abd45fd" +source = "git+https://github.com/pimalaya/io-jmap#605074f1aec3d4a30fb95d44086a6e9a4c1e3a1b" dependencies = [ "io-http", - "io-socket", "log", "secrecy", "serde", @@ -872,10 +886,8 @@ dependencies = [ [[package]] name = "io-maildir" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-maildir#86b9d82a0a4332993dca084e2f2fe0c54534b9f0" dependencies = [ - "gethostname", - "io-fs", + "gethostname 1.1.0", "log", "mail-parser", "memchr", @@ -898,27 +910,17 @@ dependencies = [ [[package]] name = "io-smtp" version = "0.0.1" -source = "git+https://github.com/pimalaya/io-smtp#d80b9961f874149a65f8fc3084c4b7ac187e7c81" +source = "git+https://github.com/pimalaya/io-smtp#4c4f4976cbb0ff544014b17f3632aaf9476fbdd4" dependencies = [ "base64", "bounded-static", "bounded-static-derive", "chumsky 1.0.0-alpha.8", - "io-socket", "log", "secrecy", "thiserror", ] -[[package]] -name = "io-socket" -version = "0.0.1" -source = "git+https://github.com/pimalaya/io-socket#5899582a02a2bde78a4c92e5a2ed2f2cfea54c29" -dependencies = [ - "log", - "thiserror", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -952,9 +954,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", "log", @@ -965,9 +967,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -1041,9 +1043,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libgit2-sys" @@ -1111,6 +1113,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mail-builder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f5871d5270ed80f2ee750b95600c8d69b05f8653ad3be913b2ad2e924fefcb" +dependencies = [ + "gethostname 0.4.3", +] + [[package]] name = "mail-parser" version = "0.11.2" @@ -1217,9 +1228,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.3.3" +version = "5.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" dependencies = [ "is-wsl", "libc", @@ -1228,9 +1239,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.77" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags", "cfg-if", @@ -1269,9 +1280,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.113" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -1324,7 +1335,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pimalaya-toolbox" version = "0.0.4" -source = "git+https://github.com/pimalaya/toolbox#60ec858fbcdc8f048c15eb54a711d80eadebcf20" +source = "git+https://github.com/pimalaya/toolbox#be0b944ad03663fec3179237f05b58b97f883e51" dependencies = [ "anyhow", "base64", @@ -1333,13 +1344,12 @@ dependencies = [ "clap_mangen", "dirs", "env_logger", - "gethostname", + "gethostname 1.1.0", "git2", "io-imap", "io-jmap", "io-process", "io-smtp", - "io-socket", "log", "native-tls", "rustls", @@ -1369,9 +1379,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -1442,9 +1452,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" dependencies = [ "ar_archive_writer", "cc", @@ -1479,9 +1489,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", @@ -1631,9 +1641,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", @@ -1659,9 +1669,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "zeroize", ] @@ -1695,9 +1705,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -1874,15 +1884,15 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.23" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" dependencies = [ "cc", "cfg-if", "libc", "psm", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2124,11 +2134,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -2137,7 +2147,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -2176,9 +2186,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -2226,16 +2236,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2247,34 +2248,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "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_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2287,24 +2321,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2329,6 +2387,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 8a99226f..34ec31be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,10 +18,10 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["imap", "smtp", "rustls-ring"] -imap = ["dep:io-imap", "dep:mail-parser", "dep:rfc2047-decoder", "pimalaya-toolbox/imap"] -jmap = ["dep:io-jmap", "dep:serde_json", "pimalaya-toolbox/jmap"] -smtp = ["dep:io-smtp", "dep:mail-parser", "pimalaya-toolbox/smtp"] -maildir = ["dep:convert_case", "dep:io-fs", "dep:io-maildir", "dep:mail-parser", "dep:mime_guess"] +imap = ["dep:io-imap", "dep:mail-parser", "dep:rfc2047-decoder", "pimalaya-toolbox/imap", "io-email/imap"] +jmap = ["dep:io-jmap", "dep:mail-parser", "dep:serde_json", "pimalaya-toolbox/jmap", "io-email/jmap"] +smtp = ["dep:io-smtp", "dep:mail-parser", "pimalaya-toolbox/smtp", "io-email/smtp"] +maildir = ["dep:convert_case", "dep:io-maildir", "dep:mail-parser", "dep:mime_guess", "io-email/maildir"] native-tls = ["pimalaya-toolbox/native-tls"] rustls-aws = ["pimalaya-toolbox/rustls-aws"] @@ -40,18 +40,18 @@ comfy-table = "7" convert_case = { version = "0.11", optional = true } dirs = "6" gethostname = "1" -io-fs = { version = "0.0.2", default-features = false, features = ["std"], optional = true } +io-email = { version = "0.0.1", default-features = false, features = ["serde"] } 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, optional = true } -io-socket = { version = "0.0.1", default-features = false, features = ["std-stream"] } log = "0.4" +mail-builder = "0.3" mail-parser = { version = "0.11", features = ["serde"], optional = true } mime_guess = { version = "2", optional = true } open = "5" -pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["config", "terminal", "secret"] } +pimalaya-toolbox = { version = "0.0.4", default-features = false, features = ["config", "terminal", "secret", "sasl", "stream"] } rfc2047-decoder = { version = "1", optional = true } secrecy = "0.10" serde = { version = "1", features = ["derive"] } @@ -70,11 +70,10 @@ serde_json = "1" tempfile = "3" [patch.crates-io] -io-fs.git = "https://github.com/pimalaya/io-fs" +io-email.path = "../io-email" io-http.git = "https://github.com/pimalaya/io-http" io-imap = { git = "https://github.com/pimalaya/io-imap", branch = "io" } io-jmap.git = "https://github.com/pimalaya/io-jmap" -io-maildir.git = "https://github.com/pimalaya/io-maildir" +io-maildir.path = "../io-maildir" io-smtp.git = "https://github.com/pimalaya/io-smtp" -io-socket.git = "https://github.com/pimalaya/io-socket" pimalaya-toolbox.git = "https://github.com/pimalaya/toolbox" diff --git a/config.sample.toml b/config.sample.toml index 23381c41..a59533a7 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -67,6 +67,47 @@ imap.sasl.login.username = "username" imap.sasl.login.password.command = ["mimosa", "password", "read", "example"] #imap.sasl.login.password.raw = "***" +# -------------------------------- +# JMAP config +# -------------------------------- + +# JMAP server address. Either a bare authority (auto-discovered via +# /.well-known/jmap) or a full session URL. +#jmap.server = "fastmail.com" +#jmap.server = "https://api.fastmail.com/jmap/session" + +# JMAP TLS provider (mirrors the imap.tls block above) +#jmap.tls.provider = "rustls" +#jmap.tls.rustls.crypto = "ring" + +# Authentication. Pick exactly one of header/bearer/basic. + +# Raw "Authorization" header value, used verbatim +#jmap.auth.header.raw = "Bearer eyJhbGciOiJ..." + +# OAuth 2.0 / API token bearer +#jmap.auth.bearer.token.command = ["mimosa", "password", "read", "fastmail-api"] + +# HTTP Basic +#jmap.auth.basic.username = "user@example.com" +#jmap.auth.basic.password.command = ["mimosa", "password", "read", "fastmail"] + +# Identity to send as. Required only for `himalaya messages send` over +# JMAP; can be discovered with `himalaya jmap identity get`. +#jmap.identity-id = "I0123abc" + +# Drafts mailbox id used to stage outgoing messages before submission. +# Required only for `himalaya messages send` over JMAP; can be +# discovered with `himalaya jmap mailbox query --role drafts`. +#jmap.drafts-mailbox-id = "M0123abc" + +# -------------------------------- +# Maildir config +# -------------------------------- + +# Root directory containing one subdirectory per mailbox (Maildir++ layout) +#maildir.root = "~/Mail/example" + # -------------------------------- # SMTP config # -------------------------------- diff --git a/src/attachments/command.rs b/src/attachments/command.rs new file mode 100644 index 00000000..dc356507 --- /dev/null +++ b/src/attachments/command.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + attachments::{download::AttachmentsDownloadCommand, list::AttachmentsListCommand}, + cli::BackendArg, + config::{AccountConfig, Config}, +}; + +/// List or download attachments carried by a single message. +/// +/// Available wherever `messages get` is — that is, IMAP, JMAP and +/// Maildir. The active backend is selected by `--backend` (default +/// `auto`). +#[derive(Debug, Subcommand)] +pub enum AttachmentsCommand { + List(AttachmentsListCommand), + Download(AttachmentsDownloadCommand), +} + +impl AttachmentsCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, config, account_config, backend), + Self::Download(cmd) => cmd.execute(printer, config, account_config, backend), + } + } +} diff --git a/src/attachments/download.rs b/src/attachments/download.rs new file mode 100644 index 00000000..8a1486e3 --- /dev/null +++ b/src/attachments/download.rs @@ -0,0 +1,153 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Result}; +use clap::Parser; +use mail_parser::{MessageParser, MimeHeaders}; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, +}; + +/// Download the attachments carried by a single message to disk. +/// +/// "Attachment" follows mail_parser's classification: parts with +/// `Content-Disposition: attachment`, or any non-body part with a +/// `filename`/`name` parameter. Inline parts are skipped by default; +/// pass `--include-inline` to download them too. +/// +/// The destination directory defaults to the account's +/// `downloads-dir` config (falling back to the global one, then the +/// platform's standard downloads directory). Pass `--dir ` to +/// override. +#[derive(Debug, Parser)] +pub struct AttachmentsDownloadCommand { + /// Identifier of the message (IMAP UID, JMAP email id, or Maildir + /// filename id). + #[arg(value_name = "ID")] + pub id: String, + + /// Mailbox name or path (IMAP/Maildir). Ignored for JMAP. + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + /// Destination directory. Overrides the account/global + /// `downloads-dir` config. + #[arg(long = "dir", short = 'd', value_name = "PATH")] + pub dir: Option, + + /// Include parts with `Content-Disposition: inline`. + #[arg(long = "include-inline")] + pub include_inline: bool, +} + +impl AttachmentsDownloadCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + let raw = crate::messages::fetch::fetch_raw( + &config, + &account_config, + backend, + &self.mailbox, + &self.id, + )?; + + let Some(message) = MessageParser::new().parse(&raw) else { + bail!("Failed to parse RFC 5322 message"); + }; + + let account = Account::new(config, account_config, ())?; + let dir = self.dir.clone().unwrap_or(account.downloads_dir); + + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + + let mut written = Vec::new(); + for (index, part) in message.attachments().enumerate() { + let inline = part + .content_disposition() + .map(|cd| cd.c_type.eq_ignore_ascii_case("inline")) + .unwrap_or(false); + if inline && !self.include_inline { + continue; + } + + let filename = part + .attachment_name() + .map(str::to_owned) + .unwrap_or_else(|| format!("attachment-{index}")); + let safe = sanitize(&filename); + let path = unique_path(&dir, &safe); + + fs::write(&path, part.contents())?; + written.push(path.display().to_string()); + } + + if written.is_empty() { + return printer.out(Message::new("No attachments to download")); + } + + printer.out(Message::new(format!( + "Downloaded {} attachment(s):\n {}", + written.len(), + written.join("\n ") + ))) + } +} + +/// Strips path separators and parent traversals so a hostile filename +/// header can't escape the download directory. +fn sanitize(name: &str) -> String { + let trimmed = name.trim(); + let cleaned: String = trimmed + .chars() + .map(|c| match c { + '/' | '\\' | '\0' => '_', + _ => c, + }) + .collect(); + let cleaned = cleaned.trim_start_matches('.').trim(); + if cleaned.is_empty() { + "attachment".to_string() + } else { + cleaned.to_string() + } +} + +/// Returns a path inside `dir` that doesn't already exist by suffixing +/// `(1)`, `(2)`, … to the stem when needed. +fn unique_path(dir: &Path, name: &str) -> PathBuf { + let candidate = dir.join(name); + if !candidate.exists() { + return candidate; + } + + let (stem, ext) = match name.rsplit_once('.') { + Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), + _ => (name.to_string(), String::new()), + }; + + for n in 1..1024 { + let candidate = dir.join(format!("{stem} ({n}){ext}")); + if !candidate.exists() { + return candidate; + } + } + dir.join(name) +} diff --git a/src/attachments/list.rs b/src/attachments/list.rs new file mode 100644 index 00000000..f31a0e6d --- /dev/null +++ b/src/attachments/list.rs @@ -0,0 +1,107 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use mail_parser::{MessageParser, MessagePart, MimeHeaders}; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + account::Account, + attachments::table::{AttachmentEntry, AttachmentsTable}, + cli::BackendArg, + config::{AccountConfig, Config}, +}; + +/// List the attachments carried by a single message in the active +/// account. +/// +/// "Attachment" follows mail_parser's classification: parts with +/// `Content-Disposition: attachment`, or any non-body part with a +/// `filename`/`name` parameter. Inline parts (e.g. embedded images +/// referenced by HTML bodies) are skipped by default; pass +/// `--include-inline` to surface them too. +#[derive(Debug, Parser)] +pub struct AttachmentsListCommand { + /// Identifier of the message (IMAP UID, JMAP email id, or Maildir + /// filename id). + #[arg(value_name = "ID")] + pub id: String, + + /// Mailbox name or path (IMAP/Maildir). Ignored for JMAP. + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + /// Include parts with `Content-Disposition: inline`. + #[arg(long = "include-inline")] + pub include_inline: bool, +} + +impl AttachmentsListCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + let raw = crate::messages::fetch::fetch_raw( + &config, + &account_config, + backend, + &self.mailbox, + &self.id, + )?; + + let Some(message) = MessageParser::new().parse(&raw) else { + bail!("Failed to parse RFC 5322 message"); + }; + + let mut attachments = Vec::new(); + for (index, part) in message.attachments().enumerate() { + let inline = is_inline(part); + if inline && !self.include_inline { + continue; + } + + attachments.push(AttachmentEntry { + index, + filename: part + .attachment_name() + .map(str::to_owned) + .unwrap_or_else(|| format!("attachment-{index}")), + mime: mime_string(part), + size: part.contents().len(), + inline, + }); + } + + // Reuse the active account's table styling. Constructing + // an `Account<()>` is enough to read the preset/arrangement. + let account = Account::new(config, account_config, ())?; + + printer.out(AttachmentsTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + attachments, + }) + } +} + +fn is_inline(part: &MessagePart<'_>) -> bool { + part.content_disposition() + .map(|cd| cd.c_type.eq_ignore_ascii_case("inline")) + .unwrap_or(false) +} + +fn mime_string(part: &MessagePart<'_>) -> String { + let Some(ct) = part.content_type() else { + return "application/octet-stream".to_string(); + }; + match ct.c_subtype.as_deref() { + Some(sub) => format!("{}/{}", ct.c_type, sub), + None => ct.c_type.to_string(), + } +} diff --git a/src/attachments/mod.rs b/src/attachments/mod.rs new file mode 100644 index 00000000..136dea6b --- /dev/null +++ b/src/attachments/mod.rs @@ -0,0 +1,4 @@ +pub mod command; +pub mod download; +pub mod list; +pub mod table; diff --git a/src/attachments/table.rs b/src/attachments/table.rs new file mode 100644 index 00000000..d702aa69 --- /dev/null +++ b/src/attachments/table.rs @@ -0,0 +1,68 @@ +use std::fmt; + +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use serde::Serialize; + +/// One row of the `attachments list` output. +#[derive(Clone, Debug, Serialize)] +pub struct AttachmentEntry { + pub index: usize, + pub filename: String, + pub mime: String, + pub size: usize, + pub inline: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AttachmentsTable { + #[serde(skip)] + pub preset: String, + #[serde(skip)] + pub arrangement: ContentArrangement, + pub attachments: Vec, +} + +impl fmt::Display for AttachmentsTable { + 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("INDEX"), + Cell::new("FILENAME"), + Cell::new("MIME"), + Cell::new("SIZE"), + Cell::new("INLINE"), + ])) + .add_rows(self.attachments.iter().map(|a| { + let mut row = Row::new(); + row.max_height(1); + row.add_cell(Cell::new(a.index)); + row.add_cell(Cell::new(&a.filename)); + row.add_cell(Cell::new(&a.mime)); + row.add_cell(Cell::new(human_size(a.size))); + row.add_cell(Cell::new(if a.inline { "yes" } else { "no" })); + row + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} + +fn human_size(bytes: usize) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{size:.1} {}", UNITS[unit]) + } +} diff --git a/src/cli.rs b/src/cli.rs index 64edb663..52562942 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,6 @@ -use std::path::PathBuf; +use std::{fmt, path::PathBuf, str::FromStr}; -use anyhow::{bail, Result}; +use anyhow::{bail, Error, Result}; use clap::{CommandFactory, Parser, Subcommand}; use pimalaya_toolbox::{ config::TomlConfig, @@ -23,7 +23,11 @@ use crate::jmap::command::JmapCommand; use crate::maildir::command::MaildirCommand; #[cfg(feature = "smtp")] use crate::smtp::command::SmtpCommand; -use crate::{account::Account, config::Config}; +use crate::{ + account::Account, config::Config, envelopes::command::EnvelopesCommand, + flags::command::FlagsCommand, mailboxes::command::MailboxesCommand, + messages::command::MessagesCommand, +}; #[derive(Parser, Debug)] #[command(name = env!("CARGO_PKG_NAME"))] @@ -49,17 +53,113 @@ pub struct HimalayaCli { pub config_paths: Vec, #[command(flatten)] pub account: AccountFlag, + + /// Force a specific backend for cross-protocol commands. + /// + /// Only consumed by the shared commands (`mailboxes`, `envelopes`, + /// `flags`, `messages`); the protocol-specific subcommands + /// (`imap`, `jmap`, `maildir`, `smtp`) ignore it and always use + /// their own backend. + /// + /// Possible values: `auto` (default), `imap`, `jmap`, `maildir`, + /// `smtp`. With `auto`, the shared command picks the first + /// configured backend it supports; with an explicit value, it uses + /// only that backend (and bails if the account has no matching + /// config block, or if the operation has no implementation for it + /// — e.g. `--backend smtp mailboxes list`). + #[arg(short, long, global = true, default_value_t)] + pub backend: BackendArg, + #[command(flatten)] pub json: JsonFlag, #[command(flatten)] pub log: LogFlags, } +/// Selects which backend a cross-protocol command should target. +/// +/// `Auto` lets the command pick the first configured-and-supported +/// backend in its own priority order. The named variants pin the +/// command to that backend; the command bails if it cannot be served +/// (config missing, or the operation has no arm for that backend). +/// +/// The protocol-specific subcommands (`imap`, `jmap`, `maildir`, +/// `smtp`) ignore this arg entirely. +#[derive(Clone, Copy, Debug, Default, Parser, PartialEq, Eq)] +pub enum BackendArg { + #[default] + Auto, + Imap, + Jmap, + Maildir, + Smtp, +} + +impl BackendArg { + /// Whether the IMAP arm of a shared command is allowed to run. + pub fn allows_imap(self) -> bool { + matches!(self, Self::Auto | Self::Imap) + } + + /// Whether the JMAP arm of a shared command is allowed to run. + pub fn allows_jmap(self) -> bool { + matches!(self, Self::Auto | Self::Jmap) + } + + /// Whether the Maildir arm of a shared command is allowed to run. + pub fn allows_maildir(self) -> bool { + matches!(self, Self::Auto | Self::Maildir) + } + + /// Whether the SMTP arm of a shared command is allowed to run. + pub fn allows_smtp(self) -> bool { + matches!(self, Self::Auto | Self::Smtp) + } +} + +impl FromStr for BackendArg { + type Err = Error; + + fn from_str(backend: &str) -> Result { + match backend { + "auto" => Ok(Self::Auto), + "imap" => Ok(Self::Imap), + "jmap" => Ok(Self::Jmap), + "maildir" => Ok(Self::Maildir), + "smtp" => Ok(Self::Smtp), + backend => bail!("Invalid backend {backend}"), + } + } +} +impl fmt::Display for BackendArg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Auto => write!(f, "auto"), + Self::Imap => write!(f, "imap"), + Self::Jmap => write!(f, "jmap"), + Self::Maildir => write!(f, "maildir"), + Self::Smtp => write!(f, "smtp"), + } + } +} + #[derive(Debug, Subcommand)] pub enum BackendCommand { Manuals(ManualCommand), Completions(CompletionCommand), + #[command(subcommand)] + Mailboxes(MailboxesCommand), + #[command(subcommand)] + Envelopes(EnvelopesCommand), + #[command(subcommand)] + Flags(FlagsCommand), + #[command(subcommand)] + Messages(MessagesCommand), + #[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] + #[command(subcommand)] + Attachments(crate::attachments::command::AttachmentsCommand), + #[cfg(feature = "imap")] #[command(subcommand)] Imap(ImapCommand), @@ -80,11 +180,39 @@ impl BackendCommand { printer: &mut impl Printer, config_paths: &[PathBuf], account_name: Option<&str>, + backend: BackendArg, ) -> Result<()> { match self { Self::Manuals(cmd) => cmd.execute(printer, HimalayaCli::command()), Self::Completions(cmd) => cmd.execute(printer, HimalayaCli::command()), + Self::Mailboxes(cmd) => { + let config = Config::from_paths_or_default(config_paths)?; + let (_, account_config) = config.get_account(account_name)?; + cmd.execute(printer, config, account_config, backend) + } + Self::Envelopes(cmd) => { + let config = Config::from_paths_or_default(config_paths)?; + let (_, account_config) = config.get_account(account_name)?; + cmd.execute(printer, config, account_config, backend) + } + Self::Flags(cmd) => { + let config = Config::from_paths_or_default(config_paths)?; + let (_, account_config) = config.get_account(account_name)?; + cmd.execute(printer, config, account_config, backend) + } + Self::Messages(cmd) => { + let config = Config::from_paths_or_default(config_paths)?; + let (_, account_config) = config.get_account(account_name)?; + cmd.execute(printer, config, account_config, backend) + } + #[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] + Self::Attachments(cmd) => { + let config = Config::from_paths_or_default(config_paths)?; + let (_, account_config) = config.get_account(account_name)?; + cmd.execute(printer, config, account_config, backend) + } + #[cfg(feature = "imap")] Self::Imap(cmd) => { let config = Config::from_paths_or_default(config_paths)?; diff --git a/src/config.rs b/src/config.rs index 0fea6b74..15bb5579 100644 --- a/src/config.rs +++ b/src/config.rs @@ -56,6 +56,7 @@ pub struct AccountConfig { #[allow(unused)] pub imap: Option, + #[allow(unused)] pub jmap: Option, #[allow(unused)] pub maildir: Option, @@ -271,6 +272,16 @@ pub struct JmapConfig { /// Authentication configuration. pub auth: JmapAuthConfig, + + /// Identity id used by `messages send` to submit emails. Required + /// only for JMAP send; can be discovered with `himalaya jmap + /// identity get`. + pub identity_id: Option, + + /// Drafts mailbox id used by `messages send` to stage emails before + /// submission. Required only for JMAP send; can be discovered with + /// `himalaya jmap mailbox query --role drafts`. + pub drafts_mailbox_id: Option, } /// JMAP authentication configuration. diff --git a/src/envelopes/command.rs b/src/envelopes/command.rs new file mode 100644 index 00000000..8fed85e0 --- /dev/null +++ b/src/envelopes/command.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + cli::BackendArg, + config::{AccountConfig, Config}, + envelopes::list::EnvelopesListCommand, +}; + +/// List envelopes through whichever backend the active account has +/// configured. +/// +/// The active backend is selected by `--backend` (defaults to `auto`, +/// which picks the first configured backend in priority order). +#[derive(Debug, Subcommand)] +pub enum EnvelopesCommand { + #[command(visible_alias = "ls")] + List(EnvelopesListCommand), +} + +impl EnvelopesCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, config, account_config, backend), + } + } +} diff --git a/src/envelopes/list.rs b/src/envelopes/list.rs new file mode 100644 index 00000000..8548bcf9 --- /dev/null +++ b/src/envelopes/list.rs @@ -0,0 +1,221 @@ +#[cfg(feature = "maildir")] +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, + envelopes::table::EnvelopesTable, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// List envelopes for the active account, regardless of the underlying +/// backend (IMAP, JMAP or Maildir). +#[derive(Debug, Parser)] +pub struct EnvelopesListCommand { + /// Path or name of the IMAP/Maildir mailbox. + #[arg( + long = "mailbox", + short = 'm', + value_name = "PATH", + default_value = "Inbox" + )] + pub mailbox: String, + + /// Page number, starting from 1. The most recent envelopes are on + /// page 1. + #[arg(long, short = 'p', value_name = "N", default_value = "1")] + pub page: u32, + + /// Maximum number of envelopes per page. + #[arg( + long = "page-size", + short = 's', + value_name = "N", + default_value = "25" + )] + pub page_size: u32, +} + +impl EnvelopesListCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use io_email::imap::envelope_list::{EnvelopeList, EnvelopeListResult}; + use io_imap::types::mailbox::Mailbox; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let mailbox: Mailbox<'static> = self.mailbox.clone().try_into()?; + let mut coroutine = EnvelopeList::new( + session.context, + mailbox, + Some(self.page), + Some(self.page_size), + ); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + let envelopes = loop { + match coroutine.resume(arg.take()) { + EnvelopeListResult::Ok(envelopes) => break envelopes, + EnvelopeListResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + EnvelopeListResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + EnvelopeListResult::Err(err) => bail!("{err}"), + } + }; + + return printer.out(EnvelopesTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + envelopes, + }); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::envelope_list::{EnvelopeList, EnvelopeListResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + let mut coroutine = EnvelopeList::new( + &session.session, + &session.http_auth, + Some(self.page), + Some(self.page_size), + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + let envelopes = loop { + match coroutine.resume(arg.take()) { + EnvelopeListResult::Ok(envelopes) => break envelopes, + EnvelopeListResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + EnvelopeListResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + EnvelopeListResult::Err(err) => bail!("{err}"), + } + }; + + return printer.out(EnvelopesTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + envelopes, + }); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::envelope_list::{ + EnvelopeList, EnvelopeListArg, EnvelopeListResult, + }; + use io_maildir::maildir::Maildir; + + let account = Account::new(config, account_config, maildir_config)?; + let path = account.backend.root.join(&self.mailbox); + let maildir = Maildir::try_from(path)?; + + let mut coroutine = + EnvelopeList::new(maildir, Some(self.page), Some(self.page_size)); + let mut arg: Option = None; + + let envelopes = loop { + match coroutine.resume(arg.take()) { + EnvelopeListResult::Ok(envelopes) => break envelopes, + EnvelopeListResult::WantsDirRead(paths) => { + arg = Some(EnvelopeListArg::DirRead(read_dirs(&paths)?)); + } + EnvelopeListResult::WantsFileRead(paths) => { + arg = Some(EnvelopeListArg::FileRead(read_files(&paths)?)); + } + EnvelopeListResult::Err(err) => bail!("{err}"), + } + }; + + return printer.out(EnvelopesTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + envelopes, + }); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } +} + +#[cfg(feature = "maildir")] +fn read_dirs(paths: &BTreeSet) -> Result>> { + use std::fs; + + let mut out = BTreeMap::new(); + + for path in paths { + let mut entries = BTreeSet::new(); + + for entry in fs::read_dir(path)? { + let entry = entry?; + + if let Some(s) = entry.path().to_str() { + entries.insert(s.to_owned()); + } + } + + out.insert(path.clone(), entries); + } + + Ok(out) +} + +#[cfg(feature = "maildir")] +fn read_files(paths: &BTreeSet) -> Result>> { + use std::fs; + + let mut out = BTreeMap::new(); + + for path in paths { + out.insert(path.clone(), fs::read(path)?); + } + + Ok(out) +} diff --git a/src/envelopes/mod.rs b/src/envelopes/mod.rs new file mode 100644 index 00000000..8ca0c9cd --- /dev/null +++ b/src/envelopes/mod.rs @@ -0,0 +1,3 @@ +pub mod command; +pub mod list; +pub mod table; diff --git a/src/envelopes/table.rs b/src/envelopes/table.rs new file mode 100644 index 00000000..b9f2b1c1 --- /dev/null +++ b/src/envelopes/table.rs @@ -0,0 +1,59 @@ +use std::fmt; + +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use io_email::envelope::Envelope; +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +pub struct EnvelopesTable { + #[serde(skip)] + pub preset: String, + #[serde(skip)] + pub arrangement: ContentArrangement, + pub envelopes: Vec, +} + +impl fmt::Display for EnvelopesTable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + table + .load_preset(&self.preset) + .set_content_arrangement(self.arrangement.clone()) + .set_header(Row::from([ + Cell::new("ID"), + Cell::new("FLAGS"), + Cell::new("SUBJECT"), + Cell::new("FROM"), + Cell::new("DATE"), + ])) + .add_rows(self.envelopes.iter().map(|e| { + let mut row = Row::new(); + row.max_height(1); + row.add_cell(Cell::new(&e.id)); + row.add_cell(Cell::new( + e.flags + .iter() + .map(|f| format!("{f:?}")) + .collect::>() + .join(", "), + )); + row.add_cell(Cell::new(&e.subject)); + row.add_cell(Cell::new( + e.from + .iter() + .map(|a| match &a.name { + Some(name) if !name.is_empty() => name.clone(), + _ => a.email.clone(), + }) + .collect::>() + .join(", "), + )); + row.add_cell(Cell::new(&e.date)); + row + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} diff --git a/src/flags/add.rs b/src/flags/add.rs new file mode 100644 index 00000000..b852fbe3 --- /dev/null +++ b/src/flags/add.rs @@ -0,0 +1,194 @@ +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, + flags::arg::{FlagsArg, MailboxFlag, MessageIdsArg}, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Add flag(s) to message(s) for the active account. +#[derive(Debug, Parser)] +pub struct FlagsAddCommand { + #[command(flatten)] + pub ids: MessageIdsArg, + #[command(flatten)] + pub flags: FlagsArg, + #[command(flatten)] + pub mailbox: MailboxFlag, +} + +impl FlagsAddCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use io_email::imap::flag_add::{FlagAdd, FlagAddResult}; + use io_imap::types::{mailbox::Mailbox, sequence::SequenceSet}; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let mailbox: Mailbox<'static> = self.mailbox.inner.clone().try_into()?; + let sequence_set: SequenceSet = self.ids.inner.join(",").as_str().try_into()?; + let imap_flags = self.flags.inner.iter().map(|f| f.imap()).collect(); + let mut coroutine = + FlagAdd::new(session.context, mailbox, sequence_set, imap_flags, true); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + FlagAddResult::Ok => break, + FlagAddResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + FlagAddResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + FlagAddResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Flag(s) successfully added")); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::flag_add::{FlagAdd, FlagAddResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + + let keywords: Vec = self + .flags + .inner + .iter() + .map(|f| f.jmap().to_owned()) + .collect(); + let mut coroutine = FlagAdd::new( + &session.session, + &session.http_auth, + self.ids.inner.iter().cloned(), + keywords, + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + FlagAddResult::Ok => break, + FlagAddResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + FlagAddResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + FlagAddResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Flag(s) successfully added")); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::flag_add::{FlagAdd, FlagAddArg, FlagAddResult}; + use io_maildir::{ + flag::{Flag, Flags}, + maildir::Maildir, + }; + + use crate::maildir::runtime; + + let account = Account::new(config, account_config, maildir_config)?; + let path = account.backend.root.join(&self.mailbox.inner); + let maildir = Maildir::try_from(path)?; + let maildir_flags: Flags = self.flags.inner.iter().map(|f| Flag::from(f)).collect(); + + for id in &self.ids.inner { + let mut coroutine = + FlagAdd::new(maildir.clone(), id.as_str(), maildir_flags.clone()); + let mut arg: Option = None; + + loop { + match coroutine.resume(arg.take()) { + FlagAddResult::Ok => break, + FlagAddResult::WantsDirRead(paths) => { + arg = Some(FlagAddArg::DirRead(read_dirs(&paths)?)); + } + FlagAddResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(FlagAddArg::Rename); + } + FlagAddResult::Err(err) => bail!("{err}"), + } + } + } + + return printer.out(Message::new("Flag(s) successfully added")); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } +} + +#[cfg(feature = "maildir")] +fn read_dirs( + paths: &std::collections::BTreeSet, +) -> Result>> { + use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + }; + + let mut out = BTreeMap::new(); + + for path in paths { + let mut entries = BTreeSet::new(); + + for entry in fs::read_dir(path)? { + let entry = entry?; + + if let Some(s) = entry.path().to_str() { + entries.insert(s.to_owned()); + } + } + + out.insert(path.clone(), entries); + } + + Ok(out) +} diff --git a/src/flags/arg.rs b/src/flags/arg.rs new file mode 100644 index 00000000..d3252b16 --- /dev/null +++ b/src/flags/arg.rs @@ -0,0 +1,81 @@ +use clap::{Parser, ValueEnum}; + +#[derive(Clone, Debug, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum FlagArg { + Seen, + Answered, + Flagged, + Deleted, + Draft, +} + +#[cfg(feature = "imap")] +impl FlagArg { + pub fn imap(&self) -> io_imap::types::flag::Flag<'static> { + use io_imap::types::flag::Flag; + + match self { + Self::Seen => Flag::Seen, + Self::Answered => Flag::Answered, + Self::Flagged => Flag::Flagged, + Self::Deleted => Flag::Deleted, + Self::Draft => Flag::Draft, + } + } +} + +#[cfg(feature = "jmap")] +impl FlagArg { + pub fn jmap(&self) -> &'static str { + match self { + Self::Seen => "$seen", + Self::Answered => "$answered", + Self::Flagged => "$flagged", + Self::Deleted => "$deleted", + Self::Draft => "$draft", + } + } +} + +#[cfg(feature = "maildir")] +impl From<&FlagArg> for io_maildir::flag::Flag { + fn from(flag: &FlagArg) -> Self { + use io_maildir::flag::Flag; + + match flag { + FlagArg::Seen => Flag::Seen, + FlagArg::Answered => Flag::Replied, + FlagArg::Flagged => Flag::Flagged, + FlagArg::Deleted => Flag::Trashed, + FlagArg::Draft => Flag::Draft, + } + } +} + +#[derive(Debug, Parser)] +pub struct MessageIdsArg { + /// Identifier(s) of message(s) (IMAP UID, JMAP email ID, Maildir filename id). + #[arg(name = "message_ids", value_name = "ID")] + #[arg(num_args = 1..)] + pub inner: Vec, +} + +#[derive(Debug, Parser)] +pub struct FlagsArg { + /// Flag(s) to apply. + #[arg(long = "flag", short, required = true, num_args = 1..)] + pub inner: Vec, +} + +#[derive(Debug, Parser)] +pub struct MailboxFlag { + /// Mailbox name or path (IMAP mailbox / Maildir path). + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub inner: String, +} diff --git a/src/flags/command.rs b/src/flags/command.rs new file mode 100644 index 00000000..b9201fde --- /dev/null +++ b/src/flags/command.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + cli::BackendArg, + config::{AccountConfig, Config}, + flags::{add::FlagsAddCommand, delete::FlagsDeleteCommand, set::FlagsSetCommand}, +}; + +/// Manage flags through whichever backend the active account has +/// configured. +/// +/// The active backend is selected by `--backend` (defaults to `auto`, +/// which picks the first configured backend in priority order). +#[derive(Debug, Subcommand)] +pub enum FlagsCommand { + Add(FlagsAddCommand), + Set(FlagsSetCommand), + #[command(visible_alias = "remove", visible_alias = "rm")] + Delete(FlagsDeleteCommand), +} + +impl FlagsCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + match self { + Self::Add(cmd) => cmd.execute(printer, config, account_config, backend), + Self::Set(cmd) => cmd.execute(printer, config, account_config, backend), + Self::Delete(cmd) => cmd.execute(printer, config, account_config, backend), + } + } +} diff --git a/src/flags/delete.rs b/src/flags/delete.rs new file mode 100644 index 00000000..4c41e9df --- /dev/null +++ b/src/flags/delete.rs @@ -0,0 +1,194 @@ +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, + flags::arg::{FlagsArg, MailboxFlag, MessageIdsArg}, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Remove flag(s) from message(s) for the active account. +#[derive(Debug, Parser)] +pub struct FlagsDeleteCommand { + #[command(flatten)] + pub ids: MessageIdsArg, + #[command(flatten)] + pub flags: FlagsArg, + #[command(flatten)] + pub mailbox: MailboxFlag, +} + +impl FlagsDeleteCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use io_email::imap::flag_delete::{FlagDelete, FlagDeleteResult}; + use io_imap::types::{mailbox::Mailbox, sequence::SequenceSet}; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let mailbox: Mailbox<'static> = self.mailbox.inner.clone().try_into()?; + let sequence_set: SequenceSet = self.ids.inner.join(",").as_str().try_into()?; + let imap_flags = self.flags.inner.iter().map(|f| f.imap()).collect(); + let mut coroutine = + FlagDelete::new(session.context, mailbox, sequence_set, imap_flags, true); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + FlagDeleteResult::Ok => break, + FlagDeleteResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + FlagDeleteResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + FlagDeleteResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Flag(s) successfully removed")); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::flag_delete::{FlagDelete, FlagDeleteResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + + let keywords: Vec = self + .flags + .inner + .iter() + .map(|f| f.jmap().to_owned()) + .collect(); + let mut coroutine = FlagDelete::new( + &session.session, + &session.http_auth, + self.ids.inner.iter().cloned(), + keywords, + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + FlagDeleteResult::Ok => break, + FlagDeleteResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + FlagDeleteResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + FlagDeleteResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Flag(s) successfully removed")); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::flag_delete::{FlagDelete, FlagDeleteArg, FlagDeleteResult}; + use io_maildir::{ + flag::{Flag, Flags}, + maildir::Maildir, + }; + + use crate::maildir::runtime; + + let account = Account::new(config, account_config, maildir_config)?; + let path = account.backend.root.join(&self.mailbox.inner); + let maildir = Maildir::try_from(path)?; + let maildir_flags: Flags = self.flags.inner.iter().map(|f| Flag::from(f)).collect(); + + for id in &self.ids.inner { + let mut coroutine = + FlagDelete::new(maildir.clone(), id.as_str(), maildir_flags.clone()); + let mut arg: Option = None; + + loop { + match coroutine.resume(arg.take()) { + FlagDeleteResult::Ok => break, + FlagDeleteResult::WantsDirRead(paths) => { + arg = Some(FlagDeleteArg::DirRead(read_dirs(&paths)?)); + } + FlagDeleteResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(FlagDeleteArg::Rename); + } + FlagDeleteResult::Err(err) => bail!("{err}"), + } + } + } + + return printer.out(Message::new("Flag(s) successfully removed")); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } +} + +#[cfg(feature = "maildir")] +fn read_dirs( + paths: &std::collections::BTreeSet, +) -> Result>> { + use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + }; + + let mut out = BTreeMap::new(); + + for path in paths { + let mut entries = BTreeSet::new(); + + for entry in fs::read_dir(path)? { + let entry = entry?; + + if let Some(s) = entry.path().to_str() { + entries.insert(s.to_owned()); + } + } + + out.insert(path.clone(), entries); + } + + Ok(out) +} diff --git a/src/flags/mod.rs b/src/flags/mod.rs new file mode 100644 index 00000000..c11a643e --- /dev/null +++ b/src/flags/mod.rs @@ -0,0 +1,5 @@ +pub mod add; +pub mod arg; +pub mod command; +pub mod delete; +pub mod set; diff --git a/src/flags/set.rs b/src/flags/set.rs new file mode 100644 index 00000000..8292ef8b --- /dev/null +++ b/src/flags/set.rs @@ -0,0 +1,194 @@ +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, + flags::arg::{FlagsArg, MailboxFlag, MessageIdsArg}, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Replace flag(s) on message(s) for the active account. +#[derive(Debug, Parser)] +pub struct FlagsSetCommand { + #[command(flatten)] + pub ids: MessageIdsArg, + #[command(flatten)] + pub flags: FlagsArg, + #[command(flatten)] + pub mailbox: MailboxFlag, +} + +impl FlagsSetCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use io_email::imap::flag_set::{FlagSet, FlagSetResult}; + use io_imap::types::{mailbox::Mailbox, sequence::SequenceSet}; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let mailbox: Mailbox<'static> = self.mailbox.inner.clone().try_into()?; + let sequence_set: SequenceSet = self.ids.inner.join(",").as_str().try_into()?; + let imap_flags = self.flags.inner.iter().map(|f| f.imap()).collect(); + let mut coroutine = + FlagSet::new(session.context, mailbox, sequence_set, imap_flags, true); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + FlagSetResult::Ok => break, + FlagSetResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + FlagSetResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + FlagSetResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Flag(s) successfully set")); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::flag_set::{FlagSet, FlagSetResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + + let keywords: Vec = self + .flags + .inner + .iter() + .map(|f| f.jmap().to_owned()) + .collect(); + let mut coroutine = FlagSet::new( + &session.session, + &session.http_auth, + self.ids.inner.iter().cloned(), + keywords, + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + FlagSetResult::Ok => break, + FlagSetResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + FlagSetResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + FlagSetResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Flag(s) successfully set")); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::flag_set::{FlagSet, FlagSetArg, FlagSetResult}; + use io_maildir::{ + flag::{Flag, Flags}, + maildir::Maildir, + }; + + use crate::maildir::runtime; + + let account = Account::new(config, account_config, maildir_config)?; + let path = account.backend.root.join(&self.mailbox.inner); + let maildir = Maildir::try_from(path)?; + let maildir_flags: Flags = self.flags.inner.iter().map(|f| Flag::from(f)).collect(); + + for id in &self.ids.inner { + let mut coroutine = + FlagSet::new(maildir.clone(), id.as_str(), maildir_flags.clone()); + let mut arg: Option = None; + + loop { + match coroutine.resume(arg.take()) { + FlagSetResult::Ok => break, + FlagSetResult::WantsDirRead(paths) => { + arg = Some(FlagSetArg::DirRead(read_dirs(&paths)?)); + } + FlagSetResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(FlagSetArg::Rename); + } + FlagSetResult::Err(err) => bail!("{err}"), + } + } + } + + return printer.out(Message::new("Flag(s) successfully set")); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } +} + +#[cfg(feature = "maildir")] +fn read_dirs( + paths: &std::collections::BTreeSet, +) -> Result>> { + use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + }; + + let mut out = BTreeMap::new(); + + for path in paths { + let mut entries = BTreeSet::new(); + + for entry in fs::read_dir(path)? { + let entry = entry?; + + if let Some(s) = entry.path().to_str() { + entries.insert(s.to_owned()); + } + } + + out.insert(path.clone(), entries); + } + + Ok(out) +} diff --git a/src/imap/envelope/get.rs b/src/imap/envelope/get.rs index d3b453d2..3b7f7190 100644 --- a/src/imap/envelope/get.rs +++ b/src/imap/envelope/get.rs @@ -1,4 +1,8 @@ -use std::{fmt, num::NonZeroU32}; +use std::{ + fmt, + io::{Read, Write}, + num::NonZeroU32, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -10,7 +14,6 @@ use io_imap::{ fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -20,6 +23,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get a single IMAP envelope. /// /// This command displays detailed envelope information for a specific @@ -45,17 +50,24 @@ impl ImapEnvelopeGetCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -67,16 +79,21 @@ impl ImapEnvelopeGetCommand { let item_names = MacroOrMessageDataItemNames::MessageDataItemNames(vec![MessageDataItemName::Envelope]); - let mut arg = None; let mut coroutine = ImapMessageFetchFirst::new(imap.context, id, item_names, !self.seq); + let mut arg: Option<&[u8]> = None; let items = loop { match coroutine.resume(arg.take()) { - ImapMessageFetchFirstResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMessageFetchFirstResult::Ok { items, .. } => break items, - ImapMessageFetchFirstResult::Err { err, .. } => bail!(err), + ImapMessageFetchFirstResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageFetchFirstResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageFetchFirstResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/envelope/list.rs b/src/imap/envelope/list.rs index d4ff24e9..a5b4fb2d 100644 --- a/src/imap/envelope/list.rs +++ b/src/imap/envelope/list.rs @@ -1,4 +1,9 @@ -use std::{collections::BTreeMap, fmt, num::NonZeroU32}; +use std::{ + collections::BTreeMap, + fmt, + io::{Read, Write}, + num::NonZeroU32, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -13,7 +18,6 @@ use io_imap::{ status::{StatusDataItem, StatusDataItemName}, }, }; -use io_socket::runtimes::std_stream::handle; use log::debug; use pimalaya_toolbox::terminal::printer::Printer; use rfc2047_decoder::{Decoder, RecoverStrategy}; @@ -24,6 +28,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// List IMAP envelopes from the given mailbox. /// /// This command displays envelopes for messages in the specified @@ -58,16 +64,15 @@ impl ImapEnvelopeListCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let exists = if self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxStatus::new(imap.context, mailbox, &[StatusDataItemName::Messages]); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxStatusResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxStatusResult::Ok { context, items } => { imap.context = context; break items.into_iter().find_map(|i| match i { @@ -75,23 +80,36 @@ impl ImapEnvelopeListCommand { _ => None, }); } - ImapMailboxStatusResult::Err { err, .. } => bail!(err), + ImapMailboxStatusResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxStatusResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxStatusResult::Err { err, .. } => bail!("{err}"), } } } else { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, data } => { imap.context = context; break data.exists; } - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } } }; @@ -113,21 +131,26 @@ impl ImapEnvelopeListCommand { MessageDataItemName::Envelope, ]); - let mut arg = None; let mut coroutine = ImapMessageFetch::new( imap.context, sequence_set, item_names, !self.sequence && has_sequence, ); + let mut arg: Option<&[u8]> = None; let data = loop { match coroutine.resume(arg.take()) { - ImapMessageFetchResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMessageFetchResult::Ok { data, .. } => break data, - ImapMessageFetchResult::Err { err, .. } => bail!(err), + ImapMessageFetchResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageFetchResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageFetchResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/envelope/search.rs b/src/imap/envelope/search.rs index e2239e1c..34bd906a 100644 --- a/src/imap/envelope/search.rs +++ b/src/imap/envelope/search.rs @@ -1,4 +1,7 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{anyhow, bail, Result}; use clap::Parser; @@ -11,7 +14,6 @@ use io_imap::{ search::SearchKey, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -20,6 +22,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Search IMAP messages by criteria. /// /// This command searches for messages matching the given criteria and @@ -66,33 +70,45 @@ impl ImapEnvelopeSearchCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } let criteria = parse_query(&self.query)?; - let mut arg = None; let mut coroutine = ImapMessageSearch::new(imap.context, criteria, !self.seq); + let mut arg: Option<&[u8]> = None; let ids = loop { match coroutine.resume(arg.take()) { - ImapMessageSearchResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMessageSearchResult::Ok { ids, .. } => break ids, - ImapMessageSearchResult::Err { err, .. } => bail!(err), + ImapMessageSearchResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageSearchResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageSearchResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/envelope/sort.rs b/src/imap/envelope/sort.rs index e3b5eff1..0edc45cb 100644 --- a/src/imap/envelope/sort.rs +++ b/src/imap/envelope/sort.rs @@ -1,4 +1,7 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -11,7 +14,6 @@ use io_imap::{ extensions::sort::{SortCriterion, SortKey}, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -19,6 +21,8 @@ use crate::imap::{ account::ImapAccount, envelope::search::parse_query, mailbox::arg::MailboxNameOptionalArg, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Sort messages by criteria. /// /// This command searches for messages matching the given query and @@ -60,17 +64,24 @@ impl ImapEnvelopeSortCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + // SELECT mailbox - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; let context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; @@ -85,15 +96,22 @@ impl ImapEnvelopeSortCommand { let search_criteria = parse_query(&self.query)?; // SORT - let mut arg = None; let mut coroutine = ImapMailboxSort::new(context, sort_criteria, search_criteria, !self.seq); + let mut arg: Option<&[u8]> = None; let ids = loop { match coroutine.resume(arg.take()) { - ImapMailboxSortResult::Io { input } => arg = Some(handle(&mut imap.stream, input)?), ImapMailboxSortResult::Ok { ids, .. } => break ids, - ImapMailboxSortResult::Err { err, .. } => bail!(err), + ImapMailboxSortResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSortResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSortResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/envelope/thread.rs b/src/imap/envelope/thread.rs index 347ec964..5495bbc3 100644 --- a/src/imap/envelope/thread.rs +++ b/src/imap/envelope/thread.rs @@ -1,4 +1,9 @@ -use std::{collections::HashMap, fmt, num::NonZeroU32}; +use std::{ + collections::HashMap, + fmt, + io::{Read, Write}, + num::NonZeroU32, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -11,7 +16,6 @@ use io_imap::{ sequence::SequenceSet, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::{stream::imap::ImapSession, terminal::printer::Printer}; use serde::{ser::SerializeStruct, Serialize, Serializer}; @@ -21,6 +25,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Thread IMAP messages by algorithm. /// /// This command groups messages into conversation threads using the @@ -54,17 +60,24 @@ impl ImapEnvelopeThreadCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -72,22 +85,27 @@ impl ImapEnvelopeThreadCommand { let algorithm = parse_algorithm(&self.algorithm)?; let search_criteria = parse_query(&self.query)?; - let mut arg = None; let mut coroutine = ImapMessageThread::new(imap.context, algorithm, search_criteria, !self.seq); + let mut arg: Option<&[u8]> = None; let threads = loop { match coroutine.resume(arg.take()) { - ImapMessageThreadResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMessageThreadResult::Ok { context, threads, .. } => { imap.context = context; break threads; } - ImapMessageThreadResult::Err { err, .. } => bail!(err), + ImapMessageThreadResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageThreadResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageThreadResult::Err { err, .. } => bail!("{err}"), } }; @@ -165,14 +183,22 @@ fn fetch_subjects( MessageDataItemName::Uid, ]); - let mut arg = None; let mut coroutine = ImapMessageFetch::new(imap.context, sequence_set, item_names, uid); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let data = loop { match coroutine.resume(arg.take()) { - ImapMessageFetchResult::Io { input } => arg = Some(handle(&mut imap.stream, input)?), ImapMessageFetchResult::Ok { data, .. } => break data, - ImapMessageFetchResult::Err { err, .. } => bail!(err), + ImapMessageFetchResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageFetchResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageFetchResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/flag/add.rs b/src/imap/flag/add.rs index 5b13c936..9803c369 100644 --- a/src/imap/flag/add.rs +++ b/src/imap/flag/add.rs @@ -1,3 +1,5 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::{ @@ -7,7 +9,6 @@ use io_imap::{ IntoStatic, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{ @@ -15,6 +16,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Add IMAP flag(s) to message(s). /// /// This command adds the given flags to messages identified by the @@ -43,17 +46,24 @@ impl ImapFlagAddCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -65,7 +75,6 @@ impl ImapFlagAddCommand { .map(|f| Flag::try_from(f.as_str()).map(|flag| flag.into_static())) .collect::>()?; - let mut arg = None; let mut coroutine = ImapMessageStoreSilent::new( imap.context, sequence_set, @@ -73,14 +82,20 @@ impl ImapFlagAddCommand { flags, !self.seq, ); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMessageStoreSilentResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMessageStoreSilentResult::Ok(_) => break, + ImapMessageStoreSilentResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMessageStoreSilentResult::Ok { .. } => break, - ImapMessageStoreSilentResult::Err { err, .. } => bail!(err), + ImapMessageStoreSilentResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageStoreSilentResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/flag/list.rs b/src/imap/flag/list.rs index b09a8fbe..1e409049 100644 --- a/src/imap/flag/list.rs +++ b/src/imap/flag/list.rs @@ -1,4 +1,8 @@ -use std::{collections::BTreeMap, fmt}; +use std::{ + collections::BTreeMap, + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -7,12 +11,13 @@ use io_imap::{ rfc3501::select::*, types::flag::{Flag, FlagPerm}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::{Serialize, Serializer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// List available IMAP flags for the given mailbox. /// /// This command displays the flags and permanent flags that are @@ -29,21 +34,27 @@ impl ImapFlagListCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (flags, permanent_flags) = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { data, .. } => { break ( data.flags.unwrap_or_default(), data.permanent_flags.unwrap_or_default(), ) } - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/flag/remove.rs b/src/imap/flag/remove.rs index ea6a35bb..abfe9ab6 100644 --- a/src/imap/flag/remove.rs +++ b/src/imap/flag/remove.rs @@ -1,3 +1,5 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::{ @@ -7,7 +9,6 @@ use io_imap::{ IntoStatic, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{ @@ -15,6 +16,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Remove IMAP flag(s) from message(s). /// /// This command removes the specified flag(s) from message(s) @@ -43,17 +46,24 @@ impl ImapFlagRemoveCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -65,7 +75,6 @@ impl ImapFlagRemoveCommand { .map(|f| Flag::try_from(f.as_str()).map(|flag| flag.into_static())) .collect::>()?; - let mut arg = None; let mut coroutine = ImapMessageStoreSilent::new( imap.context, sequence_set, @@ -73,14 +82,20 @@ impl ImapFlagRemoveCommand { flags, !self.seq, ); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMessageStoreSilentResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMessageStoreSilentResult::Ok(_) => break, + ImapMessageStoreSilentResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMessageStoreSilentResult::Ok { .. } => break, - ImapMessageStoreSilentResult::Err { err, .. } => bail!(err), + ImapMessageStoreSilentResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageStoreSilentResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/flag/set.rs b/src/imap/flag/set.rs index 9ba7a574..12472e43 100644 --- a/src/imap/flag/set.rs +++ b/src/imap/flag/set.rs @@ -1,3 +1,5 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::{ @@ -7,7 +9,6 @@ use io_imap::{ IntoStatic, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{ @@ -15,6 +16,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Set IMAP flag(s) on message(s), replacing any existing flags. /// /// This command replaces all existing flags on messages identified by @@ -43,17 +46,24 @@ impl ImapFlagSetCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -65,7 +75,6 @@ impl ImapFlagSetCommand { .map(|f| Flag::try_from(f.as_str()).map(|flag| flag.into_static())) .collect::>()?; - let mut arg = None; let mut coroutine = ImapMessageStoreSilent::new( imap.context, sequence_set, @@ -73,14 +82,20 @@ impl ImapFlagSetCommand { flags, !self.seq, ); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMessageStoreSilentResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMessageStoreSilentResult::Ok(_) => break, + ImapMessageStoreSilentResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMessageStoreSilentResult::Ok { .. } => break, - ImapMessageStoreSilentResult::Err { err, .. } => bail!(err), + ImapMessageStoreSilentResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageStoreSilentResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/id.rs b/src/imap/id.rs index fcfc4e65..7ed27c04 100644 --- a/src/imap/id.rs +++ b/src/imap/id.rs @@ -1,4 +1,8 @@ -use std::{collections::HashMap, fmt}; +use std::{ + collections::HashMap, + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -10,12 +14,13 @@ use io_imap::{ IntoStatic, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::imap::account::ImapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get information about the IMAP server. /// /// This command allows you to exchange parameters with the IMAP @@ -58,14 +63,22 @@ impl ImapIdCommand { params.extend(more); } - let mut arg = None; let mut coroutine = ImapServerId::new(imap.context, Some(params.into_iter().collect())); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let params = loop { match coroutine.resume(arg.take()) { - ImapServerIdResult::Io { input } => arg = Some(handle(&mut imap.stream, input)?), ImapServerIdResult::Ok { server_id, .. } => break server_id, - ImapServerIdResult::Err { err, .. } => bail!(err), + ImapServerIdResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapServerIdResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapServerIdResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/mailbox/close.rs b/src/imap/mailbox/close.rs index 7c133e94..581d4812 100644 --- a/src/imap/mailbox/close.rs +++ b/src/imap/mailbox/close.rs @@ -1,11 +1,14 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3501::close::*; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::account::ImapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Close the current, selected mailbox. /// /// This command also expunges the current mailbox and returns to the @@ -22,16 +25,22 @@ impl ImapMailboxCloseCommand { pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { let mut imap = account.new_imap_session()?; - let mut arg = None; let mut close_coroutine = ImapMailboxClose::new(imap.context); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match close_coroutine.resume(arg.take()) { - ImapMailboxCloseResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMailboxCloseResult::Ok(_) => break, + ImapMailboxCloseResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMailboxCloseResult::Ok { .. } => break, - ImapMailboxCloseResult::Err { err, .. } => bail!(err), + ImapMailboxCloseResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxCloseResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/create.rs b/src/imap/mailbox/create.rs index 32d4519e..665b7be8 100644 --- a/src/imap/mailbox/create.rs +++ b/src/imap/mailbox/create.rs @@ -1,11 +1,14 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3501::create::*; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Create the given mailbox. /// /// This command allows you to create a new mailbox using the given @@ -22,16 +25,22 @@ impl ImapMailboxCreateCommand { let mailbox = self.mailbox_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMailboxCreate::new(imap.context, mailbox); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxCreateResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMailboxCreateResult::Ok(_) => break, + ImapMailboxCreateResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMailboxCreateResult::Ok { .. } => break, - ImapMailboxCreateResult::Err { err, .. } => bail!(err), + ImapMailboxCreateResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxCreateResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/delete.rs b/src/imap/mailbox/delete.rs index 04f7df33..ef5fb6aa 100644 --- a/src/imap/mailbox/delete.rs +++ b/src/imap/mailbox/delete.rs @@ -1,11 +1,14 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3501::delete::*; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Delete the given mailbox. /// /// All emails from the given mailbox are definitely deleted. The @@ -21,16 +24,22 @@ impl ImapMailboxDeleteCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMailboxDelete::new(imap.context, mailbox); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxDeleteResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMailboxDeleteResult::Ok(_) => break, + ImapMailboxDeleteResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMailboxDeleteResult::Ok { .. } => break, - ImapMailboxDeleteResult::Err { err, .. } => bail!(err), + ImapMailboxDeleteResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxDeleteResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/expunge.rs b/src/imap/mailbox/expunge.rs index 7314220c..280658d2 100644 --- a/src/imap/mailbox/expunge.rs +++ b/src/imap/mailbox/expunge.rs @@ -1,7 +1,8 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3501::{expunge::*, select::*}; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{ @@ -9,6 +10,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameArg, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Expunge the given mailbox. /// /// All envelopes with the \Deleted flag will be definitely removed @@ -26,31 +29,43 @@ impl ImapMailboxExpungeCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } - let mut arg = None; let mut coroutine = ImapMailboxExpunge::new(imap.context); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxExpungeResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxExpungeResult::Ok { .. } => break, - ImapMailboxExpungeResult::Err { err, .. } => bail!(err), + ImapMailboxExpungeResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxExpungeResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxExpungeResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/list.rs b/src/imap/mailbox/list.rs index 00973475..36a9a88d 100644 --- a/src/imap/mailbox/list.rs +++ b/src/imap/mailbox/list.rs @@ -1,4 +1,7 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -7,12 +10,13 @@ use io_imap::{ rfc3501::{list::*, lsub::*}, types::{core::QuotedChar, flag::FlagNameAttribute, mailbox::Mailbox}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::imap::account::ImapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// List, search and filter mailboxes. /// /// This command allows you to list mailboxes from your IMAP account. @@ -39,30 +43,42 @@ impl ImapMailboxListCommand { let reference = self.reference.try_into()?; let pattern = self.pattern.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mailboxes = if self.all { - let mut arg = None; let mut coroutine = ImapMailboxList::new(imap.context, reference, pattern); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxListResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxListResult::Ok { mailboxes, .. } => break mailboxes, - ImapMailboxListResult::Err { err, .. } => bail!(err), + ImapMailboxListResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxListResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxListResult::Err { err, .. } => bail!("{err}"), } } } else { - let mut arg = None; let mut coroutine = ImapMailboxLsub::new(imap.context, reference, pattern); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxLsubResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxLsubResult::Ok { mailboxes, .. } => break mailboxes, - ImapMailboxLsubResult::Err { err, .. } => bail!(err), + ImapMailboxLsubResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxLsubResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxLsubResult::Err { err, .. } => bail!("{err}"), } } }; diff --git a/src/imap/mailbox/purge.rs b/src/imap/mailbox/purge.rs index d12a0230..231a8791 100644 --- a/src/imap/mailbox/purge.rs +++ b/src/imap/mailbox/purge.rs @@ -1,10 +1,11 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::{ rfc3501::{expunge::*, select::*, store::*}, types::flag::{Flag, StoreType}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{ @@ -12,6 +13,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameArg, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Shortcut for marking as deleted all envelopes then expunging the /// given mailbox. /// @@ -30,22 +33,28 @@ impl ImapMailboxPurgeCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } - let mut arg = None; let mut coroutine = ImapMessageStoreSilent::new( imap.context, "1:*".try_into()?, @@ -53,27 +62,38 @@ impl ImapMailboxPurgeCommand { vec![Flag::Deleted], false, ); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMessageStoreSilentResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMessageStoreSilentResult::Ok(context) => break context, + ImapMessageStoreSilentResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMessageStoreSilentResult::Ok { context, .. } => break context, - ImapMessageStoreSilentResult::Err { err, .. } => bail!(err), + ImapMessageStoreSilentResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageStoreSilentResult::Err { err, .. } => bail!("{err}"), } }; - let mut arg = None; let mut coroutine = ImapMailboxExpunge::new(imap.context); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxExpungeResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxExpungeResult::Ok { .. } => break, - ImapMailboxExpungeResult::Err { err, .. } => bail!(err), + ImapMailboxExpungeResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxExpungeResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxExpungeResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/rename.rs b/src/imap/mailbox/rename.rs index c2457303..85df5793 100644 --- a/src/imap/mailbox/rename.rs +++ b/src/imap/mailbox/rename.rs @@ -1,7 +1,8 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3501::rename::*; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{ @@ -9,6 +10,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameArg, TargetMailboxNameArg}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Rename the given mailbox. /// /// This command renames an existing mailbox to a new name. @@ -26,16 +29,22 @@ impl ImapMailboxRenameCommand { let from = self.mailbox_source_name.inner.try_into()?; let to = self.mailbox_dest_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMailboxRename::new(imap.context, from, to); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxRenameResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMailboxRenameResult::Ok(_) => break, + ImapMailboxRenameResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMailboxRenameResult::Ok { .. } => break, - ImapMailboxRenameResult::Err { err, .. } => bail!(err), + ImapMailboxRenameResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxRenameResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/select.rs b/src/imap/mailbox/select.rs index 0de01e80..8fc12ba0 100644 --- a/src/imap/mailbox/select.rs +++ b/src/imap/mailbox/select.rs @@ -1,11 +1,14 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3501::select::*; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Select the given mailbox. /// /// This command permanently removes all messages with the \Deleted @@ -25,16 +28,22 @@ impl ImapMailboxSelectCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { .. } => break, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/status.rs b/src/imap/mailbox/status.rs index 42b453b6..56cc665e 100644 --- a/src/imap/mailbox/status.rs +++ b/src/imap/mailbox/status.rs @@ -1,4 +1,7 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -7,12 +10,13 @@ use io_imap::{ rfc3501::status::*, types::status::{StatusDataItem, StatusDataItemName}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::{Serialize, Serializer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get the status of the given mailbox. /// /// This command displays status information about a mailbox, @@ -35,16 +39,22 @@ impl ImapMailboxStatusCommand { StatusDataItemName::UidValidity, ]; - let mut arg = None; let mut coroutine = ImapMailboxStatus::new(imap.context, mailbox, item_names); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let items = loop { match coroutine.resume(arg.take()) { - ImapMailboxStatusResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxStatusResult::Ok { items, .. } => break items, - ImapMailboxStatusResult::Err { err, .. } => bail!(err), + ImapMailboxStatusResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxStatusResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxStatusResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/mailbox/subscribe.rs b/src/imap/mailbox/subscribe.rs index b3362395..efa21f4f 100644 --- a/src/imap/mailbox/subscribe.rs +++ b/src/imap/mailbox/subscribe.rs @@ -1,11 +1,14 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3501::subscribe::*; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Subscribe to the given mailbox. /// /// This command subscribes to a mailbox, making it appear in the @@ -21,16 +24,22 @@ impl ImapMailboxSubscribeCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMailboxSubscribe::new(imap.context, mailbox); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxSubscribeResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMailboxSubscribeResult::Ok(_) => break, + ImapMailboxSubscribeResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMailboxSubscribeResult::Ok { .. } => break, - ImapMailboxSubscribeResult::Err { err, .. } => bail!(err), + ImapMailboxSubscribeResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSubscribeResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/unselect.rs b/src/imap/mailbox/unselect.rs index 7d887fa1..1d4c2ebf 100644 --- a/src/imap/mailbox/unselect.rs +++ b/src/imap/mailbox/unselect.rs @@ -1,11 +1,14 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3691::unselect::*; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::account::ImapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Unselect a current, selected mailbox. /// /// Unlike CLOSE, UNSELECT does not expunge deleted messages. @@ -20,16 +23,23 @@ pub struct ImapMailboxUnselectCommand; impl ImapMailboxUnselectCommand { pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { let mut imap = account.new_imap_session()?; - let mut arg = None; + let mut coroutine = ImapMailboxUnselect::new(imap.context); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxUnselectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMailboxUnselectResult::Ok(_) => break, + ImapMailboxUnselectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMailboxUnselectResult::Ok { .. } => break, - ImapMailboxUnselectResult::Err { err, .. } => bail!(err), + ImapMailboxUnselectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxUnselectResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/mailbox/unsubscribe.rs b/src/imap/mailbox/unsubscribe.rs index 120e13a0..67e727e3 100644 --- a/src/imap/mailbox/unsubscribe.rs +++ b/src/imap/mailbox/unsubscribe.rs @@ -1,11 +1,14 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::rfc3501::unsubscribe::*; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Unsubscribe from the given mailbox. /// /// This command unsubscribes from a mailbox, removing it from the @@ -21,16 +24,22 @@ impl ImapMailboxUnsubscribeCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMailboxUnsubscribe::new(imap.context, mailbox); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMailboxUnsubscribeResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) + ImapMailboxUnsubscribeResult::Ok(_) => break, + ImapMailboxUnsubscribeResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - ImapMailboxUnsubscribeResult::Ok { .. } => break, - ImapMailboxUnsubscribeResult::Err { err, .. } => bail!(err), + ImapMailboxUnsubscribeResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxUnsubscribeResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/message/copy.rs b/src/imap/message/copy.rs index c8e52631..eafdeb6c 100644 --- a/src/imap/message/copy.rs +++ b/src/imap/message/copy.rs @@ -1,10 +1,11 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::{ rfc3501::{copy::*, select::*}, types::mailbox::Mailbox, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{ @@ -12,6 +13,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag, TargetMailboxNameArg}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Copy IMAP message(s) to the given mailbox. /// /// This command copies message(s) identified by the given sequence @@ -39,17 +42,24 @@ impl ImapMessageCopyCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -57,15 +67,22 @@ impl ImapMessageCopyCommand { let sequence_set = self.sequence_set.as_str().try_into()?; let destination: Mailbox = self.mailbox_dest_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMessageCopy::new(imap.context, sequence_set, destination, !self.seq); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMessageCopyResult::Io { input } => arg = Some(handle(&mut imap.stream, input)?), ImapMessageCopyResult::Ok { .. } => break, - ImapMessageCopyResult::Err { err, .. } => bail!(err), + ImapMessageCopyResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageCopyResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageCopyResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/message/export.rs b/src/imap/message/export.rs index 70b7c014..cc4ab4dc 100644 --- a/src/imap/message/export.rs +++ b/src/imap/message/export.rs @@ -1,6 +1,6 @@ use std::{ fs, - io::{self, Write}, + io::{self, Read, Write}, num::NonZeroU32, path::PathBuf, }; @@ -11,12 +11,13 @@ use io_imap::{ rfc3501::{fetch::*, select::*}, types::fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, }; -use io_socket::runtimes::std_stream::handle; use mail_parser::{MessageParser, MimeHeaders}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Export type for message export. #[derive(Debug, Clone, clap::ValueEnum)] pub enum ExportType { @@ -65,17 +66,24 @@ impl ImapMessageExportCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + // SELECT mailbox - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; let context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; @@ -91,16 +99,21 @@ impl ImapMessageExportCommand { peek: true, }]); - let mut arg = None; let mut coroutine = ImapMessageFetchFirst::new(context, id, item_names, !self.seq); + let mut arg: Option<&[u8]> = None; let items = loop { match coroutine.resume(arg.take()) { - ImapMessageFetchFirstResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMessageFetchFirstResult::Ok { items, .. } => break items, - ImapMessageFetchFirstResult::Err { err, .. } => bail!(err), + ImapMessageFetchFirstResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageFetchFirstResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageFetchFirstResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/message/get.rs b/src/imap/message/get.rs index e5b641cf..e634ce5d 100644 --- a/src/imap/message/get.rs +++ b/src/imap/message/get.rs @@ -1,4 +1,8 @@ -use std::{fmt, num::NonZeroU32}; +use std::{ + fmt, + io::{Read, Write}, + num::NonZeroU32, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -7,7 +11,6 @@ use io_imap::{ rfc3501::{fetch::*, select::*}, types::fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, }; -use io_socket::runtimes::std_stream::handle; use mail_parser::{Addr, Address, ContentType, Message, MessageParser, MimeHeaders}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -17,6 +20,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get a message and display its structure. /// /// This command fetches a message and displays its headers along with @@ -43,17 +48,24 @@ impl ImapMessageGetCommand { bail!("ID must be non-zero"); }; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -65,16 +77,21 @@ impl ImapMessageGetCommand { peek: true, }]); - let mut arg = None; let mut coroutine = ImapMessageFetchFirst::new(imap.context, id, item_names, !self.seq); + let mut arg: Option<&[u8]> = None; let items = loop { match coroutine.resume(arg.take()) { - ImapMessageFetchFirstResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMessageFetchFirstResult::Ok { items, .. } => break items, - ImapMessageFetchFirstResult::Err { err, .. } => bail!(err), + ImapMessageFetchFirstResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageFetchFirstResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageFetchFirstResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/message/move.rs b/src/imap/message/move.rs index 3afbe97b..67e9d235 100644 --- a/src/imap/message/move.rs +++ b/src/imap/message/move.rs @@ -1,7 +1,8 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; use io_imap::{rfc3501::select::*, rfc6851::r#move::*, types::mailbox::Mailbox}; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{ @@ -9,6 +10,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag, TargetMailboxNameArg}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Move message(s) to the given mailbox. /// /// This command moves messages identified by the given sequence set @@ -37,17 +40,24 @@ impl ImapMessageMoveCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -55,15 +65,22 @@ impl ImapMessageMoveCommand { let sequence_set = self.sequence_set.as_str().try_into()?; let destination: Mailbox<'static> = self.mailbox_dest_name.inner.try_into()?; - let mut arg = None; let mut coroutine = ImapMessageMove::new(imap.context, sequence_set, destination, !self.seq); + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMessageMoveResult::Io { input } => arg = Some(handle(&mut imap.stream, input)?), ImapMessageMoveResult::Ok { .. } => break, - ImapMessageMoveResult::Err { err, .. } => bail!(err), + ImapMessageMoveResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageMoveResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageMoveResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/imap/message/read.rs b/src/imap/message/read.rs index ebaefd80..fdd6ac07 100644 --- a/src/imap/message/read.rs +++ b/src/imap/message/read.rs @@ -1,4 +1,8 @@ -use std::{fmt, num::NonZeroU32}; +use std::{ + fmt, + io::{Read, Write}, + num::NonZeroU32, +}; use anyhow::{bail, Result}; use clap::Parser; @@ -6,7 +10,6 @@ use io_imap::{ rfc3501::{fetch::*, select::*}, types::fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, }; -use io_socket::runtimes::std_stream::handle; use mail_parser::{Message, MessageParser}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -16,6 +19,8 @@ use crate::imap::{ mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Read message content. /// /// This command fetches a message and displays its text content. @@ -45,17 +50,24 @@ impl ImapMessageReadCommand { let mut imap = account.new_imap_session()?; let mailbox = self.mailbox_name.inner.try_into()?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + if !self.mailbox_no_select.inner { - let mut arg = None; let mut coroutine = ImapMailboxSelect::new(imap.context, mailbox); + let mut arg: Option<&[u8]> = None; imap.context = loop { match coroutine.resume(arg.take()) { - ImapMailboxSelectResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMailboxSelectResult::Ok { context, .. } => break context, - ImapMailboxSelectResult::Err { err, .. } => bail!(err), + ImapMailboxSelectResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMailboxSelectResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMailboxSelectResult::Err { err, .. } => bail!("{err}"), } }; } @@ -71,16 +83,21 @@ impl ImapMessageReadCommand { peek: true, }]); - let mut arg = None; let mut coroutine = ImapMessageFetchFirst::new(imap.context, id, item_names, !self.seq); + let mut arg: Option<&[u8]> = None; let items = loop { match coroutine.resume(arg.take()) { - ImapMessageFetchFirstResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMessageFetchFirstResult::Ok { items, .. } => break items, - ImapMessageFetchFirstResult::Err { err, .. } => bail!(err), + ImapMessageFetchFirstResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageFetchFirstResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageFetchFirstResult::Err { err, .. } => bail!("{err}"), } }; diff --git a/src/imap/message/save.rs b/src/imap/message/save.rs index 65a2976b..ea3a724e 100644 --- a/src/imap/message/save.rs +++ b/src/imap/message/save.rs @@ -1,4 +1,4 @@ -use std::io::{stdin, BufRead, IsTerminal}; +use std::io::{stdin, BufRead, IsTerminal, Read, Write}; use anyhow::{bail, Result}; use clap::Parser; @@ -9,11 +9,12 @@ use io_imap::{ IntoStatic, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Save a message to a mailbox. /// /// This command appends a message to the specified mailbox. The @@ -60,16 +61,22 @@ impl ImapMessageSaveCommand { .map(|f| Flag::try_from(f).map(IntoStatic::into_static)) .collect::>()?; - let mut arg = None; let mut coroutine = ImapMessageAppend::new(imap.context, mailbox, flags, None, message); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - ImapMessageAppendResult::Io { input } => { - arg = Some(handle(&mut imap.stream, input)?) - } ImapMessageAppendResult::Ok { .. } => break, - ImapMessageAppendResult::Err { err, .. } => bail!(err), + ImapMessageAppendResult::WantsRead => { + let n = imap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + ImapMessageAppendResult::WantsWrite(bytes) => { + imap.stream.write_all(&bytes)?; + arg = None; + } + ImapMessageAppendResult::Err { err, .. } => bail!("{err}"), } } diff --git a/src/jmap/email/copy.rs b/src/jmap/email/copy.rs index b33fc2ae..48328067 100644 --- a/src/jmap/email/copy.rs +++ b/src/jmap/email/copy.rs @@ -1,15 +1,19 @@ -use std::collections::HashMap; +use std::{ + collections::BTreeMap, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::{ - rfc8621::coroutines::email_copy::{JmapEmailCopy, JmapEmailCopyResult}, - rfc8621::types::email::EmailCopy, +use io_jmap::rfc8621::{ + email::EmailCopy, + email_copy::{JmapEmailCopy, JmapEmailCopyResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Copy JMAP emails from another account (Email/copy). #[derive(Debug, Parser)] @@ -31,10 +35,10 @@ impl JmapEmailCopyCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; - let mailbox_ids: HashMap = + let mailbox_ids: BTreeMap = self.mailbox_id.into_iter().map(|m| (m, true)).collect(); - let emails: HashMap = self + let emails: BTreeMap = self .ids .into_iter() .map(|id| { @@ -56,13 +60,21 @@ impl JmapEmailCopyCommand { self.from_account.clone(), emails, )?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_created = loop { match coroutine.resume(arg.take()) { - JmapEmailCopyResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapEmailCopyResult::Ok { not_created, .. } => break not_created, - JmapEmailCopyResult::Err { err, .. } => bail!(err), + JmapEmailCopyResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailCopyResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailCopyResult::Err(err) => bail!("{err}"), } }; @@ -71,18 +83,7 @@ impl JmapEmailCopyCommand { for (id, err) in not_created { msg.push_str(&format!("\n `{id}`")); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } + msg.push_str(&format_set_error(&err)); } bail!(msg) diff --git a/src/jmap/email/delete.rs b/src/jmap/email/delete.rs index 34d0b26d..c3d9f976 100644 --- a/src/jmap/email/delete.rs +++ b/src/jmap/email/delete.rs @@ -1,10 +1,13 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::email_set::{JmapEmailSet, JmapEmailSetArgs, JmapEmailSetResult}; -use io_socket::runtimes::std_stream::handle; +use io_jmap::rfc8621::email_set::{JmapEmailSet, JmapEmailSetArgs, JmapEmailSetResult}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Delete JMAP emails (Email/set destroy). #[derive(Debug, Parser)] @@ -25,13 +28,21 @@ impl JmapEmailDestroyCommand { } let mut coroutine = JmapEmailSet::new(&jmap.session, &jmap.http_auth, args)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_destroyed = loop { match coroutine.resume(arg.take()) { - JmapEmailSetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapEmailSetResult::Ok { not_destroyed, .. } => break not_destroyed, - JmapEmailSetResult::Err { err, .. } => bail!(err), + JmapEmailSetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailSetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailSetResult::Err(err) => bail!("{err}"), } }; @@ -40,18 +51,7 @@ impl JmapEmailDestroyCommand { for (id, err) in not_destroyed { msg.push_str(&format!("\n `{id}`")); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } + msg.push_str(&format_set_error(&err)); } bail!(msg) diff --git a/src/jmap/email/export.rs b/src/jmap/email/export.rs index 6b0cb566..246a927c 100644 --- a/src/jmap/email/export.rs +++ b/src/jmap/email/export.rs @@ -1,18 +1,21 @@ +use std::io::{Read, Write}; + use anyhow::{anyhow, bail, Result}; use clap::Parser; use io_jmap::{ - rfc8620::{ - coroutines::blob_download::{JmapBlobDownload, JmapBlobDownloadResult}, - types::session::capabilities::MAIL, + rfc8620::blob_download::{JmapBlobDownload, JmapBlobDownloadResult}, + rfc8621::{ + capabilities::MAIL, + email_get::{JmapEmailGet, JmapEmailGetResult}, }, - rfc8621::coroutines::email_get::{JmapEmailGet, JmapEmailGetResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use url::Url; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Export a raw RFC 5322 message to stdout (Email/get + blob download). /// /// Fetches the blobId via Email/get then downloads the raw message blob. @@ -30,7 +33,6 @@ impl JmapEmailExportCommand { let properties = Some(vec!["id".to_owned(), "blobId".to_owned()]); - let mut arg = None; let mut coroutine = JmapEmailGet::new( &jmap.session, &jmap.http_auth, @@ -40,14 +42,21 @@ impl JmapEmailExportCommand { false, 0, )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let emails = loop { match coroutine.resume(arg.take()) { - JmapEmailGetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), - JmapEmailGetResult::Ok { emails, .. } => { - break emails; + JmapEmailGetResult::Ok { emails, .. } => break emails, + JmapEmailGetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - JmapEmailGetResult::Err { err, .. } => bail!(err), + JmapEmailGetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailGetResult::Err(err) => bail!("{err}"), } }; @@ -63,7 +72,7 @@ impl JmapEmailExportCommand { .and_then(|e| e.blob_id) .ok_or_else(|| anyhow!("Email `{}` not found or has no blobId", self.id))?; - let url: Url = jmap + let mut url: Url = jmap .session .download_url .replace("{accountId}", account_id) @@ -72,17 +81,30 @@ impl JmapEmailExportCommand { .replace("{name}", "message.eml") .parse()?; - let mut stream = jmap.connect_if_different(&url, &tls)?; - let stream = stream.as_mut().unwrap_or(&mut jmap.stream); - - let mut coroutine = JmapBlobDownload::new(&jmap.http_auth, &url)?; - let mut arg = None; + let mut extra_stream = jmap.connect_if_different(&url, &tls)?; + let mut coroutine = JmapBlobDownload::new(&jmap.http_auth, &url); + let mut arg: Option<&[u8]> = None; let data = loop { match coroutine.resume(arg.take()) { - JmapBlobDownloadResult::Io { io } => arg = Some(handle(&mut *stream, io)?), JmapBlobDownloadResult::Ok { data, .. } => break data, - JmapBlobDownloadResult::Err { err, .. } => bail!(err), + JmapBlobDownloadResult::WantsRead => { + let stream = extra_stream.as_mut().unwrap_or(&mut jmap.stream); + let n = stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapBlobDownloadResult::WantsWrite(bytes) => { + let stream = extra_stream.as_mut().unwrap_or(&mut jmap.stream); + stream.write_all(&bytes)?; + arg = None; + } + JmapBlobDownloadResult::WantsRedirect { url: new_url, .. } => { + url = new_url; + extra_stream = jmap.connect_if_different(&url, &tls)?; + coroutine = JmapBlobDownload::new(&jmap.http_auth, &url); + arg = None; + } + JmapBlobDownloadResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/email/get.rs b/src/jmap/email/get.rs index daeff919..6ae16663 100644 --- a/src/jmap/email/get.rs +++ b/src/jmap/email/get.rs @@ -1,12 +1,15 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::email_get::{JmapEmailGet, JmapEmailGetResult}; -use io_socket::runtimes::std_stream::handle; +use io_jmap::rfc8621::email_get::{JmapEmailGet, JmapEmailGetResult}; use log::warn; use pimalaya_toolbox::terminal::printer::Printer; use crate::jmap::{account::JmapAccount, email::query::EmailsTable}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get JMAP emails by ID (Email/get). /// /// Fetches and displays email envelopes as a table. @@ -30,15 +33,23 @@ impl JmapEmailGetCommand { false, 0, )?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (emails, not_found) = loop { match coroutine.resume(arg.take()) { - JmapEmailGetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapEmailGetResult::Ok { emails, not_found, .. } => break (emails, not_found), - JmapEmailGetResult::Err { err, .. } => bail!(err), + JmapEmailGetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailGetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailGetResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/email/import.rs b/src/jmap/email/import.rs index 9425da62..0fce44b1 100644 --- a/src/jmap/email/import.rs +++ b/src/jmap/email/import.rs @@ -1,25 +1,24 @@ use std::{ - collections::HashMap, - io::{stdin, BufRead, IsTerminal}, + collections::BTreeMap, + io::{stdin, BufRead, IsTerminal, Read, Write}, }; use anyhow::{bail, Result}; use clap::Parser; use io_jmap::{ - rfc8620::{ - coroutines::blob_upload::{JmapBlobUpload, JmapBlobUploadResult}, - types::session::capabilities::MAIL, - }, + rfc8620::blob_upload::{JmapBlobUpload, JmapBlobUploadResult}, rfc8621::{ - coroutines::email_import::{JmapEmailImport, JmapEmailImportResult}, - types::email::EmailImport, + capabilities::MAIL, + email::EmailImport, + email_import::{JmapEmailImport, JmapEmailImportResult}, }, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use url::Url; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Import an RFC 5322 message into a mailbox (upload + Email/import). /// @@ -78,18 +77,25 @@ impl JmapEmailImportCommand { .parse()?; let mut extra_stream = jmap.connect_if_different(&url, &tls)?; - let upload_stream = extra_stream.as_mut().unwrap_or(&mut jmap.stream); - let mut coroutine = JmapBlobUpload::new(&jmap.http_auth, &url, "message/rfc822", data)?; - let mut arg = None; + let mut coroutine = JmapBlobUpload::new(&jmap.http_auth, &url, "message/rfc822", data); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let blob_id = loop { + let stream = extra_stream.as_mut().unwrap_or(&mut jmap.stream); + match coroutine.resume(arg.take()) { - JmapBlobUploadResult::Io { io } => arg = Some(handle(&mut *upload_stream, io)?), - JmapBlobUploadResult::Ok { blob_id, .. } => { - break blob_id; + JmapBlobUploadResult::Ok { blob_id, .. } => break blob_id, + JmapBlobUploadResult::WantsRead => { + let n = stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - JmapBlobUploadResult::Err { err, .. } => bail!(err), + JmapBlobUploadResult::WantsWrite(bytes) => { + stream.write_all(&bytes)?; + arg = None; + } + JmapBlobUploadResult::Err(err) => bail!("{err}"), } }; @@ -97,7 +103,7 @@ impl JmapEmailImportCommand { return printer.out(Message::new(blob_id)); } - let mailbox_ids: HashMap = + let mailbox_ids: BTreeMap = self.mailbox_id.into_iter().map(|m| (m, true)).collect(); let keywords = if self.keyword.is_empty() { @@ -113,35 +119,30 @@ impl JmapEmailImportCommand { received_at: self.received_at, }; - let mut emails = HashMap::new(); + let mut emails = BTreeMap::new(); emails.insert(blob_id.clone(), import); let mut coroutine = JmapEmailImport::new(&jmap.session, &jmap.http_auth, emails)?; - let mut arg = None; + let mut arg: Option<&[u8]> = None; let not_created = loop { match coroutine.resume(arg.take()) { - JmapEmailImportResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapEmailImportResult::Ok { not_created, .. } => break not_created, - JmapEmailImportResult::Err { err, .. } => bail!(err), + JmapEmailImportResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailImportResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailImportResult::Err(err) => bail!("{err}"), } }; if let Some(err) = not_created.get(&blob_id) { let mut msg = format!("Import JMAP email from blob `{blob_id}` error"); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } - + msg.push_str(&format_set_error(err)); bail!(msg); } diff --git a/src/jmap/email/parse.rs b/src/jmap/email/parse.rs index e71970dd..0f130b3f 100644 --- a/src/jmap/email/parse.rs +++ b/src/jmap/email/parse.rs @@ -1,13 +1,16 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::email_parse::{JmapEmailParse, JmapEmailParseResult}; -use io_socket::runtimes::std_stream::handle; +use io_jmap::rfc8621::email_parse::{JmapEmailParse, JmapEmailParseResult}; use log::warn; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Parse RFC 5322 message blobs without storing them (Email/parse). /// /// Useful for reading attached .eml files or message blobs that are @@ -25,20 +28,26 @@ impl JmapEmailParseCommand { let mut coroutine = JmapEmailParse::new(&jmap.session, &jmap.http_auth, self.blob_ids.clone(), None)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (parsed, not_parsable, not_found) = loop { match coroutine.resume(arg.take()) { - JmapEmailParseResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapEmailParseResult::Ok { parsed, not_parsable, not_found, .. - } => { - break (parsed, not_parsable, not_found); + } => break (parsed, not_parsable, not_found), + JmapEmailParseResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - JmapEmailParseResult::Err { err, .. } => bail!(err), + JmapEmailParseResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailParseResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/email/query.rs b/src/jmap/email/query.rs index 00790921..dcb3b607 100644 --- a/src/jmap/email/query.rs +++ b/src/jmap/email/query.rs @@ -1,18 +1,22 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::{Parser, ValueEnum}; use comfy_table::{Cell, ContentArrangement, Row, Table}; -use io_jmap::{ - rfc8621::coroutines::email_query::{JmapEmailQuery, JmapEmailQueryResult}, - rfc8621::types::email::{Email, EmailAddress, EmailComparator, EmailFilter, EmailSortProperty}, +use io_jmap::rfc8621::{ + email::{Email, EmailAddress, EmailComparator, EmailFilter, EmailSortProperty}, + email_query::{JmapEmailQuery, JmapEmailQueryResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Query JMAP emails (Email/query + Email/get). /// /// Lists, filters and sorts email envelopes. @@ -141,7 +145,6 @@ impl JmapEmailQueryCommand { keyword: None, }]); - let mut arg = None; let mut coroutine = JmapEmailQuery::new( &jmap.session, &jmap.http_auth, @@ -151,12 +154,21 @@ impl JmapEmailQueryCommand { Some(self.page_size), None, )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let emails = loop { match coroutine.resume(arg.take()) { - JmapEmailQueryResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapEmailQueryResult::Ok { emails, .. } => break emails, - JmapEmailQueryResult::Err { err, .. } => bail!(err), + JmapEmailQueryResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailQueryResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailQueryResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/email/read.rs b/src/jmap/email/read.rs index 86388f76..049048f5 100644 --- a/src/jmap/email/read.rs +++ b/src/jmap/email/read.rs @@ -1,15 +1,18 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::{ - rfc8621::coroutines::email_get::{JmapEmailGet, JmapEmailGetResult}, - rfc8621::types::email::EmailAddress, +use io_jmap::rfc8621::{ + email::EmailAddress, + email_get::{JmapEmailGet, JmapEmailGetResult}, }; -use io_socket::runtimes::std_stream::handle; use log::warn; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Read the content of a JMAP email (Email/get with body). /// /// Shows headers and plain text body by default. @@ -28,7 +31,6 @@ impl JmapEmailReadCommand { pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { let mut jmap = account.new_jmap_session()?; - let mut arg = None; let mut coroutine = JmapEmailGet::new( &jmap.session, &jmap.http_auth, @@ -38,14 +40,23 @@ impl JmapEmailReadCommand { self.html, 0, )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (emails, not_found) = loop { match coroutine.resume(arg.take()) { - JmapEmailGetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapEmailGetResult::Ok { emails, not_found, .. } => break (emails, not_found), - JmapEmailGetResult::Err { err, .. } => bail!(err), + JmapEmailGetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailGetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailGetResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/email/update.rs b/src/jmap/email/update.rs index 7f4f584f..c940e8fb 100644 --- a/src/jmap/email/update.rs +++ b/src/jmap/email/update.rs @@ -1,12 +1,16 @@ -use std::collections::HashMap; +use std::{ + collections::BTreeMap, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::email_set::{JmapEmailSet, JmapEmailSetArgs, JmapEmailSetResult}; -use io_socket::runtimes::std_stream::handle; +use io_jmap::rfc8621::email_set::{JmapEmailSet, JmapEmailSetArgs, JmapEmailSetResult}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Update JMAP emails via patch operations (Email/set). #[derive(Debug, Parser)] @@ -55,7 +59,7 @@ impl JmapEmailUpdateCommand { } if let Some(kws) = &self.keywords { - let map: HashMap = kws.iter().map(|kw| (kw.clone(), true)).collect(); + let map: BTreeMap = kws.iter().map(|kw| (kw.clone(), true)).collect(); args.replace_keywords(id.clone(), map); } @@ -68,19 +72,28 @@ impl JmapEmailUpdateCommand { } if let Some(mboxes) = &self.mailboxes { - let map: HashMap = mboxes.iter().map(|m| (m.clone(), true)).collect(); + let map: BTreeMap = + mboxes.iter().map(|m| (m.clone(), true)).collect(); args.replace_mailbox_ids(id.clone(), map); } } let mut coroutine = JmapEmailSet::new(&jmap.session, &jmap.http_auth, args)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_updated = loop { match coroutine.resume(arg.take()) { - JmapEmailSetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapEmailSetResult::Ok { not_updated, .. } => break not_updated, - JmapEmailSetResult::Err { err, .. } => bail!(err), + JmapEmailSetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailSetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailSetResult::Err(err) => bail!("{err}"), } }; @@ -89,18 +102,7 @@ impl JmapEmailUpdateCommand { for (id, err) in not_updated { msg.push_str(&format!("\n `{id}`")); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } + msg.push_str(&format_set_error(&err)); } bail!(msg) diff --git a/src/jmap/error.rs b/src/jmap/error.rs new file mode 100644 index 00000000..2b4830a8 --- /dev/null +++ b/src/jmap/error.rs @@ -0,0 +1,230 @@ +use io_jmap::rfc8621::{ + email::{EmailCopyError, EmailImportError, EmailSetError}, + email_submission::EmailSubmissionSetError, + identity::IdentitySetError, + mailbox::MailboxSetError, +}; + +/// Returns the optional human-readable description carried by a JMAP set error. +pub trait JmapSetError { + fn description(&self) -> Option<&str>; + fn properties(&self) -> &[String]; + fn type_name(&self) -> &'static str; +} + +/// Renders a JMAP `*Set`-style error suffix like +/// `: invalidProperties (\`name\`) — too long`. +pub fn format_set_error(err: &E) -> String { + let mut msg = format!(": {}", err.type_name()); + + if !err.properties().is_empty() { + msg.push_str(" (`"); + msg.push_str(&err.properties().join("`, `")); + msg.push(')'); + } + + if let Some(desc) = err.description() { + msg.push_str(" — "); + msg.push_str(desc.trim_end_matches(['.', '\n'])); + } + + msg +} + +impl JmapSetError for MailboxSetError { + fn description(&self) -> Option<&str> { + match self { + Self::MailboxHasChild { description } + | Self::MailboxHasEmail { description } + | Self::NotFound { description } + | Self::InvalidPatch { description } + | Self::WillDestroy { description } + | Self::InvalidProperties { description, .. } + | Self::Singleton { description } => description.as_deref(), + Self::Unknown => None, + } + } + + fn properties(&self) -> &[String] { + match self { + Self::InvalidProperties { properties, .. } => properties, + _ => &[], + } + } + + fn type_name(&self) -> &'static str { + match self { + Self::MailboxHasChild { .. } => "mailboxHasChild", + Self::MailboxHasEmail { .. } => "mailboxHasEmail", + Self::NotFound { .. } => "notFound", + Self::InvalidPatch { .. } => "invalidPatch", + Self::WillDestroy { .. } => "willDestroy", + Self::InvalidProperties { .. } => "invalidProperties", + Self::Singleton { .. } => "singleton", + Self::Unknown => "unknown", + } + } +} + +impl JmapSetError for EmailSetError { + fn description(&self) -> Option<&str> { + match self { + Self::TooManyKeywords { description } + | Self::TooManyMailboxes { description } + | Self::BlobNotFound { description } + | Self::NotFound { description } + | Self::InvalidPatch { description } + | Self::WillDestroy { description } + | Self::InvalidProperties { description, .. } + | Self::Singleton { description } => description.as_deref(), + Self::Unknown => None, + } + } + + fn properties(&self) -> &[String] { + match self { + Self::InvalidProperties { properties, .. } => properties, + _ => &[], + } + } + + fn type_name(&self) -> &'static str { + match self { + Self::TooManyKeywords { .. } => "tooManyKeywords", + Self::TooManyMailboxes { .. } => "tooManyMailboxes", + Self::BlobNotFound { .. } => "blobNotFound", + Self::NotFound { .. } => "notFound", + Self::InvalidPatch { .. } => "invalidPatch", + Self::WillDestroy { .. } => "willDestroy", + Self::InvalidProperties { .. } => "invalidProperties", + Self::Singleton { .. } => "singleton", + Self::Unknown => "unknown", + } + } +} + +impl JmapSetError for EmailImportError { + fn description(&self) -> Option<&str> { + match self { + Self::InvalidEmail { description } + | Self::NotFound { description } + | Self::InvalidProperties { description, .. } => description.as_deref(), + Self::Unknown => None, + } + } + + fn properties(&self) -> &[String] { + match self { + Self::InvalidProperties { properties, .. } => properties, + _ => &[], + } + } + + fn type_name(&self) -> &'static str { + match self { + Self::InvalidEmail { .. } => "invalidEmail", + Self::NotFound { .. } => "notFound", + Self::InvalidProperties { .. } => "invalidProperties", + Self::Unknown => "unknown", + } + } +} + +impl JmapSetError for EmailCopyError { + fn description(&self) -> Option<&str> { + match self { + Self::AlreadyExists { description } + | Self::NotFound { description } + | Self::InvalidProperties { description, .. } => description.as_deref(), + Self::Unknown => None, + } + } + + fn properties(&self) -> &[String] { + match self { + Self::InvalidProperties { properties, .. } => properties, + _ => &[], + } + } + + fn type_name(&self) -> &'static str { + match self { + Self::AlreadyExists { .. } => "alreadyExists", + Self::NotFound { .. } => "notFound", + Self::InvalidProperties { .. } => "invalidProperties", + Self::Unknown => "unknown", + } + } +} + +impl JmapSetError for IdentitySetError { + fn description(&self) -> Option<&str> { + match self { + Self::NotFound { description } + | Self::InvalidPatch { description } + | Self::WillDestroy { description } + | Self::InvalidProperties { description, .. } + | Self::Singleton { description } => description.as_deref(), + Self::Unknown => None, + } + } + + fn properties(&self) -> &[String] { + match self { + Self::InvalidProperties { properties, .. } => properties, + _ => &[], + } + } + + fn type_name(&self) -> &'static str { + match self { + Self::NotFound { .. } => "notFound", + Self::InvalidPatch { .. } => "invalidPatch", + Self::WillDestroy { .. } => "willDestroy", + Self::InvalidProperties { .. } => "invalidProperties", + Self::Singleton { .. } => "singleton", + Self::Unknown => "unknown", + } + } +} + +impl JmapSetError for EmailSubmissionSetError { + fn description(&self) -> Option<&str> { + match self { + Self::TooManyRecipients { description } + | Self::NoRecipients { description } + | Self::InvalidRecipients { description } + | Self::ForbiddenMailFrom { description } + | Self::ForbiddenFrom { description } + | Self::ForbiddenToSend { description } + | Self::CannotUnsendMessage { description } + | Self::InvalidEmail { description } + | Self::NotFound { description } + | Self::InvalidProperties { description, .. } => description.as_deref(), + Self::Unknown => None, + } + } + + fn properties(&self) -> &[String] { + match self { + Self::InvalidProperties { properties, .. } => properties, + _ => &[], + } + } + + fn type_name(&self) -> &'static str { + match self { + Self::TooManyRecipients { .. } => "tooManyRecipients", + Self::NoRecipients { .. } => "noRecipients", + Self::InvalidRecipients { .. } => "invalidRecipients", + Self::ForbiddenMailFrom { .. } => "forbiddenMailFrom", + Self::ForbiddenFrom { .. } => "forbiddenFrom", + Self::ForbiddenToSend { .. } => "forbiddenToSend", + Self::CannotUnsendMessage { .. } => "cannotUnsendMessage", + Self::InvalidEmail { .. } => "invalidEmail", + Self::NotFound { .. } => "notFound", + Self::InvalidProperties { .. } => "invalidProperties", + Self::Unknown => "unknown", + } + } +} diff --git a/src/jmap/identity/create.rs b/src/jmap/identity/create.rs index 3891609d..0a72ce9e 100644 --- a/src/jmap/identity/create.rs +++ b/src/jmap/identity/create.rs @@ -1,15 +1,16 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::{ - rfc8621::coroutines::identity_set::{ - JmapIdentitySet, JmapIdentitySetArgs, JmapIdentitySetResult, - }, - rfc8621::types::identity::IdentityCreate, +use io_jmap::rfc8621::{ + identity::IdentityCreate, + identity_set::{JmapIdentitySet, JmapIdentitySetArgs, JmapIdentitySetResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Create a JMAP sender identity (Identity/set). #[derive(Debug, Parser)] @@ -48,31 +49,27 @@ impl JmapIdentityCreateCommand { args.create(create_id, identity); let mut coroutine = JmapIdentitySet::new(&jmap.session, &jmap.http_auth, args)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_created = loop { match coroutine.resume(arg.take()) { - JmapIdentitySetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapIdentitySetResult::Ok { not_created, .. } => break not_created, - JmapIdentitySetResult::Err { err, .. } => bail!(err), + JmapIdentitySetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapIdentitySetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapIdentitySetResult::Err(err) => bail!("{err}"), } }; if let Some(err) = not_created.get(create_id) { let mut msg = format!("Create identity for `{}` error", self.email); - - if !err.properties.is_empty() { - msg.push_str(": invalid propertie(s) `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push_str(")"); - } - + msg.push_str(&format_set_error(err)); bail!(msg); } diff --git a/src/jmap/identity/delete.rs b/src/jmap/identity/delete.rs index b22c0a19..b5c979fe 100644 --- a/src/jmap/identity/delete.rs +++ b/src/jmap/identity/delete.rs @@ -1,12 +1,13 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::identity_set::{ - JmapIdentitySet, JmapIdentitySetArgs, JmapIdentitySetResult, -}; -use io_socket::runtimes::std_stream::handle; +use io_jmap::rfc8621::identity_set::{JmapIdentitySet, JmapIdentitySetArgs, JmapIdentitySetResult}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Delete a JMAP sender identity (Identity/set). #[derive(Debug, Parser)] @@ -27,13 +28,21 @@ impl JmapIdentityDeleteCommand { } let mut coroutine = JmapIdentitySet::new(&jmap.session, &jmap.http_auth, args)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_destroyed = loop { match coroutine.resume(arg.take()) { - JmapIdentitySetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapIdentitySetResult::Ok { not_destroyed, .. } => break not_destroyed, - JmapIdentitySetResult::Err { err, .. } => bail!(err), + JmapIdentitySetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapIdentitySetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapIdentitySetResult::Err(err) => bail!("{err}"), } }; @@ -42,18 +51,7 @@ impl JmapIdentityDeleteCommand { for (id, err) in not_destroyed { msg.push_str(&format!("\n `{id}`")); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } + msg.push_str(&format_set_error(&err)); } bail!(msg) diff --git a/src/jmap/identity/get.rs b/src/jmap/identity/get.rs index 8c876e6d..092e290c 100644 --- a/src/jmap/identity/get.rs +++ b/src/jmap/identity/get.rs @@ -1,19 +1,23 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; use comfy_table::{Cell, Row, Table}; -use io_jmap::{ - rfc8621::coroutines::identity_get::{JmapIdentityGet, JmapIdentityGetResult}, - rfc8621::types::identity::Identity, +use io_jmap::rfc8621::{ + identity::Identity, + identity_get::{JmapIdentityGet, JmapIdentityGetResult}, }; -use io_socket::runtimes::std_stream::handle; use log::warn; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get JMAP identities (Identity/get). /// /// Lists sender identities available for sending email. Pass no IDs to @@ -30,17 +34,25 @@ impl JmapIdentityGetCommand { let mut jmap = account.new_jmap_session()?; let mut coroutine = JmapIdentityGet::new(&jmap.session, &jmap.http_auth, self.ids)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (identities, not_found) = loop { match coroutine.resume(arg.take()) { - JmapIdentityGetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapIdentityGetResult::Ok { identities, not_found, .. } => break (identities, not_found), - JmapIdentityGetResult::Err { err, .. } => bail!(err), + JmapIdentityGetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapIdentityGetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapIdentityGetResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/identity/update.rs b/src/jmap/identity/update.rs index 22fd874d..6c583659 100644 --- a/src/jmap/identity/update.rs +++ b/src/jmap/identity/update.rs @@ -1,15 +1,16 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::{ - rfc8621::coroutines::identity_set::{ - JmapIdentitySet, JmapIdentitySetArgs, JmapIdentitySetResult, - }, - rfc8621::types::identity::IdentityUpdate, +use io_jmap::rfc8621::{ + identity::IdentityUpdate, + identity_set::{JmapIdentitySet, JmapIdentitySetArgs, JmapIdentitySetResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Update a JMAP sender identity (Identity/set). #[derive(Debug, Parser)] @@ -46,31 +47,27 @@ impl JmapIdentityUpdateCommand { args.update(self.id.clone(), patch); let mut coroutine = JmapIdentitySet::new(&jmap.session, &jmap.http_auth, args)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_updated = loop { match coroutine.resume(arg.take()) { - JmapIdentitySetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapIdentitySetResult::Ok { not_updated, .. } => break not_updated, - JmapIdentitySetResult::Err { err, .. } => bail!(err), + JmapIdentitySetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapIdentitySetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapIdentitySetResult::Err(err) => bail!("{err}"), } }; if let Some(err) = not_updated.get(&self.id) { let mut msg = format!("Update identity `{}` error", self.id); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } - + msg.push_str(&format_set_error(err)); bail!(msg); } diff --git a/src/jmap/mailbox/create.rs b/src/jmap/mailbox/create.rs index d3213496..91332104 100644 --- a/src/jmap/mailbox/create.rs +++ b/src/jmap/mailbox/create.rs @@ -1,15 +1,19 @@ -use std::collections::HashMap; +use std::{ + collections::BTreeMap, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::{ - rfc8621::coroutines::mailbox_set::{JmapMailboxSet, JmapMailboxSetArgs, JmapMailboxSetResult}, - rfc8621::types::mailbox::MailboxCreate, +use io_jmap::rfc8621::{ + mailbox::MailboxCreate, + mailbox_set::{JmapMailboxSet, JmapMailboxSetArgs, JmapMailboxSetResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Create a JMAP mailbox. #[derive(Debug, Parser)] @@ -39,38 +43,34 @@ impl JmapMailboxCreateCommand { ..Default::default() }; - let mut create = HashMap::new(); + let mut create = BTreeMap::new(); create.insert(self.name.clone(), new_mailbox); let mut args = JmapMailboxSetArgs::default(); args.create = Some(create); let mut coroutine = JmapMailboxSet::new(&jmap.session, &jmap.http_auth, args)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_created = loop { match coroutine.resume(arg.take()) { - JmapMailboxSetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapMailboxSetResult::Ok { not_created, .. } => break not_created, - JmapMailboxSetResult::Err { err, .. } => bail!(err), + JmapMailboxSetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapMailboxSetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapMailboxSetResult::Err(err) => bail!("{err}"), } }; if let Some(err) = not_created.get(&self.name) { let mut msg = format!("Create JMAP mailbox `{}` error", self.name); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } - + msg.push_str(&format_set_error(err)); bail!(msg) } diff --git a/src/jmap/mailbox/destroy.rs b/src/jmap/mailbox/destroy.rs index e8f45c38..80e113e1 100644 --- a/src/jmap/mailbox/destroy.rs +++ b/src/jmap/mailbox/destroy.rs @@ -1,12 +1,13 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::mailbox_set::{ - JmapMailboxSet, JmapMailboxSetArgs, JmapMailboxSetResult, -}; -use io_socket::runtimes::std_stream::handle; +use io_jmap::rfc8621::mailbox_set::{JmapMailboxSet, JmapMailboxSetArgs, JmapMailboxSetResult}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Delete a JMAP mailbox. #[derive(Debug, Parser)] @@ -28,14 +29,22 @@ impl JmapMailboxDestroyCommand { 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 = JmapMailboxSet::new(&jmap.session, &jmap.http_auth, args)?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_destroyed = loop { match coroutine.resume(arg.take()) { - JmapMailboxSetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapMailboxSetResult::Ok { not_destroyed, .. } => break not_destroyed, - JmapMailboxSetResult::Err { err, .. } => bail!(err), + JmapMailboxSetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapMailboxSetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapMailboxSetResult::Err(err) => bail!("{err}"), } }; @@ -44,18 +53,7 @@ impl JmapMailboxDestroyCommand { for (id, err) in not_destroyed { msg.push_str(&format!("\n `{id}`")); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } + msg.push_str(&format_set_error(&err)); } bail!(msg) diff --git a/src/jmap/mailbox/get.rs b/src/jmap/mailbox/get.rs index c350fafd..16392c48 100644 --- a/src/jmap/mailbox/get.rs +++ b/src/jmap/mailbox/get.rs @@ -1,12 +1,15 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::mailbox_get::{JmapMailboxGet, JmapMailboxGetResult}; -use io_socket::runtimes::std_stream::handle; +use io_jmap::rfc8621::mailbox_get::{JmapMailboxGet, JmapMailboxGetResult}; use log::warn; use pimalaya_toolbox::terminal::printer::Printer; use crate::jmap::{account::JmapAccount, mailbox::query::MailboxesTable}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get JMAP mailboxes by ID (Mailbox/get). #[derive(Debug, Parser)] pub struct JmapMailboxGetCommand { @@ -21,17 +24,25 @@ impl JmapMailboxGetCommand { let mut coroutine = JmapMailboxGet::new(&jmap.session, &jmap.http_auth, Some(self.ids.clone()), None)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (mailboxes, not_found) = loop { match coroutine.resume(arg.take()) { - JmapMailboxGetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapMailboxGetResult::Ok { mailboxes, not_found, .. } => break (mailboxes, not_found), - JmapMailboxGetResult::Err { err, .. } => bail!(err), + JmapMailboxGetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapMailboxGetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapMailboxGetResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/mailbox/query.rs b/src/jmap/mailbox/query.rs index c68b41c7..620c8506 100644 --- a/src/jmap/mailbox/query.rs +++ b/src/jmap/mailbox/query.rs @@ -1,20 +1,24 @@ -use std::{convert::Infallible, fmt, str::FromStr}; +use std::{ + convert::Infallible, + fmt, + io::{Read, Write}, + str::FromStr, +}; use anyhow::{bail, Result}; use clap::{Parser, ValueEnum}; use comfy_table::{Cell, Row, Table}; -use io_jmap::{ - rfc8621::coroutines::mailbox_query::{JmapMailboxQuery, JmapMailboxQueryResult}, - rfc8621::types::mailbox::{ - Mailbox, MailboxFilter, MailboxRole, MailboxSortComparator, MailboxSortProperty, - }, +use io_jmap::rfc8621::{ + mailbox::{Mailbox, MailboxFilter, MailboxRole, MailboxSortComparator, MailboxSortProperty}, + mailbox_query::{JmapMailboxQuery, JmapMailboxQueryResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Query JMAP mailboxes (Mailbox/query + Mailbox/get). /// /// Lists, filters and sorts mailboxes. @@ -89,7 +93,6 @@ impl JmapMailboxQueryCommand { is_ascending: Some(!self.desc), }]); - let mut arg = None; let mut coroutine = JmapMailboxQuery::new( &jmap.session, &jmap.http_auth, @@ -99,12 +102,21 @@ impl JmapMailboxQueryCommand { Some(self.page_size), None, )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let mailboxes = loop { match coroutine.resume(arg.take()) { - JmapMailboxQueryResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapMailboxQueryResult::Ok { mailboxes, .. } => break mailboxes, - JmapMailboxQueryResult::Err { err, .. } => bail!(err), + JmapMailboxQueryResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapMailboxQueryResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapMailboxQueryResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/mailbox/update.rs b/src/jmap/mailbox/update.rs index 1c6ac44c..8fa40a7b 100644 --- a/src/jmap/mailbox/update.rs +++ b/src/jmap/mailbox/update.rs @@ -1,15 +1,19 @@ -use std::collections::HashMap; +use std::{ + collections::BTreeMap, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::{ - rfc8621::coroutines::mailbox_set::{JmapMailboxSet, JmapMailboxSetArgs, JmapMailboxSetResult}, - rfc8621::types::mailbox::MailboxUpdate, +use io_jmap::rfc8621::{ + mailbox::MailboxUpdate, + mailbox_set::{JmapMailboxSet, JmapMailboxSetArgs, JmapMailboxSetResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, mailbox::query::RoleArg}; +use crate::jmap::{account::JmapAccount, error::format_set_error, mailbox::query::RoleArg}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Update a JMAP mailbox. #[derive(Debug, Parser)] @@ -63,38 +67,34 @@ impl JmapMailboxUpdateCommand { is_subscribed, }; - let mut update = HashMap::new(); + let mut update = BTreeMap::new(); update.insert(self.id.clone(), patch); let mut args = JmapMailboxSetArgs::default(); args.update = Some(update); - let mut arg = None; let mut coroutine = JmapMailboxSet::new(&jmap.session, &jmap.http_auth, args)?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_updated = loop { match coroutine.resume(arg.take()) { - JmapMailboxSetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapMailboxSetResult::Ok { not_updated, .. } => break not_updated, - JmapMailboxSetResult::Err { err, .. } => bail!(err), + JmapMailboxSetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapMailboxSetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapMailboxSetResult::Err(err) => bail!("{err}"), } }; if let Some(err) = not_updated.get(&self.id) { let mut msg = format!("Update JMAP mailbox `{}` error", self.id); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } - + msg.push_str(&format_set_error(err)); bail!(msg); } diff --git a/src/jmap/mod.rs b/src/jmap/mod.rs index 90f97f0e..0eed486b 100644 --- a/src/jmap/mod.rs +++ b/src/jmap/mod.rs @@ -1,6 +1,7 @@ pub mod account; pub mod command; pub mod email; +pub mod error; pub mod identity; pub mod mailbox; pub mod query; diff --git a/src/jmap/query.rs b/src/jmap/query.rs index 531f7139..9c87449c 100644 --- a/src/jmap/query.rs +++ b/src/jmap/query.rs @@ -1,21 +1,25 @@ use std::{ fmt, - io::{stdin, BufRead}, + io::{stdin, BufRead, Read, Write}, }; use anyhow::{bail, Context, Result}; use clap::Parser; -use io_jmap::rfc8620::{ - coroutines::send::{JmapRequest, JmapSend, JmapSendResult}, - types::session::capabilities::{CORE, MAIL}, +use io_jmap::{ + rfc8620::{ + send::{JmapRequest, JmapSend, JmapSendResult}, + session::capabilities::CORE, + }, + rfc8621::capabilities::MAIL, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use serde_json::Value; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Send a raw JMAP method-calls array and print the response. /// /// METHOD_CALLS must be a JSON array of JMAP method call tuples: @@ -113,15 +117,21 @@ impl JmapQueryCommand { }; let mut coroutine = JmapSend::new(&jmap.http_auth, &jmap.session.api_url, request)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let response = loop { match coroutine.resume(arg.take()) { - JmapSendResult::Ok { response, .. } => { - break response; + JmapSendResult::Ok { response, .. } => break response, + JmapSendResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); } - JmapSendResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), - JmapSendResult::Err { err, .. } => return Err(err.into()), + JmapSendResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapSendResult::Err(err) => return Err(err.into()), } }; diff --git a/src/jmap/submission/cancel.rs b/src/jmap/submission/cancel.rs index 3d928b4a..d018a5cf 100644 --- a/src/jmap/submission/cancel.rs +++ b/src/jmap/submission/cancel.rs @@ -1,12 +1,15 @@ +use std::io::{Read, Write}; + use anyhow::{anyhow, bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::email_submission_cancel::{ +use io_jmap::rfc8621::email_submission_cancel::{ JmapEmailSubmissionCancel, JmapEmailSubmissionCancelResult, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::{account::JmapAccount, error::format_set_error}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Cancel (undo) a pending JMAP email submission (EmailSubmission/set). /// @@ -26,15 +29,21 @@ impl JmapSubmissionCancelCommand { let mut coroutine = JmapEmailSubmissionCancel::new(&jmap.session, &jmap.http_auth, self.ids.clone()) .map_err(|e| anyhow!("{e}"))?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let not_updated = loop { match coroutine.resume(arg.take()) { - JmapEmailSubmissionCancelResult::Io { io } => { - arg = Some(handle(&mut jmap.stream, io)?) - } JmapEmailSubmissionCancelResult::Ok { not_updated, .. } => break not_updated, - JmapEmailSubmissionCancelResult::Err { err, .. } => bail!(err), + JmapEmailSubmissionCancelResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailSubmissionCancelResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailSubmissionCancelResult::Err(err) => bail!("{err}"), } }; @@ -43,18 +52,7 @@ impl JmapSubmissionCancelCommand { for (id, err) in ¬_updated { msg.push_str(&format!("\n `{id}`")); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } + msg.push_str(&format_set_error(err)); } bail!(msg); diff --git a/src/jmap/submission/create.rs b/src/jmap/submission/create.rs index 853b91e6..69bc4f08 100644 --- a/src/jmap/submission/create.rs +++ b/src/jmap/submission/create.rs @@ -1,19 +1,21 @@ -use std::collections::HashMap; +use std::{ + collections::BTreeMap, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::{ - rfc8621::coroutines::email_submission_set::{ - JmapEmailSubmissionSet, JmapEmailSubmissionSetResult, - }, - rfc8621::types::email_submission::{ - EmailAddressWithParameters, EmailSubmissionCreate, Envelope, - }, +use io_jmap::rfc8621::{ + email_submission::{EmailAddressWithParameters, EmailSubmissionCreate, Envelope}, + email_submission_set::{JmapEmailSubmissionSet, JmapEmailSubmissionSetResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; -use crate::jmap::{account::JmapAccount, submission::query::SubmissionsTable}; +use crate::jmap::{ + account::JmapAccount, error::format_set_error, submission::query::SubmissionsTable, +}; + +const READ_BUFFER_SIZE: usize = 16 * 1024; /// Submit a JMAP email for sending (EmailSubmission/set). /// @@ -68,42 +70,36 @@ impl JmapSubmissionCreateCommand { envelope, }; - let mut submissions = HashMap::new(); + let mut submissions = BTreeMap::new(); submissions.insert(self.email_id.clone(), submission); let mut coroutine = JmapEmailSubmissionSet::new(&jmap.session, &jmap.http_auth, submissions)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (created, not_created) = loop { match coroutine.resume(arg.take()) { - JmapEmailSubmissionSetResult::Io { io } => { - arg = Some(handle(&mut jmap.stream, io)?) - } JmapEmailSubmissionSetResult::Ok { created, not_created, .. } => break (created, not_created), - JmapEmailSubmissionSetResult::Err { err, .. } => bail!(err), + JmapEmailSubmissionSetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailSubmissionSetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailSubmissionSetResult::Err(err) => bail!("{err}"), } }; if let Some(err) = not_created.get(&self.email_id) { let mut msg = format!("Send email `{}` error", self.email_id); - - if !err.properties.is_empty() { - msg.push_str(": invalid properties `"); - msg.push_str(&err.properties.join("`, `")); - msg.push('`'); - } - - if let Some(desc) = &err.description { - msg.push_str(" ("); - msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); - msg.push(')'); - } - + msg.push_str(&format_set_error(err)); bail!(msg); } diff --git a/src/jmap/submission/get.rs b/src/jmap/submission/get.rs index 357a12ae..d7619141 100644 --- a/src/jmap/submission/get.rs +++ b/src/jmap/submission/get.rs @@ -1,14 +1,17 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::rfc8621::coroutines::email_submission_get::{ +use io_jmap::rfc8621::email_submission_get::{ JmapEmailSubmissionGet, JmapEmailSubmissionGetResult, }; -use io_socket::runtimes::std_stream::handle; use log::warn; use pimalaya_toolbox::terminal::printer::Printer; use crate::jmap::{account::JmapAccount, submission::query::SubmissionsTable}; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get JMAP email submissions by ID (EmailSubmission/get). #[derive(Debug, Parser)] pub struct JmapSubmissionGetCommand { @@ -23,19 +26,25 @@ impl JmapSubmissionGetCommand { let mut coroutine = JmapEmailSubmissionGet::new(&jmap.session, &jmap.http_auth, Some(self.ids.clone()))?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (submissions, not_found) = loop { match coroutine.resume(arg.take()) { - JmapEmailSubmissionGetResult::Io { io } => { - arg = Some(handle(&mut jmap.stream, io)?) - } JmapEmailSubmissionGetResult::Ok { submissions, not_found, .. } => break (submissions, not_found), - JmapEmailSubmissionGetResult::Err { err, .. } => bail!(err), + JmapEmailSubmissionGetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailSubmissionGetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailSubmissionGetResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/submission/query.rs b/src/jmap/submission/query.rs index 46310a04..7f19f1bf 100644 --- a/src/jmap/submission/query.rs +++ b/src/jmap/submission/query.rs @@ -1,20 +1,22 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::{Parser, ValueEnum}; use comfy_table::{Cell, Row, Table}; -use io_jmap::{ - rfc8621::coroutines::email_submission_query::{ - JmapEmailSubmissionQuery, JmapEmailSubmissionQueryResult, - }, - rfc8621::types::email_submission::{EmailSubmission, EmailSubmissionFilter, UndoStatus}, +use io_jmap::rfc8621::{ + email_submission::{EmailSubmission, EmailSubmissionFilter, UndoStatus}, + email_submission_query::{JmapEmailSubmissionQuery, JmapEmailSubmissionQueryResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// CLI proxy for [`UndoStatus`]. #[derive(Clone, Debug, ValueEnum)] pub enum UndoStatusArg { @@ -78,7 +80,6 @@ impl JmapSubmissionQueryCommand { } }; - let mut arg = None; let mut coroutine = JmapEmailSubmissionQuery::new( &jmap.session, &jmap.http_auth, @@ -87,14 +88,21 @@ impl JmapSubmissionQueryCommand { Some(self.page.saturating_sub(1) * self.page_size), Some(self.page_size), )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let submissions = loop { match coroutine.resume(arg.take()) { - JmapEmailSubmissionQueryResult::Io { io } => { - arg = Some(handle(&mut jmap.stream, io)?) - } JmapEmailSubmissionQueryResult::Ok { submissions, .. } => break submissions, - JmapEmailSubmissionQueryResult::Err { err, .. } => bail!(err), + JmapEmailSubmissionQueryResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapEmailSubmissionQueryResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapEmailSubmissionQueryResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/thread/get.rs b/src/jmap/thread/get.rs index 2a64196e..0f9ad07c 100644 --- a/src/jmap/thread/get.rs +++ b/src/jmap/thread/get.rs @@ -1,19 +1,23 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; use comfy_table::{Cell, Row, Table}; -use io_jmap::{ - rfc8621::coroutines::thread_get::{JmapThreadGet, JmapThreadGetResult}, - rfc8621::types::thread::Thread, +use io_jmap::rfc8621::{ + thread::Thread, + thread_get::{JmapThreadGet, JmapThreadGetResult}, }; -use io_socket::runtimes::std_stream::handle; use log::warn; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get JMAP threads by ID (Thread/get). /// /// Each thread contains an ordered list of email IDs in the thread. @@ -29,15 +33,23 @@ impl JmapThreadGetCommand { let mut jmap = account.new_jmap_session()?; let mut coroutine = JmapThreadGet::new(&jmap.session, &jmap.http_auth, self.ids.clone())?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let (threads, not_found) = loop { match coroutine.resume(arg.take()) { - JmapThreadGetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapThreadGetResult::Ok { threads, not_found, .. } => break (threads, not_found), - JmapThreadGetResult::Err { err, .. } => bail!(err), + JmapThreadGetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapThreadGetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapThreadGetResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/vacation/get.rs b/src/jmap/vacation/get.rs index bd698f66..27fd04af 100644 --- a/src/jmap/vacation/get.rs +++ b/src/jmap/vacation/get.rs @@ -1,21 +1,23 @@ -use std::fmt; +use std::{ + fmt, + io::{Read, Write}, +}; use anyhow::{bail, Result}; use clap::Parser; use comfy_table::{Cell, Row, Table}; -use io_jmap::{ - rfc8620::types::session::capabilities::VACATION_RESPONSE, - rfc8621::coroutines::vacation_response_get::{ - JmapVacationResponseGet, JmapVacationResponseGetResult, - }, - rfc8621::types::vacation_response::VacationResponse, +use io_jmap::rfc8621::{ + capabilities::VACATION_RESPONSE, + vacation_response::VacationResponse, + vacation_response_get::{JmapVacationResponseGet, JmapVacationResponseGetResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use serde::Serialize; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Get the JMAP vacation response (VacationResponse/get). #[derive(Debug, Parser)] pub struct JmapVacationGetCommand; @@ -33,17 +35,23 @@ impl JmapVacationGetCommand { } let mut coroutine = JmapVacationResponseGet::new(&jmap.session, &jmap.http_auth)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; let vacation = loop { match coroutine.resume(arg.take()) { - JmapVacationResponseGetResult::Io { io } => { - arg = Some(handle(&mut jmap.stream, io)?) - } JmapVacationResponseGetResult::Ok { vacation_response, .. } => break vacation_response, - JmapVacationResponseGetResult::Err { err, .. } => bail!(err), + JmapVacationResponseGetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapVacationResponseGetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapVacationResponseGetResult::Err(err) => bail!("{err}"), } }; diff --git a/src/jmap/vacation/set.rs b/src/jmap/vacation/set.rs index f948fe3d..7d1e0d45 100644 --- a/src/jmap/vacation/set.rs +++ b/src/jmap/vacation/set.rs @@ -1,17 +1,18 @@ +use std::io::{Read, Write}; + use anyhow::{bail, Result}; use clap::Parser; -use io_jmap::{ - rfc8620::types::session::capabilities::VACATION_RESPONSE, - rfc8621::coroutines::vacation_response_set::{ - JmapVacationResponseSet, JmapVacationResponseSetResult, - }, - rfc8621::types::vacation_response::VacationResponseUpdate, +use io_jmap::rfc8621::{ + capabilities::VACATION_RESPONSE, + vacation_response::VacationResponseUpdate, + vacation_response_set::{JmapVacationResponseSet, JmapVacationResponseSetResult}, }; -use io_socket::runtimes::std_stream::handle; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::jmap::account::JmapAccount; +const READ_BUFFER_SIZE: usize = 16 * 1024; + /// Update the JMAP vacation response (VacationResponse/set). #[derive(Debug, Parser)] pub struct JmapVacationSetCommand { @@ -74,15 +75,21 @@ impl JmapVacationSetCommand { }; let mut coroutine = JmapVacationResponseSet::new(&jmap.session, &jmap.http_auth, patch)?; - let mut arg = None; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - JmapVacationResponseSetResult::Io { io } => { - arg = Some(handle(&mut jmap.stream, io)?) - } JmapVacationResponseSetResult::Ok { .. } => break, - JmapVacationResponseSetResult::Err { err, .. } => bail!(err), + JmapVacationResponseSetResult::WantsRead => { + let n = jmap.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + JmapVacationResponseSetResult::WantsWrite(bytes) => { + jmap.stream.write_all(&bytes)?; + arg = None; + } + JmapVacationResponseSetResult::Err(err) => bail!("{err}"), } } diff --git a/src/mailboxes/command.rs b/src/mailboxes/command.rs new file mode 100644 index 00000000..3b76e49c --- /dev/null +++ b/src/mailboxes/command.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + cli::BackendArg, + config::{AccountConfig, Config}, + mailboxes::list::MailboxesListCommand, +}; + +/// Manage mailboxes through whichever backend the active account has +/// configured. +/// +/// The active backend is selected by `--backend` (defaults to `auto`, +/// which picks the first configured backend in priority order). +#[derive(Debug, Subcommand)] +pub enum MailboxesCommand { + #[command(visible_alias = "ls")] + List(MailboxesListCommand), +} + +impl MailboxesCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, config, account_config, backend), + } + } +} diff --git a/src/mailboxes/list.rs b/src/mailboxes/list.rs new file mode 100644 index 00000000..25eb0ce4 --- /dev/null +++ b/src/mailboxes/list.rs @@ -0,0 +1,164 @@ +#[cfg(feature = "maildir")] +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, + mailboxes::table::MailboxesTable, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 8 * 1024; + +/// List mailboxes for the active account, regardless of the underlying +/// backend (IMAP, JMAP or Maildir). +#[derive(Debug, Parser)] +pub struct MailboxesListCommand; + +impl MailboxesListCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use io_email::imap::mailbox_list::{MailboxList, MailboxListResult}; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + let mut coroutine = MailboxList::new(session.context); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + let mailboxes = loop { + match coroutine.resume(arg.take()) { + MailboxListResult::Ok(mailboxes) => break mailboxes, + MailboxListResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MailboxListResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MailboxListResult::Err(err) => bail!("{err}"), + } + }; + + return printer.out(MailboxesTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + mailboxes, + }); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::mailbox_list::{MailboxList, MailboxListResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + let mut coroutine = MailboxList::new(&session.session, &session.http_auth)?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + let mailboxes = loop { + match coroutine.resume(arg.take()) { + MailboxListResult::Ok(mailboxes) => break mailboxes, + MailboxListResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MailboxListResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MailboxListResult::Err(err) => bail!("{err}"), + } + }; + + return printer.out(MailboxesTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + mailboxes, + }); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::mailbox_list::{ + MailboxList, MailboxListArg, MailboxListResult, + }; + + let account = Account::new(config, account_config, maildir_config)?; + let mut coroutine = MailboxList::new(&account.backend.root); + let mut arg: Option = None; + + let mailboxes = loop { + match coroutine.resume(arg.take()) { + MailboxListResult::Ok(mailboxes) => break mailboxes, + MailboxListResult::WantsDirRead(paths) => { + arg = Some(MailboxListArg::DirRead(read_dirs(&paths)?)); + } + MailboxListResult::Err(err) => bail!("{err}"), + } + }; + + return printer.out(MailboxesTable { + preset: account.table_preset, + arrangement: account.table_arrangement, + mailboxes, + }); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } +} + +#[cfg(feature = "maildir")] +fn read_dirs(paths: &BTreeSet) -> Result>> { + use std::fs; + + let mut out = BTreeMap::new(); + + for path in paths { + let mut entries = BTreeSet::new(); + + for entry in fs::read_dir(path)? { + let entry = entry?; + + if let Some(s) = entry.path().to_str() { + entries.insert(s.to_owned()); + } + } + + out.insert(path.clone(), entries); + } + + Ok(out) +} diff --git a/src/mailboxes/mod.rs b/src/mailboxes/mod.rs new file mode 100644 index 00000000..8ca0c9cd --- /dev/null +++ b/src/mailboxes/mod.rs @@ -0,0 +1,3 @@ +pub mod command; +pub mod list; +pub mod table; diff --git a/src/mailboxes/table.rs b/src/mailboxes/table.rs new file mode 100644 index 00000000..a539ef1f --- /dev/null +++ b/src/mailboxes/table.rs @@ -0,0 +1,45 @@ +use std::fmt; + +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use io_email::mailbox::Mailbox; +use serde::Serialize; + +#[derive(Clone, Debug, Serialize)] +pub struct MailboxesTable { + #[serde(skip)] + pub preset: String, + #[serde(skip)] + pub arrangement: ContentArrangement, + 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_content_arrangement(self.arrangement.clone()) + .set_header(Row::from([ + Cell::new("ID"), + Cell::new("NAME"), + Cell::new("ROLE"), + Cell::new("ATTRIBUTES"), + ])) + .add_rows(self.mailboxes.iter().map(|m| { + let mut row = Row::new(); + row.max_height(1); + row.add_cell(Cell::new(&m.id)); + row.add_cell(Cell::new(&m.name)); + row.add_cell(match &m.role { + Some(role) => Cell::new(format!("{role:?}")), + None => Cell::new(""), + }); + row.add_cell(Cell::new(m.attributes.join(", "))); + row + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} diff --git a/src/maildir/create.rs b/src/maildir/create.rs index f6c6ae69..96df03a1 100644 --- a/src/maildir/create.rs +++ b/src/maildir/create.rs @@ -1,10 +1,11 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::coroutines::create_maildir::*; +use io_maildir::coroutines::maildir_create::{ + MaildirCreate, MaildirCreateArg, MaildirCreateResult, +}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::maildir::{account::MaildirAccount, arg::MaildirNameArg}; +use crate::maildir::{account::MaildirAccount, arg::MaildirNameArg, runtime}; /// Create the given mailbox. /// @@ -20,14 +21,17 @@ impl MaildirMailboxCreateCommand { pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { let path = account.backend.root.join(self.maildir_name.inner); + let mut coroutine = MaildirCreate::new(path); let mut arg = None; - let mut coroutine = CreateMaildir::new(path); loop { match coroutine.resume(arg.take()) { - CreateMaildirResult::Ok => break, - CreateMaildirResult::Io(io) => arg = Some(handle(io)?), - CreateMaildirResult::Err(err) => bail!(err), + MaildirCreateResult::Ok => break, + MaildirCreateResult::WantsDirCreate(paths) => { + runtime::dir_create(paths)?; + arg = Some(MaildirCreateArg::DirCreate); + } + MaildirCreateResult::Err(err) => bail!("{err}"), } } diff --git a/src/maildir/delete.rs b/src/maildir/delete.rs index 14b1988d..a35fa614 100644 --- a/src/maildir/delete.rs +++ b/src/maildir/delete.rs @@ -1,10 +1,11 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::coroutines::delete_maildir::*; +use io_maildir::coroutines::maildir_delete::{ + MaildirDelete, MaildirDeleteArg, MaildirDeleteResult, +}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; -use crate::maildir::{account::MaildirAccount, arg::MaildirPathFlag}; +use crate::maildir::{account::MaildirAccount, arg::MaildirPathFlag, runtime}; /// Delete the given mailbox. /// @@ -20,14 +21,17 @@ impl MaildirMailboxDeleteCommand { pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { let path = account.backend.root.join(self.maildir_path.inner); + let mut coroutine = MaildirDelete::new(path); let mut arg = None; - let mut coroutine = DeleteMaildir::new(path); loop { match coroutine.resume(arg.take()) { - DeleteMaildirResult::Ok => break, - DeleteMaildirResult::Io(io) => arg = Some(handle(io)?), - DeleteMaildirResult::Err(err) => bail!(err), + MaildirDeleteResult::Ok => break, + MaildirDeleteResult::WantsDirRemove(paths) => { + runtime::dir_remove(paths)?; + arg = Some(MaildirDeleteArg::DirRemove); + } + MaildirDeleteResult::Err(err) => bail!("{err}"), } } diff --git a/src/maildir/envelope/get.rs b/src/maildir/envelope/get.rs index a1a19b5e..fe194404 100644 --- a/src/maildir/envelope/get.rs +++ b/src/maildir/envelope/get.rs @@ -3,8 +3,10 @@ use std::fmt; use anyhow::{bail, Result}; use clap::Parser; use comfy_table::{Cell, Row, Table}; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::get_message::*, maildir::Maildir}; +use io_maildir::{ + coroutines::message_get::{MaildirMessageGet, MaildirMessageGetArg, MaildirMessageGetResult}, + maildir::Maildir, +}; use mail_parser::Header; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -12,6 +14,7 @@ use serde::Serialize; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MessageIdArg}, + runtime, }; /// Get a single MAILDIR envelope. @@ -34,24 +37,31 @@ impl MaildirEnvelopeGetCommand { Err(_) => Maildir::try_from(account.backend.root.join(self.maildir.inner))?, }; + let mut coroutine = MaildirMessageGet::new(maildir, &self.id.inner); let mut arg = None; - let mut coroutine = GetMaildirMessage::new(maildir, &self.id.inner); let message = loop { match coroutine.resume(arg.take()) { - GetMaildirMessageResult::Io(io) => arg = Some(handle(io)?), - GetMaildirMessageResult::Ok(msg) => break msg, - GetMaildirMessageResult::Err(err) => bail!(err), - }; + MaildirMessageGetResult::Ok(message) => break message, + MaildirMessageGetResult::WantsDirRead(paths) => { + arg = Some(MaildirMessageGetArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirMessageGetResult::WantsFileRead(paths) => { + arg = Some(MaildirMessageGetArg::FileRead(runtime::file_read(paths)?)); + } + MaildirMessageGetResult::Err(err) => bail!("{err}"), + } }; - let Some(message) = message.headers() else { - bail!("Invalid MIME message at {}", message.path().display()); + let path = message.path().to_owned(); + + let Some(parsed) = message.headers() else { + bail!("Invalid MIME message at {}", path.display()); }; let table = EnvelopeTable { preset: account.table_preset, - headers: message.headers(), + headers: parsed.headers(), }; printer.out(table) diff --git a/src/maildir/envelope/list.rs b/src/maildir/envelope/list.rs index f6812e4c..e1201a4c 100644 --- a/src/maildir/envelope/list.rs +++ b/src/maildir/envelope/list.rs @@ -3,12 +3,16 @@ use std::fmt; use anyhow::{bail, Result}; use clap::Parser; use comfy_table::{Cell, ContentArrangement, Row, Table}; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::list_messages::*, maildir::Maildir}; +use io_maildir::{ + coroutines::message_list::{ + MaildirMessagesList, MaildirMessagesListArg, MaildirMessagesListResult, + }, + maildir::Maildir, +}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; -use crate::maildir::{account::MaildirAccount, arg::MaildirPathFlag}; +use crate::maildir::{account::MaildirAccount, arg::MaildirPathFlag, runtime}; /// List MAILDIR envelopes from the given mailbox. /// @@ -28,15 +32,20 @@ impl MaildirEnvelopeListCommand { Err(_) => Maildir::try_from(account.backend.root.join(self.maildir.inner))?, }; + let mut coroutine = MaildirMessagesList::new(maildir); let mut arg = None; - let mut coroutine = ListMaildirMessages::new(maildir); let messages = loop { match coroutine.resume(arg.take()) { - ListMaildirMessagesResult::Io(io) => arg = Some(handle(io)?), - ListMaildirMessagesResult::Ok(messages) => break messages, - ListMaildirMessagesResult::Err(err) => bail!(err), - }; + MaildirMessagesListResult::Ok(messages) => break messages, + MaildirMessagesListResult::WantsDirRead(paths) => { + arg = Some(MaildirMessagesListArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirMessagesListResult::WantsFileRead(paths) => { + arg = Some(MaildirMessagesListArg::FileRead(runtime::file_read(paths)?)); + } + MaildirMessagesListResult::Err(err) => bail!("{err}"), + } }; let mut envelopes = Vec::with_capacity(messages.len()); @@ -45,21 +54,22 @@ impl MaildirEnvelopeListCommand { let Some(id) = message.id() else { continue; }; + let id = id.to_owned(); - let Some(headers) = message.headers() else { + let Some(parsed) = message.headers() else { continue; }; let mut row = EnvelopesTableEntry::default(); - row.id = id.to_owned(); - row.subject = headers.subject().unwrap_or("").to_owned(); + row.id = id; + row.subject = parsed.subject().unwrap_or("").to_owned(); - if let Some(addr) = headers.from().and_then(|a| a.first()) { + if let Some(addr) = parsed.from().and_then(|a| a.first()) { row.from = addr.name().or(addr.address()).unwrap_or("").to_owned(); } - if let Some(date) = headers.date() { + if let Some(date) = parsed.date() { row.date = date.to_rfc822(); } diff --git a/src/maildir/flag/add.rs b/src/maildir/flag/add.rs index 982c2e04..99bb89f6 100644 --- a/src/maildir/flag/add.rs +++ b/src/maildir/flag/add.rs @@ -1,13 +1,17 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::add_flags::*, flag::Flags, maildir::Maildir}; +use io_maildir::{ + coroutines::flags_add::{MaildirFlagsAdd, MaildirFlagsAddArg, MaildirFlagsAddResult}, + flag::Flags, + maildir::Maildir, +}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MessageIdsArg}, flag::arg::FlagArg, + runtime, }; /// Add MAILDIR flag(s) to message(s). @@ -36,14 +40,20 @@ impl MaildirFlagAddCommand { let flags = Flags::from_iter(self.flags.into_iter().map(Into::into)); for id in self.ids.inner { + let mut coroutine = MaildirFlagsAdd::new(maildir.clone(), id, flags.clone()); let mut arg = None; - let mut coroutine = AddMaildirFlags::new(maildir.clone(), id, flags.clone()); loop { match coroutine.resume(arg.take()) { - AddMaildirFlagsResult::Ok => break, - AddMaildirFlagsResult::Io(io) => arg = Some(handle(io)?), - AddMaildirFlagsResult::Err(err) => bail!(err), + MaildirFlagsAddResult::Ok => break, + MaildirFlagsAddResult::WantsDirRead(paths) => { + arg = Some(MaildirFlagsAddArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirFlagsAddResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(MaildirFlagsAddArg::Rename); + } + MaildirFlagsAddResult::Err(err) => bail!("{err}"), } } } diff --git a/src/maildir/flag/remove.rs b/src/maildir/flag/remove.rs index 9e4def47..312c6e29 100644 --- a/src/maildir/flag/remove.rs +++ b/src/maildir/flag/remove.rs @@ -1,13 +1,19 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::remove_flags::*, flag::Flags, maildir::Maildir}; +use io_maildir::{ + coroutines::flags_remove::{ + MaildirFlagsRemove, MaildirFlagsRemoveArg, MaildirFlagsRemoveResult, + }, + flag::Flags, + maildir::Maildir, +}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MessageIdsArg}, flag::arg::FlagArg, + runtime, }; /// Remove MAILDIR flag(s) to message(s). @@ -36,14 +42,20 @@ impl MaildirFlagRemoveCommand { let flags = Flags::from_iter(self.flags.into_iter().map(Into::into)); for id in self.ids.inner { + let mut coroutine = MaildirFlagsRemove::new(maildir.clone(), id, flags.clone()); let mut arg = None; - let mut coroutine = RemoveMaildirFlags::new(maildir.clone(), id, flags.clone()); loop { match coroutine.resume(arg.take()) { - RemoveMaildirFlagsResult::Ok => break, - RemoveMaildirFlagsResult::Io(io) => arg = Some(handle(io)?), - RemoveMaildirFlagsResult::Err(err) => bail!(err), + MaildirFlagsRemoveResult::Ok => break, + MaildirFlagsRemoveResult::WantsDirRead(paths) => { + arg = Some(MaildirFlagsRemoveArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirFlagsRemoveResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(MaildirFlagsRemoveArg::Rename); + } + MaildirFlagsRemoveResult::Err(err) => bail!("{err}"), } } } diff --git a/src/maildir/flag/set.rs b/src/maildir/flag/set.rs index 9ca17cb4..4bcc0b86 100644 --- a/src/maildir/flag/set.rs +++ b/src/maildir/flag/set.rs @@ -1,13 +1,17 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::set_flags::*, flag::Flags, maildir::Maildir}; +use io_maildir::{ + coroutines::flags_set::{MaildirFlagsSet, MaildirFlagsSetArg, MaildirFlagsSetResult}, + flag::Flags, + maildir::Maildir, +}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MessageIdsArg}, flag::arg::FlagArg, + runtime, }; /// Set MAILDIR flag(s) to message(s). @@ -36,14 +40,20 @@ impl MaildirFlagSetCommand { let flags = Flags::from_iter(self.flags.into_iter().map(Into::into)); for id in self.ids.inner { + let mut coroutine = MaildirFlagsSet::new(maildir.clone(), id, flags.clone()); let mut arg = None; - let mut coroutine = SetMaildirFlags::new(maildir.clone(), id, flags.clone()); loop { match coroutine.resume(arg.take()) { - SetMaildirFlagsResult::Ok => break, - SetMaildirFlagsResult::Io(io) => arg = Some(handle(io)?), - SetMaildirFlagsResult::Err(err) => bail!(err), + MaildirFlagsSetResult::Ok => break, + MaildirFlagsSetResult::WantsDirRead(paths) => { + arg = Some(MaildirFlagsSetArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirFlagsSetResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(MaildirFlagsSetArg::Rename); + } + MaildirFlagsSetResult::Err(err) => bail!("{err}"), } } } diff --git a/src/maildir/list.rs b/src/maildir/list.rs index d2d27218..8aba1875 100644 --- a/src/maildir/list.rs +++ b/src/maildir/list.rs @@ -3,12 +3,14 @@ use std::{fmt, path::PathBuf}; use anyhow::{bail, Result}; use clap::Parser; use comfy_table::{Cell, Row, Table}; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::list_maildirs::*, maildir::Maildir}; +use io_maildir::{ + coroutines::maildir_list::{MaildirList, MaildirListArg, MaildirListResult}, + maildir::Maildir, +}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; -use crate::maildir::account::MaildirAccount; +use crate::maildir::{account::MaildirAccount, runtime}; /// List, search and filter maildirs. /// @@ -20,14 +22,16 @@ pub struct MaildirMailboxListCommand; impl MaildirMailboxListCommand { pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + let mut coroutine = MaildirList::new(account.backend.root); let mut arg = None; - let mut coroutine = ListMaildirs::new(account.backend.root); let maildirs = loop { match coroutine.resume(arg.take()) { - ListMaildirsResult::Io(io) => arg = Some(handle(io)?), - ListMaildirsResult::Ok(maildirs) => break maildirs, - ListMaildirsResult::Err(err) => bail!(err), + MaildirListResult::Ok(maildirs) => break maildirs, + MaildirListResult::WantsDirRead(paths) => { + arg = Some(MaildirListArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirListResult::Err(err) => bail!("{err}"), } }; diff --git a/src/maildir/message/copy.rs b/src/maildir/message/copy.rs index 84eff867..3ae31701 100644 --- a/src/maildir/message/copy.rs +++ b/src/maildir/message/copy.rs @@ -1,12 +1,17 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::copy_message::*, maildir::Maildir}; +use io_maildir::{ + coroutines::message_copy::{ + MaildirMessageCopy, MaildirMessageCopyArg, MaildirMessageCopyResult, + }, + maildir::Maildir, +}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MaildirSubdirArg, MessageIdsArg, TargetMaildirPathFlag}, + runtime, }; /// Copy Maildir message to the given mailbox. @@ -40,19 +45,25 @@ impl MaildirMessageCopyCommand { }; for id in self.ids.inner { - let mut arg = None; - let mut coroutine = CopyMaildirMessage::new( + let mut coroutine = MaildirMessageCopy::new( id, source.clone(), target.clone(), self.subdir.clone().map(Into::into), ); + let mut arg = None; loop { match coroutine.resume(arg.take()) { - CopyMaildirMessageResult::Io(io) => arg = Some(handle(io)?), - CopyMaildirMessageResult::Ok => break, - CopyMaildirMessageResult::Err(err) => bail!(err), + MaildirMessageCopyResult::Ok => break, + MaildirMessageCopyResult::WantsDirRead(paths) => { + arg = Some(MaildirMessageCopyArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirMessageCopyResult::WantsCopy(pairs) => { + runtime::copy(pairs)?; + arg = Some(MaildirMessageCopyArg::Copy); + } + MaildirMessageCopyResult::Err(err) => bail!("{err}"), } } } diff --git a/src/maildir/message/export.rs b/src/maildir/message/export.rs index 926cde76..316485b5 100644 --- a/src/maildir/message/export.rs +++ b/src/maildir/message/export.rs @@ -3,8 +3,11 @@ use std::{fmt, fs, path::PathBuf}; use anyhow::{bail, Result}; use clap::{Parser, ValueEnum}; use convert_case::ccase; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::get_message::*, maildir::Maildir, types::MimeHeaders}; +use io_maildir::{ + coroutines::message_get::{MaildirMessageGet, MaildirMessageGetArg, MaildirMessageGetResult}, + maildir::Maildir, +}; +use mail_parser::MimeHeaders; use mime_guess::{get_mime_extensions_str, mime::OCTET_STREAM}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -12,6 +15,7 @@ use serde::Serialize; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MessageIdArg}, + runtime, }; /// Export a message. @@ -47,15 +51,20 @@ impl MaildirMessageExportCommand { Err(_) => Maildir::try_from(account.backend.root.join(self.maildir.inner))?, }; + let mut coroutine = MaildirMessageGet::new(maildir, &self.id.inner); let mut arg = None; - let mut coroutine = GetMaildirMessage::new(maildir, &self.id.inner); let msg = loop { match coroutine.resume(arg.take()) { - GetMaildirMessageResult::Io(io) => arg = Some(handle(io)?), - GetMaildirMessageResult::Ok(msg) => break msg, - GetMaildirMessageResult::Err(err) => bail!(err), - }; + MaildirMessageGetResult::Ok(msg) => break msg, + MaildirMessageGetResult::WantsDirRead(paths) => { + arg = Some(MaildirMessageGetArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirMessageGetResult::WantsFileRead(paths) => { + arg = Some(MaildirMessageGetArg::FileRead(runtime::file_read(paths)?)); + } + MaildirMessageGetResult::Err(err) => bail!("{err}"), + } }; match self.r#type { @@ -64,8 +73,10 @@ impl MaildirMessageExportCommand { printer.out(ExportRaw { contents })?; } ExportType::Parts => { - let Some(msg) = msg.parsed() else { - bail!("Invalid MIME message at {}", msg.path().display()); + let path = msg.path().to_owned(); + + let Some(parsed) = msg.parsed() else { + bail!("Invalid MIME message at {}", path.display()); }; let dir = match self.directory { @@ -77,7 +88,7 @@ impl MaildirMessageExportCommand { let mut parts = Vec::new(); - for (i, part) in msg.parts.iter().enumerate() { + for (i, part) in parsed.parts.iter().enumerate() { let cr = part.content_type().map(|ct| match &ct.c_subtype { Some(sub) => format!("{}/{}", ct.c_type, sub), None => ct.c_type.to_string(), diff --git a/src/maildir/message/get.rs b/src/maildir/message/get.rs index ba141586..bf9c8c8d 100644 --- a/src/maildir/message/get.rs +++ b/src/maildir/message/get.rs @@ -2,14 +2,18 @@ use std::fmt; use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::get_message::*, maildir::Maildir, types::Message}; +use io_maildir::{ + coroutines::message_get::{MaildirMessageGet, MaildirMessageGetArg, MaildirMessageGetResult}, + maildir::Maildir, + types::Message, +}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MessageIdArg}, + runtime, }; /// Get Maildir message to the given mailbox. @@ -31,22 +35,29 @@ impl MaildirMessageGetCommand { Err(_) => Maildir::try_from(account.backend.root.join(self.maildir.inner))?, }; + let mut coroutine = MaildirMessageGet::new(maildir, &self.id.inner); let mut arg = None; - let mut coroutine = GetMaildirMessage::new(maildir, &self.id.inner); let msg = loop { match coroutine.resume(arg.take()) { - GetMaildirMessageResult::Io(io) => arg = Some(handle(io)?), - GetMaildirMessageResult::Ok(msg) => break msg, - GetMaildirMessageResult::Err(err) => bail!(err), - }; + MaildirMessageGetResult::Ok(msg) => break msg, + MaildirMessageGetResult::WantsDirRead(paths) => { + arg = Some(MaildirMessageGetArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirMessageGetResult::WantsFileRead(paths) => { + arg = Some(MaildirMessageGetArg::FileRead(runtime::file_read(paths)?)); + } + MaildirMessageGetResult::Err(err) => bail!("{err}"), + } }; - let Some(msg) = msg.headers() else { - bail!("Invalid MIME message at {}", msg.path().display()); + let path = msg.path().to_owned(); + + let Some(parsed) = msg.headers() else { + bail!("Invalid MIME message at {}", path.display()); }; - printer.out(MessageView(msg)) + printer.out(MessageView(parsed)) } } diff --git a/src/maildir/message/move.rs b/src/maildir/message/move.rs index 29a79b72..6621ecd2 100644 --- a/src/maildir/message/move.rs +++ b/src/maildir/message/move.rs @@ -1,12 +1,17 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::move_message::*, maildir::Maildir}; +use io_maildir::{ + coroutines::message_move::{ + MaildirMessageMove, MaildirMessageMoveArg, MaildirMessageMoveResult, + }, + maildir::Maildir, +}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MaildirSubdirArg, MessageIdsArg, TargetMaildirPathFlag}, + runtime, }; /// Move Maildir message to the given mailbox. @@ -40,19 +45,25 @@ impl MaildirMessageMoveCommand { }; for id in self.ids.inner { - let mut arg = None; - let mut coroutine = MoveMaildirMessage::new( + let mut coroutine = MaildirMessageMove::new( id, source.clone(), target.clone(), self.subdir.clone().map(Into::into), ); + let mut arg = None; loop { match coroutine.resume(arg.take()) { - MoveMaildirMessageResult::Io(io) => arg = Some(handle(io)?), - MoveMaildirMessageResult::Ok => break, - MoveMaildirMessageResult::Err(err) => bail!(err), + MaildirMessageMoveResult::Ok => break, + MaildirMessageMoveResult::WantsDirRead(paths) => { + arg = Some(MaildirMessageMoveArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirMessageMoveResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(MaildirMessageMoveArg::Rename); + } + MaildirMessageMoveResult::Err(err) => bail!("{err}"), } } } diff --git a/src/maildir/message/read.rs b/src/maildir/message/read.rs index fdb0e3a6..cd2cf38c 100644 --- a/src/maildir/message/read.rs +++ b/src/maildir/message/read.rs @@ -2,14 +2,18 @@ use std::fmt; use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::get_message::*, maildir::Maildir, types::Message}; +use io_maildir::{ + coroutines::message_get::{MaildirMessageGet, MaildirMessageGetArg, MaildirMessageGetResult}, + maildir::Maildir, + types::Message, +}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MessageIdArg}, + runtime, }; /// Read message content. @@ -38,25 +42,32 @@ impl MaildirMessageReadCommand { Err(_) => Maildir::try_from(account.backend.root.join(self.maildir.inner))?, }; + let mut coroutine = MaildirMessageGet::new(maildir, &self.id.inner); let mut arg = None; - let mut coroutine = GetMaildirMessage::new(maildir, &self.id.inner); let message = loop { match coroutine.resume(arg.take()) { - GetMaildirMessageResult::Io(io) => arg = Some(handle(io)?), - GetMaildirMessageResult::Ok(msg) => break msg, - GetMaildirMessageResult::Err(err) => bail!(err), - }; + MaildirMessageGetResult::Ok(msg) => break msg, + MaildirMessageGetResult::WantsDirRead(paths) => { + arg = Some(MaildirMessageGetArg::DirRead(runtime::dir_read(paths)?)); + } + MaildirMessageGetResult::WantsFileRead(paths) => { + arg = Some(MaildirMessageGetArg::FileRead(runtime::file_read(paths)?)); + } + MaildirMessageGetResult::Err(err) => bail!("{err}"), + } }; - let Some(message) = message.parsed() else { - bail!("Invalid MIME message at {}", message.path().display()); + let path = message.path().to_owned(); + + let Some(parsed) = message.parsed() else { + bail!("Invalid MIME message at {}", path.display()); }; if self.html { - printer.out(MessageHtmlView { message }) + printer.out(MessageHtmlView { message: parsed }) } else { - printer.out(MessagePlainView { message }) + printer.out(MessagePlainView { message: parsed }) } } } diff --git a/src/maildir/message/save.rs b/src/maildir/message/save.rs index e45dd628..9c0a8d64 100644 --- a/src/maildir/message/save.rs +++ b/src/maildir/message/save.rs @@ -6,8 +6,13 @@ use std::{ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::{coroutines::store_message::*, flag::Flags, maildir::Maildir}; +use io_maildir::{ + coroutines::message_store::{ + MaildirMessageStore, MaildirMessageStoreArg, MaildirMessageStoreResult, + }, + flag::Flags, + maildir::Maildir, +}; use pimalaya_toolbox::terminal::printer::Printer; use serde::Serialize; @@ -15,6 +20,7 @@ use crate::maildir::{ account::MaildirAccount, arg::{MaildirPathFlag, MaildirSubdirArg}, flag::arg::FlagArg, + runtime, }; /// Save a message to a mailbox. @@ -64,15 +70,22 @@ impl MaildirMessageSaveCommand { let flags = Flags::from_iter(self.flags.into_iter().map(Into::into)); - let mut arg = None; let mut coroutine = - StoreMaildirMessage::new(maildir, self.subdir.into(), flags, msg.into_bytes()); + MaildirMessageStore::new(maildir, self.subdir.into(), flags, msg.into_bytes()); + let mut arg = None; let out = loop { match coroutine.resume(arg.take()) { - StoreMaildirMessageResult::Io(io) => arg = Some(handle(io)?), - StoreMaildirMessageResult::Ok { id, path } => break StoredMessage { id, path }, - StoreMaildirMessageResult::Err(err) => bail!(err), + MaildirMessageStoreResult::Ok { id, path } => break StoredMessage { id, path }, + MaildirMessageStoreResult::WantsFileCreate(files) => { + runtime::file_create(files)?; + arg = Some(MaildirMessageStoreArg::FileCreate); + } + MaildirMessageStoreResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(MaildirMessageStoreArg::Rename); + } + MaildirMessageStoreResult::Err(err) => bail!("{err}"), } }; diff --git a/src/maildir/mod.rs b/src/maildir/mod.rs index 8b7ba665..7e1386e5 100644 --- a/src/maildir/mod.rs +++ b/src/maildir/mod.rs @@ -8,3 +8,4 @@ pub mod flag; pub mod list; pub mod message; pub mod rename; +pub mod runtime; diff --git a/src/maildir/rename.rs b/src/maildir/rename.rs index 9876739c..5f5e81ca 100644 --- a/src/maildir/rename.rs +++ b/src/maildir/rename.rs @@ -1,12 +1,14 @@ use anyhow::{bail, Result}; use clap::Parser; -use io_fs::runtimes::std::handle; -use io_maildir::coroutines::rename_maildir::*; +use io_maildir::coroutines::maildir_rename::{ + MaildirRename, MaildirRenameArg, MaildirRenameResult, +}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::maildir::{ account::MaildirAccount, arg::{MaildirNameArg, MaildirPathFlag}, + runtime, }; /// Rename the given mailbox. @@ -25,14 +27,17 @@ impl MaildirMailboxRenameCommand { pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { let path = account.backend.root.join(self.maildir_path.inner); + let mut coroutine = MaildirRename::new(path, self.maildir_name.inner); let mut arg = None; - let mut coroutine = RenameMaildir::new(path, self.maildir_name.inner); loop { match coroutine.resume(arg.take()) { - RenameMaildirResult::Ok => break, - RenameMaildirResult::Io(io) => arg = Some(handle(io)?), - RenameMaildirResult::Err(err) => bail!(err), + MaildirRenameResult::Ok => break, + MaildirRenameResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(MaildirRenameArg::Rename); + } + MaildirRenameResult::Err(err) => bail!("{err}"), } } diff --git a/src/maildir/runtime.rs b/src/maildir/runtime.rs new file mode 100644 index 00000000..4fb4dd24 --- /dev/null +++ b/src/maildir/runtime.rs @@ -0,0 +1,93 @@ +//! Synchronous filesystem driver for io-maildir coroutines. +//! +//! io-maildir is fully I/O-free; coroutines emit `Wants*` requests and +//! the caller is responsible for performing the matching std::fs +//! operation and feeding the resulting per-coroutine `*Arg` variant +//! back via `resume(Some(arg))`. +//! +//! Each helper performs the operation and returns the raw output +//! (only meaningful for read-style ops). Callers wrap the result in +//! the appropriate `*Arg` variant for their coroutine. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File}, + io::{self, Write}, + path::PathBuf, +}; + +/// Copies each `(source, target)` pair via [`std::fs::copy`]. +pub fn copy(pairs: Vec<(String, String)>) -> io::Result<()> { + for (source, target) in pairs { + fs::copy(PathBuf::from(source), PathBuf::from(target))?; + } + + Ok(()) +} + +/// Creates each directory via [`std::fs::create_dir`]. +pub fn dir_create(paths: BTreeSet) -> io::Result<()> { + for path in paths { + fs::create_dir(PathBuf::from(path))?; + } + + Ok(()) +} + +/// Reads the entries inside each directory via [`std::fs::read_dir`]. +pub fn dir_read(paths: BTreeSet) -> io::Result>> { + let mut entries = BTreeMap::new(); + + for path in paths { + let mut children = BTreeSet::new(); + + for entry in fs::read_dir(PathBuf::from(&path))? { + let entry = entry?; + children.insert(entry.path().to_string_lossy().into_owned()); + } + + entries.insert(path, children); + } + + Ok(entries) +} + +/// Removes each directory and all its contents via [`std::fs::remove_dir_all`]. +pub fn dir_remove(paths: BTreeSet) -> io::Result<()> { + for path in paths { + fs::remove_dir_all(PathBuf::from(path))?; + } + + Ok(()) +} + +/// Creates each file with the associated contents. +pub fn file_create(files: BTreeMap>) -> io::Result<()> { + for (path, contents) in files { + let mut file = File::create(PathBuf::from(path))?; + file.write_all(&contents)?; + } + + Ok(()) +} + +/// Reads each file via [`std::fs::read`]. +pub fn file_read(paths: BTreeSet) -> io::Result>> { + let mut contents = BTreeMap::new(); + + for path in paths { + let data = fs::read(PathBuf::from(&path))?; + contents.insert(path, data); + } + + Ok(contents) +} + +/// Renames or moves each `(from, to)` pair via [`std::fs::rename`]. +pub fn rename(pairs: Vec<(String, String)>) -> io::Result<()> { + for (from, to) in pairs { + fs::rename(PathBuf::from(from), PathBuf::from(to))?; + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 913d9dfc..23064ae5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,18 @@ mod account; +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +mod attachments; mod cli; mod config; +mod envelopes; +mod flags; #[cfg(feature = "imap")] mod imap; #[cfg(feature = "jmap")] mod jmap; +mod mailboxes; #[cfg(feature = "maildir")] mod maildir; +mod messages; #[cfg(feature = "smtp")] mod smtp; @@ -23,10 +29,11 @@ fn main() { let mut printer = StdoutPrinter::new(&cli.json); let config_paths = cli.config_paths.as_ref(); let account_name = cli.account.name.as_deref(); + let backend = cli.backend; let result = cli .command - .execute(&mut printer, config_paths, account_name); + .execute(&mut printer, config_paths, account_name, backend); ErrorReport::eval(&mut printer, result) } diff --git a/src/messages/add.rs b/src/messages/add.rs new file mode 100644 index 00000000..8604ce69 --- /dev/null +++ b/src/messages/add.rs @@ -0,0 +1,203 @@ +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::Write as _; +use std::{ + io::{stdin, IsTerminal, Read}, + path::PathBuf, +}; + +use anyhow::{bail, Result}; +use clap::Parser; +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +use pimalaya_toolbox::terminal::printer::Message; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + cli::BackendArg, + config::{AccountConfig, Config}, + flags::arg::FlagArg, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Add a raw RFC 5322 message to a mailbox. +/// +/// The message body is read from stdin by default; pass `--file +/// ` to read from a file instead. IMAP appends via `APPEND` +/// (RFC 3501); JMAP uploads the blob and imports it via `Email/import` +/// (the destination mailbox is resolved from `--mailbox` by exact-match +/// name); Maildir writes a new file under the target maildir's `cur/` +/// subdir using the standard tmp-then-rename delivery protocol. +#[derive(Debug, Parser)] +pub struct MessagesAddCommand { + /// Destination mailbox name or path. Mandatory. + #[arg(long = "mailbox", short = 'm', value_name = "NAME")] + pub mailbox: String, + + /// Flag(s) to set on the new message. Optional. + #[arg(long = "flag", short = 'f', value_name = "FLAG", num_args = 0..)] + pub flag: Vec, + + /// Read the raw message from this file instead of stdin. + #[arg(long = "file", value_name = "PATH")] + pub file: Option, +} + +impl MessagesAddCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + #[cfg_attr( + not(any(feature = "imap", feature = "jmap", feature = "maildir")), + allow(unused_mut) + )] + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + let raw = read_raw(&self.file)?; + + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use io_email::imap::message_add::{MessageAdd, MessageAddResult}; + use io_imap::types::mailbox::Mailbox; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = crate::account::Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let mailbox: Mailbox<'static> = self.mailbox.clone().try_into()?; + let imap_flags = self.flag.iter().map(|f| f.imap()).collect(); + let mut coroutine = MessageAdd::new(session.context, mailbox, imap_flags, raw)?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + MessageAddResult::Ok { .. } => break, + MessageAddResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageAddResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageAddResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message successfully added")); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::message_add::{MessageAdd, MessageAddResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = crate::account::Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + + let keywords: Vec = self.flag.iter().map(|f| f.jmap().to_owned()).collect(); + + let mut coroutine = MessageAdd::new( + &session.session, + &session.http_auth, + raw, + &self.mailbox, + keywords, + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + MessageAddResult::Ok { .. } => break, + MessageAddResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageAddResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageAddResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message successfully added")); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::message_add::{MessageAdd, MessageAddArg, MessageAddResult}; + use io_maildir::{ + flag::{Flag as MaildirFlag, Flags as MaildirFlags}, + maildir::Maildir, + }; + + use crate::maildir::runtime; + + let account = crate::account::Account::new(config, account_config, maildir_config)?; + let path = account.backend.root.join(&self.mailbox); + let maildir = Maildir::try_from(path)?; + + let flags: MaildirFlags = self.flag.iter().map(MaildirFlag::from).collect(); + let mut coroutine = MessageAdd::new(maildir, None, flags, raw); + let mut arg: Option = None; + + loop { + match coroutine.resume(arg.take()) { + MessageAddResult::Ok { .. } => break, + MessageAddResult::WantsFileCreate(files) => { + runtime::file_create(files)?; + arg = Some(MessageAddArg::FileCreate); + } + MessageAddResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(MessageAddArg::Rename); + } + MessageAddResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message successfully added")); + } + } + + let _ = config; + let _ = account_config; + let _ = raw; + let _ = printer; + bail!("no backend matching `{backend}` is configured for this account") + } +} + +fn read_raw(file: &Option) -> Result> { + if let Some(path) = file { + return Ok(std::fs::read(path)?); + } + + if stdin().is_terminal() { + bail!( + "`messages add` reads the raw message from stdin or `--file ` — \ + nothing was provided" + ); + } + + let mut buf = Vec::new(); + stdin().read_to_end(&mut buf)?; + Ok(buf) +} diff --git a/src/messages/command.rs b/src/messages/command.rs new file mode 100644 index 00000000..1adb3837 --- /dev/null +++ b/src/messages/command.rs @@ -0,0 +1,49 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_toolbox::terminal::printer::Printer; + +use crate::{ + cli::BackendArg, + config::{AccountConfig, Config}, + messages::{ + add::MessagesAddCommand, compose::MessagesComposeCommand, copy::MessagesCopyCommand, + get::MessagesGetCommand, mv::MessagesMoveCommand, send::MessagesSendCommand, + }, +}; + +/// Manage messages through whichever backend the active account has +/// configured. +/// +/// The active backend is selected by `--backend` (defaults to `auto`, +/// which picks the first configured backend in priority order). Note +/// that `messages send` only has SMTP and JMAP arms; the others have +/// IMAP, JMAP and Maildir arms. +#[derive(Debug, Subcommand)] +pub enum MessagesCommand { + Add(MessagesAddCommand), + Compose(MessagesComposeCommand), + Copy(MessagesCopyCommand), + Get(MessagesGetCommand), + #[command(name = "move")] + Move(MessagesMoveCommand), + Send(MessagesSendCommand), +} + +impl MessagesCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + match self { + Self::Add(cmd) => cmd.execute(printer, config, account_config, backend), + Self::Compose(cmd) => cmd.execute(printer, config, account_config, backend), + Self::Copy(cmd) => cmd.execute(printer, config, account_config, backend), + Self::Get(cmd) => cmd.execute(printer, config, account_config, backend), + Self::Move(cmd) => cmd.execute(printer, config, account_config, backend), + Self::Send(cmd) => cmd.execute(printer, config, account_config, backend), + } + } +} diff --git a/src/messages/compose.rs b/src/messages/compose.rs new file mode 100644 index 00000000..55c73d64 --- /dev/null +++ b/src/messages/compose.rs @@ -0,0 +1,599 @@ +use std::{ + io::{stdin, stdout, IsTerminal, Read as _, Write as _}, + path::PathBuf, +}; + +use anyhow::{anyhow, bail, Result}; +use clap::{Parser, ValueEnum}; +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +use mail_builder::headers::raw::Raw; +use mail_builder::{headers::address::Address, MessageBuilder}; +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +use mail_parser::{HeaderValue, MessageParser}; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + cli::BackendArg, + config::{AccountConfig, Config}, +}; + +/// Compose a message from CLI arguments. +/// +/// By default the assembled RFC 5322 bytes are written to stdout. +/// Pass `--send` to route the message through the active account's +/// send path (SMTP or JMAP). `--reply ` and `--forward ` are +/// mutually exclusive: each fetches the referenced source message, +/// pre-fills the relevant headers (Subject prefix, In-Reply-To, +/// References, and the recipient when replying), and includes the +/// quoted source body according to `--posting-style`. For richer +/// composition (multipart MIME, MML directives, etc.) build the +/// message externally and pipe it through `messages send`. +#[derive(Debug, Parser)] +pub struct MessagesComposeCommand { + /// Sender address (`From` header). Plain `local@host` form. + #[arg(long, value_name = "ADDR")] + pub from: Option, + + /// Recipient address(es) (`To` header). Repeat the flag or use a + /// comma-separated list. + #[arg(long, short = 't', value_name = "ADDR", value_delimiter = ',')] + pub to: Vec, + + /// Carbon-copy recipient(s) (`Cc` header). + #[arg(long, value_name = "ADDR", value_delimiter = ',')] + pub cc: Vec, + + /// Blind carbon-copy recipient(s) (`Bcc` header). + #[arg(long, value_name = "ADDR", value_delimiter = ',')] + pub bcc: Vec, + + /// Subject line. + #[arg(long, short = 's', value_name = "TEXT")] + pub subject: Option, + + /// Inline body. Mutually exclusive with `--body-file` and stdin. + #[arg(long, value_name = "TEXT", conflicts_with = "body_file")] + pub body: Option, + + /// Read the body from a file. Mutually exclusive with `--body` + /// and stdin. + #[arg(long = "body-file", value_name = "PATH")] + pub body_file: Option, + + /// Attachment file(s). + #[arg(long = "attach", value_name = "PATH")] + pub attach: Vec, + + /// Reply to the message with this id. Pre-fills `To`, `Subject`, + /// `In-Reply-To` and `References` from the source. Mutually + /// exclusive with `--forward`. + #[arg(long, value_name = "ID", conflicts_with = "forward")] + pub reply: Option, + + /// Forward the message with this id. Pre-fills the `Subject` + /// prefix and `References` header from the source. Mutually + /// exclusive with `--reply`. + #[arg(long, value_name = "ID")] + pub forward: Option, + + /// Mailbox the source message lives in (only relevant for + /// `--reply`/`--forward`). Ignored for JMAP, which addresses + /// messages by id directly. + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + /// How to lay out the quoted source body relative to the user's + /// body. Only meaningful with `--reply` / `--forward`. Interleaved + /// posting is left to the user — write your reply directly inside + /// the quoted block. + #[arg( + long = "posting-style", + short = 'P', + value_name = "STYLE", + default_value = "top" + )] + pub posting_style: PostingStyle, + + /// Plain-text headline placed before the quoted source body + /// (e.g. `"On {date}, {from} wrote:"`). Empty by default — no + /// substitution is performed; pass the literal string you want. + #[arg(long = "quote-headline", short = 'Q', value_name = "TEXT")] + pub quote_headline: Option, + + /// Signature appended after the body, separated by the standard + /// `-- ` delimiter (RFC 3676 §4.3). + #[arg(long, value_name = "TEXT")] + pub signature: Option, + + /// Read the signature from a file. Mutually exclusive with + /// `--signature`. + #[arg( + long = "signature-file", + value_name = "PATH", + conflicts_with = "signature" + )] + pub signature_file: Option, + + /// Send the assembled message instead of writing it to stdout. + /// Routes through the same SMTP/JMAP path as `messages send`. + #[arg(long)] + pub send: bool, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum PostingStyle { + /// User body above the quoted source body. + Top, + /// Quoted source body above the user body. + Bottom, +} + +impl MessagesComposeCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + #[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] + let source_raw = match (&self.reply, &self.forward) { + (Some(id), None) | (None, Some(id)) => Some(crate::messages::fetch::fetch_raw( + &config, + &account_config, + backend, + &self.mailbox, + id, + )?), + _ => None, + }; + + #[cfg(not(any(feature = "imap", feature = "jmap", feature = "maildir")))] + let source_raw: Option> = if self.reply.is_some() || self.forward.is_some() { + bail!( + "`--reply` / `--forward` need IMAP, JMAP or Maildir support, \ + but this build has none" + ); + } else { + None + }; + + let raw = build_message(&self, source_raw.as_deref())?; + + if self.send { + send_raw(config, account_config, backend, raw)?; + return printer.out(Message::new("Message successfully composed and sent")); + } + + let mut out = stdout().lock(); + out.write_all(&raw)?; + Ok(()) + } +} + +fn build_message( + cmd: &MessagesComposeCommand, + #[cfg_attr( + not(any(feature = "imap", feature = "jmap", feature = "maildir")), + allow(unused_variables) + )] + source: Option<&[u8]>, +) -> Result> { + let mut builder = MessageBuilder::new(); + + if let Some(from) = &cmd.from { + builder = builder.from(from.as_str()); + } + + if !cmd.to.is_empty() { + builder = builder.to(addresses(&cmd.to)); + } + if !cmd.cc.is_empty() { + builder = builder.cc(addresses(&cmd.cc)); + } + if !cmd.bcc.is_empty() { + builder = builder.bcc(addresses(&cmd.bcc)); + } + + #[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] + let parsed_source = source.and_then(|raw| MessageParser::new().parse(raw)); + + #[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] + let mut subject = cmd.subject.clone(); + #[cfg(not(any(feature = "imap", feature = "jmap", feature = "maildir")))] + let subject = cmd.subject.clone(); + + #[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] + let source_text: String = parsed_source + .as_ref() + .and_then(|m| m.body_text(0)) + .map(|c| c.into_owned()) + .unwrap_or_default(); + #[cfg(not(any(feature = "imap", feature = "jmap", feature = "maildir")))] + let source_text = String::new(); + + #[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] + if let Some(msg) = parsed_source.as_ref() { + let prefix = if cmd.reply.is_some() { "Re: " } else { "Fwd: " }; + let src_subject = msg.subject().unwrap_or(""); + + if subject.is_none() { + subject = Some(if has_prefix(src_subject, prefix) { + src_subject.to_string() + } else { + format!("{prefix}{src_subject}") + }); + } + + if cmd.reply.is_some() && cmd.to.is_empty() { + if let Some(addrs) = reply_recipients(msg) { + builder = builder.to(addrs); + } + } + + if let Some(message_id) = msg.message_id() { + if cmd.reply.is_some() { + builder = builder.in_reply_to(vec![message_id.to_string()]); + } + let refs = compute_references(msg, message_id); + if !refs.is_empty() { + builder = builder.header("References", Raw::new(refs)); + } + } + } + + if let Some(s) = subject { + builder = builder.subject(s); + } + + let user_body = read_body(cmd)?; + let signature = read_signature(cmd)?; + let body = compose_body( + &user_body, + &source_text, + cmd.quote_headline.as_deref().unwrap_or(""), + signature.as_deref().unwrap_or(""), + cmd.posting_style, + ); + builder = builder.text_body(body); + + for path in &cmd.attach { + let bytes = std::fs::read(path) + .map_err(|err| anyhow!("read attachment {}: {err}", path.display()))?; + let file_name = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "attachment".to_string()); + let mime = mime_for(path); + builder = builder.attachment(mime, file_name, bytes); + } + + let raw = builder + .write_to_vec() + .map_err(|err| anyhow!("serialize composed message: {err}"))?; + Ok(raw) +} + +fn addresses(values: &[String]) -> Address<'static> { + Address::new_list( + values + .iter() + .map(|s| Address::new_address(None::<&str>, s.clone())) + .collect(), + ) +} + +fn read_body(cmd: &MessagesComposeCommand) -> Result { + if let Some(body) = &cmd.body { + return Ok(body.clone()); + } + + if let Some(path) = &cmd.body_file { + return std::fs::read_to_string(path) + .map_err(|err| anyhow!("read body file {}: {err}", path.display())); + } + + if !stdin().is_terminal() { + let mut buf = String::new(); + stdin().read_to_string(&mut buf)?; + return Ok(buf); + } + + Ok(String::new()) +} + +fn read_signature(cmd: &MessagesComposeCommand) -> Result> { + if let Some(sig) = &cmd.signature { + return Ok(Some(sig.clone())); + } + + if let Some(path) = &cmd.signature_file { + let s = std::fs::read_to_string(path) + .map_err(|err| anyhow!("read signature file {}: {err}", path.display()))?; + return Ok(Some(s)); + } + + Ok(None) +} + +/// Builds the final text body from user input, optional source text +/// (reply/forward), an optional headline, an optional signature, and +/// the requested posting style. +fn compose_body( + user_body: &str, + source_text: &str, + headline: &str, + signature: &str, + style: PostingStyle, +) -> String { + let user_body = user_body.trim_end_matches('\n'); + let source_text = source_text.trim(); + + let quote = if source_text.is_empty() { + String::new() + } else { + let mut buf = String::new(); + if !headline.is_empty() { + buf.push_str(headline.trim_end_matches('\n')); + buf.push('\n'); + } + for line in source_text.lines() { + buf.push('>'); + if !line.starts_with('>') { + buf.push(' '); + } + buf.push_str(line); + buf.push('\n'); + } + // drop trailing newline; sections are joined with "\n\n" + buf.pop(); + buf + }; + + let mut body = match (style, quote.is_empty()) { + (_, true) => user_body.to_string(), + (PostingStyle::Top, false) => { + if user_body.is_empty() { + quote + } else { + format!("{user_body}\n\n{quote}") + } + } + (PostingStyle::Bottom, false) => { + if user_body.is_empty() { + quote + } else { + format!("{quote}\n\n{user_body}") + } + } + }; + + if !signature.trim().is_empty() { + let sig = signature.trim_end_matches('\n'); + body.push_str("\n\n-- \n"); + body.push_str(sig); + } + + body +} + +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +fn has_prefix(subject: &str, prefix: &str) -> bool { + let s = subject.trim_start(); + let p = prefix.trim_end_matches(' ').trim_end_matches(':'); + s.len() >= p.len() && s.get(..p.len()).map(|h| h.eq_ignore_ascii_case(p)) == Some(true) +} + +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +fn reply_recipients(msg: &mail_parser::Message<'_>) -> Option> { + use mail_parser::Address as ParserAddress; + + let header = msg + .header("Reply-To") + .or_else(|| msg.header("From")) + .map(|h| h.clone()); + + let HeaderValue::Address(addr) = header? else { + return None; + }; + + let collected: Vec> = match addr { + ParserAddress::List(list) => list + .into_iter() + .filter_map(|a| { + let email = a.address?.into_owned(); + let name = a.name.map(|s| s.into_owned()); + Some(Address::new_address(name, email)) + }) + .collect(), + ParserAddress::Group(groups) => groups + .into_iter() + .flat_map(|g| g.addresses.into_iter()) + .filter_map(|a| { + let email = a.address?.into_owned(); + let name = a.name.map(|s| s.into_owned()); + Some(Address::new_address(name, email)) + }) + .collect(), + }; + + if collected.is_empty() { + None + } else { + Some(Address::new_list(collected)) + } +} + +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +fn compute_references(msg: &mail_parser::Message<'_>, source_message_id: &str) -> String { + let mut out = String::new(); + + if let Some(header) = msg.header("References") { + if let HeaderValue::TextList(items) = header { + for r in items { + push_msg_id(&mut out, r); + } + } else if let HeaderValue::Text(s) = header { + for r in s.split_whitespace() { + push_msg_id(&mut out, r); + } + } + } else if let Some(header) = msg.header("In-Reply-To") { + if let HeaderValue::TextList(items) = header { + for r in items { + push_msg_id(&mut out, r); + } + } else if let HeaderValue::Text(s) = header { + for r in s.split_whitespace() { + push_msg_id(&mut out, r); + } + } + } + + push_msg_id(&mut out, source_message_id); + out +} + +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +fn push_msg_id(out: &mut String, id: &str) { + let id = id.trim(); + if id.is_empty() { + return; + } + if !out.is_empty() { + out.push(' '); + } + if id.starts_with('<') { + out.push_str(id); + } else { + out.push('<'); + out.push_str(id); + out.push('>'); + } +} + +fn mime_for(path: &std::path::Path) -> &'static str { + #[cfg(feature = "maildir")] + { + let guess = mime_guess::from_path(path).first_or_octet_stream(); + // mime_guess returns owned String — leak to make 'static. Safe + // because the few possible MIME strings recur indefinitely. + let s = guess.essence_str().to_string(); + return Box::leak(s.into_boxed_str()); + } + #[cfg(not(feature = "maildir"))] + { + let _ = path; + "application/octet-stream" + } +} + +fn send_raw( + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + raw: Vec, +) -> Result<()> { + #[cfg(any(feature = "smtp", feature = "jmap"))] + use std::io::{Read, Write}; + + #[cfg(any(feature = "smtp", feature = "jmap"))] + const READ_BUFFER_SIZE: usize = 16 * 1024; + + #[cfg(feature = "smtp")] + if backend.allows_smtp() { + if let Some(smtp_config) = account_config.smtp.take() { + use io_email::smtp::message_send::{MessageSend, MessageSendResult}; + use pimalaya_toolbox::stream::smtp::SmtpSession; + + let account = crate::account::Account::new(config, account_config, smtp_config)?; + let mut session = SmtpSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let (reverse_path, forward_paths) = crate::messages::send::parse_envelope(&raw)?; + let mut coroutine = MessageSend::new(reverse_path, forward_paths, raw); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + return loop { + match coroutine.resume(arg.take()) { + MessageSendResult::Ok => break Ok(()), + MessageSendResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageSendResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageSendResult::Err(err) => bail!("{err}"), + } + }; + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::message_send::{MessageSend, MessageSendResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let identity_id = jmap_config.identity_id.clone().ok_or_else(|| { + anyhow!( + "JMAP send requires `identity-id` in the [jmap] config; \ + run `himalaya jmap identity get` to find one" + ) + })?; + let drafts_mailbox_id = jmap_config.drafts_mailbox_id.clone().ok_or_else(|| { + anyhow!( + "JMAP send requires `drafts-mailbox-id` in the [jmap] config; \ + run `himalaya jmap mailbox query --role drafts` to find one" + ) + })?; + let account = crate::account::Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + + let mut coroutine = MessageSend::new( + &session.session, + &session.http_auth, + raw, + identity_id, + drafts_mailbox_id, + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + return loop { + match coroutine.resume(arg.take()) { + MessageSendResult::Ok => break Ok(()), + MessageSendResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageSendResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageSendResult::Err(err) => bail!("{err}"), + } + }; + } + } + + let _ = config; + let _ = account_config; + let _ = raw; + bail!("no backend matching `{backend}` allows sending for this account") +} diff --git a/src/messages/copy.rs b/src/messages/copy.rs new file mode 100644 index 00000000..4e95d7e8 --- /dev/null +++ b/src/messages/copy.rs @@ -0,0 +1,173 @@ +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, + flags::arg::MessageIdsArg, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Copy message(s) from one mailbox to another within the active +/// account. +/// +/// IMAP uses `UID COPY` (RFC 3501); JMAP uses `Email/set` patches that +/// add the destination to each email's `mailboxIds`; Maildir copies +/// the underlying file. Cross-account / cross-backend copy is out of +/// scope. +#[derive(Debug, Parser)] +pub struct MessagesCopyCommand { + #[command(flatten)] + pub ids: MessageIdsArg, + + /// Source mailbox name or path (IMAP/Maildir). For JMAP this is + /// resolved by exact-match name against `Mailbox/get`. + #[arg( + long = "from", + short = 'f', + value_name = "NAME", + default_value = "Inbox" + )] + pub from: String, + + /// Destination mailbox name or path. Mandatory. + #[arg(long = "to", short = 't', value_name = "NAME")] + pub to: String, +} + +impl MessagesCopyCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use io_email::imap::message_copy::{MessageCopy, MessageCopyResult}; + use io_imap::types::{mailbox::Mailbox, sequence::SequenceSet}; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let from: Mailbox<'static> = self.from.clone().try_into()?; + let to: Mailbox<'static> = self.to.clone().try_into()?; + let sequence_set: SequenceSet = self.ids.inner.join(",").as_str().try_into()?; + let mut coroutine = MessageCopy::new(session.context, from, to, sequence_set, true); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + MessageCopyResult::Ok => break, + MessageCopyResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageCopyResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageCopyResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message(s) successfully copied")); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::message_copy::{MessageCopy, MessageCopyResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + + let mut coroutine = MessageCopy::new( + &session.session, + &session.http_auth, + self.ids.inner.iter().cloned(), + &self.to, + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + MessageCopyResult::Ok => break, + MessageCopyResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageCopyResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageCopyResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message(s) successfully copied")); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::message_copy::{ + MessageCopy, MessageCopyArg, MessageCopyResult, + }; + use io_maildir::maildir::Maildir; + + use crate::maildir::runtime; + + let account = Account::new(config, account_config, maildir_config)?; + let source = Maildir::try_from(account.backend.root.join(&self.from))?; + let target = Maildir::try_from(account.backend.root.join(&self.to))?; + + for id in &self.ids.inner { + let mut coroutine = + MessageCopy::new(id.as_str(), source.clone(), target.clone(), None); + let mut arg: Option = None; + + loop { + match coroutine.resume(arg.take()) { + MessageCopyResult::Ok => break, + MessageCopyResult::WantsDirRead(paths) => { + arg = Some(MessageCopyArg::DirRead(runtime::dir_read(paths)?)); + } + MessageCopyResult::WantsCopy(pairs) => { + runtime::copy(pairs)?; + arg = Some(MessageCopyArg::Copy); + } + MessageCopyResult::Err(err) => bail!("{err}"), + } + } + } + + return printer.out(Message::new("Message(s) successfully copied")); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } +} diff --git a/src/messages/fetch.rs b/src/messages/fetch.rs new file mode 100644 index 00000000..bfdc8315 --- /dev/null +++ b/src/messages/fetch.rs @@ -0,0 +1,143 @@ +//! Shared helper for fetching the raw RFC 5322 bytes of a single +//! message via the active account's backend. +//! +//! Used by `messages compose --reply/--forward`, `attachments list`, +//! `attachments download`, etc. Cross-backend commands always treat +//! the IMAP id as a UID — sequence-number addressing belongs to the +//! protocol-specific `imap` subcommands. + +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; + +use crate::{ + cli::BackendArg, + config::{AccountConfig, Config}, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Fetches the raw RFC 5322 bytes of `id` from `mailbox` via the first +/// configured backend that matches `backend`. Bails when no backend +/// matches. +pub(crate) fn fetch_raw( + config: &Config, + account_config: &AccountConfig, + backend: BackendArg, + mailbox: &str, + id: &str, +) -> Result> { + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.clone() { + use std::num::NonZeroU32; + + use io_email::imap::message_get::{MessageGet, MessageGetResult}; + use io_imap::types::mailbox::Mailbox; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = + crate::account::Account::new(config.clone(), account_config.clone(), imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let imap_mailbox: Mailbox<'static> = mailbox.to_owned().try_into()?; + let id: NonZeroU32 = id.parse()?; + let mut coroutine = MessageGet::new(session.context, imap_mailbox, id, true); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + return loop { + match coroutine.resume(arg.take()) { + MessageGetResult::Ok(raw) => break Ok(raw), + MessageGetResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageGetResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageGetResult::Err(err) => bail!("{err}"), + } + }; + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.clone() { + use io_email::jmap::message_get::{MessageGet, MessageGetResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let _ = mailbox; + let account = + crate::account::Account::new(config.clone(), account_config.clone(), jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + let mut coroutine = MessageGet::new(&session.session, &session.http_auth, id)?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + return loop { + match coroutine.resume(arg.take()) { + MessageGetResult::Ok(raw) => break Ok(raw), + MessageGetResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageGetResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageGetResult::Err(err) => bail!("{err}"), + } + }; + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.clone() { + use io_email::maildir::message_get::{MessageGet, MessageGetArg, MessageGetResult}; + use io_maildir::maildir::Maildir; + + let account = crate::account::Account::new( + config.clone(), + account_config.clone(), + maildir_config, + )?; + let path = account.backend.root.join(mailbox); + let maildir = Maildir::try_from(path)?; + + let mut coroutine = MessageGet::new(maildir, id); + let mut arg: Option = None; + + return loop { + match coroutine.resume(arg.take()) { + MessageGetResult::Ok(raw) => break Ok(raw), + MessageGetResult::WantsDirRead(paths) => { + arg = Some(MessageGetArg::DirRead(crate::maildir::runtime::dir_read( + paths, + )?)); + } + MessageGetResult::WantsFileRead(paths) => { + arg = Some(MessageGetArg::FileRead(crate::maildir::runtime::file_read( + paths, + )?)); + } + MessageGetResult::Err(err) => bail!("{err}"), + } + }; + } + } + + bail!("no backend matching `{backend}` is configured for this account") +} diff --git a/src/messages/get.rs b/src/messages/get.rs new file mode 100644 index 00000000..1ca7f7fe --- /dev/null +++ b/src/messages/get.rs @@ -0,0 +1,255 @@ +#[cfg(feature = "maildir")] +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +use std::fmt; +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::Read; +use std::io::{stdout, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +use mail_parser::{Message, MessageParser}; +use pimalaya_toolbox::terminal::printer::Printer; +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +use serde::Serialize; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Get a message from the active account. +/// +/// By default the message is parsed and rendered as headers + text +/// bodies. Pass `--raw` to dump the original RFC 5322 bytes to stdout +/// instead, or use the global `--json` flag to emit the parsed message +/// as JSON. +#[derive(Debug, Parser)] +pub struct MessagesGetCommand { + /// Identifier of the message (IMAP UID, JMAP email id, or Maildir + /// filename id). + #[arg(value_name = "ID")] + pub id: String, + + /// Mailbox name or path (IMAP mailbox / Maildir path). Ignored for + /// JMAP, which addresses messages by id directly. + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + /// Write the raw RFC 5322 bytes to stdout. Mutually exclusive with + /// the global `--json` flag. + #[arg(long)] + pub raw: bool, +} + +impl MessagesGetCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + if self.raw && printer.is_json() { + bail!("`--raw` and `--json` cannot be combined"); + } + + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use std::num::NonZeroU32; + + use io_email::imap::message_get::{MessageGet, MessageGetResult}; + use io_imap::types::mailbox::Mailbox; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let mailbox: Mailbox<'static> = self.mailbox.clone().try_into()?; + let id: NonZeroU32 = self.id.parse()?; + let mut coroutine = MessageGet::new(session.context, mailbox, id, true); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + let raw = loop { + match coroutine.resume(arg.take()) { + MessageGetResult::Ok(raw) => break raw, + MessageGetResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageGetResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageGetResult::Err(err) => bail!("{err}"), + } + }; + + return emit(printer, raw, self.raw); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::message_get::{MessageGet, MessageGetResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + let mut coroutine = + MessageGet::new(&session.session, &session.http_auth, &self.id)?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + let raw = loop { + match coroutine.resume(arg.take()) { + MessageGetResult::Ok(raw) => break raw, + MessageGetResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageGetResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageGetResult::Err(err) => bail!("{err}"), + } + }; + + return emit(printer, raw, self.raw); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::message_get::{MessageGet, MessageGetArg, MessageGetResult}; + use io_maildir::maildir::Maildir; + + let account = Account::new(config, account_config, maildir_config)?; + let path = account.backend.root.join(&self.mailbox); + let maildir = Maildir::try_from(path)?; + + let mut coroutine = MessageGet::new(maildir, &self.id); + let mut arg: Option = None; + + let raw = loop { + match coroutine.resume(arg.take()) { + MessageGetResult::Ok(raw) => break raw, + MessageGetResult::WantsDirRead(paths) => { + arg = Some(MessageGetArg::DirRead(read_dirs(&paths)?)); + } + MessageGetResult::WantsFileRead(paths) => { + arg = Some(MessageGetArg::FileRead(read_files(&paths)?)); + } + MessageGetResult::Err(err) => bail!("{err}"), + } + }; + + return emit(printer, raw, self.raw); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } +} + +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +fn emit(printer: &mut impl Printer, raw: Vec, raw_mode: bool) -> Result<()> { + if raw_mode { + let mut out = stdout().lock(); + out.write_all(&raw)?; + return Ok(()); + } + + let Some(parsed) = MessageParser::new().parse(&raw) else { + bail!("Failed to parse RFC 5322 message"); + }; + + printer.out(MessageView(parsed.into_owned())) +} + +#[cfg(feature = "maildir")] +fn read_dirs(paths: &BTreeSet) -> Result>> { + use std::fs; + + let mut out = BTreeMap::new(); + + for path in paths { + let mut entries = BTreeSet::new(); + + for entry in fs::read_dir(path)? { + let entry = entry?; + + if let Some(s) = entry.path().to_str() { + entries.insert(s.to_owned()); + } + } + + out.insert(path.clone(), entries); + } + + Ok(out) +} + +#[cfg(feature = "maildir")] +fn read_files(paths: &BTreeSet) -> Result>> { + use std::fs; + + let mut out = BTreeMap::new(); + + for path in paths { + out.insert(path.clone(), fs::read(path)?); + } + + Ok(out) +} + +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +#[derive(Serialize)] +#[serde(transparent)] +pub struct MessageView(Message<'static>); + +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +impl fmt::Display for MessageView { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for header in self.0.headers() { + writeln!(f, "{}: {:?}", header.name.as_str(), header.value)?; + } + + writeln!(f)?; + + for (i, part) in self.0.text_bodies().enumerate() { + if i > 0 { + writeln!(f)?; + writeln!(f)?; + } + + if let Some(contents) = part.text_contents() { + write!(f, "{}", contents.trim_end())?; + } + } + + Ok(()) + } +} diff --git a/src/messages/mod.rs b/src/messages/mod.rs new file mode 100644 index 00000000..6849055a --- /dev/null +++ b/src/messages/mod.rs @@ -0,0 +1,9 @@ +pub mod add; +pub mod command; +pub mod compose; +pub mod copy; +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +pub mod fetch; +pub mod get; +pub mod mv; +pub mod send; diff --git a/src/messages/mv.rs b/src/messages/mv.rs new file mode 100644 index 00000000..92ec837c --- /dev/null +++ b/src/messages/mv.rs @@ -0,0 +1,174 @@ +#[cfg(any(feature = "imap", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + account::Account, + cli::BackendArg, + config::{AccountConfig, Config}, + flags::arg::MessageIdsArg, +}; + +#[cfg(any(feature = "imap", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Move message(s) from one mailbox to another within the active +/// account. +/// +/// IMAP uses `UID MOVE` (RFC 6851); JMAP uses `Email/set` patches that +/// remove the source and add the destination from each email's +/// `mailboxIds`; Maildir renames the underlying file. Cross-account / +/// cross-backend move is out of scope. +#[derive(Debug, Parser)] +pub struct MessagesMoveCommand { + #[command(flatten)] + pub ids: MessageIdsArg, + + /// Source mailbox name or path (IMAP/Maildir). For JMAP this is + /// resolved by exact-match name against `Mailbox/get`. + #[arg( + long = "from", + short = 'f', + value_name = "NAME", + default_value = "Inbox" + )] + pub from: String, + + /// Destination mailbox name or path. Mandatory. + #[arg(long = "to", short = 't', value_name = "NAME")] + pub to: String, +} + +impl MessagesMoveCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.take() { + use io_email::imap::message_move::{MessageMove, MessageMoveResult}; + use io_imap::types::{mailbox::Mailbox, sequence::SequenceSet}; + use pimalaya_toolbox::stream::imap::ImapSession; + + let account = Account::new(config, account_config, imap_config)?; + let mut session = ImapSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let from: Mailbox<'static> = self.from.clone().try_into()?; + let to: Mailbox<'static> = self.to.clone().try_into()?; + let sequence_set: SequenceSet = self.ids.inner.join(",").as_str().try_into()?; + let mut coroutine = MessageMove::new(session.context, from, to, sequence_set, true); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + MessageMoveResult::Ok => break, + MessageMoveResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageMoveResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageMoveResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message(s) successfully moved")); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::message_move::{MessageMove, MessageMoveResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let account = Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + + let mut coroutine = MessageMove::new( + &session.session, + &session.http_auth, + self.ids.inner.iter().cloned(), + &self.from, + &self.to, + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + MessageMoveResult::Ok => break, + MessageMoveResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageMoveResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageMoveResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message(s) successfully moved")); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.take() { + use io_email::maildir::message_move::{ + MessageMove, MessageMoveArg, MessageMoveResult, + }; + use io_maildir::maildir::Maildir; + + use crate::maildir::runtime; + + let account = Account::new(config, account_config, maildir_config)?; + let source = Maildir::try_from(account.backend.root.join(&self.from))?; + let target = Maildir::try_from(account.backend.root.join(&self.to))?; + + for id in &self.ids.inner { + let mut coroutine = + MessageMove::new(id.as_str(), source.clone(), target.clone(), None); + let mut arg: Option = None; + + loop { + match coroutine.resume(arg.take()) { + MessageMoveResult::Ok => break, + MessageMoveResult::WantsDirRead(paths) => { + arg = Some(MessageMoveArg::DirRead(runtime::dir_read(paths)?)); + } + MessageMoveResult::WantsRename(pairs) => { + runtime::rename(pairs)?; + arg = Some(MessageMoveArg::Rename); + } + MessageMoveResult::Err(err) => bail!("{err}"), + } + } + } + + return printer.out(Message::new("Message(s) successfully moved")); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } +} diff --git a/src/messages/send.rs b/src/messages/send.rs new file mode 100644 index 00000000..c72ce3b1 --- /dev/null +++ b/src/messages/send.rs @@ -0,0 +1,257 @@ +use std::io::{stdin, BufRead, IsTerminal}; +#[cfg(any(feature = "smtp", feature = "jmap"))] +use std::io::{Read, Write}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_toolbox::terminal::printer::{Message, Printer}; + +use crate::{ + cli::BackendArg, + config::{AccountConfig, Config}, +}; + +#[cfg(any(feature = "smtp", feature = "jmap"))] +const READ_BUFFER_SIZE: usize = 16 * 1024; + +/// Send a message via the active account. +/// +/// Supported over SMTP and JMAP. JMAP requires `identity-id` and +/// `drafts-mailbox-id` to be set on the account's `[jmap]` config block. +#[derive(Debug, Parser)] +pub struct MessagesSendCommand { + /// The raw message, including headers and body. + #[arg(trailing_var_arg = true)] + #[arg(name = "message", value_name = "MESSAGE")] + pub message: Vec, +} + +impl MessagesSendCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config: Config, + mut account_config: AccountConfig, + backend: BackendArg, + ) -> Result<()> { + let raw = if stdin().is_terminal() || printer.is_json() { + self.message + .join(" ") + .replace('\r', "") + .replace('\n', "\r\n") + } else { + stdin() + .lock() + .lines() + .map_while(Result::ok) + .collect::>() + .join("\r\n") + }; + + #[cfg(feature = "smtp")] + if backend.allows_smtp() { + if let Some(smtp_config) = account_config.smtp.take() { + use io_email::smtp::message_send::{MessageSend, MessageSendResult}; + use pimalaya_toolbox::stream::smtp::SmtpSession; + + let account = crate::account::Account::new(config, account_config, smtp_config)?; + let mut session = SmtpSession::new( + account.backend.url.clone(), + account.backend.tls.clone().try_into()?, + account.backend.starttls, + account.backend.sasl.clone().try_into()?, + )?; + + let (reverse_path, forward_paths) = parse_envelope(raw.as_bytes())?; + let mut coroutine = MessageSend::new(reverse_path, forward_paths, raw.into_bytes()); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + MessageSendResult::Ok => break, + MessageSendResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageSendResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageSendResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message successfully sent")); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.take() { + use io_email::jmap::message_send::{MessageSend, MessageSendResult}; + use pimalaya_toolbox::stream::jmap::JmapSession; + + let identity_id = jmap_config.identity_id.clone().ok_or_else(|| { + anyhow::anyhow!( + "JMAP send requires `identity-id` in the [jmap] config; \ + run `himalaya jmap identity get` to find one" + ) + })?; + let drafts_mailbox_id = jmap_config.drafts_mailbox_id.clone().ok_or_else(|| { + anyhow::anyhow!( + "JMAP send requires `drafts-mailbox-id` in the [jmap] config; \ + run `himalaya jmap mailbox query --role drafts` to find one" + ) + })?; + let account = crate::account::Account::new(config, account_config, jmap_config)?; + let mut session = JmapSession::new( + account.backend.server.clone(), + account.backend.tls.clone().try_into()?, + account.backend.auth.clone().try_into()?, + )?; + + let mut coroutine = MessageSend::new( + &session.session, + &session.http_auth, + raw.into_bytes(), + identity_id, + drafts_mailbox_id, + )?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + MessageSendResult::Ok => break, + MessageSendResult::WantsRead => { + let n = session.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + MessageSendResult::WantsWrite(bytes) => { + session.stream.write_all(&bytes)?; + } + MessageSendResult::Err(err) => bail!("{err}"), + } + } + + return printer.out(Message::new("Message successfully sent")); + } + } + + let _ = config; + let _ = raw; + bail!("no backend matching `{backend}` is configured for this account") + } +} + +#[cfg(feature = "smtp")] +pub(crate) fn parse_envelope<'a>( + msg: &[u8], +) -> Result<( + io_smtp::rfc5321::types::reverse_path::ReversePath<'a>, + Vec>, +)> { + use std::{borrow::Cow, collections::HashSet}; + + use io_smtp::rfc5321::types::{ + domain::Domain, ehlo_domain::EhloDomain, forward_path::ForwardPath, local_part::LocalPart, + mailbox::Mailbox, reverse_path::ReversePath, + }; + use mail_parser::{Address, HeaderName, HeaderValue, MessageParser}; + + let Some(parsed) = MessageParser::new().parse_headers(msg) else { + bail!("Invalid message to send") + }; + + let mut mail_from = None; + let mut rcpt_to = HashSet::new(); + + for header in parsed.headers() { + let key = &header.name; + let val = header.value(); + + match key { + HeaderName::From => match val { + HeaderValue::Address(Address::List(addrs)) => { + if let Some(email) = addrs.first().and_then(find_valid_email) { + mail_from = email.to_string().into(); + } + } + HeaderValue::Address(Address::Group(groups)) => { + if let Some(group) = groups.first() { + if let Some(email) = group.addresses.first().and_then(find_valid_email) { + mail_from = email.to_string().into(); + } + } + } + _ => (), + }, + HeaderName::To | HeaderName::Cc | HeaderName::Bcc => match val { + HeaderValue::Address(Address::List(addrs)) => { + rcpt_to.extend(addrs.iter().filter_map(find_valid_email)); + } + HeaderValue::Address(Address::Group(groups)) => { + rcpt_to.extend( + groups + .iter() + .flat_map(|group| group.addresses.iter()) + .filter_map(find_valid_email), + ); + } + _ => (), + }, + _ => (), + }; + } + + let Some(mail_from) = mail_from else { + bail!("The message does not contain any sender"); + }; + + if rcpt_to.is_empty() { + bail!("The message does not contain any recipient"); + } + + let Some((local, domain)) = mail_from.split_once('@') else { + bail!("The message contains an invalid sender"); + }; + + let mbox = Mailbox { + local_part: LocalPart(Cow::Owned(local.to_owned())), + domain: EhloDomain::Domain(Domain(Cow::Owned(domain.to_owned()))), + }; + + let reverse_path = ReversePath::Mailbox(mbox); + + let mut forward_paths = Vec::new(); + + for rcpt in rcpt_to { + let Some((local, domain)) = rcpt.split_once('@') else { + bail!("The message contains an invalid recipient: {rcpt}"); + }; + + let mbox = Mailbox { + local_part: LocalPart(Cow::Owned(local.to_owned())), + domain: EhloDomain::Domain(Domain(Cow::Owned(domain.to_owned()))), + }; + + forward_paths.push(ForwardPath(mbox)) + } + + Ok((reverse_path, forward_paths)) +} + +#[cfg(feature = "smtp")] +fn find_valid_email(addr: &mail_parser::Addr) -> Option { + match &addr.address { + None => None, + Some(email) => { + let email = email.trim(); + if email.is_empty() { + None + } else { + Some(email.to_string()) + } + } + } +} diff --git a/src/smtp/message/send.rs b/src/smtp/message/send.rs index 043c31fa..91c137aa 100644 --- a/src/smtp/message/send.rs +++ b/src/smtp/message/send.rs @@ -1,7 +1,7 @@ use std::{ borrow::Cow, collections::HashSet, - io::{stdin, BufRead, IsTerminal}, + io::{stdin, BufRead, IsTerminal, Read, Write}, }; use anyhow::{bail, Result}; @@ -13,12 +13,13 @@ use io_smtp::{ }, send::*, }; -use io_socket::runtimes::std_stream::handle; use mail_parser::{Addr, Address, HeaderName, HeaderValue, MessageParser}; use pimalaya_toolbox::terminal::printer::{Message, Printer}; use crate::smtp::account::SmtpAccount; +const READ_BUFFER_SIZE: usize = 8 * 1024; + /// Send a message to a mailbox. /// /// This command appends a message to the specified mailbox. The @@ -33,7 +34,7 @@ pub struct SmtpMessageSendCommand { impl SmtpMessageSendCommand { pub fn execute(self, printer: &mut impl Printer, account: SmtpAccount) -> Result<()> { - let mut imap = account.new_smtp_session()?; + let mut smtp = account.new_smtp_session()?; let message = if stdin().is_terminal() || printer.is_json() { self.message @@ -51,14 +52,22 @@ impl SmtpMessageSendCommand { let (reverse_path, forward_paths) = into_smtp_msg(message.as_bytes())?; - let mut arg = None; let mut coroutine = SmtpMessageSend::new(reverse_path, forward_paths, message.into_bytes()); + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut arg: Option<&[u8]> = None; loop { match coroutine.resume(arg.take()) { - SmtpMessageSendResult::Io { input } => arg = Some(handle(&mut imap.stream, input)?), SmtpMessageSendResult::Ok => break, - SmtpMessageSendResult::Err { err } => bail!(err), + SmtpMessageSendResult::WantsRead => { + let n = smtp.stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + SmtpMessageSendResult::WantsWrite(bytes) => { + smtp.stream.write_all(&bytes)?; + arg = None; + } + SmtpMessageSendResult::Err(err) => bail!("{err}"), } } diff --git a/tests/common/jmap.rs b/tests/common/jmap.rs index 39cbeae6..a13d6ac1 100644 --- a/tests/common/jmap.rs +++ b/tests/common/jmap.rs @@ -5,7 +5,7 @@ use std::{ }; use assert_cmd::Command; -use io_jmap::rfc8621::types::{ +use io_jmap::rfc8621::{ email::Email, email_submission::EmailSubmission, identity::Identity, mailbox::Mailbox, thread::Thread, vacation_response::VacationResponse, };