From cd27969e14043c003e8709ea49ccbd9d74231cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Wed, 6 May 2026 23:02:11 +0200 Subject: [PATCH] clean part 1 --- Cargo.lock | 226 ++++++- Cargo.toml | 12 +- src/account.rs | 48 -- src/account/check.rs | 215 ++++++ src/account/cli.rs | 42 ++ src/account/configure.rs | 34 + src/account/context.rs | 137 ++++ src/account/list.rs | 124 ++++ src/account/mod.rs | 5 + src/attachments/cli.rs | 35 - src/attachments/download.rs | 153 ----- src/attachments/list.rs | 107 --- src/attachments/table.rs | 68 -- src/cli.rs | 264 ++++---- src/config.rs | 101 ++- src/email_client.rs | 119 ---- src/envelopes/cli.rs | 34 - src/envelopes/list.rs | 59 -- src/envelopes/table.rs | 59 -- src/flags/add.rs | 40 -- src/flags/cli.rs | 38 -- src/flags/delete.rs | 40 -- src/flags/set.rs | 40 -- src/imap/account.rs | 21 - src/imap/cli.rs | 16 +- src/imap/client.rs | 73 +++ src/imap/envelope/cli.rs | 14 +- src/imap/envelope/get.rs | 7 +- src/imap/envelope/list.rs | 9 +- src/imap/envelope/search.rs | 9 +- src/imap/envelope/sort.rs | 5 +- src/imap/envelope/thread.rs | 16 +- src/imap/flag/add.rs | 5 +- src/imap/flag/cli.rs | 12 +- src/imap/flag/list.rs | 9 +- src/imap/flag/remove.rs | 5 +- src/imap/flag/set.rs | 5 +- src/imap/id.rs | 7 +- src/imap/mailbox/cli.rs | 28 +- src/imap/mailbox/close.rs | 5 +- src/imap/mailbox/create.rs | 5 +- src/imap/mailbox/delete.rs | 5 +- src/imap/mailbox/expunge.rs | 5 +- src/imap/mailbox/list.rs | 19 +- src/imap/mailbox/purge.rs | 5 +- src/imap/mailbox/rename.rs | 5 +- src/imap/mailbox/select.rs | 5 +- src/imap/mailbox/status.rs | 7 +- src/imap/mailbox/subscribe.rs | 5 +- src/imap/mailbox/unselect.rs | 5 +- src/imap/mailbox/unsubscribe.rs | 5 +- src/imap/message/cli.rs | 16 +- src/imap/message/copy.rs | 5 +- src/imap/message/export.rs | 9 +- src/imap/message/get.rs | 5 +- src/imap/message/move.rs | 5 +- src/imap/message/read.rs | 5 +- src/imap/message/save.rs | 5 +- src/imap/mod.rs | 2 +- src/imap/session.rs | 6 +- src/jmap/account.rs | 24 - src/jmap/cli.rs | 20 +- src/jmap/client.rs | 82 +++ src/jmap/email/cli.rs | 22 +- src/jmap/email/copy.rs | 6 +- src/jmap/email/delete.rs | 5 +- src/jmap/email/export.rs | 16 +- src/jmap/email/get.rs | 9 +- src/jmap/email/import.rs | 16 +- src/jmap/email/parse.rs | 5 +- src/jmap/email/query.rs | 10 +- src/jmap/email/read.rs | 5 +- src/jmap/email/update.rs | 5 +- src/jmap/identity/cli.rs | 12 +- src/jmap/identity/create.rs | 6 +- src/jmap/identity/delete.rs | 5 +- src/jmap/identity/get.rs | 7 +- src/jmap/identity/update.rs | 6 +- src/jmap/mailbox/cli.rs | 14 +- src/jmap/mailbox/create.rs | 6 +- src/jmap/mailbox/destroy.rs | 6 +- src/jmap/mailbox/get.rs | 7 +- src/jmap/mailbox/query.rs | 8 +- src/jmap/mailbox/update.rs | 6 +- src/jmap/mod.rs | 2 +- src/jmap/query.rs | 6 +- src/jmap/session.rs | 4 +- src/jmap/submission/cancel.rs | 5 +- src/jmap/submission/cli.rs | 12 +- src/jmap/submission/create.rs | 8 +- src/jmap/submission/get.rs | 7 +- src/jmap/submission/query.rs | 8 +- src/jmap/thread/cli.rs | 6 +- src/jmap/thread/get.rs | 7 +- src/jmap/vacation/cli.rs | 8 +- src/jmap/vacation/get.rs | 8 +- src/jmap/vacation/set.rs | 6 +- src/mailboxes/cli.rs | 34 - src/mailboxes/list.rs | 34 - src/mailboxes/table.rs | 45 -- src/maildir/account.rs | 13 - src/maildir/cli.rs | 24 +- src/maildir/client.rs | 73 +++ src/maildir/command.rs | 2 +- src/maildir/create.rs | 8 +- src/maildir/delete.rs | 8 +- src/maildir/envelope/cli.rs | 8 +- src/maildir/envelope/get.rs | 9 +- src/maildir/envelope/list.rs | 11 +- src/maildir/flag/add.rs | 7 +- src/maildir/flag/cli.rs | 12 +- src/maildir/flag/list.rs | 8 +- src/maildir/flag/remove.rs | 7 +- src/maildir/flag/set.rs | 7 +- src/maildir/list.rs | 7 +- src/maildir/message/cli.rs | 16 +- src/maildir/message/copy.rs | 10 +- src/maildir/message/export.rs | 7 +- src/maildir/message/get.rs | 7 +- src/maildir/message/move.rs | 10 +- src/maildir/message/read.rs | 7 +- src/maildir/message/save.rs | 8 +- src/maildir/mod.rs | 3 +- src/maildir/rename.rs | 8 +- src/maildir/runtime.rs | 97 --- src/main.rs | 31 +- src/messages/add.rs | 203 ------ src/messages/cli.rs | 49 -- src/messages/compose.rs | 599 ----------------- src/messages/copy.rs | 173 ----- src/messages/fetch.rs | 29 - src/messages/mod.rs | 9 - src/messages/mv.rs | 174 ----- src/messages/send.rs | 257 -------- src/shared/attachments/cli.rs | 28 + src/shared/attachments/download.rs | 161 +++++ src/shared/attachments/list.rs | 164 +++++ src/{ => shared}/attachments/mod.rs | 1 - src/shared/client.rs | 159 +++++ src/shared/envelopes/cli.rs | 24 + src/shared/envelopes/list.rs | 184 ++++++ src/{ => shared}/envelopes/mod.rs | 1 - src/shared/flags/add.rs | 46 ++ src/{ => shared}/flags/arg.rs | 42 +- src/shared/flags/cli.rs | 29 + src/{ => shared}/flags/mod.rs | 2 +- src/shared/flags/remove.rs | 46 ++ src/shared/flags/set.rs | 46 ++ src/shared/mailboxes/cli.rs | 22 + src/shared/mailboxes/list.rs | 86 +++ src/{ => shared}/mailboxes/mod.rs | 1 - src/shared/messages/add.rs | 61 ++ src/shared/messages/builder.rs | 371 +++++++++++ src/shared/messages/cli.rs | 59 ++ src/shared/messages/compose.rs | 104 +++ src/shared/messages/compose_with.rs | 57 ++ src/shared/messages/copy.rs | 40 ++ src/shared/messages/forward.rs | 113 ++++ src/shared/messages/forward_with.rs | 59 ++ src/shared/messages/mod.rs | 16 + src/shared/messages/mv.rs | 40 ++ src/shared/messages/output.rs | 44 ++ .../get.rs => shared/messages/read.rs} | 31 +- src/shared/messages/read_with.rs | 60 ++ src/shared/messages/reply.rs | 124 ++++ src/shared/messages/reply_with.rs | 64 ++ src/shared/messages/runner.rs | 105 +++ src/shared/messages/send.rs | 41 ++ src/shared/mod.rs | 7 + src/smtp/account.rs | 23 - src/smtp/cli.rs | 8 +- src/smtp/client.rs | 74 +++ src/smtp/message/cli.rs | 6 +- src/smtp/message/send.rs | 6 +- src/smtp/mod.rs | 3 +- src/smtp/session.rs | 216 ++++++ src/wizard.rs | 620 ++++++++++++++++-- tests/common/shared.rs | 403 ++++++++++++ tests/fastmail-imap.rs | 20 +- 179 files changed, 5244 insertions(+), 3357 deletions(-) delete mode 100644 src/account.rs create mode 100644 src/account/check.rs create mode 100644 src/account/cli.rs create mode 100644 src/account/configure.rs create mode 100644 src/account/context.rs create mode 100644 src/account/list.rs create mode 100644 src/account/mod.rs delete mode 100644 src/attachments/cli.rs delete mode 100644 src/attachments/download.rs delete mode 100644 src/attachments/list.rs delete mode 100644 src/attachments/table.rs delete mode 100644 src/email_client.rs delete mode 100644 src/envelopes/cli.rs delete mode 100644 src/envelopes/list.rs delete mode 100644 src/envelopes/table.rs delete mode 100644 src/flags/add.rs delete mode 100644 src/flags/cli.rs delete mode 100644 src/flags/delete.rs delete mode 100644 src/flags/set.rs delete mode 100644 src/imap/account.rs create mode 100644 src/imap/client.rs delete mode 100644 src/jmap/account.rs create mode 100644 src/jmap/client.rs delete mode 100644 src/mailboxes/cli.rs delete mode 100644 src/mailboxes/list.rs delete mode 100644 src/mailboxes/table.rs delete mode 100644 src/maildir/account.rs create mode 100644 src/maildir/client.rs delete mode 100644 src/maildir/runtime.rs delete mode 100644 src/messages/add.rs delete mode 100644 src/messages/cli.rs delete mode 100644 src/messages/compose.rs delete mode 100644 src/messages/copy.rs delete mode 100644 src/messages/fetch.rs delete mode 100644 src/messages/mod.rs delete mode 100644 src/messages/mv.rs delete mode 100644 src/messages/send.rs create mode 100644 src/shared/attachments/cli.rs create mode 100644 src/shared/attachments/download.rs create mode 100644 src/shared/attachments/list.rs rename src/{ => shared}/attachments/mod.rs (75%) create mode 100644 src/shared/client.rs create mode 100644 src/shared/envelopes/cli.rs create mode 100644 src/shared/envelopes/list.rs rename src/{ => shared}/envelopes/mod.rs (64%) create mode 100644 src/shared/flags/add.rs rename src/{ => shared}/flags/arg.rs (65%) create mode 100644 src/shared/flags/cli.rs rename src/{ => shared}/flags/mod.rs (76%) create mode 100644 src/shared/flags/remove.rs create mode 100644 src/shared/flags/set.rs create mode 100644 src/shared/mailboxes/cli.rs create mode 100644 src/shared/mailboxes/list.rs rename src/{ => shared}/mailboxes/mod.rs (64%) create mode 100644 src/shared/messages/add.rs create mode 100644 src/shared/messages/builder.rs create mode 100644 src/shared/messages/cli.rs create mode 100644 src/shared/messages/compose.rs create mode 100644 src/shared/messages/compose_with.rs create mode 100644 src/shared/messages/copy.rs create mode 100644 src/shared/messages/forward.rs create mode 100644 src/shared/messages/forward_with.rs create mode 100644 src/shared/messages/mod.rs create mode 100644 src/shared/messages/mv.rs create mode 100644 src/shared/messages/output.rs rename src/{messages/get.rs => shared/messages/read.rs} (72%) create mode 100644 src/shared/messages/read_with.rs create mode 100644 src/shared/messages/reply.rs create mode 100644 src/shared/messages/reply_with.rs create mode 100644 src/shared/messages/runner.rs create mode 100644 src/shared/messages/send.rs create mode 100644 src/shared/mod.rs delete mode 100644 src/smtp/account.rs create mode 100644 src/smtp/client.rs create mode 100644 src/smtp/session.rs create mode 100644 tests/common/shared.rs diff --git a/Cargo.lock b/Cargo.lock index b63d29b4..e322159c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -229,7 +238,10 @@ version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ + "iana-time-zone", "num-traits", + "serde", + "windows-link", ] [[package]] @@ -643,6 +655,30 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "fuzzy-matcher" version = "0.3.7" @@ -782,12 +818,12 @@ dependencies = [ "convert_case 0.11.0", "dirs", "gethostname 1.1.0", + "humansize", "io-discovery", "io-email", "io-imap", "io-jmap", "io-maildir", - "io-process", "io-smtp", "log", "mail-builder", @@ -814,6 +850,39 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1000,6 +1069,7 @@ dependencies = [ name = "io-email" version = "0.0.1" dependencies = [ + "chrono", "io-imap", "io-jmap", "io-maildir", @@ -1015,12 +1085,13 @@ dependencies = [ [[package]] name = "io-http" version = "0.0.3" -source = "git+https://github.com/pimalaya/io-http#c299cf8bc7512507a0e108561e10eaf4194de1a5" dependencies = [ + "anyhow", "base64", "httparse", "log", "memchr", + "pimalaya-stream", "secrecy", "thiserror 2.0.18", "url", @@ -1060,19 +1131,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "io-process" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54362e497536be1b10ad35a790838e89ce30d5095a66f12f5315154cf5041d03" -dependencies = [ - "dirs", - "log", - "serde", - "shellexpand", - "thiserror 2.0.18", -] - [[package]] name = "io-smtp" version = "0.0.1" @@ -1200,6 +1258,18 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1224,6 +1294,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -1527,6 +1603,7 @@ dependencies = [ "clap_complete", "clap_mangen", "comfy-table", + "crossterm", "env_logger", "git2", "inquire", @@ -1545,7 +1622,6 @@ version = "0.0.1" dependencies = [ "anyhow", "dirs", - "io-process", "log", "secrecy", "serde", @@ -1560,8 +1636,6 @@ name = "pimalaya-stream" version = "0.0.1" dependencies = [ "anyhow", - "gethostname 1.1.0", - "io-smtp", "log", "native-tls", "rustls", @@ -1572,6 +1646,12 @@ dependencies = [ "url", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.33" @@ -1922,6 +2002,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "same-file" version = "1.0.6" @@ -2131,6 +2217,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -2452,6 +2544,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -2526,12 +2663,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 61a0d556..85263e7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ default = ["imap", "smtp", "maildir", "jmap", "rustls-ring"] imap = ["dep:io-imap", "dep:mail-parser", "dep:rfc2047-decoder", "io-email/imap", "io-imap/client"] jmap = ["dep:base64", "dep:io-jmap", "dep:mail-parser", "dep:serde_json", "io-email/jmap", "io-jmap/client"] -smtp = ["dep:io-smtp", "dep:mail-parser", "pimalaya-stream/smtp", "io-email/smtp"] +smtp = ["dep:io-smtp", "dep:mail-parser", "io-email/smtp"] maildir = ["dep:convert_case", "dep:io-maildir", "dep:mail-parser", "dep:mime_guess", "io-email/maildir", "io-maildir/client"] native-tls = ["pimalaya-stream/native-tls", "io-discovery/native-tls"] @@ -35,27 +35,27 @@ pimalaya-cli = { version = "0.0.1", default-features = false, features = ["build [dependencies] anyhow = "1" base64 = { version = "0.22", optional = true } -chrono = { version = "0.4", default-features = false } +chrono = { version = "0.4", default-features = false, features = ["clock"] } clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } comfy-table = "7" convert_case = { version = "0.11", optional = true } dirs = "6" gethostname = "1" +humansize = "2" io-discovery = { version = "0.0.1", default-features = false, features = ["pacc", "autoconfig", "client"] } io-email = { version = "0.0.1", default-features = false, features = ["serde", "client"] } 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 } 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-cli = { version = "0.0.1", default-features = false, features = ["terminal", "table", "prompt", "wizard", "imap", "smtp"] } +pimalaya-cli = { version = "0.0.1", default-features = false, features = ["terminal", "table", "prompt", "wizard", "imap", "smtp", "jmap", "spinner"] } pimalaya-config = { version = "0.0.1", default-features = false, features = ["toml", "secret"] } -pimalaya-stream = { version = "0.0.1", default-features = false, features = ["std", "http"] } +pimalaya-stream = { version = "0.0.1", default-features = false, features = ["std"] } rfc2047-decoder = { version = "1", optional = true } secrecy = "0.10" serde = { version = "1", features = ["derive"] } @@ -78,7 +78,7 @@ tempfile = "3" domain = { git = "https://github.com/soywod/domain", branch = "new-srv" } io-discovery.path = "../io-discovery" io-email.path = "../io-email" -io-http.git = "https://github.com/pimalaya/io-http" +io-http.path = "../io-http" io-imap.path = "../io-imap" io-jmap.path = "../io-jmap" io-maildir.path = "../io-maildir" diff --git a/src/account.rs b/src/account.rs deleted file mode 100644 index cdb3fb69..00000000 --- a/src/account.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::{env::temp_dir, path::PathBuf}; - -use crate::config::{AccountConfig, Config}; -use anyhow::Result; -use comfy_table::{presets, ContentArrangement}; -use dirs::download_dir; - -#[derive(Clone, Debug)] -pub struct Account { - pub backend: B, - pub downloads_dir: PathBuf, - - pub table_preset: String, - pub table_arrangement: ContentArrangement, -} - -impl Account { - pub fn new(config: Config, account_config: AccountConfig, backend: B) -> Result { - Ok(Self { - backend, - - downloads_dir: account_config - .downloads_dir - .as_ref() - .and_then(|dir| dir.to_str()) - .and_then(|dir| shellexpand::full(dir).ok()) - .map(|dir| PathBuf::from(dir.to_string())) - .or(config - .downloads_dir - .as_ref() - .and_then(|dir| dir.to_str()) - .and_then(|dir| shellexpand::full(dir).ok()) - .map(|dir| PathBuf::from(dir.to_string()))) - .or(download_dir()) - .unwrap_or_else(temp_dir), - - table_preset: config - .table_preset - .or(account_config.table_preset) - .unwrap_or(presets::UTF8_FULL_CONDENSED.to_string()), - table_arrangement: config - .table_arrangement - .or(account_config.table_arrangement) - .unwrap_or_default() - .into(), - }) - } -} diff --git a/src/account/check.rs b/src/account/check.rs new file mode 100644 index 00000000..d6a22eb9 --- /dev/null +++ b/src/account/check.rs @@ -0,0 +1,215 @@ +use std::{fmt, path::PathBuf}; + +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_cli::printer::Printer; +use pimalaya_config::toml::TomlConfig; +use serde::Serialize; + +use crate::{ + cli::BackendFlag, + config::{AccountConfig, Config}, +}; + +/// Validate the account configuration. +/// +/// Loads the TOML configuration, picks the active account (via the +/// global `--account` flag or the default), and checks each backend +/// allowed by `--backend`. The check tries to instantiate a client per +/// backend, which exercises the same handshake / authentication paths +/// the other commands would take. +#[derive(Debug, Parser)] +pub struct AccountCheckCommand; + +impl AccountCheckCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config_paths: &[PathBuf], + account_name: Option<&str>, + backend: BackendFlag, + ) -> Result<()> { + let mut config = match Config::from_paths_or_default(config_paths)? { + Some(config) => config, + None => bail!( + "No configuration found. Run `himalaya` once to launch the wizard, \ + or `himalaya account configure ` to create one." + ), + }; + + let (name, account_config) = config + .take_account(account_name)? + .ok_or_else(|| anyhow::anyhow!("Cannot find account"))?; + + let mut report = CheckReport { + account: name, + backends: Vec::new(), + }; + + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = account_config.imap.clone() { + report + .backends + .push(check_imap(&config, &account_config, imap_config)); + } + } + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = account_config.jmap.clone() { + report + .backends + .push(check_jmap(&config, &account_config, jmap_config)); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = account_config.maildir.clone() { + report + .backends + .push(check_maildir(&config, &account_config, maildir_config)); + } + } + + #[cfg(feature = "smtp")] + if backend.allows_smtp() { + if let Some(smtp_config) = account_config.smtp.clone() { + report + .backends + .push(check_smtp(&config, &account_config, smtp_config)); + } + } + + if report.backends.is_empty() { + bail!("No backend matching `{backend}` is configured for this account"); + } + + printer.out(report) + } +} + +#[cfg(feature = "imap")] +fn check_imap( + _config: &Config, + _account_config: &AccountConfig, + imap_config: crate::config::ImapConfig, +) -> BackendCheck { + use crate::imap::session::ImapSession; + + let result = (|| -> Result<()> { + let _session = ImapSession::new( + imap_config.url.clone(), + imap_config.tls.clone().try_into()?, + imap_config.starttls, + imap_config.sasl.clone().try_into()?, + )?; + Ok(()) + })(); + + BackendCheck::from("imap", result) +} + +#[cfg(feature = "jmap")] +fn check_jmap( + _config: &Config, + _account_config: &AccountConfig, + jmap_config: crate::config::JmapConfig, +) -> BackendCheck { + use crate::jmap::session::JmapSession; + + let result = (|| -> Result<()> { + let _session = JmapSession::new( + jmap_config.server.clone(), + jmap_config.tls.clone().try_into()?, + jmap_config.auth.clone().try_into()?, + )?; + Ok(()) + })(); + + BackendCheck::from("jmap", result) +} + +#[cfg(feature = "maildir")] +fn check_maildir( + _config: &Config, + _account_config: &AccountConfig, + maildir_config: crate::config::MaildirConfig, +) -> BackendCheck { + let result = (|| -> Result<()> { + if !maildir_config.root.is_dir() { + bail!( + "Maildir root `{}` does not exist or is not a directory", + maildir_config.root.display() + ); + } + Ok(()) + })(); + + BackendCheck::from("maildir", result) +} + +#[cfg(feature = "smtp")] +fn check_smtp( + _config: &Config, + _account_config: &AccountConfig, + smtp_config: crate::config::SmtpConfig, +) -> BackendCheck { + use crate::smtp::session::SmtpSession; + + let result = (|| -> Result<()> { + let _session = SmtpSession::new( + smtp_config.url.clone(), + smtp_config.tls.clone().try_into()?, + smtp_config.starttls, + smtp_config.sasl.clone().try_into()?, + )?; + Ok(()) + })(); + + BackendCheck::from("smtp", result) +} + +#[derive(Clone, Debug, Serialize)] +pub struct CheckReport { + pub account: String, + pub backends: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BackendCheck { + pub backend: &'static str, + pub ok: bool, + pub error: Option, +} + +impl BackendCheck { + fn from(backend: &'static str, result: Result<()>) -> Self { + match result { + Ok(()) => Self { + backend, + ok: true, + error: None, + }, + Err(err) => Self { + backend, + ok: false, + error: Some(format!("{err:#}")), + }, + } + } +} + +impl fmt::Display for CheckReport { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Account: {}", self.account)?; + for check in &self.backends { + match &check.error { + None => writeln!(f, " {}: OK", check.backend)?, + Some(err) => writeln!(f, " {}: FAIL ({err})", check.backend)?, + } + } + Ok(()) + } +} diff --git a/src/account/cli.rs b/src/account/cli.rs new file mode 100644 index 00000000..55a79ef9 --- /dev/null +++ b/src/account/cli.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::{ + account::{ + check::AccountCheckCommand, configure::AccountConfigureCommand, list::AccountListCommand, + }, + cli::BackendFlag, +}; + +/// Manage accounts defined in the TOML configuration file. +/// +/// An account is a named group of backend settings (imap, jmap, +/// maildir, smtp). Use these subcommands to inspect them, validate +/// them, or edit them through the interactive wizard. +#[derive(Debug, Subcommand)] +pub enum AccountCommand { + #[command(visible_alias = "ls")] + List(AccountListCommand), + Check(AccountCheckCommand), + #[command(visible_alias = "edit")] + Configure(AccountConfigureCommand), +} + +impl AccountCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config_paths: &[PathBuf], + account_name: Option<&str>, + backend: BackendFlag, + ) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, config_paths), + Self::Check(cmd) => cmd.execute(printer, config_paths, account_name, backend), + Self::Configure(cmd) => cmd.execute(printer, config_paths), + } + } +} diff --git a/src/account/configure.rs b/src/account/configure.rs new file mode 100644 index 00000000..e48b7531 --- /dev/null +++ b/src/account/configure.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; +use pimalaya_cli::printer::Printer; +use pimalaya_config::toml::TomlConfig; + +use crate::{config::Config, wizard}; + +/// Edit (or create) the given account through the wizard. +/// +/// Loads the configuration if any, then runs the IMAP and SMTP +/// wizards with the account's current values as defaults. Provider +/// discovery is skipped: the wizard prompts you for each field with +/// what you previously had. Creates a new account if `name` is not +/// known. +#[derive(Debug, Parser)] +pub struct AccountConfigureCommand { + /// Name of the account to edit. A new entry is created if no + /// account with this name exists in the configuration. + #[arg(value_name = "NAME")] + pub name: String, +} + +impl AccountConfigureCommand { + pub fn execute(self, _printer: &mut impl Printer, config_paths: &[PathBuf]) -> Result<()> { + let target = Config::target_path(config_paths)?; + let config = Config::from_paths_or_default(config_paths)?.unwrap_or_default(); + + wizard::edit_account(&target, config, &self.name)?; + + Ok(()) + } +} diff --git a/src/account/context.rs b/src/account/context.rs new file mode 100644 index 00000000..4785513b --- /dev/null +++ b/src/account/context.rs @@ -0,0 +1,137 @@ +//! Merged runtime account — the DTO every command consumes. +//! +//! Built by the dispatch layer (`crate::cli`) in this order: +//! +//! 1. [`Account::default`] (all fields `None` / empty). +//! 2. Fold the global [`Config`] via `Account::from(config)`. +//! 3. Fold the selected `[accounts.]` via [`Account::merge`] +//! with `Account::from(account_config)`. +//! +//! Defaults are applied at consumption time by the `*` accessor +//! methods, not baked in during merge — keeping `Option` fields +//! lets layers compose cleanly. + +use std::{collections::HashMap, env::temp_dir, path::PathBuf}; + +use comfy_table::{presets, ContentArrangement}; +use dirs::download_dir; + +use crate::config::{AccountConfig, ComposerConfig, Config, ReaderConfig, TableArrangementConfig}; + +const DEFAULT_DATETIME_FMT: &str = "%F %R%:z"; + +#[derive(Clone, Debug, Default)] +pub struct Account { + pub downloads_dir: Option, + pub table_preset: Option, + pub table_arrangement: Option, + + pub datetime_fmt: Option, + pub datetime_local_tz: Option, + + /// User-defined composers. Only sourced from the global + /// [`Config`]; account-level configs do not override these. + pub composer: HashMap, + /// User-defined readers. See [`Account::composer`]. + pub reader: HashMap, +} + +impl Account { + /// Folds `other`'s set fields on top of `self`. Each `Option` + /// field is taken from `other` when `Some`, otherwise from + /// `self`. The composer/reader maps are extended (entries from + /// `other` overwrite same-named entries from `self`). + pub fn merge(self, other: Self) -> Self { + let mut composer = self.composer; + composer.extend(other.composer); + + let mut reader = self.reader; + reader.extend(other.reader); + + Self { + downloads_dir: other.downloads_dir.or(self.downloads_dir), + table_preset: other.table_preset.or(self.table_preset), + table_arrangement: other.table_arrangement.or(self.table_arrangement), + + datetime_fmt: other.datetime_fmt.or(self.datetime_fmt), + datetime_local_tz: other.datetime_local_tz.or(self.datetime_local_tz), + + composer, + reader, + } + } + + /// Effective downloads directory. Tries the merged + /// `downloads_dir` (shell-expanded), then the system default + /// downloads dir, then the temp dir. + pub fn downloads_dir(&self) -> PathBuf { + self.downloads_dir + .as_ref() + .and_then(|dir| dir.to_str()) + .and_then(|dir| shellexpand::full(dir).ok()) + .map(|dir| PathBuf::from(dir.to_string())) + .or_else(download_dir) + .unwrap_or_else(temp_dir) + } + + /// Effective `comfy_table` preset string. Defaults to + /// `UTF8_FULL_CONDENSED`. + pub fn table_preset(&self) -> &str { + self.table_preset + .as_deref() + .unwrap_or(presets::UTF8_FULL_CONDENSED) + } + + /// Effective `comfy_table` content arrangement. Defaults to + /// `Dynamic`. + pub fn table_arrangement(&self) -> ContentArrangement { + self.table_arrangement + .clone() + .unwrap_or(TableArrangementConfig::Dynamic) + .into() + } + + /// Effective `chrono` `strftime` format for envelope DATE + /// columns. Defaults to `%F %R%:z`. + pub fn datetime_fmt(&self) -> &str { + self.datetime_fmt.as_deref().unwrap_or(DEFAULT_DATETIME_FMT) + } + + /// Whether to convert envelope `Date:` headers to the system + /// local timezone. Defaults to `false`. + pub fn datetime_local_tz(&self) -> bool { + self.datetime_local_tz.unwrap_or(false) + } +} + +impl From for Account { + fn from(config: Config) -> Self { + Self { + downloads_dir: config.downloads_dir, + table_preset: config.table_preset, + table_arrangement: config.table_arrangement, + + datetime_fmt: config.envelope.list.datetime_fmt, + datetime_local_tz: config.envelope.list.datetime_local_tz, + + composer: config.message.composer, + reader: config.message.reader, + } + } +} + +impl From for Account { + fn from(config: AccountConfig) -> Self { + Self { + downloads_dir: config.downloads_dir, + table_preset: config.table_preset, + table_arrangement: config.table_arrangement, + + datetime_fmt: config.envelope.list.datetime_fmt, + datetime_local_tz: config.envelope.list.datetime_local_tz, + + composer: HashMap::new(), + reader: HashMap::new(), + } + } +} diff --git a/src/account/list.rs b/src/account/list.rs new file mode 100644 index 00000000..d90d3396 --- /dev/null +++ b/src/account/list.rs @@ -0,0 +1,124 @@ +use std::{fmt, path::PathBuf}; + +use anyhow::Result; +use clap::Parser; +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use pimalaya_cli::printer::Printer; +use pimalaya_config::toml::TomlConfig; +use serde::Serialize; + +use crate::config::{AccountConfig, Config, TableArrangementConfig}; + +/// List all accounts declared in the configuration. +/// +/// Each row shows the account name, the backends with a config block, +/// and whether it is the default account. +#[derive(Debug, Parser)] +pub struct AccountListCommand; + +impl AccountListCommand { + pub fn execute(self, printer: &mut impl Printer, config_paths: &[PathBuf]) -> Result<()> { + let config = load_config(config_paths)?; + + let preset = config + .table_preset + .clone() + .unwrap_or_else(|| comfy_table::presets::UTF8_FULL_CONDENSED.to_string()); + let arrangement = config + .table_arrangement + .clone() + .unwrap_or(TableArrangementConfig::Dynamic) + .into(); + + let mut accounts: Vec = config + .accounts + .iter() + .map(|(name, account)| AccountRow::from_account(name, account)) + .collect(); + accounts.sort_by(|a, b| a.name.cmp(&b.name)); + + let table = AccountsTable { + preset, + arrangement, + accounts, + }; + + printer.out(table) + } +} + +fn load_config(paths: &[PathBuf]) -> Result { + match Config::from_paths_or_default(paths)? { + Some(config) => Ok(config), + None => anyhow::bail!( + "No configuration found. Run `himalaya` once to launch the wizard, \ + or `himalaya account configure ` to create one." + ), + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct AccountRow { + pub name: String, + pub default: bool, + pub backends: Vec<&'static str>, +} + +impl AccountRow { + fn from_account(name: &str, account: &AccountConfig) -> Self { + let mut backends = Vec::new(); + if account.imap.is_some() { + backends.push("imap"); + } + if account.jmap.is_some() { + backends.push("jmap"); + } + if account.maildir.is_some() { + backends.push("maildir"); + } + if account.smtp.is_some() { + backends.push("smtp"); + } + + Self { + name: name.to_owned(), + default: account.default, + backends, + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct AccountsTable { + #[serde(skip)] + pub preset: String, + #[serde(skip)] + pub arrangement: ContentArrangement, + pub accounts: Vec, +} + +impl fmt::Display for AccountsTable { + 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(vec![ + Cell::new("NAME"), + Cell::new("BACKENDS"), + Cell::new("DEFAULT"), + ])) + .add_rows(self.accounts.iter().map(|account| { + let mut row = Row::new(); + row.max_height(1); + row.add_cell(Cell::new(&account.name)); + row.add_cell(Cell::new(account.backends.join(", "))); + row.add_cell(Cell::new(if account.default { "yes" } else { "" })); + row + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} diff --git a/src/account/mod.rs b/src/account/mod.rs new file mode 100644 index 00000000..75364554 --- /dev/null +++ b/src/account/mod.rs @@ -0,0 +1,5 @@ +pub mod check; +pub mod cli; +pub mod configure; +pub mod context; +pub mod list; diff --git a/src/attachments/cli.rs b/src/attachments/cli.rs deleted file mode 100644 index 5e6b0216..00000000 --- a/src/attachments/cli.rs +++ /dev/null @@ -1,35 +0,0 @@ -use anyhow::Result; -use clap::Subcommand; -use pimalaya_cli::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 deleted file mode 100644 index 2bf0e01c..00000000 --- a/src/attachments/download.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -use anyhow::{bail, Result}; -use clap::Parser; -use mail_parser::{MessageParser, MimeHeaders}; -use pimalaya_cli::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 deleted file mode 100644 index 0ac65728..00000000 --- a/src/attachments/list.rs +++ /dev/null @@ -1,107 +0,0 @@ -use anyhow::{bail, Result}; -use clap::Parser; -use mail_parser::{MessageParser, MessagePart, MimeHeaders}; -use pimalaya_cli::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/table.rs b/src/attachments/table.rs deleted file mode 100644 index d702aa69..00000000 --- a/src/attachments/table.rs +++ /dev/null @@ -1,68 +0,0 @@ -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 0f7915e2..f0cbaad0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -14,16 +14,22 @@ use pimalaya_cli::{ use pimalaya_config::toml::TomlConfig; #[cfg(feature = "imap")] -use crate::imap::cli::ImapCommand; +use crate::imap::{cli::ImapCommand, client::build_imap_client}; #[cfg(feature = "jmap")] -use crate::jmap::cli::JmapCommand; +use crate::jmap::{cli::JmapCommand, client::build_jmap_client}; #[cfg(feature = "maildir")] -use crate::maildir::cli::MaildirCommand; +use crate::maildir::{cli::MaildirCommand, client::build_maildir_client}; #[cfg(feature = "smtp")] -use crate::smtp::cli::SmtpCommand; +use crate::smtp::{cli::SmtpCommand, client::build_smtp_client}; use crate::{ - account::Account, config::Config, envelopes::cli::EnvelopesCommand, flags::cli::FlagsCommand, - mailboxes::cli::MailboxesCommand, messages::cli::MessagesCommand, + account::cli::AccountCommand, + config::Config, + shared::{ + attachments::cli::AttachmentCommand, client::build_email_client, + envelopes::cli::EnvelopeCommand, flags::cli::FlagCommand, mailboxes::cli::MailboxCommand, + messages::cli::MessageCommand, + }, + wizard, }; #[derive(Parser, Debug)] @@ -33,7 +39,7 @@ use crate::{ #[command(propagate_version = true, infer_subcommands = true)] pub struct HimalayaCli { #[command(subcommand)] - pub command: BackendCommand, + pub command: HimalayaCommand, /// Override the default configuration file path. /// @@ -50,7 +56,6 @@ 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`, @@ -65,14 +70,115 @@ pub struct HimalayaCli { /// 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, - + pub backend: BackendFlag, #[command(flatten)] pub json: JsonFlag, #[command(flatten)] pub log: LogFlags, } +#[derive(Debug, Subcommand)] +pub enum HimalayaCommand { + // --- Shared API + // + #[command(subcommand, aliases = ["mboxes", "mbox"])] + Mailboxes(MailboxCommand), + #[command(subcommand)] + Envelopes(EnvelopeCommand), + #[command(subcommand)] + Flags(FlagCommand), + #[command(subcommand)] + Messages(MessageCommand), + #[command(subcommand)] + Attachments(AttachmentCommand), + + // --- Protocol-specific APIs + // + #[cfg(feature = "imap")] + #[command(subcommand)] + Imap(ImapCommand), + #[cfg(feature = "jmap")] + #[command(subcommand)] + Jmap(JmapCommand), + #[cfg(feature = "maildir")] + #[command(subcommand)] + Maildir(MaildirCommand), + #[cfg(feature = "smtp")] + #[command(subcommand)] + Smtp(SmtpCommand), + + // --- Meta + // + #[command(subcommand)] + Account(AccountCommand), + Completions(CompletionCommand), + Manuals(ManualCommand), +} + +impl HimalayaCommand { + pub fn execute( + self, + printer: &mut impl Printer, + config_paths: &[PathBuf], + account_name: Option<&str>, + backend: BackendFlag, + ) -> Result<()> { + match self { + // --- Shared API + // + Self::Mailboxes(cmd) => { + let client = build_email_client(config_paths, account_name, backend)?; + cmd.execute(printer, client) + } + Self::Envelopes(cmd) => { + let client = build_email_client(config_paths, account_name, backend)?; + cmd.execute(printer, client) + } + Self::Flags(cmd) => { + let client = build_email_client(config_paths, account_name, backend)?; + cmd.execute(printer, client) + } + Self::Messages(cmd) => { + let client = build_email_client(config_paths, account_name, backend)?; + cmd.execute(printer, client) + } + Self::Attachments(cmd) => { + let client = build_email_client(config_paths, account_name, backend)?; + cmd.execute(printer, client) + } + + // --- Protocol-specific APIs + // + #[cfg(feature = "imap")] + Self::Imap(cmd) => { + let client = build_imap_client(config_paths, account_name)?; + cmd.execute(printer, client) + } + #[cfg(feature = "jmap")] + Self::Jmap(cmd) => { + let client = build_jmap_client(config_paths, account_name)?; + cmd.execute(printer, client) + } + #[cfg(feature = "maildir")] + Self::Maildir(cmd) => { + let client = build_maildir_client(config_paths, account_name)?; + cmd.execute(printer, client) + } + #[cfg(feature = "smtp")] + Self::Smtp(cmd) => { + let client = build_smtp_client(config_paths, account_name)?; + cmd.execute(printer, client) + } + + // --- Meta + // + Self::Account(cmd) => cmd.execute(printer, config_paths, account_name, backend), + Self::Completions(cmd) => cmd.execute(printer, HimalayaCli::command()), + Self::Manuals(cmd) => cmd.execute(printer, HimalayaCli::command()), + } + } +} + /// Selects which backend a cross-protocol command should target. /// /// `Auto` lets the command pick the first configured-and-supported @@ -83,7 +189,7 @@ pub struct HimalayaCli { /// The protocol-specific subcommands (`imap`, `jmap`, `maildir`, /// `smtp`) ignore this arg entirely. #[derive(Clone, Copy, Debug, Default, Parser, PartialEq, Eq)] -pub enum BackendArg { +pub enum BackendFlag { #[default] Auto, Imap, @@ -92,7 +198,7 @@ pub enum BackendArg { Smtp, } -impl BackendArg { +impl BackendFlag { /// Whether the IMAP arm of a shared command is allowed to run. pub fn allows_imap(self) -> bool { matches!(self, Self::Auto | Self::Imap) @@ -114,7 +220,7 @@ impl BackendArg { } } -impl FromStr for BackendArg { +impl FromStr for BackendFlag { type Err = Error; fn from_str(backend: &str) -> Result { @@ -128,7 +234,7 @@ impl FromStr for BackendArg { } } } -impl fmt::Display for BackendArg { +impl fmt::Display for BackendFlag { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Auto => write!(f, "auto"), @@ -140,138 +246,12 @@ impl fmt::Display for BackendArg { } } -#[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::cli::AttachmentsCommand), - - #[cfg(feature = "imap")] - #[command(subcommand)] - Imap(ImapCommand), - #[cfg(feature = "jmap")] - #[command(subcommand)] - Jmap(JmapCommand), - #[cfg(feature = "maildir")] - #[command(subcommand)] - Maildir(MaildirCommand), - #[cfg(feature = "smtp")] - #[command(subcommand)] - Smtp(SmtpCommand), -} - -impl BackendCommand { - pub fn execute( - self, - 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 = load_or_wizard(config_paths)?; - let (_, account_config) = config.get_account(account_name)?; - cmd.execute(printer, config, account_config, backend) - } - Self::Envelopes(cmd) => { - let config = load_or_wizard(config_paths)?; - let (_, account_config) = config.get_account(account_name)?; - cmd.execute(printer, config, account_config, backend) - } - Self::Flags(cmd) => { - let config = load_or_wizard(config_paths)?; - let (_, account_config) = config.get_account(account_name)?; - cmd.execute(printer, config, account_config, backend) - } - Self::Messages(cmd) => { - let config = load_or_wizard(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 = load_or_wizard(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 = load_or_wizard(config_paths)?; - let (account_name, mut account_config) = config.get_account(account_name)?; - - let Some(imap_config) = account_config.imap.take() else { - bail!("IMAP config is missing for account `{account_name}`") - }; - - let account = Account::new(config, account_config, imap_config)?; - - cmd.execute(printer, account) - } - #[cfg(feature = "jmap")] - Self::Jmap(cmd) => { - let config = load_or_wizard(config_paths)?; - let (account_name, mut account_config) = config.get_account(account_name)?; - - let Some(jmap_config) = account_config.jmap.take() else { - bail!("JMAP config is missing for account `{account_name}`") - }; - - let account = Account::new(config, account_config, jmap_config)?; - - cmd.execute(printer, account) - } - #[cfg(feature = "maildir")] - Self::Maildir(cmd) => { - let config = load_or_wizard(config_paths)?; - let (account_name, mut account_config) = config.get_account(account_name)?; - - let Some(maildir_config) = account_config.maildir.take() else { - bail!("Maildir config is missing for account `{account_name}`") - }; - - let account = Account::new(config, account_config, maildir_config)?; - - cmd.execute(printer, account) - } - #[cfg(feature = "smtp")] - Self::Smtp(cmd) => { - let config = load_or_wizard(config_paths)?; - let (account_name, mut account_config) = config.get_account(account_name)?; - - let Some(smtp_config) = account_config.smtp.take() else { - bail!("SMTP config is missing for account `{account_name}`") - }; - - let account = Account::new(config, account_config, smtp_config)?; - - cmd.execute(printer, account) - } - } - } -} - /// Loads `Config` from `paths`, or runs the wizard if no config file /// is found. Centralises the `Result>` → `Config` /// adaptation so call sites stay readable. -fn load_or_wizard(paths: &[PathBuf]) -> Result { +pub(crate) fn load_or_wizard(paths: &[PathBuf]) -> Result { match Config::from_paths_or_default(paths)? { Some(config) => Ok(config), - None => crate::wizard::run_or_exit(&Config::target_path(paths)?), + None => wizard::run_or_exit(&Config::target_path(paths)?), } } diff --git a/src/config.rs b/src/config.rs index 84e548c9..d60c243a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,7 @@ use pimalaya_config::{ }; use pimalaya_stream::{ sasl::{Sasl, SaslAnonymous, SaslLogin, SaslMechanism, SaslPlain}, - tls::{Rustls, RustlsCrypto, Tls, TlsProvider}, + std::tls::{Rustls, RustlsCrypto, Tls, TlsProvider}, }; use serde::{Deserialize, Serialize}; use url::Url; @@ -22,6 +22,10 @@ pub struct Config { pub downloads_dir: Option, pub table_preset: Option, pub table_arrangement: Option, + #[serde(default)] + pub envelope: EnvelopeConfig, + #[serde(default)] + pub message: MessageConfig, pub accounts: HashMap, } @@ -32,17 +36,17 @@ impl TomlConfig for Config { env!("CARGO_PKG_NAME") } - fn find_default_account(&self) -> Option<(String, Self::Account)> { - self.accounts - .iter() - .find(|(_, account)| account.default) - .map(|(name, account)| (name.to_owned(), account.clone())) + fn take_named_account(&mut self, name: &str) -> Option<(String, Self::Account)> { + self.accounts.remove_entry(name) } - fn find_account(&self, name: &str) -> Option<(String, Self::Account)> { - self.accounts - .get(name) - .map(|account| (name.to_owned(), account.clone())) + fn take_default_account(&mut self) -> Option<(String, Self::Account)> { + let name = self + .accounts + .iter() + .find_map(|(name, account)| account.default.then(|| name.clone()))?; + + self.take_named_account(&name) } } @@ -77,6 +81,9 @@ pub struct AccountConfig { pub table_preset: Option, pub table_arrangement: Option, + #[serde(default)] + pub envelope: EnvelopeConfig, + #[allow(unused)] pub imap: Option, #[allow(unused)] @@ -87,6 +94,80 @@ pub struct AccountConfig { pub smtp: Option, } +/// Envelope-level rendering options. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct EnvelopeConfig { + #[serde(default)] + pub list: EnvelopeListConfig, +} + +/// `envelopes list` rendering options. Mirrors the pre-v2 +/// `envelope.list.*` keys. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct EnvelopeListConfig { + /// chrono `strftime` format used to render the DATE column. + /// Defaults to `"%F %R%:z"` (e.g. `2026-05-06 14:30+02:00`) when + /// neither the global nor the account config sets it. + pub datetime_fmt: Option, + + /// When `true`, the `Date:` header timezone offset is converted + /// to the system's local timezone before formatting. Defaults to + /// `false`, which preserves the wire offset. + pub datetime_local_tz: Option, +} + +/// Message-level configuration: user-defined composers and readers. +/// +/// Composers produce a MIME draft on stdout (called by `compose-with`, +/// `reply-with`, `forward-with`). Readers consume a MIME message from +/// stdin and emit human-readable bytes on stdout (called by +/// `read-with`). Both are looked up by name; the entry flagged +/// `default = true` is used when no name is passed. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct MessageConfig { + #[serde(default)] + pub composer: HashMap, + #[serde(default)] + pub reader: HashMap, +} + +/// Single composer entry under `[message.composer.]`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ComposerConfig { + /// Shell command line invoked via `sh -c`. Stdin carries the + /// source MIME bytes (empty for new messages); stdout is + /// captured as the MIME draft; stderr is inherited so the + /// composer can prompt the user. + pub command: String, + + /// Marks this entry as the fallback when `compose-with` / + /// `reply-with` / `forward-with` are invoked without a name. + /// Exactly one composer should set this; if several do, the + /// first one returned by the config lookup wins. + #[serde(default)] + pub default: bool, +} + +/// Single reader entry under `[message.reader.]`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ReaderConfig { + /// Shell command line invoked via `sh -c`. Stdin carries the + /// source MIME bytes; stdout is forwarded to the terminal (zero + /// bytes is fine — the reader may have spawned its own UI); + /// stderr is inherited. + pub command: String, + + /// Marks this entry as the fallback when `read-with` is + /// invoked without a name. + #[serde(default)] + pub default: bool, +} + #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub enum TableArrangementConfig { diff --git a/src/email_client.rs b/src/email_client.rs deleted file mode 100644 index 1baacd18..00000000 --- a/src/email_client.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Builder for the unified [`io_email::client::EmailClient`] used by -//! cross-protocol shared subcommands (`mailboxes`, `envelopes`, -//! `flags`, `messages`). -//! -//! The legacy per-backend dispatch — three nearly-identical -//! `if backend.allows_X() { … resume loop … }` blocks per command — -//! is replaced by a single call to [`build`] that returns a fully -//! authenticated [`EmailContext`]. The shared command then calls one -//! [`EmailClient`] method and renders the result. -//! -//! Construction is still backend-asymmetric (IMAP needs TLS + SASL, -//! JMAP needs an HTTP credential, Maildir just needs a root path), -//! and that asymmetry is collapsed here. We delegate to the existing -//! transitional [`ImapSession`] / [`JmapSession`] helpers for the -//! handshake/auth flow, then bridge the resulting `(stream, context)` -//! pairs into [`io_imap::client::ImapClient`] / [`io_jmap::client::JmapClient`] -//! via their `from_parts` constructors. -//! -//! [`ImapSession`]: crate::imap::session::ImapSession -//! [`JmapSession`]: crate::jmap::session::JmapSession - -use std::path::PathBuf; - -use anyhow::{bail, Result}; -use comfy_table::ContentArrangement; -use io_email::client::EmailClient; - -use crate::{ - account::Account, - cli::BackendArg, - config::{AccountConfig, Config}, -}; - -/// Bundle handed to shared commands: a fully-built [`EmailClient`] -/// plus the account-level rendering settings the per-backend -/// dispatchers used to extract independently. -pub struct EmailContext { - pub client: EmailClient, - #[allow(dead_code)] - pub downloads_dir: PathBuf, - pub table_preset: String, - pub table_arrangement: ContentArrangement, -} - -/// Builds an [`EmailContext`] from `(config, account_config, backend)`. -/// -/// Tries each backend in `imap → jmap → maildir` order, picking the -/// first one whose config block is present and whose [`BackendArg`] -/// filter allows it. Bails when nothing matches. SMTP is omitted on -/// purpose: none of the shared read-side operations have an SMTP -/// implementation. -pub fn build( - 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 crate::imap::session::ImapSession; - use io_imap::client::ImapClient; - - let account = Account::new(config, account_config, imap_config)?; - let session = ImapSession::new( - account.backend.url.clone(), - account.backend.tls.clone().try_into()?, - account.backend.starttls, - account.backend.sasl.clone().try_into()?, - )?; - let client = ImapClient::from_parts(session.stream, session.context); - return Ok(EmailContext { - client: client.into(), - downloads_dir: account.downloads_dir, - table_preset: account.table_preset, - table_arrangement: account.table_arrangement, - }); - } - } - - #[cfg(feature = "jmap")] - if backend.allows_jmap() { - if let Some(jmap_config) = account_config.jmap.take() { - use crate::jmap::session::JmapSession; - use io_jmap::client::JmapClient; - - let account = Account::new(config, account_config, jmap_config)?; - let session = JmapSession::new( - account.backend.server.clone(), - account.backend.tls.clone().try_into()?, - account.backend.auth.clone().try_into()?, - )?; - let client = JmapClient::from_parts(session.stream, session.http_auth, session.session); - return Ok(EmailContext { - client: client.into(), - downloads_dir: account.downloads_dir, - table_preset: account.table_preset, - table_arrangement: account.table_arrangement, - }); - } - } - - #[cfg(feature = "maildir")] - if backend.allows_maildir() { - if let Some(maildir_config) = account_config.maildir.take() { - use io_maildir::client::MaildirClient; - - let account = Account::new(config, account_config, maildir_config)?; - let client = MaildirClient::new(account.backend.root.clone()); - return Ok(EmailContext { - client: client.into(), - downloads_dir: account.downloads_dir, - table_preset: account.table_preset, - table_arrangement: account.table_arrangement, - }); - } - } - - bail!("no backend matching `{backend}` is configured for this account") -} diff --git a/src/envelopes/cli.rs b/src/envelopes/cli.rs deleted file mode 100644 index 886155a5..00000000 --- a/src/envelopes/cli.rs +++ /dev/null @@ -1,34 +0,0 @@ -use anyhow::Result; -use clap::Subcommand; -use pimalaya_cli::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 deleted file mode 100644 index 5f75ec95..00000000 --- a/src/envelopes/list.rs +++ /dev/null @@ -1,59 +0,0 @@ -use anyhow::Result; -use clap::Parser; -use pimalaya_cli::printer::Printer; - -use crate::{ - cli::BackendArg, - config::{AccountConfig, Config}, - email_client::build, - envelopes::table::EnvelopesTable, -}; - -/// 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, - account_config: AccountConfig, - backend: BackendArg, - ) -> Result<()> { - let mut ctx = build(config, account_config, backend)?; - let envelopes = - ctx.client - .list_envelopes(&self.mailbox, Some(self.page), Some(self.page_size))?; - - printer.out(EnvelopesTable { - preset: ctx.table_preset, - arrangement: ctx.table_arrangement, - envelopes, - }) - } -} diff --git a/src/envelopes/table.rs b/src/envelopes/table.rs deleted file mode 100644 index b9f2b1c1..00000000 --- a/src/envelopes/table.rs +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 7b8f99eb..00000000 --- a/src/flags/add.rs +++ /dev/null @@ -1,40 +0,0 @@ -use anyhow::Result; -use clap::Parser; -use pimalaya_cli::printer::{Message, Printer}; - -use crate::{ - cli::BackendArg, - config::{AccountConfig, Config}, - email_client::build, - flags::arg::{FlagsArg, MailboxFlag, MessageIdsArg}, -}; - -/// 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, - account_config: AccountConfig, - backend: BackendArg, - ) -> Result<()> { - let mut ctx = build(config, account_config, backend)?; - - let ids: Vec<&str> = self.ids.inner.iter().map(String::as_str).collect(); - let flags: Vec = self.flags.inner.iter().map(Into::into).collect(); - - ctx.client.add_flags(&self.mailbox.inner, &ids, &flags)?; - - printer.out(Message::new("Flag(s) successfully added")) - } -} diff --git a/src/flags/cli.rs b/src/flags/cli.rs deleted file mode 100644 index 2c929dd3..00000000 --- a/src/flags/cli.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anyhow::Result; -use clap::Subcommand; -use pimalaya_cli::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 deleted file mode 100644 index 9ed13359..00000000 --- a/src/flags/delete.rs +++ /dev/null @@ -1,40 +0,0 @@ -use anyhow::Result; -use clap::Parser; -use pimalaya_cli::printer::{Message, Printer}; - -use crate::{ - cli::BackendArg, - config::{AccountConfig, Config}, - email_client::build, - flags::arg::{FlagsArg, MailboxFlag, MessageIdsArg}, -}; - -/// 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, - account_config: AccountConfig, - backend: BackendArg, - ) -> Result<()> { - let mut ctx = build(config, account_config, backend)?; - - let ids: Vec<&str> = self.ids.inner.iter().map(String::as_str).collect(); - let flags: Vec = self.flags.inner.iter().map(Into::into).collect(); - - ctx.client.delete_flags(&self.mailbox.inner, &ids, &flags)?; - - printer.out(Message::new("Flag(s) successfully removed")) - } -} diff --git a/src/flags/set.rs b/src/flags/set.rs deleted file mode 100644 index 225a0617..00000000 --- a/src/flags/set.rs +++ /dev/null @@ -1,40 +0,0 @@ -use anyhow::Result; -use clap::Parser; -use pimalaya_cli::printer::{Message, Printer}; - -use crate::{ - cli::BackendArg, - config::{AccountConfig, Config}, - email_client::build, - flags::arg::{FlagsArg, MailboxFlag, MessageIdsArg}, -}; - -/// Replace the flags of message(s) with the given set. -#[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, - account_config: AccountConfig, - backend: BackendArg, - ) -> Result<()> { - let mut ctx = build(config, account_config, backend)?; - - let ids: Vec<&str> = self.ids.inner.iter().map(String::as_str).collect(); - let flags: Vec = self.flags.inner.iter().map(Into::into).collect(); - - ctx.client.set_flags(&self.mailbox.inner, &ids, &flags)?; - - printer.out(Message::new("Flag(s) successfully set")) - } -} diff --git a/src/imap/account.rs b/src/imap/account.rs deleted file mode 100644 index c99e9472..00000000 --- a/src/imap/account.rs +++ /dev/null @@ -1,21 +0,0 @@ -use anyhow::Result; -use io_imap::client::ImapClient; - -use crate::{account::Account, config::ImapConfig, imap::session::ImapSession}; - -pub type ImapAccount = Account; - -impl ImapAccount { - /// Opens the IMAP connection (TCP/TLS/STARTTLS, greeting, SASL), - /// then hands the established stream and context off to a fresh - /// [`ImapClient`]. - pub fn new_imap_client(&self) -> Result { - let session = ImapSession::new( - self.backend.url.clone(), - self.backend.tls.clone().try_into()?, - self.backend.starttls, - self.backend.sasl.clone().try_into()?, - )?; - Ok(ImapClient::from_parts(session.stream, session.context)) - } -} diff --git a/src/imap/cli.rs b/src/imap/cli.rs index 67ff2dae..4f03ed28 100644 --- a/src/imap/cli.rs +++ b/src/imap/cli.rs @@ -3,11 +3,11 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::imap::{ - account::ImapAccount, envelope::cli::ImapEnvelopeCommand, flag::cli::ImapFlagCommand, + client::ImapClient, envelope::cli::ImapEnvelopeCommand, flag::cli::ImapFlagCommand, id::ImapIdCommand, mailbox::cli::ImapMailboxCommand, message::cli::ImapMessageCommand, }; -/// IMAP CLI (requires the `imap` cargo feature). +/// IMAP CLI. /// /// This command gives you access to the IMAP CLI API, and allows you /// to manage IMAP mailboxes, envelopes, flags, messages etc. @@ -29,14 +29,14 @@ pub enum ImapCommand { } impl ImapCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: ImapClient) -> Result<()> { match self { - Self::Id(cmd) => cmd.execute(printer, account), + Self::Id(cmd) => cmd.execute(printer, client), - Self::Envelopes(cmd) => cmd.execute(printer, account), - Self::Flags(cmd) => cmd.execute(printer, account), - Self::Mailboxes(cmd) => cmd.execute(printer, account), - Self::Messages(cmd) => cmd.execute(printer, account), + Self::Envelopes(cmd) => cmd.execute(printer, client), + Self::Flags(cmd) => cmd.execute(printer, client), + Self::Mailboxes(cmd) => cmd.execute(printer, client), + Self::Messages(cmd) => cmd.execute(printer, client), } } } diff --git a/src/imap/client.rs b/src/imap/client.rs new file mode 100644 index 00000000..7099aa93 --- /dev/null +++ b/src/imap/client.rs @@ -0,0 +1,73 @@ +//! Himalaya wrapper around [`io_imap::client::ImapClient`] that +//! bundles the merged [`Account`] alongside the live IMAP client. +//! +//! This is what every IMAP-specific subcommand receives: the dispatch +//! layer (`crate::cli`) opens the session up front via +//! [`build_imap_client`] and hands the ready-to-use wrapper down. + +use std::{ + ops::{Deref, DerefMut}, + path::PathBuf, +}; + +use anyhow::{anyhow, Result}; +use io_imap::client::ImapClient as Inner; +use pimalaya_config::toml::TomlConfig; + +use crate::{ + account::context::Account, cli::load_or_wizard, config::ImapConfig, imap::session::ImapSession, +}; + +pub struct ImapClient { + inner: Inner, + pub account: Account, +} + +impl ImapClient { + /// Opens the IMAP connection (TCP/TLS/STARTTLS, greeting, SASL) + /// then wraps the resulting stream + context in an + /// [`io_imap::client::ImapClient`] alongside `account`. + pub fn new(config: ImapConfig, account: Account) -> Result { + let session = ImapSession::new( + config.url, + config.tls.try_into()?, + config.starttls, + config.sasl.try_into()?, + )?; + let inner = Inner::from_parts(session.stream, session.context); + Ok(Self { inner, account }) + } +} + +impl Deref for ImapClient { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for ImapClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +/// Loads the configuration, picks the active account, builds the +/// merged [`Account`] then opens the IMAP session. Bails when the +/// account has no `[imap]` block. +pub fn build_imap_client( + config_paths: &[PathBuf], + account_name: Option<&str>, +) -> Result { + let mut config = load_or_wizard(config_paths)?; + let (name, mut ac) = config + .take_account(account_name)? + .ok_or_else(|| anyhow!("Cannot find account"))?; + let imap_config = ac + .imap + .take() + .ok_or_else(|| anyhow!("IMAP config is missing for account `{name}`"))?; + let account = Account::from(config).merge(Account::from(ac)); + ImapClient::new(imap_config, account) +} diff --git a/src/imap/envelope/cli.rs b/src/imap/envelope/cli.rs index e4182038..4a37b66f 100644 --- a/src/imap/envelope/cli.rs +++ b/src/imap/envelope/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::imap::{ - account::ImapAccount, + client::ImapClient, envelope::{ get::ImapEnvelopeGetCommand, list::ImapEnvelopeListCommand, search::ImapEnvelopeSearchCommand, sort::ImapEnvelopeSortCommand, @@ -26,13 +26,13 @@ pub enum ImapEnvelopeCommand { } impl ImapEnvelopeCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: ImapClient) -> Result<()> { match self { - Self::Get(cmd) => cmd.execute(printer, account), - Self::List(cmd) => cmd.execute(printer, account), - Self::Search(cmd) => cmd.execute(printer, account), - Self::Sort(cmd) => cmd.execute(printer, account), - Self::Thread(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, client), + Self::List(cmd) => cmd.execute(printer, client), + Self::Search(cmd) => cmd.execute(printer, client), + Self::Sort(cmd) => cmd.execute(printer, client), + Self::Thread(cmd) => cmd.execute(printer, client), } } } diff --git a/src/imap/envelope/get.rs b/src/imap/envelope/get.rs index fae18c13..94449e4c 100644 --- a/src/imap/envelope/get.rs +++ b/src/imap/envelope/get.rs @@ -11,7 +11,7 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::imap::{ - account::ImapAccount, + client::ImapClient, envelope::list::{decode_mime, format_address}, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -37,8 +37,7 @@ pub struct ImapEnvelopeGetCommand { } impl ImapEnvelopeGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { @@ -60,7 +59,7 @@ impl ImapEnvelopeGetCommand { }; let table = EnvelopeTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), envelope: items.into(), }; diff --git a/src/imap/envelope/list.rs b/src/imap/envelope/list.rs index 8c866d8b..d0533151 100644 --- a/src/imap/envelope/list.rs +++ b/src/imap/envelope/list.rs @@ -16,7 +16,7 @@ use rfc2047_decoder::{Decoder, RecoverStrategy}; use serde::Serialize; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -50,8 +50,7 @@ pub struct ImapEnvelopeListCommand { } impl ImapEnvelopeListCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; let exists = if self.mailbox_no_select.inner { @@ -84,8 +83,8 @@ impl ImapEnvelopeListCommand { let data = client.fetch(sequence_set, item_names, !self.sequence && has_sequence)?; let table = EnvelopesTable { - preset: account.table_preset, - arrangement: account.table_arrangement, + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), envelopes: map_envelopes_table_entries(data), }; diff --git a/src/imap/envelope/search.rs b/src/imap/envelope/search.rs index 159d2d12..820375d4 100644 --- a/src/imap/envelope/search.rs +++ b/src/imap/envelope/search.rs @@ -12,7 +12,7 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -58,8 +58,7 @@ pub struct ImapEnvelopeSearchCommand { } impl ImapEnvelopeSearchCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { @@ -70,8 +69,8 @@ impl ImapEnvelopeSearchCommand { let ids = client.search(criteria, !self.seq)?; let table = SearchTable { - preset: account.table_preset, - arrangement: account.table_arrangement, + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), ids: ids .into_iter() .map(|id| SearchResult { id: id.get() }) diff --git a/src/imap/envelope/sort.rs b/src/imap/envelope/sort.rs index 4bb27381..bc03c0a3 100644 --- a/src/imap/envelope/sort.rs +++ b/src/imap/envelope/sort.rs @@ -11,7 +11,7 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::imap::{ - account::ImapAccount, envelope::search::parse_query, mailbox::arg::MailboxNameOptionalArg, + client::ImapClient, envelope::search::parse_query, mailbox::arg::MailboxNameOptionalArg, }; /// Sort messages by criteria. @@ -51,8 +51,7 @@ pub struct ImapEnvelopeSortCommand { } impl ImapEnvelopeSortCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; client.select(mailbox)?; diff --git a/src/imap/envelope/thread.rs b/src/imap/envelope/thread.rs index 6352f2d7..90e07873 100644 --- a/src/imap/envelope/thread.rs +++ b/src/imap/envelope/thread.rs @@ -2,19 +2,16 @@ use std::{collections::HashMap, fmt, num::NonZeroU32}; use anyhow::{bail, Result}; use clap::Parser; -use io_imap::{ - client::ImapClient, - types::{ - extensions::thread::{Thread, ThreadingAlgorithm}, - fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, - sequence::SequenceSet, - }, +use io_imap::types::{ + extensions::thread::{Thread, ThreadingAlgorithm}, + fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName}, + sequence::SequenceSet, }; use pimalaya_cli::printer::Printer; use serde::{ser::SerializeStruct, Serialize, Serializer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, envelope::{list::decode_mime, search::parse_query}, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -48,8 +45,7 @@ pub struct ImapEnvelopeThreadCommand { } impl ImapEnvelopeThreadCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/flag/add.rs b/src/imap/flag/add.rs index 3751d9c3..90d41ff1 100644 --- a/src/imap/flag/add.rs +++ b/src/imap/flag/add.rs @@ -7,7 +7,7 @@ use io_imap::types::{ use pimalaya_cli::printer::{Message, Printer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -35,8 +35,7 @@ pub struct ImapFlagAddCommand { } impl ImapFlagAddCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/flag/cli.rs b/src/imap/flag/cli.rs index 5201f3ed..4af173f0 100644 --- a/src/imap/flag/cli.rs +++ b/src/imap/flag/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::imap::{ - account::ImapAccount, + client::ImapClient, flag::{ add::ImapFlagAddCommand, list::ImapFlagListCommand, remove::ImapFlagRemoveCommand, set::ImapFlagSetCommand, @@ -23,12 +23,12 @@ pub enum ImapFlagCommand { } impl ImapFlagCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: ImapClient) -> Result<()> { match self { - Self::List(cmd) => cmd.execute(printer, account), - Self::Add(cmd) => cmd.execute(printer, account), - Self::Set(cmd) => cmd.execute(printer, account), - Self::Remove(cmd) => cmd.execute(printer, account), + Self::List(cmd) => cmd.execute(printer, client), + Self::Add(cmd) => cmd.execute(printer, client), + Self::Set(cmd) => cmd.execute(printer, client), + Self::Remove(cmd) => cmd.execute(printer, client), } } } diff --git a/src/imap/flag/list.rs b/src/imap/flag/list.rs index ab9168d4..8f03f4de 100644 --- a/src/imap/flag/list.rs +++ b/src/imap/flag/list.rs @@ -7,7 +7,7 @@ use io_imap::types::flag::{Flag, FlagPerm}; use pimalaya_cli::printer::Printer; use serde::{Serialize, Serializer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; /// List available IMAP flags for the given mailbox. /// @@ -21,8 +21,7 @@ pub struct ImapFlagListCommand { } impl ImapFlagListCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; let data = client.select(mailbox)?; @@ -30,8 +29,8 @@ impl ImapFlagListCommand { let permanent_flags = data.permanent_flags.unwrap_or_default(); let table = FlagsTable { - preset: account.table_preset, - arrangement: account.table_arrangement, + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), flags, permanent_flags, }; diff --git a/src/imap/flag/remove.rs b/src/imap/flag/remove.rs index b4d6d54a..56982a51 100644 --- a/src/imap/flag/remove.rs +++ b/src/imap/flag/remove.rs @@ -7,7 +7,7 @@ use io_imap::types::{ use pimalaya_cli::printer::{Message, Printer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -35,8 +35,7 @@ pub struct ImapFlagRemoveCommand { } impl ImapFlagRemoveCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/flag/set.rs b/src/imap/flag/set.rs index f5a92709..36f6028f 100644 --- a/src/imap/flag/set.rs +++ b/src/imap/flag/set.rs @@ -7,7 +7,7 @@ use io_imap::types::{ use pimalaya_cli::printer::{Message, Printer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -35,8 +35,7 @@ pub struct ImapFlagSetCommand { } impl ImapFlagSetCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/id.rs b/src/imap/id.rs index 34378d87..3bbbc70f 100644 --- a/src/imap/id.rs +++ b/src/imap/id.rs @@ -10,7 +10,7 @@ use io_imap::types::{ use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::imap::account::ImapAccount; +use crate::imap::client::ImapClient; /// Get information about the IMAP server. /// @@ -27,8 +27,7 @@ pub struct ImapIdCommand { } impl ImapIdCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mut params = HashMap::new(); params.extend([ @@ -57,7 +56,7 @@ impl ImapIdCommand { let params = client.id(Some(params.into_iter().collect()))?; let table = ServerIdTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), server_id: params .unwrap_or_default() .into_iter() diff --git a/src/imap/mailbox/cli.rs b/src/imap/mailbox/cli.rs index f1df4c11..f7b41555 100644 --- a/src/imap/mailbox/cli.rs +++ b/src/imap/mailbox/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::{ close::ImapMailboxCloseCommand, create::ImapMailboxCreateCommand, delete::ImapMailboxDeleteCommand, expunge::ImapMailboxExpungeCommand, @@ -38,20 +38,20 @@ pub enum ImapMailboxCommand { } impl ImapMailboxCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: ImapClient) -> Result<()> { match self { - Self::Close(cmd) => cmd.execute(printer, account), - Self::Create(cmd) => cmd.execute(printer, account), - Self::Delete(cmd) => cmd.execute(printer, account), - Self::Expunge(cmd) => cmd.execute(printer, account), - Self::List(cmd) => cmd.execute(printer, account), - Self::Purge(cmd) => cmd.execute(printer, account), - Self::Rename(cmd) => cmd.execute(printer, account), - Self::Select(cmd) => cmd.execute(printer, account), - Self::Status(cmd) => cmd.execute(printer, account), - Self::Subscribe(cmd) => cmd.execute(printer, account), - Self::Unselect(cmd) => cmd.execute(printer, account), - Self::Unsubscribe(cmd) => cmd.execute(printer, account), + Self::Close(cmd) => cmd.execute(printer, client), + Self::Create(cmd) => cmd.execute(printer, client), + Self::Delete(cmd) => cmd.execute(printer, client), + Self::Expunge(cmd) => cmd.execute(printer, client), + Self::List(cmd) => cmd.execute(printer, client), + Self::Purge(cmd) => cmd.execute(printer, client), + Self::Rename(cmd) => cmd.execute(printer, client), + Self::Select(cmd) => cmd.execute(printer, client), + Self::Status(cmd) => cmd.execute(printer, client), + Self::Subscribe(cmd) => cmd.execute(printer, client), + Self::Unselect(cmd) => cmd.execute(printer, client), + Self::Unsubscribe(cmd) => cmd.execute(printer, client), } } } diff --git a/src/imap/mailbox/close.rs b/src/imap/mailbox/close.rs index aa01ea99..b58d4895 100644 --- a/src/imap/mailbox/close.rs +++ b/src/imap/mailbox/close.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::account::ImapAccount; +use crate::imap::client::ImapClient; /// Close the current, selected mailbox. /// @@ -17,8 +17,7 @@ use crate::imap::account::ImapAccount; pub struct ImapMailboxCloseCommand; impl ImapMailboxCloseCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { client.close()?; printer.out(Message::new("Mailbox successfully closed")) } diff --git a/src/imap/mailbox/create.rs b/src/imap/mailbox/create.rs index a3b11d3d..08016c4d 100644 --- a/src/imap/mailbox/create.rs +++ b/src/imap/mailbox/create.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; /// Create the given mailbox. /// @@ -15,8 +15,7 @@ pub struct ImapMailboxCreateCommand { } impl ImapMailboxCreateCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; client.create(mailbox)?; printer.out(Message::new("Mailbox successfully created")) diff --git a/src/imap/mailbox/delete.rs b/src/imap/mailbox/delete.rs index e4e34cdc..32b02206 100644 --- a/src/imap/mailbox/delete.rs +++ b/src/imap/mailbox/delete.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; /// Delete the given mailbox. /// @@ -15,8 +15,7 @@ pub struct ImapMailboxDeleteCommand { } impl ImapMailboxDeleteCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; client.delete(mailbox)?; printer.out(Message::new("Mailbox successfully deleted")) diff --git a/src/imap/mailbox/expunge.rs b/src/imap/mailbox/expunge.rs index af2afe9a..6d94bb4b 100644 --- a/src/imap/mailbox/expunge.rs +++ b/src/imap/mailbox/expunge.rs @@ -3,7 +3,7 @@ use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameArg, MailboxNoSelectFlag}, }; @@ -20,8 +20,7 @@ pub struct ImapMailboxExpungeCommand { } impl ImapMailboxExpungeCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/mailbox/list.rs b/src/imap/mailbox/list.rs index c3a5b3e6..820387c9 100644 --- a/src/imap/mailbox/list.rs +++ b/src/imap/mailbox/list.rs @@ -3,11 +3,12 @@ use std::fmt; use anyhow::Result; use clap::Parser; use comfy_table::{Cell, Row, Table}; +use io_email::mailbox::MailboxRole; use io_imap::types::{core::QuotedChar, flag::FlagNameAttribute, mailbox::Mailbox}; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::imap::account::ImapAccount; +use crate::imap::client::ImapClient; /// List, search and filter mailboxes. /// @@ -30,8 +31,7 @@ pub struct ImapMailboxListCommand { } impl ImapMailboxListCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let reference = self.reference.try_into()?; let pattern = self.pattern.try_into()?; @@ -42,7 +42,7 @@ impl ImapMailboxListCommand { }; let table = MailboxesTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), mailboxes: mailboxes.into_iter().map(From::from).collect(), }; @@ -66,14 +66,25 @@ impl fmt::Display for MailboxesTable { .set_header(Row::from([ Cell::new("NAME"), Cell::new("DELIMITER"), + Cell::new("ROLE"), Cell::new("ATTRIBUTES"), ])) .add_rows(self.mailboxes.iter().map(|mbox| { let mut row = Row::new(); + let role = mbox + .attributes + .iter() + .find_map(|raw| match MailboxRole::parse(raw) { + MailboxRole::Other(_) => None, + role => Some(format!("{role:?}")), + }) + .unwrap_or_default(); + row.max_height(1) .add_cell(Cell::new(&mbox.name)) .add_cell(Cell::new(&mbox.delimiter)) + .add_cell(Cell::new(role)) .add_cell(Cell::new(mbox.attributes.join(", "))); row diff --git a/src/imap/mailbox/purge.rs b/src/imap/mailbox/purge.rs index 330bc0cf..b9ebf419 100644 --- a/src/imap/mailbox/purge.rs +++ b/src/imap/mailbox/purge.rs @@ -4,7 +4,7 @@ use io_imap::types::flag::{Flag, StoreType}; use pimalaya_cli::printer::{Message, Printer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameArg, MailboxNoSelectFlag}, }; @@ -22,8 +22,7 @@ pub struct ImapMailboxPurgeCommand { } impl ImapMailboxPurgeCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/mailbox/rename.rs b/src/imap/mailbox/rename.rs index 09220529..f9f0e9c4 100644 --- a/src/imap/mailbox/rename.rs +++ b/src/imap/mailbox/rename.rs @@ -3,7 +3,7 @@ use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameArg, TargetMailboxNameArg}, }; @@ -19,8 +19,7 @@ pub struct ImapMailboxRenameCommand { } impl ImapMailboxRenameCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let from = self.mailbox_source_name.inner.try_into()?; let to = self.mailbox_dest_name.inner.try_into()?; client.rename(from, to)?; diff --git a/src/imap/mailbox/select.rs b/src/imap/mailbox/select.rs index 1afe243d..959c0690 100644 --- a/src/imap/mailbox/select.rs +++ b/src/imap/mailbox/select.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; /// Select the given mailbox. /// @@ -19,8 +19,7 @@ pub struct ImapMailboxSelectCommand { } impl ImapMailboxSelectCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; client.select(mailbox)?; printer.out(Message::new("Mailbox successfully selected")) diff --git a/src/imap/mailbox/status.rs b/src/imap/mailbox/status.rs index 131b5339..dfacaed8 100644 --- a/src/imap/mailbox/status.rs +++ b/src/imap/mailbox/status.rs @@ -7,7 +7,7 @@ use io_imap::types::status::{StatusDataItem, StatusDataItemName}; use pimalaya_cli::printer::Printer; use serde::{Serialize, Serializer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; /// Get the status of the given mailbox. /// @@ -20,8 +20,7 @@ pub struct ImapMailboxStatusCommand { } impl ImapMailboxStatusCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; let item_names = vec![ StatusDataItemName::Messages, @@ -34,7 +33,7 @@ impl ImapMailboxStatusCommand { let items = client.status(mailbox, item_names)?; let table = MailboxStatusTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), status: items.into(), }; diff --git a/src/imap/mailbox/subscribe.rs b/src/imap/mailbox/subscribe.rs index bad78962..ce64849a 100644 --- a/src/imap/mailbox/subscribe.rs +++ b/src/imap/mailbox/subscribe.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; /// Subscribe to the given mailbox. /// @@ -15,8 +15,7 @@ pub struct ImapMailboxSubscribeCommand { } impl ImapMailboxSubscribeCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; client.subscribe(mailbox)?; printer.out(Message::new("Mailbox successfully subscribed")) diff --git a/src/imap/mailbox/unselect.rs b/src/imap/mailbox/unselect.rs index 8a3f51b7..b79d89a9 100644 --- a/src/imap/mailbox/unselect.rs +++ b/src/imap/mailbox/unselect.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::account::ImapAccount; +use crate::imap::client::ImapClient; /// Unselect a current, selected mailbox. /// @@ -16,8 +16,7 @@ use crate::imap::account::ImapAccount; pub struct ImapMailboxUnselectCommand; impl ImapMailboxUnselectCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { client.unselect()?; printer.out(Message::new("Mailbox successfully unselected")) } diff --git a/src/imap/mailbox/unsubscribe.rs b/src/imap/mailbox/unsubscribe.rs index bacd5a2e..18089b13 100644 --- a/src/imap/mailbox/unsubscribe.rs +++ b/src/imap/mailbox/unsubscribe.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; /// Unsubscribe from the given mailbox. /// @@ -15,8 +15,7 @@ pub struct ImapMailboxUnsubscribeCommand { } impl ImapMailboxUnsubscribeCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; client.unsubscribe(mailbox)?; printer.out(Message::new("Mailbox successfully unsubscribed")) diff --git a/src/imap/message/cli.rs b/src/imap/message/cli.rs index 5035fb15..c4814b1c 100644 --- a/src/imap/message/cli.rs +++ b/src/imap/message/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::imap::{ - account::ImapAccount, + client::ImapClient, message::{ copy::ImapMessageCopyCommand, export::ImapMessageExportCommand, get::ImapMessageGetCommand, r#move::ImapMessageMoveCommand, read::ImapMessageReadCommand, save::ImapMessageSaveCommand, @@ -26,14 +26,14 @@ pub enum ImapMessageCommand { } impl ImapMessageCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: ImapClient) -> Result<()> { match self { - Self::Save(cmd) => cmd.execute(printer, account), - Self::Get(cmd) => cmd.execute(printer, account), - Self::Read(cmd) => cmd.execute(printer, account), - Self::Export(cmd) => cmd.execute(printer, account), - Self::Copy(cmd) => cmd.execute(printer, account), - Self::Move(cmd) => cmd.execute(printer, account), + Self::Save(cmd) => cmd.execute(printer, client), + Self::Get(cmd) => cmd.execute(printer, client), + Self::Read(cmd) => cmd.execute(printer, client), + Self::Export(cmd) => cmd.execute(printer, client), + Self::Copy(cmd) => cmd.execute(printer, client), + Self::Move(cmd) => cmd.execute(printer, client), } } } diff --git a/src/imap/message/copy.rs b/src/imap/message/copy.rs index 848c446a..c622475c 100644 --- a/src/imap/message/copy.rs +++ b/src/imap/message/copy.rs @@ -4,7 +4,7 @@ use io_imap::types::mailbox::Mailbox; use pimalaya_cli::printer::{Message, Printer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag, TargetMailboxNameArg}, }; @@ -31,8 +31,7 @@ pub struct ImapMessageCopyCommand { } impl ImapMessageCopyCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/message/export.rs b/src/imap/message/export.rs index 7ed425a2..9a4b2ecd 100644 --- a/src/imap/message/export.rs +++ b/src/imap/message/export.rs @@ -10,7 +10,7 @@ use io_imap::types::fetch::{MacroOrMessageDataItemNames, MessageDataItem, Messag use mail_parser::{MessageParser, MimeHeaders}; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameOptionalFlag}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameOptionalFlag}; /// Export type for message export. #[derive(Debug, Clone, clap::ValueEnum)] @@ -56,8 +56,7 @@ pub struct ImapMessageExportCommand { } impl ImapMessageExportCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; client.select(mailbox)?; @@ -117,7 +116,9 @@ impl ImapMessageExportCommand { // Generate filename from subject or message-id let filename = generate_eml_filename(&message, self.id); - let dir = self.directory.unwrap_or(account.downloads_dir); + let dir = self + .directory + .unwrap_or_else(|| client.account.downloads_dir()); if !dir.exists() { fs::create_dir_all(&dir)?; diff --git a/src/imap/message/get.rs b/src/imap/message/get.rs index 4a21abe8..ad2486bd 100644 --- a/src/imap/message/get.rs +++ b/src/imap/message/get.rs @@ -9,7 +9,7 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -32,8 +32,7 @@ pub struct ImapMessageGetCommand { } impl ImapMessageGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if self.id == 0 { bail!("ID must be non-zero"); diff --git a/src/imap/message/move.rs b/src/imap/message/move.rs index 7e82905b..e2d855bd 100644 --- a/src/imap/message/move.rs +++ b/src/imap/message/move.rs @@ -4,7 +4,7 @@ use io_imap::types::mailbox::Mailbox; use pimalaya_cli::printer::{Message, Printer}; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag, TargetMailboxNameArg}, }; @@ -32,8 +32,7 @@ pub struct ImapMessageMoveCommand { } impl ImapMessageMoveCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/message/read.rs b/src/imap/message/read.rs index 67d6949b..07001180 100644 --- a/src/imap/message/read.rs +++ b/src/imap/message/read.rs @@ -8,7 +8,7 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::imap::{ - account::ImapAccount, + client::ImapClient, mailbox::arg::{MailboxNameOptionalFlag, MailboxNoSelectFlag}, }; @@ -37,8 +37,7 @@ pub struct ImapMessageReadCommand { } impl ImapMessageReadCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox = self.mailbox_name.inner.try_into()?; if !self.mailbox_no_select.inner { diff --git a/src/imap/message/save.rs b/src/imap/message/save.rs index b4928c62..61eed4aa 100644 --- a/src/imap/message/save.rs +++ b/src/imap/message/save.rs @@ -7,7 +7,7 @@ use io_imap::types::{ }; use pimalaya_cli::printer::{Message, Printer}; -use crate::imap::{account::ImapAccount, mailbox::arg::MailboxNameArg}; +use crate::imap::{client::ImapClient, mailbox::arg::MailboxNameArg}; /// Save a message to a mailbox. /// @@ -29,8 +29,7 @@ pub struct ImapMessageSaveCommand { } impl ImapMessageSaveCommand { - pub fn execute(self, printer: &mut impl Printer, account: ImapAccount) -> Result<()> { - let mut client = account.new_imap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: ImapClient) -> Result<()> { let mailbox: Mailbox<'static> = self.mailbox.inner.try_into()?; let message = if !self.message.is_empty() || stdin().is_terminal() || printer.is_json() { self.message diff --git a/src/imap/mod.rs b/src/imap/mod.rs index b1c119b7..c4c1638d 100644 --- a/src/imap/mod.rs +++ b/src/imap/mod.rs @@ -1,5 +1,5 @@ -pub mod account; pub mod cli; +pub mod client; pub mod envelope; pub mod flag; pub mod id; diff --git a/src/imap/session.rs b/src/imap/session.rs index 8a29058d..6e45b3da 100644 --- a/src/imap/session.rs +++ b/src/imap/session.rs @@ -30,8 +30,10 @@ use io_imap::{ use log::info; use pimalaya_stream::{ sasl::{Sasl, SaslMechanism}, - std::stream::Stream, - tls::{upgrade_tls, Tls}, + std::{ + stream::Stream, + tls::{upgrade_tls, Tls}, + }, }; #[cfg(windows)] use uds_windows::UnixStream; diff --git a/src/jmap/account.rs b/src/jmap/account.rs deleted file mode 100644 index 664ad9af..00000000 --- a/src/jmap/account.rs +++ /dev/null @@ -1,24 +0,0 @@ -use anyhow::Result; -use io_jmap::client::JmapClient; - -use crate::{account::Account, config::JmapConfig, jmap::session::JmapSession}; - -pub type JmapAccount = Account; - -impl JmapAccount { - /// Establishes the JMAP session (TLS, `/.well-known/jmap` discovery) - /// then hands the resulting stream, bearer token and discovered - /// session off to a fresh [`JmapClient`]. - pub fn new_jmap_client(&self) -> Result { - let session = JmapSession::new( - self.backend.server.clone(), - self.backend.tls.clone().try_into()?, - self.backend.auth.clone().try_into()?, - )?; - Ok(JmapClient::from_parts( - session.stream, - session.http_auth, - session.session, - )) - } -} diff --git a/src/jmap/cli.rs b/src/jmap/cli.rs index 21b00c82..b49e4285 100644 --- a/src/jmap/cli.rs +++ b/src/jmap/cli.rs @@ -3,13 +3,13 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::jmap::{ - account::JmapAccount, email::cli::JmapEmailCommand, identity::cli::JmapIdentityCommand, + client::JmapClient, email::cli::JmapEmailCommand, identity::cli::JmapIdentityCommand, mailbox::cli::JmapMailboxCommand, query::JmapQueryCommand, submission::cli::JmapSubmissionCommand, thread::cli::JmapThreadCommand, vacation::cli::JmapVacationCommand, }; -/// JMAP CLI (requires the `jmap` cargo feature). +/// JMAP CLI. /// /// This command gives you access to the JMAP CLI API, and allows you /// to manage JMAP mailboxes, threads, emails, identities, submissions @@ -40,16 +40,16 @@ pub enum JmapCommand { } impl JmapCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: JmapClient) -> Result<()> { match self { - Self::Mailboxes(cmd) => cmd.execute(printer, account), - Self::Emails(cmd) => cmd.execute(printer, account), + Self::Mailboxes(cmd) => cmd.execute(printer, client), + Self::Emails(cmd) => cmd.execute(printer, client), - Self::Threads(cmd) => cmd.execute(printer, account), - Self::Identity(cmd) => cmd.execute(printer, account), - Self::Submission(cmd) => cmd.execute(printer, account), - Self::Vacation(cmd) => cmd.execute(printer, account), - Self::Query(cmd) => cmd.execute(printer, account), + Self::Threads(cmd) => cmd.execute(printer, client), + Self::Identity(cmd) => cmd.execute(printer, client), + Self::Submission(cmd) => cmd.execute(printer, client), + Self::Vacation(cmd) => cmd.execute(printer, client), + Self::Query(cmd) => cmd.execute(printer, client), } } } diff --git a/src/jmap/client.rs b/src/jmap/client.rs new file mode 100644 index 00000000..ab213177 --- /dev/null +++ b/src/jmap/client.rs @@ -0,0 +1,82 @@ +//! Himalaya wrapper around [`io_jmap::client::JmapClient`] that +//! bundles the merged [`Account`] alongside the live JMAP client. +//! +//! Built up front by the dispatch layer (`crate::cli`) via +//! [`build_jmap_client`] and handed down to every JMAP-specific +//! subcommand. + +use std::{ + ops::{Deref, DerefMut}, + path::PathBuf, +}; + +use anyhow::{anyhow, Result}; +use io_jmap::client::JmapClient as Inner; +use pimalaya_config::toml::TomlConfig; + +use crate::{ + account::context::Account, cli::load_or_wizard, config::JmapConfig, jmap::session::JmapSession, +}; + +pub struct JmapClient { + inner: Inner, + pub account: Account, + /// The original JMAP config block, kept around so commands like + /// `email import` / `email export` can spin up their own + /// auxiliary sessions (e.g. against the upload/download URL when + /// it lives on a different authority than the API URL). + pub config: JmapConfig, +} + +impl JmapClient { + /// Establishes the JMAP session (TLS, `/.well-known/jmap` + /// discovery) then wraps the resulting client alongside + /// `account`. + pub fn new(config: JmapConfig, account: Account) -> Result { + let session = JmapSession::new( + config.server.clone(), + config.tls.clone().try_into()?, + config.auth.clone().try_into()?, + )?; + let inner = Inner::from_parts(session.stream, session.http_auth, session.session); + + Ok(Self { + inner, + account, + config, + }) + } +} + +impl Deref for JmapClient { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for JmapClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +/// Loads the configuration, picks the active account, builds the +/// merged [`Account`] then opens the JMAP session. Bails when the +/// account has no `[jmap]` block. +pub fn build_jmap_client( + config_paths: &[PathBuf], + account_name: Option<&str>, +) -> Result { + let mut config = load_or_wizard(config_paths)?; + let (name, mut ac) = config + .take_account(account_name)? + .ok_or_else(|| anyhow!("Cannot find account"))?; + let jmap_config = ac + .jmap + .take() + .ok_or_else(|| anyhow!("JMAP config is missing for account `{name}`"))?; + let account = Account::from(config).merge(Account::from(ac)); + JmapClient::new(jmap_config, account) +} diff --git a/src/jmap/email/cli.rs b/src/jmap/email/cli.rs index 4bd397d4..408ade5d 100644 --- a/src/jmap/email/cli.rs +++ b/src/jmap/email/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::jmap::{ - account::JmapAccount, + client::JmapClient, email::{ copy::JmapEmailCopyCommand, delete::JmapEmailDestroyCommand, export::JmapEmailExportCommand, get::JmapEmailGetCommand, import::JmapEmailImportCommand, @@ -30,17 +30,17 @@ pub enum JmapEmailCommand { } impl JmapEmailCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: JmapClient) -> Result<()> { match self { - Self::Get(cmd) => cmd.execute(printer, account), - Self::Query(cmd) => cmd.execute(printer, account), - Self::Read(cmd) => cmd.execute(printer, account), - Self::Update(cmd) => cmd.execute(printer, account), - Self::Delete(cmd) => cmd.execute(printer, account), - Self::Copy(cmd) => cmd.execute(printer, account), - Self::Export(cmd) => cmd.execute(printer, account), - Self::Import(cmd) => cmd.execute(printer, account), - Self::Parse(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, client), + Self::Query(cmd) => cmd.execute(printer, client), + Self::Read(cmd) => cmd.execute(printer, client), + Self::Update(cmd) => cmd.execute(printer, client), + Self::Delete(cmd) => cmd.execute(printer, client), + Self::Copy(cmd) => cmd.execute(printer, client), + Self::Export(cmd) => cmd.execute(printer, client), + Self::Import(cmd) => cmd.execute(printer, client), + Self::Parse(cmd) => cmd.execute(printer, client), } } } diff --git a/src/jmap/email/copy.rs b/src/jmap/email/copy.rs index 694bc801..6f11c2c3 100644 --- a/src/jmap/email/copy.rs +++ b/src/jmap/email/copy.rs @@ -5,7 +5,7 @@ use clap::Parser; use io_jmap::rfc8621::email::EmailCopy; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Copy JMAP emails from another account (Email/copy). #[derive(Debug, Parser)] @@ -24,9 +24,7 @@ pub struct JmapEmailCopyCommand { } impl JmapEmailCopyCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let mailbox_ids: BTreeMap = self.mailbox_id.into_iter().map(|m| (m, true)).collect(); diff --git a/src/jmap/email/delete.rs b/src/jmap/email/delete.rs index b8110908..4b955610 100644 --- a/src/jmap/email/delete.rs +++ b/src/jmap/email/delete.rs @@ -3,7 +3,7 @@ use clap::Parser; use io_jmap::rfc8621::email_set::JmapEmailSetArgs; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Delete JMAP emails (Email/set destroy). #[derive(Debug, Parser)] @@ -14,8 +14,7 @@ pub struct JmapEmailDestroyCommand { } impl JmapEmailDestroyCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let mut args = JmapEmailSetArgs::default(); for id in self.ids { diff --git a/src/jmap/email/export.rs b/src/jmap/email/export.rs index 7d78222b..af5f166c 100644 --- a/src/jmap/email/export.rs +++ b/src/jmap/email/export.rs @@ -2,13 +2,13 @@ use std::net::TcpStream; use anyhow::{anyhow, Result}; use clap::Parser; -use io_jmap::{client::JmapClient, rfc8621::capabilities::MAIL}; +use io_jmap::{client::JmapClient as InnerJmapClient, rfc8621::capabilities::MAIL}; use pimalaya_cli::printer::{Message, Printer}; -use pimalaya_stream::tls::upgrade_tls; +use pimalaya_stream::std::tls::upgrade_tls; use secrecy::SecretString; use url::Url; -use crate::jmap::{account::JmapAccount, session::JmapAuth}; +use crate::jmap::{client::JmapClient, session::JmapAuth}; /// Export a raw RFC 5322 message to stdout (Email/get + blob download). /// @@ -21,13 +21,11 @@ pub struct JmapEmailExportCommand { } impl JmapEmailExportCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let tls = account.backend.tls.clone().try_into()?; - let auth: JmapAuth = account.backend.auth.clone().try_into()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { + let tls = client.config.tls.clone().try_into()?; + let auth: JmapAuth = client.config.auth.clone().try_into()?; let http_auth: SecretString = auth.into(); - let mut client = account.new_jmap_client()?; - let properties = Some(vec!["id".to_owned(), "blobId".to_owned()]); let output = client.email_get(vec![self.id.clone()], properties, false, false, 0)?; @@ -61,7 +59,7 @@ impl JmapEmailExportCommand { let port = download_url.port_or_known_default().unwrap_or(443); let tcp = TcpStream::connect((host, port))?; let stream = upgrade_tls(host, tcp, &tls, &[b"http/1.1"])?; - let mut download_client = JmapClient::new(stream, http_auth); + let mut download_client = InnerJmapClient::new(stream, http_auth); download_client.blob_download(&download_url)? }; diff --git a/src/jmap/email/get.rs b/src/jmap/email/get.rs index 09894e4c..a37adf49 100644 --- a/src/jmap/email/get.rs +++ b/src/jmap/email/get.rs @@ -3,7 +3,7 @@ use clap::Parser; use log::warn; use pimalaya_cli::printer::Printer; -use crate::jmap::{account::JmapAccount, email::query::EmailsTable}; +use crate::jmap::{client::JmapClient, email::query::EmailsTable}; /// Get JMAP emails by ID (Email/get). /// @@ -16,8 +16,7 @@ pub struct JmapEmailGetCommand { } impl JmapEmailGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let output = client.email_get(self.ids.clone(), None, false, false, 0)?; for id in output.not_found { @@ -25,8 +24,8 @@ impl JmapEmailGetCommand { } let table = EmailsTable { - preset: account.table_preset, - arrangement: account.table_arrangement, + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), emails: output.emails, }; diff --git a/src/jmap/email/import.rs b/src/jmap/email/import.rs index d90eef3f..0177c8b5 100644 --- a/src/jmap/email/import.rs +++ b/src/jmap/email/import.rs @@ -7,15 +7,15 @@ use std::{ use anyhow::{bail, Result}; use clap::Parser; use io_jmap::{ - client::JmapClient, + client::JmapClient as InnerJmapClient, rfc8621::{capabilities::MAIL, email::EmailImport}, }; use pimalaya_cli::printer::{Message, Printer}; -use pimalaya_stream::tls::upgrade_tls; +use pimalaya_stream::std::tls::upgrade_tls; use secrecy::SecretString; use url::Url; -use crate::jmap::{account::JmapAccount, error::format_set_error, session::JmapAuth}; +use crate::jmap::{client::JmapClient, error::format_set_error, session::JmapAuth}; /// Import an RFC 5322 message into a mailbox (upload + Email/import). /// @@ -46,13 +46,11 @@ pub struct JmapEmailImportCommand { } impl JmapEmailImportCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let tls = account.backend.tls.clone().try_into()?; - let auth: JmapAuth = account.backend.auth.clone().try_into()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { + let tls = client.config.tls.clone().try_into()?; + let auth: JmapAuth = client.config.auth.clone().try_into()?; let http_auth: SecretString = auth.into(); - let mut client = account.new_jmap_client()?; - let data: Vec = if stdin().is_terminal() || printer.is_json() { self.message .join(" ") @@ -85,7 +83,7 @@ impl JmapEmailImportCommand { let port = upload_url.port_or_known_default().unwrap_or(443); let tcp = TcpStream::connect((host, port))?; let stream = upgrade_tls(host, tcp, &tls, &[b"http/1.1"])?; - let mut upload_client = JmapClient::new(stream, http_auth); + let mut upload_client = InnerJmapClient::new(stream, http_auth); upload_client .blob_upload(&upload_url, "message/rfc822", data)? .blob_id diff --git a/src/jmap/email/parse.rs b/src/jmap/email/parse.rs index a555f9a1..01ed26e0 100644 --- a/src/jmap/email/parse.rs +++ b/src/jmap/email/parse.rs @@ -4,7 +4,7 @@ use log::warn; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Parse RFC 5322 message blobs without storing them (Email/parse). /// @@ -18,8 +18,7 @@ pub struct JmapEmailParseCommand { } impl JmapEmailParseCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let output = client.email_parse(self.blob_ids.clone(), None)?; for id in output.not_found { diff --git a/src/jmap/email/query.rs b/src/jmap/email/query.rs index 2439f5a8..8d64da5c 100644 --- a/src/jmap/email/query.rs +++ b/src/jmap/email/query.rs @@ -9,7 +9,7 @@ use io_jmap::rfc8621::email::{ use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Query JMAP emails (Email/query + Email/get). /// @@ -86,9 +86,7 @@ pub struct JmapEmailQueryCommand { } impl JmapEmailQueryCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let filter = { let f = EmailFilter { in_mailbox: self.mailbox, @@ -148,8 +146,8 @@ impl JmapEmailQueryCommand { )?; let table = EmailsTable { - preset: account.table_preset, - arrangement: account.table_arrangement, + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), emails: output.emails, }; diff --git a/src/jmap/email/read.rs b/src/jmap/email/read.rs index 7563b01d..8239d96f 100644 --- a/src/jmap/email/read.rs +++ b/src/jmap/email/read.rs @@ -4,7 +4,7 @@ use io_jmap::rfc8621::email::EmailAddress; use log::warn; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Read the content of a JMAP email (Email/get with body). /// @@ -21,8 +21,7 @@ pub struct JmapEmailReadCommand { } impl JmapEmailReadCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let output = client.email_get(self.ids.clone(), None, !self.html, self.html, 0)?; for id in output.not_found { diff --git a/src/jmap/email/update.rs b/src/jmap/email/update.rs index 31139cd2..14190905 100644 --- a/src/jmap/email/update.rs +++ b/src/jmap/email/update.rs @@ -5,7 +5,7 @@ use clap::Parser; use io_jmap::rfc8621::email_set::JmapEmailSetArgs; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Update JMAP emails via patch operations (Email/set). #[derive(Debug, Parser)] @@ -40,8 +40,7 @@ pub struct JmapEmailUpdateCommand { } impl JmapEmailUpdateCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let mut args = JmapEmailSetArgs::default(); for id in &self.ids { diff --git a/src/jmap/identity/cli.rs b/src/jmap/identity/cli.rs index a2477086..a26ed1ab 100644 --- a/src/jmap/identity/cli.rs +++ b/src/jmap/identity/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::jmap::{ - account::JmapAccount, + client::JmapClient, identity::{ create::JmapIdentityCreateCommand, delete::JmapIdentityDeleteCommand, get::JmapIdentityGetCommand, update::JmapIdentityUpdateCommand, @@ -28,12 +28,12 @@ pub enum JmapIdentityCommand { } impl JmapIdentityCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: JmapClient) -> Result<()> { match self { - Self::Get(cmd) => cmd.execute(printer, account), - Self::Create(cmd) => cmd.execute(printer, account), - Self::Update(cmd) => cmd.execute(printer, account), - Self::Delete(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, client), + Self::Create(cmd) => cmd.execute(printer, client), + Self::Update(cmd) => cmd.execute(printer, client), + Self::Delete(cmd) => cmd.execute(printer, client), } } } diff --git a/src/jmap/identity/create.rs b/src/jmap/identity/create.rs index e2387f70..d1204907 100644 --- a/src/jmap/identity/create.rs +++ b/src/jmap/identity/create.rs @@ -3,7 +3,7 @@ use clap::Parser; use io_jmap::rfc8621::{identity::IdentityCreate, identity_set::JmapIdentitySetArgs}; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Create a JMAP sender identity (Identity/set). #[derive(Debug, Parser)] @@ -24,9 +24,7 @@ pub struct JmapIdentityCreateCommand { } impl JmapIdentityCreateCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let identity = IdentityCreate { name: self.name.clone(), email: self.email.clone(), diff --git a/src/jmap/identity/delete.rs b/src/jmap/identity/delete.rs index b8a9ddde..60a7d108 100644 --- a/src/jmap/identity/delete.rs +++ b/src/jmap/identity/delete.rs @@ -3,7 +3,7 @@ use clap::Parser; use io_jmap::rfc8621::identity_set::JmapIdentitySetArgs; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Delete a JMAP sender identity (Identity/set). #[derive(Debug, Parser)] @@ -14,8 +14,7 @@ pub struct JmapIdentityDeleteCommand { } impl JmapIdentityDeleteCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let mut args = JmapIdentitySetArgs::default(); for id in self.ids { diff --git a/src/jmap/identity/get.rs b/src/jmap/identity/get.rs index a2afa665..13d6d0ec 100644 --- a/src/jmap/identity/get.rs +++ b/src/jmap/identity/get.rs @@ -8,7 +8,7 @@ use log::warn; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Get JMAP identities (Identity/get). /// @@ -22,8 +22,7 @@ pub struct JmapIdentityGetCommand { } impl JmapIdentityGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let output = client.identity_get(self.ids)?; for id in output.not_found { @@ -31,7 +30,7 @@ impl JmapIdentityGetCommand { } let table = IdentitiesTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), identities: output.identities, }; diff --git a/src/jmap/identity/update.rs b/src/jmap/identity/update.rs index e8310c16..a02a0dd7 100644 --- a/src/jmap/identity/update.rs +++ b/src/jmap/identity/update.rs @@ -3,7 +3,7 @@ use clap::Parser; use io_jmap::rfc8621::{identity::IdentityUpdate, identity_set::JmapIdentitySetArgs}; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Update a JMAP sender identity (Identity/set). #[derive(Debug, Parser)] @@ -25,9 +25,7 @@ pub struct JmapIdentityUpdateCommand { } impl JmapIdentityUpdateCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let patch = IdentityUpdate { name: self.name, reply_to: None, diff --git a/src/jmap/mailbox/cli.rs b/src/jmap/mailbox/cli.rs index 14a65067..e060c456 100644 --- a/src/jmap/mailbox/cli.rs +++ b/src/jmap/mailbox/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::jmap::{ - account::JmapAccount, + client::JmapClient, mailbox::{ create::JmapMailboxCreateCommand, destroy::JmapMailboxDestroyCommand, get::JmapMailboxGetCommand, query::JmapMailboxQueryCommand, @@ -24,13 +24,13 @@ pub enum JmapMailboxCommand { } impl JmapMailboxCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: JmapClient) -> Result<()> { match self { - Self::Get(cmd) => cmd.execute(printer, account), - Self::Query(cmd) => cmd.execute(printer, account), - Self::Create(cmd) => cmd.execute(printer, account), - Self::Update(cmd) => cmd.execute(printer, account), - Self::Destroy(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, client), + Self::Query(cmd) => cmd.execute(printer, client), + Self::Create(cmd) => cmd.execute(printer, client), + Self::Update(cmd) => cmd.execute(printer, client), + Self::Destroy(cmd) => cmd.execute(printer, client), } } } diff --git a/src/jmap/mailbox/create.rs b/src/jmap/mailbox/create.rs index 3aec1fcb..93158a6b 100644 --- a/src/jmap/mailbox/create.rs +++ b/src/jmap/mailbox/create.rs @@ -5,7 +5,7 @@ use clap::Parser; use io_jmap::rfc8621::{mailbox::MailboxCreate, mailbox_set::JmapMailboxSetArgs}; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Create a JMAP mailbox. #[derive(Debug, Parser)] @@ -25,9 +25,7 @@ pub struct JmapMailboxCreateCommand { } impl JmapMailboxCreateCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let new_mailbox = MailboxCreate { name: Some(self.name.clone()), parent_id: self.parent_id, diff --git a/src/jmap/mailbox/destroy.rs b/src/jmap/mailbox/destroy.rs index 2100e3c5..c41db425 100644 --- a/src/jmap/mailbox/destroy.rs +++ b/src/jmap/mailbox/destroy.rs @@ -3,7 +3,7 @@ use clap::Parser; use io_jmap::rfc8621::mailbox_set::JmapMailboxSetArgs; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Delete a JMAP mailbox. #[derive(Debug, Parser)] @@ -18,9 +18,7 @@ pub struct JmapMailboxDestroyCommand { } impl JmapMailboxDestroyCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let mut args = JmapMailboxSetArgs::default(); args.destroy = Some(self.ids.clone()); args.on_destroy_remove_emails = if self.purge { Some(true) } else { None }; diff --git a/src/jmap/mailbox/get.rs b/src/jmap/mailbox/get.rs index c5fa14e4..b72349a6 100644 --- a/src/jmap/mailbox/get.rs +++ b/src/jmap/mailbox/get.rs @@ -3,7 +3,7 @@ use clap::Parser; use log::warn; use pimalaya_cli::printer::Printer; -use crate::jmap::{account::JmapAccount, mailbox::query::MailboxesTable}; +use crate::jmap::{client::JmapClient, mailbox::query::MailboxesTable}; /// Get JMAP mailboxes by ID (Mailbox/get). #[derive(Debug, Parser)] @@ -14,8 +14,7 @@ pub struct JmapMailboxGetCommand { } impl JmapMailboxGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let output = client.mailbox_get(Some(self.ids.clone()), None)?; for id in output.not_found { @@ -23,7 +22,7 @@ impl JmapMailboxGetCommand { } let table = MailboxesTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), mailboxes: output.mailboxes, }; diff --git a/src/jmap/mailbox/query.rs b/src/jmap/mailbox/query.rs index 71185b77..7ed30094 100644 --- a/src/jmap/mailbox/query.rs +++ b/src/jmap/mailbox/query.rs @@ -9,7 +9,7 @@ use io_jmap::rfc8621::mailbox::{ use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Query JMAP mailboxes (Mailbox/query + Mailbox/get). /// @@ -55,9 +55,7 @@ pub struct JmapMailboxQueryCommand { } impl JmapMailboxQueryCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let filter = { let f = MailboxFilter { parent_id: self.parent_id, @@ -94,7 +92,7 @@ impl JmapMailboxQueryCommand { )?; let table = MailboxesTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), mailboxes: output.mailboxes, }; diff --git a/src/jmap/mailbox/update.rs b/src/jmap/mailbox/update.rs index 784958c6..448a76f3 100644 --- a/src/jmap/mailbox/update.rs +++ b/src/jmap/mailbox/update.rs @@ -5,7 +5,7 @@ use clap::Parser; use io_jmap::rfc8621::{mailbox::MailboxUpdate, mailbox_set::JmapMailboxSetArgs}; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error, mailbox::query::RoleArg}; +use crate::jmap::{client::JmapClient, error::format_set_error, mailbox::query::RoleArg}; /// Update a JMAP mailbox. #[derive(Debug, Parser)] @@ -40,9 +40,7 @@ pub struct JmapMailboxUpdateCommand { } impl JmapMailboxUpdateCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let is_subscribed = if self.subscribe { Some(true) } else if self.unsubscribe { diff --git a/src/jmap/mod.rs b/src/jmap/mod.rs index c1628be7..2c84c302 100644 --- a/src/jmap/mod.rs +++ b/src/jmap/mod.rs @@ -1,5 +1,5 @@ -pub mod account; pub mod cli; +pub mod client; pub mod email; pub mod error; pub mod identity; diff --git a/src/jmap/query.rs b/src/jmap/query.rs index f35c4bb4..9df85f62 100644 --- a/src/jmap/query.rs +++ b/src/jmap/query.rs @@ -13,7 +13,7 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use serde_json::Value; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Send a raw JMAP method-calls array and print the response. /// @@ -37,9 +37,7 @@ pub struct JmapQueryCommand { } impl JmapQueryCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let raw = if self.method_calls.is_empty() || self.method_calls.first().map(|s| s.as_str()) == Some("-") { diff --git a/src/jmap/session.rs b/src/jmap/session.rs index 615bd465..66fe37a3 100644 --- a/src/jmap/session.rs +++ b/src/jmap/session.rs @@ -15,8 +15,8 @@ use io_jmap::rfc8620::{ session_get::{JmapSessionGet, JmapSessionGetResult}, }; use log::info; -use pimalaya_stream::{ - std::stream::Stream, +use pimalaya_stream::std::{ + stream::Stream, tls::{upgrade_tls, Tls}, }; use secrecy::{ExposeSecret, SecretString}; diff --git a/src/jmap/submission/cancel.rs b/src/jmap/submission/cancel.rs index 3b342c1a..7aca58a9 100644 --- a/src/jmap/submission/cancel.rs +++ b/src/jmap/submission/cancel.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Result}; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::{account::JmapAccount, error::format_set_error}; +use crate::jmap::{client::JmapClient, error::format_set_error}; /// Cancel (undo) a pending JMAP email submission (EmailSubmission/set). /// @@ -16,8 +16,7 @@ pub struct JmapSubmissionCancelCommand { } impl JmapSubmissionCancelCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let output = client.email_submission_cancel(self.ids.clone())?; if !output.not_updated.is_empty() { diff --git a/src/jmap/submission/cli.rs b/src/jmap/submission/cli.rs index 3be78281..bb8e3bda 100644 --- a/src/jmap/submission/cli.rs +++ b/src/jmap/submission/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::jmap::{ - account::JmapAccount, + client::JmapClient, submission::{ cancel::JmapSubmissionCancelCommand, create::JmapSubmissionCreateCommand, get::JmapSubmissionGetCommand, query::JmapSubmissionQueryCommand, @@ -26,12 +26,12 @@ pub enum JmapSubmissionCommand { } impl JmapSubmissionCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: JmapClient) -> Result<()> { match self { - Self::Get(cmd) => cmd.execute(printer, account), - Self::Query(cmd) => cmd.execute(printer, account), - Self::Create(cmd) => cmd.execute(printer, account), - Self::Cancel(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, client), + Self::Query(cmd) => cmd.execute(printer, client), + Self::Create(cmd) => cmd.execute(printer, client), + Self::Cancel(cmd) => cmd.execute(printer, client), } } } diff --git a/src/jmap/submission/create.rs b/src/jmap/submission/create.rs index 8cf34395..0745c16e 100644 --- a/src/jmap/submission/create.rs +++ b/src/jmap/submission/create.rs @@ -8,7 +8,7 @@ use io_jmap::rfc8621::email_submission::{ use pimalaya_cli::printer::Printer; use crate::jmap::{ - account::JmapAccount, error::format_set_error, submission::query::SubmissionsTable, + client::JmapClient, error::format_set_error, submission::query::SubmissionsTable, }; /// Submit a JMAP email for sending (EmailSubmission/set). @@ -35,9 +35,7 @@ pub struct JmapSubmissionCreateCommand { } impl JmapSubmissionCreateCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let envelope = if let Some(mail_from_addr) = self.mail_from { let rcpt_to = self .rcpt_to @@ -76,7 +74,7 @@ impl JmapSubmissionCreateCommand { } let table = SubmissionsTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), submissions: output.created.into_values().collect(), }; diff --git a/src/jmap/submission/get.rs b/src/jmap/submission/get.rs index 73cb7e04..35f1b340 100644 --- a/src/jmap/submission/get.rs +++ b/src/jmap/submission/get.rs @@ -3,7 +3,7 @@ use clap::Parser; use log::warn; use pimalaya_cli::printer::Printer; -use crate::jmap::{account::JmapAccount, submission::query::SubmissionsTable}; +use crate::jmap::{client::JmapClient, submission::query::SubmissionsTable}; /// Get JMAP email submissions by ID (EmailSubmission/get). #[derive(Debug, Parser)] @@ -14,8 +14,7 @@ pub struct JmapSubmissionGetCommand { } impl JmapSubmissionGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let output = client.email_submission_get(Some(self.ids.clone()))?; for id in output.not_found { @@ -23,7 +22,7 @@ impl JmapSubmissionGetCommand { } let table = SubmissionsTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), submissions: output.submissions, }; diff --git a/src/jmap/submission/query.rs b/src/jmap/submission/query.rs index 90fa7acf..20a563af 100644 --- a/src/jmap/submission/query.rs +++ b/src/jmap/submission/query.rs @@ -7,7 +7,7 @@ use io_jmap::rfc8621::email_submission::{EmailSubmission, EmailSubmissionFilter, use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// CLI proxy for [`UndoStatus`]. #[derive(Clone, Debug, ValueEnum)] @@ -52,9 +52,7 @@ pub struct JmapSubmissionQueryCommand { } impl JmapSubmissionQueryCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let filter = { let f = EmailSubmissionFilter { undo_status: self.undo_status.map(Into::into), @@ -80,7 +78,7 @@ impl JmapSubmissionQueryCommand { )?; let table = SubmissionsTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), submissions: output.submissions, }; diff --git a/src/jmap/thread/cli.rs b/src/jmap/thread/cli.rs index 92a35baa..7dd9ca33 100644 --- a/src/jmap/thread/cli.rs +++ b/src/jmap/thread/cli.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Subcommand; use pimalaya_cli::printer::Printer; -use crate::jmap::{account::JmapAccount, thread::get::JmapThreadGetCommand}; +use crate::jmap::{client::JmapClient, thread::get::JmapThreadGetCommand}; /// Manage JMAP threads. #[derive(Debug, Subcommand)] @@ -12,9 +12,9 @@ pub enum JmapThreadCommand { } impl JmapThreadCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: JmapClient) -> Result<()> { match self { - Self::Get(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, client), } } } diff --git a/src/jmap/thread/get.rs b/src/jmap/thread/get.rs index 188099be..095c19e9 100644 --- a/src/jmap/thread/get.rs +++ b/src/jmap/thread/get.rs @@ -8,7 +8,7 @@ use log::warn; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Get JMAP threads by ID (Thread/get). /// @@ -21,8 +21,7 @@ pub struct JmapThreadGetCommand { } impl JmapThreadGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let output = client.thread_get(self.ids.clone())?; for id in output.not_found { @@ -30,7 +29,7 @@ impl JmapThreadGetCommand { } printer.out(ThreadsTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), threads: output.threads, }) } diff --git a/src/jmap/vacation/cli.rs b/src/jmap/vacation/cli.rs index 6b1881ed..39470209 100644 --- a/src/jmap/vacation/cli.rs +++ b/src/jmap/vacation/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::jmap::{ - account::JmapAccount, + client::JmapClient, vacation::{get::JmapVacationGetCommand, set::JmapVacationSetCommand}, }; @@ -17,10 +17,10 @@ pub enum JmapVacationCommand { } impl JmapVacationCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: JmapClient) -> Result<()> { match self { - Self::Get(cmd) => cmd.execute(printer, account), - Self::Set(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, client), + Self::Set(cmd) => cmd.execute(printer, client), } } } diff --git a/src/jmap/vacation/get.rs b/src/jmap/vacation/get.rs index 370eecc6..de4feddb 100644 --- a/src/jmap/vacation/get.rs +++ b/src/jmap/vacation/get.rs @@ -7,16 +7,14 @@ use io_jmap::rfc8621::{capabilities::VACATION_RESPONSE, vacation_response::Vacat use pimalaya_cli::printer::{Message, Printer}; use serde::Serialize; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Get the JMAP vacation response (VacationResponse/get). #[derive(Debug, Parser)] pub struct JmapVacationGetCommand; impl JmapVacationGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let has_vacation = client .session() .map(|s| s.capabilities.contains_key(VACATION_RESPONSE)) @@ -31,7 +29,7 @@ impl JmapVacationGetCommand { }; let table = VacationTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), vacation, }; diff --git a/src/jmap/vacation/set.rs b/src/jmap/vacation/set.rs index bd35b689..622cfcef 100644 --- a/src/jmap/vacation/set.rs +++ b/src/jmap/vacation/set.rs @@ -5,7 +5,7 @@ use io_jmap::rfc8621::{ }; use pimalaya_cli::printer::{Message, Printer}; -use crate::jmap::account::JmapAccount; +use crate::jmap::client::JmapClient; /// Update the JMAP vacation response (VacationResponse/set). #[derive(Debug, Parser)] @@ -40,9 +40,7 @@ pub struct JmapVacationSetCommand { } impl JmapVacationSetCommand { - pub fn execute(self, printer: &mut impl Printer, account: JmapAccount) -> Result<()> { - let mut client = account.new_jmap_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: JmapClient) -> Result<()> { let has_vacation = client .session() .map(|s| s.capabilities.contains_key(VACATION_RESPONSE)) diff --git a/src/mailboxes/cli.rs b/src/mailboxes/cli.rs deleted file mode 100644 index ad7dcd48..00000000 --- a/src/mailboxes/cli.rs +++ /dev/null @@ -1,34 +0,0 @@ -use anyhow::Result; -use clap::Subcommand; -use pimalaya_cli::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 deleted file mode 100644 index a4ff5d9e..00000000 --- a/src/mailboxes/list.rs +++ /dev/null @@ -1,34 +0,0 @@ -use anyhow::Result; -use clap::Parser; -use pimalaya_cli::printer::Printer; - -use crate::{ - cli::BackendArg, - config::{AccountConfig, Config}, - email_client::build, - mailboxes::table::MailboxesTable, -}; - -/// 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, - account_config: AccountConfig, - backend: BackendArg, - ) -> Result<()> { - let mut ctx = build(config, account_config, backend)?; - let mailboxes = ctx.client.list_mailboxes()?; - - printer.out(MailboxesTable { - preset: ctx.table_preset, - arrangement: ctx.table_arrangement, - mailboxes, - }) - } -} diff --git a/src/mailboxes/table.rs b/src/mailboxes/table.rs deleted file mode 100644 index a539ef1f..00000000 --- a/src/mailboxes/table.rs +++ /dev/null @@ -1,45 +0,0 @@ -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/account.rs b/src/maildir/account.rs deleted file mode 100644 index 3c86937c..00000000 --- a/src/maildir/account.rs +++ /dev/null @@ -1,13 +0,0 @@ -use io_maildir::client::MaildirClient; - -use crate::{account::Account, config::MaildirConfig}; - -pub type MaildirAccount = Account; - -impl MaildirAccount { - /// Builds a [`MaildirClient`] rooted at the configured Maildir - /// path. - pub fn new_maildir_client(&self) -> MaildirClient { - MaildirClient::new(self.backend.root.clone()) - } -} diff --git a/src/maildir/cli.rs b/src/maildir/cli.rs index d17ae003..167aefa0 100644 --- a/src/maildir/cli.rs +++ b/src/maildir/cli.rs @@ -3,16 +3,16 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::maildir::{ - account::MaildirAccount, create::MaildirMailboxCreateCommand, + client::MaildirClient, create::MaildirMailboxCreateCommand, delete::MaildirMailboxDeleteCommand, envelope::cli::MaildirEnvelopeCommand, flag::cli::MaildirFlagCommand, list::MaildirMailboxListCommand, message::cli::MaildirMessageCommand, rename::MaildirMailboxRenameCommand, }; -/// MAILDIR CLI (requires the `maildir` cargo feature). +/// Maildir CLI. /// -/// This command gives you access to the MAILDIR CLI API, and allows you -/// to manage MAILDIR mailboxes, envelopes, flags, messages etc. +/// This command gives you access to the Maildir CLI API, and allows +/// you to manage Maildir mailboxes, envelopes, flags, messages etc. #[derive(Debug, Subcommand)] #[command(rename_all = "kebab-case")] pub enum MaildirCommand { @@ -31,16 +31,16 @@ pub enum MaildirCommand { } impl MaildirCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { match self { - Self::Create(cmd) => cmd.execute(printer, account), - Self::Rename(cmd) => cmd.execute(printer, account), - Self::Delete(cmd) => cmd.execute(printer, account), - Self::List(cmd) => cmd.execute(printer, account), + Self::Create(cmd) => cmd.execute(printer, client), + Self::Rename(cmd) => cmd.execute(printer, client), + Self::Delete(cmd) => cmd.execute(printer, client), + Self::List(cmd) => cmd.execute(printer, client), - Self::Messages(cmd) => cmd.execute(printer, account), - Self::Flags(cmd) => cmd.execute(printer, account), - Self::Envelopes(cmd) => cmd.execute(printer, account), + Self::Messages(cmd) => cmd.execute(printer, client), + Self::Flags(cmd) => cmd.execute(printer, client), + Self::Envelopes(cmd) => cmd.execute(printer, client), } } } diff --git a/src/maildir/client.rs b/src/maildir/client.rs new file mode 100644 index 00000000..8d783058 --- /dev/null +++ b/src/maildir/client.rs @@ -0,0 +1,73 @@ +//! Himalaya wrapper around [`io_maildir::client::MaildirClient`] +//! that bundles the merged [`Account`] alongside the maildir client. +//! +//! Built up front by the dispatch layer (`crate::cli`) via +//! [`build_maildir_client`] and handed down to every maildir-specific +//! subcommand. + +use std::{ + ops::{Deref, DerefMut}, + path::PathBuf, +}; + +use anyhow::{anyhow, Result}; +use io_maildir::client::MaildirClient as Inner; +use pimalaya_config::toml::TomlConfig; + +use crate::{account::context::Account, cli::load_or_wizard, config::MaildirConfig}; + +pub struct MaildirClient { + inner: Inner, + pub account: Account, + /// Filesystem root of the configured maildir. Kept on the wrapper + /// so commands can join sub-paths (per-mailbox) without needing + /// the original [`MaildirConfig`]. + pub root: PathBuf, +} + +impl MaildirClient { + /// Builds a [`MaildirClient`] rooted at the configured maildir + /// path. + pub fn new(config: MaildirConfig, account: Account) -> Self { + let root = config.root.clone(); + let inner = Inner::new(config.root); + Self { + inner, + account, + root, + } + } +} + +impl Deref for MaildirClient { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for MaildirClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +/// Loads the configuration, picks the active account, builds the +/// merged [`Account`] then opens the maildir client. Bails when the +/// account has no `[maildir]` block. +pub fn build_maildir_client( + config_paths: &[PathBuf], + account_name: Option<&str>, +) -> Result { + let mut config = load_or_wizard(config_paths)?; + let (name, mut ac) = config + .take_account(account_name)? + .ok_or_else(|| anyhow!("Cannot find account"))?; + let maildir_config = ac + .maildir + .take() + .ok_or_else(|| anyhow!("Maildir config is missing for account `{name}`"))?; + let account = Account::from(config).merge(Account::from(ac)); + Ok(MaildirClient::new(maildir_config, account)) +} diff --git a/src/maildir/command.rs b/src/maildir/command.rs index b35e07cd..50346e91 100644 --- a/src/maildir/command.rs +++ b/src/maildir/command.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::maildir::{ - account::MaildirAccount, create::MaildirMailboxCreateCommand, + client::MaildirClient, create::MaildirMailboxCreateCommand, delete::MaildirMailboxDeleteCommand, envelope::command::MaildirEnvelopeCommand, flag::command::MaildirFlagCommand, list::MaildirMailboxListCommand, message::command::MaildirMessageCommand, rename::MaildirMailboxRenameCommand, diff --git a/src/maildir/create.rs b/src/maildir/create.rs index 2eb944cd..ec8f4ecb 100644 --- a/src/maildir/create.rs +++ b/src/maildir/create.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::maildir::{account::MaildirAccount, arg::MaildirNameArg}; +use crate::maildir::{arg::MaildirNameArg, client::MaildirClient}; /// Create the given mailbox. /// @@ -15,9 +15,9 @@ pub struct MaildirMailboxCreateCommand { } impl MaildirMailboxCreateCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { - let path = account.backend.root.join(&self.maildir_name.inner); - let client = account.new_maildir_client(); + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { + let path = client.root.join(&self.maildir_name.inner); + client.create_maildir(path)?; printer.out(Message::new("Maildir successfully created")) } diff --git a/src/maildir/delete.rs b/src/maildir/delete.rs index 23903349..e084101b 100644 --- a/src/maildir/delete.rs +++ b/src/maildir/delete.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; -use crate::maildir::{account::MaildirAccount, arg::MaildirPathFlag}; +use crate::maildir::{arg::MaildirPathFlag, client::MaildirClient}; /// Delete the given mailbox. /// @@ -15,9 +15,9 @@ pub struct MaildirMailboxDeleteCommand { } impl MaildirMailboxDeleteCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { - let path = account.backend.root.join(&self.maildir_path.inner); - let client = account.new_maildir_client(); + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { + let path = client.root.join(&self.maildir_path.inner); + client.delete_maildir(path)?; printer.out(Message::new("Maildir successfully deleted")) } diff --git a/src/maildir/envelope/cli.rs b/src/maildir/envelope/cli.rs index c82b2eab..8911643c 100644 --- a/src/maildir/envelope/cli.rs +++ b/src/maildir/envelope/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::maildir::{ - account::MaildirAccount, + client::MaildirClient, envelope::{get::MaildirEnvelopeGetCommand, list::MaildirEnvelopeListCommand}, }; @@ -19,10 +19,10 @@ pub enum MaildirEnvelopeCommand { } impl MaildirEnvelopeCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { match self { - Self::Get(cmd) => cmd.execute(printer, account), - Self::List(cmd) => cmd.execute(printer, account), + Self::Get(cmd) => cmd.execute(printer, client), + Self::List(cmd) => cmd.execute(printer, client), } } } diff --git a/src/maildir/envelope/get.rs b/src/maildir/envelope/get.rs index 4ce9d8e5..2fd0eda5 100644 --- a/src/maildir/envelope/get.rs +++ b/src/maildir/envelope/get.rs @@ -9,8 +9,8 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MessageIdArg}, + client::MaildirClient, }; /// Get a single MAILDIR envelope. @@ -27,13 +27,12 @@ pub struct MaildirEnvelopeGetCommand { } impl MaildirEnvelopeGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; - let client = account.new_maildir_client(); let message = client.get(maildir, &self.id.inner)?; let path = message.path().to_owned(); @@ -43,7 +42,7 @@ impl MaildirEnvelopeGetCommand { }; let table = EnvelopeTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), headers: parsed.headers(), }; diff --git a/src/maildir/envelope/list.rs b/src/maildir/envelope/list.rs index bd35d321..f95d7e55 100644 --- a/src/maildir/envelope/list.rs +++ b/src/maildir/envelope/list.rs @@ -7,7 +7,7 @@ use io_maildir::maildir::Maildir; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::maildir::{account::MaildirAccount, arg::MaildirPathFlag}; +use crate::maildir::{arg::MaildirPathFlag, client::MaildirClient}; /// List MAILDIR envelopes from the given mailbox. /// @@ -21,13 +21,12 @@ pub struct MaildirEnvelopeListCommand { } impl MaildirEnvelopeListCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; - let client = account.new_maildir_client(); let messages = client.list_messages(maildir)?; let mut envelopes = Vec::with_capacity(messages.len()); @@ -61,8 +60,8 @@ impl MaildirEnvelopeListCommand { envelopes.sort_by(|a, b| a.date.cmp(&b.date)); let table = EnvelopesTable { - preset: account.table_preset, - arrangement: account.table_arrangement, + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), envelopes, }; diff --git a/src/maildir/flag/add.rs b/src/maildir/flag/add.rs index eb601796..5f294b1a 100644 --- a/src/maildir/flag/add.rs +++ b/src/maildir/flag/add.rs @@ -4,8 +4,8 @@ use io_maildir::{flag::Flags, maildir::Maildir}; use pimalaya_cli::printer::{Message, Printer}; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MessageIdsArg}, + client::MaildirClient, flag::arg::FlagArg, }; @@ -26,14 +26,13 @@ pub struct MaildirFlagAddCommand { } impl MaildirFlagAddCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; let flags = Flags::from_iter(self.flags.into_iter().map(Into::into)); - let client = account.new_maildir_client(); for id in self.ids.inner { client.add_flags(maildir.clone(), id, flags.clone())?; diff --git a/src/maildir/flag/cli.rs b/src/maildir/flag/cli.rs index 0ec18bc9..b1ea6aa2 100644 --- a/src/maildir/flag/cli.rs +++ b/src/maildir/flag/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::maildir::{ - account::MaildirAccount, + client::MaildirClient, flag::{ add::MaildirFlagAddCommand, list::MaildirFlagListCommand, remove::MaildirFlagRemoveCommand, set::MaildirFlagSetCommand, @@ -23,12 +23,12 @@ pub enum MaildirFlagCommand { } impl MaildirFlagCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { match self { - Self::List(cmd) => cmd.execute(printer, account), - Self::Add(cmd) => cmd.execute(printer, account), - Self::Set(cmd) => cmd.execute(printer, account), - Self::Remove(cmd) => cmd.execute(printer, account), + Self::List(cmd) => cmd.execute(printer, client), + Self::Add(cmd) => cmd.execute(printer, client), + Self::Set(cmd) => cmd.execute(printer, client), + Self::Remove(cmd) => cmd.execute(printer, client), } } } diff --git a/src/maildir/flag/list.rs b/src/maildir/flag/list.rs index 05448fa9..7316b193 100644 --- a/src/maildir/flag/list.rs +++ b/src/maildir/flag/list.rs @@ -7,7 +7,7 @@ use io_maildir::flag::Flag; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::maildir::account::MaildirAccount; +use crate::maildir::client::MaildirClient; /// List available MAILDIR flags for the given mailbox. /// @@ -18,10 +18,10 @@ use crate::maildir::account::MaildirAccount; pub struct MaildirFlagListCommand; impl MaildirFlagListCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let table = FlagsTable { - preset: account.table_preset, - arrangement: account.table_arrangement, + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), flags: vec![ FlagRow::new(Flag::Passed), FlagRow::new(Flag::Replied), diff --git a/src/maildir/flag/remove.rs b/src/maildir/flag/remove.rs index f664d304..7d17f140 100644 --- a/src/maildir/flag/remove.rs +++ b/src/maildir/flag/remove.rs @@ -4,8 +4,8 @@ use io_maildir::{flag::Flags, maildir::Maildir}; use pimalaya_cli::printer::{Message, Printer}; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MessageIdsArg}, + client::MaildirClient, flag::arg::FlagArg, }; @@ -26,14 +26,13 @@ pub struct MaildirFlagRemoveCommand { } impl MaildirFlagRemoveCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; let flags = Flags::from_iter(self.flags.into_iter().map(Into::into)); - let client = account.new_maildir_client(); for id in self.ids.inner { client.remove_flags(maildir.clone(), id, flags.clone())?; diff --git a/src/maildir/flag/set.rs b/src/maildir/flag/set.rs index 1d351bae..3bebd76b 100644 --- a/src/maildir/flag/set.rs +++ b/src/maildir/flag/set.rs @@ -4,8 +4,8 @@ use io_maildir::{flag::Flags, maildir::Maildir}; use pimalaya_cli::printer::{Message, Printer}; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MessageIdsArg}, + client::MaildirClient, flag::arg::FlagArg, }; @@ -26,14 +26,13 @@ pub struct MaildirFlagSetCommand { } impl MaildirFlagSetCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; let flags = Flags::from_iter(self.flags.into_iter().map(Into::into)); - let client = account.new_maildir_client(); for id in self.ids.inner { client.set_flags(maildir.clone(), id, flags.clone())?; diff --git a/src/maildir/list.rs b/src/maildir/list.rs index 9638c19d..d555e0d7 100644 --- a/src/maildir/list.rs +++ b/src/maildir/list.rs @@ -7,7 +7,7 @@ use io_maildir::maildir::Maildir; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::maildir::account::MaildirAccount; +use crate::maildir::client::MaildirClient; /// List, search and filter maildirs. /// @@ -18,12 +18,11 @@ use crate::maildir::account::MaildirAccount; pub struct MaildirMailboxListCommand; impl MaildirMailboxListCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { - let client = account.new_maildir_client(); + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildirs = client.list_maildirs()?; let table = MaildirsTable { - preset: account.table_preset, + preset: client.account.table_preset().to_string(), rows: maildirs.into_iter().map(From::from).collect(), }; diff --git a/src/maildir/message/cli.rs b/src/maildir/message/cli.rs index 36874470..9375d584 100644 --- a/src/maildir/message/cli.rs +++ b/src/maildir/message/cli.rs @@ -3,7 +3,7 @@ use clap::Subcommand; use pimalaya_cli::printer::Printer; use crate::maildir::{ - account::MaildirAccount, + client::MaildirClient, message::{ copy::MaildirMessageCopyCommand, export::MaildirMessageExportCommand, get::MaildirMessageGetCommand, r#move::MaildirMessageMoveCommand, @@ -27,14 +27,14 @@ pub enum MaildirMessageCommand { } impl MaildirMessageCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { match self { - Self::Save(cmd) => cmd.execute(printer, account), - Self::Get(cmd) => cmd.execute(printer, account), - Self::Read(cmd) => cmd.execute(printer, account), - Self::Export(cmd) => cmd.execute(printer, account), - Self::Copy(cmd) => cmd.execute(printer, account), - Self::Move(cmd) => cmd.execute(printer, account), + Self::Save(cmd) => cmd.execute(printer, client), + Self::Get(cmd) => cmd.execute(printer, client), + Self::Read(cmd) => cmd.execute(printer, client), + Self::Export(cmd) => cmd.execute(printer, client), + Self::Copy(cmd) => cmd.execute(printer, client), + Self::Move(cmd) => cmd.execute(printer, client), } } } diff --git a/src/maildir/message/copy.rs b/src/maildir/message/copy.rs index 08d40fc9..feeb000f 100644 --- a/src/maildir/message/copy.rs +++ b/src/maildir/message/copy.rs @@ -4,8 +4,8 @@ use io_maildir::maildir::Maildir; use pimalaya_cli::printer::{Message, Printer}; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MaildirSubdirArg, MessageIdsArg, TargetMaildirPathFlag}, + client::MaildirClient, }; /// Copy Maildir message to the given mailbox. @@ -27,19 +27,17 @@ pub struct MaildirMessageCopyCommand { } impl MaildirMessageCopyCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let source = match Maildir::try_from(self.source.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.source.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.source.inner))?, }; let target = match Maildir::try_from(self.target.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.target.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.target.inner))?, }; - let client = account.new_maildir_client(); - for id in self.ids.inner { client.copy( id, diff --git a/src/maildir/message/export.rs b/src/maildir/message/export.rs index 995ccba3..fcf1766e 100644 --- a/src/maildir/message/export.rs +++ b/src/maildir/message/export.rs @@ -10,8 +10,8 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MessageIdArg}, + client::MaildirClient, }; /// Export a message. @@ -41,13 +41,12 @@ pub struct MaildirMessageExportCommand { } impl MaildirMessageExportCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; - let client = account.new_maildir_client(); let msg = client.get(maildir, &self.id.inner)?; match self.r#type { diff --git a/src/maildir/message/get.rs b/src/maildir/message/get.rs index 1a8b4a83..f52c9c0b 100644 --- a/src/maildir/message/get.rs +++ b/src/maildir/message/get.rs @@ -7,8 +7,8 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MessageIdArg}, + client::MaildirClient, }; /// Get Maildir message to the given mailbox. @@ -24,13 +24,12 @@ pub struct MaildirMessageGetCommand { } impl MaildirMessageGetCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; - let client = account.new_maildir_client(); let msg = client.get(maildir, &self.id.inner)?; let path = msg.path().to_owned(); diff --git a/src/maildir/message/move.rs b/src/maildir/message/move.rs index 1aed9b90..70e476a9 100644 --- a/src/maildir/message/move.rs +++ b/src/maildir/message/move.rs @@ -4,8 +4,8 @@ use io_maildir::maildir::Maildir; use pimalaya_cli::printer::{Message, Printer}; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MaildirSubdirArg, MessageIdsArg, TargetMaildirPathFlag}, + client::MaildirClient, }; /// Move Maildir message to the given mailbox. @@ -27,19 +27,17 @@ pub struct MaildirMessageMoveCommand { } impl MaildirMessageMoveCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let source = match Maildir::try_from(self.source.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.source.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.source.inner))?, }; let target = match Maildir::try_from(self.target.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.target.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.target.inner))?, }; - let client = account.new_maildir_client(); - for id in self.ids.inner { client.r#move( id, diff --git a/src/maildir/message/read.rs b/src/maildir/message/read.rs index 598dfbcf..27486499 100644 --- a/src/maildir/message/read.rs +++ b/src/maildir/message/read.rs @@ -7,8 +7,8 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MessageIdArg}, + client::MaildirClient, }; /// Read message content. @@ -31,13 +31,12 @@ pub struct MaildirMessageReadCommand { } impl MaildirMessageReadCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; - let client = account.new_maildir_client(); let message = client.get(maildir, &self.id.inner)?; let path = message.path().to_owned(); diff --git a/src/maildir/message/save.rs b/src/maildir/message/save.rs index dae17145..9d067be3 100644 --- a/src/maildir/message/save.rs +++ b/src/maildir/message/save.rs @@ -11,8 +11,8 @@ use pimalaya_cli::printer::Printer; use serde::Serialize; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirPathFlag, MaildirSubdirArg}, + client::MaildirClient, flag::arg::FlagArg, }; @@ -41,10 +41,10 @@ pub struct MaildirMessageSaveCommand { } impl MaildirMessageSaveCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { let maildir = match Maildir::try_from(self.maildir.inner.clone()) { Ok(maildir) => maildir, - Err(_) => Maildir::try_from(account.backend.root.join(&self.maildir.inner))?, + Err(_) => Maildir::try_from(client.root.join(&self.maildir.inner))?, }; let msg = if stdin().is_terminal() || printer.is_json() { @@ -62,7 +62,7 @@ impl MaildirMessageSaveCommand { }; let flags = Flags::from_iter(self.flags.into_iter().map(Into::into)); - let client = account.new_maildir_client(); + let (id, path) = client.store(maildir, self.subdir.into(), flags, msg.into_bytes())?; printer.out(StoredMessage { id, path }) diff --git a/src/maildir/mod.rs b/src/maildir/mod.rs index 07f7496f..9229db21 100644 --- a/src/maildir/mod.rs +++ b/src/maildir/mod.rs @@ -1,6 +1,6 @@ -pub mod account; pub mod arg; pub mod cli; +pub mod client; pub mod create; pub mod delete; pub mod envelope; @@ -8,4 +8,3 @@ 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 c4ba1f44..3a48af18 100644 --- a/src/maildir/rename.rs +++ b/src/maildir/rename.rs @@ -3,8 +3,8 @@ use clap::Parser; use pimalaya_cli::printer::{Message, Printer}; use crate::maildir::{ - account::MaildirAccount, arg::{MaildirNameArg, MaildirPathFlag}, + client::MaildirClient, }; /// Rename the given mailbox. @@ -20,9 +20,9 @@ pub struct MaildirMailboxRenameCommand { } impl MaildirMailboxRenameCommand { - pub fn execute(self, printer: &mut impl Printer, account: MaildirAccount) -> Result<()> { - let path = account.backend.root.join(&self.maildir_path.inner); - let client = account.new_maildir_client(); + pub fn execute(self, printer: &mut impl Printer, client: MaildirClient) -> Result<()> { + let path = client.root.join(&self.maildir_path.inner); + client.rename_maildir(path, self.maildir_name.inner)?; printer.out(Message::new("Maildir successfully renamed")) } diff --git a/src/maildir/runtime.rs b/src/maildir/runtime.rs deleted file mode 100644 index 580d60dc..00000000 --- a/src/maildir/runtime.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! 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. -//! -//! Used by the cross-protocol shared commands (`messages add`, `mv`, -//! `copy`); the protocol-specific `maildir` subcommands now go -//! through [`io_maildir::client::MaildirClient`]. - -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 9793da9f..5f76cb5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,18 @@ mod account; -#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] -mod attachments; mod cli; mod config; -mod email_client; -mod envelopes; -mod flags; #[cfg(feature = "imap")] mod imap; #[cfg(feature = "jmap")] mod jmap; -mod mailboxes; #[cfg(feature = "maildir")] mod maildir; -mod messages; +mod shared; #[cfg(feature = "smtp")] mod smtp; mod wizard; +use anyhow::Result; use clap::Parser; use pimalaya_cli::{error::ErrorReport, log::Logger, printer::StdoutPrinter}; @@ -25,17 +20,15 @@ use crate::cli::HimalayaCli; fn main() { let cli = HimalayaCli::parse(); - - Logger::init(&cli.log); - 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, backend); - - ErrorReport::eval(&mut printer, result) + let result = execute(cli, &mut printer); + ErrorReport::eval(&mut printer, result); +} + +fn execute(cli: HimalayaCli, printer: &mut StdoutPrinter) -> Result<()> { + Logger::try_init(&cli.log)?; + let config = cli.config_paths.as_ref(); + let account = cli.account.name.as_deref(); + let backend = cli.backend; + cli.command.execute(printer, config, account, backend) } diff --git a/src/messages/add.rs b/src/messages/add.rs deleted file mode 100644 index bbb29c40..00000000 --- a/src/messages/add.rs +++ /dev/null @@ -1,203 +0,0 @@ -#[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_cli::printer::Message; -use pimalaya_cli::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 crate::imap::session::ImapSession; - use io_email::imap::message_add::{MessageAdd, MessageAddResult}; - use io_imap::types::mailbox::Mailbox; - - 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 crate::jmap::session::JmapSession; - use io_email::jmap::message_add::{MessageAdd, MessageAddResult}; - - 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/cli.rs b/src/messages/cli.rs deleted file mode 100644 index 4c98e4ad..00000000 --- a/src/messages/cli.rs +++ /dev/null @@ -1,49 +0,0 @@ -use anyhow::Result; -use clap::Subcommand; -use pimalaya_cli::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 deleted file mode 100644 index d4b308fc..00000000 --- a/src/messages/compose.rs +++ /dev/null @@ -1,599 +0,0 @@ -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_cli::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_stream::std::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 crate::jmap::session::JmapSession; - use io_email::jmap::message_send::{MessageSend, MessageSendResult}; - - 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 deleted file mode 100644 index 07dee973..00000000 --- a/src/messages/copy.rs +++ /dev/null @@ -1,173 +0,0 @@ -#[cfg(any(feature = "imap", feature = "jmap"))] -use std::io::{Read, Write}; - -use anyhow::{bail, Result}; -use clap::Parser; -use pimalaya_cli::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 crate::imap::session::ImapSession; - use io_email::imap::message_copy::{MessageCopy, MessageCopyResult}; - use io_imap::types::{mailbox::Mailbox, sequence::SequenceSet}; - - 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 crate::jmap::session::JmapSession; - use io_email::jmap::message_copy::{MessageCopy, MessageCopyResult}; - - 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 deleted file mode 100644 index 66b1814d..00000000 --- a/src/messages/fetch.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! 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. - -use anyhow::Result; - -use crate::{ - cli::BackendArg, - config::{AccountConfig, Config}, - email_client::build, -}; - -/// 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> { - let mut ctx = build(config.clone(), account_config.clone(), backend)?; - Ok(ctx.client.get_message(mailbox, id)?) -} diff --git a/src/messages/mod.rs b/src/messages/mod.rs deleted file mode 100644 index 968652e2..00000000 --- a/src/messages/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -pub mod add; -pub mod cli; -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 deleted file mode 100644 index 1ff44bef..00000000 --- a/src/messages/mv.rs +++ /dev/null @@ -1,174 +0,0 @@ -#[cfg(any(feature = "imap", feature = "jmap"))] -use std::io::{Read, Write}; - -use anyhow::{bail, Result}; -use clap::Parser; -use pimalaya_cli::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 crate::imap::session::ImapSession; - use io_email::imap::message_move::{MessageMove, MessageMoveResult}; - use io_imap::types::{mailbox::Mailbox, sequence::SequenceSet}; - - 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 crate::jmap::session::JmapSession; - use io_email::jmap::message_move::{MessageMove, MessageMoveResult}; - - 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 deleted file mode 100644 index 4b608acf..00000000 --- a/src/messages/send.rs +++ /dev/null @@ -1,257 +0,0 @@ -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_cli::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_stream::std::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 crate::jmap::session::JmapSession; - use io_email::jmap::message_send::{MessageSend, MessageSendResult}; - - 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/shared/attachments/cli.rs b/src/shared/attachments/cli.rs new file mode 100644 index 00000000..1fbb6e11 --- /dev/null +++ b/src/shared/attachments/cli.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + attachments::{download::AttachmentDownloadCommand, list::AttachmentListCommand}, + client::EmailClient, +}; + +/// Shared API to manage attachments for the active account. +/// +/// An attachment is a binary part of a message. +#[derive(Debug, Subcommand)] +pub enum AttachmentCommand { + #[command(visible_alias = "ls")] + List(AttachmentListCommand), + #[command(visible_alias = "dl")] + Download(AttachmentDownloadCommand), +} + +impl AttachmentCommand { + pub fn execute(self, printer: &mut impl Printer, client: EmailClient) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, client), + Self::Download(cmd) => cmd.execute(printer, client), + } + } +} diff --git a/src/shared/attachments/download.rs b/src/shared/attachments/download.rs new file mode 100644 index 00000000..19f36937 --- /dev/null +++ b/src/shared/attachments/download.rs @@ -0,0 +1,161 @@ +use std::{ + collections::BTreeSet, + fs, + path::{Path, PathBuf}, +}; + +use anyhow::{bail, Result}; +use clap::Parser; +use mail_parser::{MessageParser, MimeHeaders}; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + attachments::list::{mime_string, Attachment, Attachments}, + client::EmailClient, + flags::arg::MailboxIdArg, +}; + +/// Download specific attachments of a single message to disk. +/// +/// The attachment ids are the 1-based positions reported by +/// `attachments list`. Pass one or more ids to fetch exactly those +/// parts. Inline parts are addressable by their id too — the id you +/// see in `attachments list --inline` is the same id you pass here. +/// +/// 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 AttachmentDownloadCommand { + #[command(flatten)] + pub mailbox_id: MailboxIdArg, + + /// Identifier of the message. + #[arg(value_name = "MESSAGE-ID")] + pub message_id: String, + + /// Attachment identifier(s) to download. + /// + /// Omit identifiers to download all attachments. + #[arg(value_name = "ATTACHMENT-ID", num_args = 0..)] + pub attachment_ids: Vec, + + /// Destination directory. + /// + /// Overrides the account/global `downloads-dir` config. + #[arg(long, short, value_name = "PATH")] + pub dir: Option, +} + +impl AttachmentDownloadCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let raw = client.get_message(&self.mailbox_id.inner, &self.message_id)?; + + let Some(message) = MessageParser::new().parse(&raw) else { + bail!("Failed to parse RFC 5322 message"); + }; + + let dir = self + .dir + .clone() + .unwrap_or_else(|| client.account.downloads_dir()); + + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + + let wanted_all = self.attachment_ids.is_empty(); + let mut remaining: BTreeSet = self.attachment_ids.iter().cloned().collect(); + let mut written = Vec::new(); + + for (index, part) in message.attachments().enumerate() { + let id = (index + 1).to_string(); + if !wanted_all && !remaining.remove(&id) { + continue; + } + + let inline = part + .content_disposition() + .map(|cd| cd.c_type.eq_ignore_ascii_case("inline")) + .unwrap_or(false); + let filename = part.attachment_name().map(str::to_owned); + let on_disk_name = filename + .clone() + .unwrap_or_else(|| format!("attachment-{id}")); + let safe = sanitize(&on_disk_name); + let path = unique_path(&dir, &safe); + + fs::write(&path, part.contents())?; + + written.push(Attachment { + id, + filename, + mime: mime_string(part), + size: part.contents().len() as u64, + inline, + path: Some(path.display().to_string()), + }); + } + + if !remaining.is_empty() { + let missing: Vec = remaining.into_iter().collect(); + bail!( + "no attachment with id {} on message `{}`", + missing.join(", "), + self.message_id, + ); + } + + let attachments = Attachments { + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), + with_inline: written.iter().any(|a| a.inline), + with_path: true, + attachments: written, + }; + + printer.out(attachments) + } +} + +/// 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/shared/attachments/list.rs b/src/shared/attachments/list.rs new file mode 100644 index 00000000..0dcff475 --- /dev/null +++ b/src/shared/attachments/list.rs @@ -0,0 +1,164 @@ +use std::fmt; + +use anyhow::{bail, Result}; +use clap::Parser; +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use humansize::{format_size, BINARY}; +use mail_parser::{MessageParser, MessagePart, MimeHeaders}; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::shared::{client::EmailClient, flags::arg::MailboxIdArg}; + +/// List the attachments carried by a single message in the active +/// account. +/// +/// Each row carries a 1-based `ID` matching the position of the part +/// in mail_parser's attachment iteration order. The `ID` is stable +/// regardless of the `--inline` filter — listing only the attachment +/// parts and listing every non-body part assign the same id to the +/// same underlying part. So if a message has parts `1=attachment, +/// 2=attachment, 3=inline, 4=attachment`, the default listing shows +/// `1 2 4` and `--inline` shows `1 2 3 4`. +/// +/// Pass `--inline` to surface inline parts (typically embedded images +/// referenced by HTML bodies via `cid:`). +#[derive(Debug, Parser)] +pub struct AttachmentListCommand { + #[command(flatten)] + pub mailbox_id: MailboxIdArg, + /// Identifier of the message. + #[arg(value_name = "MESSAGE-ID")] + pub message_id: String, + /// Include parts with `Content-Disposition: inline`. + #[arg(long, short)] + pub inline: bool, +} + +impl AttachmentListCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let raw = client.get_message(&self.mailbox_id.inner, &self.message_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 = part + .content_disposition() + .map(|cd| cd.c_type.eq_ignore_ascii_case("inline")) + .unwrap_or(false); + + if inline && !self.inline { + continue; + } + + attachments.push(Attachment { + id: (index + 1).to_string(), + filename: part.attachment_name().map(str::to_owned), + mime: mime_string(part), + size: part.contents().len() as u64, + inline, + path: None, + }); + } + + let attachments = Attachments { + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), + with_inline: self.inline, + with_path: false, + attachments, + }; + + printer.out(attachments) + } +} + +/// One row of the `attachments list` / `attachments download` output. +#[derive(Clone, Debug, Serialize)] +pub struct Attachment { + /// 1-based linear index in mail-parser's attachment iteration + /// order. Stable across the `--inline` filter. + pub id: String, + /// Filename from `Content-Disposition: filename=` (or + /// `Content-Type: name=`), RFC 2231-decoded. `None` when the + /// source provides no name. + pub filename: Option, + /// MIME type (e.g. `"application/pdf"`). `None` when the source + /// omits the `Content-Type` header. + pub mime: Option, + /// Size in bytes of the decoded part body. + pub size: u64, + /// `true` when the part carries `Content-Disposition: inline`. + pub inline: bool, + /// Destination path the bytes were written to (set by + /// `attachments download`; `None` for `attachments list`). + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Attachments { + #[serde(skip)] + pub preset: String, + #[serde(skip)] + pub arrangement: ContentArrangement, + #[serde(skip)] + pub with_inline: bool, + #[serde(skip)] + pub with_path: bool, + pub attachments: Vec, +} + +impl fmt::Display for Attachments { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + let mut header = vec![ + Cell::new("ID"), + Cell::new("FILENAME"), + Cell::new("TYPE"), + Cell::new("SIZE"), + ]; + if self.with_inline { + header.push(Cell::new("INLINE")); + } + if self.with_path { + header.push(Cell::new("PATH")); + } + + table + .load_preset(&self.preset) + .set_content_arrangement(self.arrangement.clone()) + .set_header(Row::from(header)) + .add_rows(self.attachments.iter().map(|a| { + let mut row = Row::new(); + row.max_height(1); + row.add_cell(Cell::new(&a.id)); + row.add_cell(Cell::new(a.filename.as_deref().unwrap_or(""))); + row.add_cell(Cell::new(a.mime.as_deref().unwrap_or(""))); + row.add_cell(Cell::new(format_size(a.size, BINARY))); + if self.with_inline { + row.add_cell(Cell::new(if a.inline { "yes" } else { "no" })); + } + if self.with_path { + row.add_cell(Cell::new(a.path.as_deref().unwrap_or(""))); + } + row + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} + +pub(super) fn mime_string(part: &MessagePart<'_>) -> Option { + let ct = part.content_type()?; + + Some(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/shared/attachments/mod.rs similarity index 75% rename from src/attachments/mod.rs rename to src/shared/attachments/mod.rs index 1cff55d2..183b8f18 100644 --- a/src/attachments/mod.rs +++ b/src/shared/attachments/mod.rs @@ -1,4 +1,3 @@ pub mod cli; pub mod download; pub mod list; -pub mod table; diff --git a/src/shared/client.rs b/src/shared/client.rs new file mode 100644 index 00000000..7241f27b --- /dev/null +++ b/src/shared/client.rs @@ -0,0 +1,159 @@ +//! Cross-protocol [`EmailClient`] for the shared subcommands +//! (`mailboxes`, `envelopes`, `flags`, `messages`, `attachments`). +//! +//! Wraps [`io_email::client::EmailClient`] and bundles the active +//! [`Account`] (display, identity, composer/reader registries) the +//! shared commands need alongside the I/O client. Implements +//! [`Deref`]/[`DerefMut`] onto the inner client so callers can call +//! its methods directly. +//! +//! Construction is backend-asymmetric (IMAP needs TLS + SASL, JMAP +//! needs an HTTP credential, Maildir just needs a root path). Each +//! `new_` constructor delegates to the transitional +//! [`ImapSession`] / [`JmapSession`] helpers for the handshake/auth +//! flow then bridges the resulting `(stream, context)` pairs into +//! [`io_imap::client::ImapClient`] / [`io_jmap::client::JmapClient`] +//! via their `from_parts` constructors. +//! +//! [`ImapSession`]: crate::imap::session::ImapSession +//! [`JmapSession`]: crate::jmap::session::JmapSession + +use std::{ + ops::{Deref, DerefMut}, + path::PathBuf, +}; + +use anyhow::{anyhow, bail, Result}; +use io_email::client::SendMessageOpts; +use pimalaya_config::toml::TomlConfig; + +use crate::{ + account::context::Account, + cli::{load_or_wizard, BackendFlag}, +}; + +pub struct EmailClient { + inner: io_email::client::EmailClient, + pub account: Account, + /// Pre-computed options for [`io_email::client::EmailClient::send_message`]. + /// Populated by the per-protocol constructors with the bits each + /// backend needs (currently only the JMAP identity / drafts + /// mailbox ids); other fields are filled in at send time from the + /// outgoing message itself. + pub send_opts: SendMessageOpts, +} + +impl EmailClient { + /// Loads the configuration, picks the active account, builds the + /// merged [`Account`], then constructs an [`EmailClient`] for the + /// first backend allowed by `backend` that is configured on the + /// account. Selection order is `jmap → imap → maildir`. Bails when + /// no backend matches. + pub fn new( + config_paths: &[PathBuf], + account_name: Option<&str>, + backend: BackendFlag, + ) -> Result { + let mut config = load_or_wizard(config_paths)?; + let (_, mut ac) = config + .take_account(account_name)? + .ok_or_else(|| anyhow!("Cannot find account"))?; + + #[cfg(feature = "jmap")] + if backend.allows_jmap() { + if let Some(jmap_config) = ac.jmap.take() { + let account = Account::from(config).merge(Account::from(ac)); + return EmailClient::new_jmap(jmap_config, account); + } + } + + #[cfg(feature = "imap")] + if backend.allows_imap() { + if let Some(imap_config) = ac.imap.take() { + let account = Account::from(config).merge(Account::from(ac)); + return EmailClient::new_imap(imap_config, account); + } + } + + #[cfg(feature = "maildir")] + if backend.allows_maildir() { + if let Some(maildir_config) = ac.maildir.take() { + let account = Account::from(config).merge(Account::from(ac)); + return EmailClient::new_maildir(maildir_config, account); + } + } + + bail!("no backend matching `{backend}` is configured for this account") + } + + #[cfg(feature = "imap")] + pub fn new_imap(config: crate::config::ImapConfig, account: Account) -> Result { + use io_imap::client::ImapClient; + + use crate::imap::session::ImapSession; + + let session = ImapSession::new( + config.url, + config.tls.try_into()?, + config.starttls, + config.sasl.try_into()?, + )?; + let client = ImapClient::from_parts(session.stream, session.context); + Ok(Self { + inner: client.into(), + account, + send_opts: SendMessageOpts::default(), + }) + } + + #[cfg(feature = "jmap")] + pub fn new_jmap(config: crate::config::JmapConfig, account: Account) -> Result { + use io_jmap::client::JmapClient; + + use crate::jmap::session::JmapSession; + + let send_opts = SendMessageOpts { + jmap_identity_id: config.identity_id.clone(), + jmap_drafts_mailbox_id: config.drafts_mailbox_id.clone(), + ..SendMessageOpts::default() + }; + + let session = JmapSession::new( + config.server, + config.tls.try_into()?, + config.auth.try_into()?, + )?; + let client = JmapClient::from_parts(session.stream, session.http_auth, session.session); + Ok(Self { + inner: client.into(), + account, + send_opts, + }) + } + + #[cfg(feature = "maildir")] + pub fn new_maildir(config: crate::config::MaildirConfig, account: Account) -> Result { + use io_maildir::client::MaildirClient; + + let client = MaildirClient::new(config.root); + Ok(Self { + inner: client.into(), + account, + send_opts: SendMessageOpts::default(), + }) + } +} + +impl Deref for EmailClient { + type Target = io_email::client::EmailClient; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for EmailClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} diff --git a/src/shared/envelopes/cli.rs b/src/shared/envelopes/cli.rs new file mode 100644 index 00000000..7a223c76 --- /dev/null +++ b/src/shared/envelopes/cli.rs @@ -0,0 +1,24 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::shared::{client::EmailClient, envelopes::list::EnvelopeListCommand}; + +/// Shared API to manage envelopes for the active account. +/// +/// An envelope is a message headers subset. It is usually small, and +/// contains enough information to have an overall understanding of +/// what a message is about. +#[derive(Debug, Subcommand)] +pub enum EnvelopeCommand { + #[command(visible_alias = "ls")] + List(EnvelopeListCommand), +} + +impl EnvelopeCommand { + pub fn execute(self, printer: &mut impl Printer, client: EmailClient) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, client), + } + } +} diff --git a/src/shared/envelopes/list.rs b/src/shared/envelopes/list.rs new file mode 100644 index 00000000..91b81e52 --- /dev/null +++ b/src/shared/envelopes/list.rs @@ -0,0 +1,184 @@ +use std::{collections::BTreeSet, fmt}; + +use anyhow::Result; +use chrono::{DateTime, FixedOffset, Local}; +use clap::Parser; +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use humansize::{format_size, BINARY}; +use io_email::{address::Address, envelope::Envelope, flag::Flag}; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::shared::{client::EmailClient, flags::arg::MailboxIdArg}; + +/// List envelopes for the active account, regardless of the underlying +/// backend (IMAP, JMAP or Maildir). +#[derive(Debug, Parser)] +pub struct EnvelopeListCommand { + #[command(flatten)] + pub mailbox_id: MailboxIdArg, + + /// Page number, starting from 1. The most recent envelopes are on + /// page 1. + #[arg(long, short = 'p')] + #[arg(value_name = "N", default_value = "1")] + pub page: u32, + + /// Maximum number of envelopes per page. + #[arg(long = "page-size", short = 's')] + #[arg(value_name = "N", default_value = "25")] + pub page_size: u32, + + /// Render recipients (`To:`) instead of senders (`From:`). Useful + /// for sent folders. + #[arg(long, short)] + pub recipient: bool, + + /// Populate the ATT column. Free on JMAP; on IMAP this fetches + /// `BODYSTRUCTURE` in addition to `ENVELOPE`; Maildir already + /// parses the message body for subject/from/to so the toggle is + /// essentially free there. + #[arg(long = "has-attachment")] + pub has_attachment: bool, +} + +impl EnvelopeListCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let page = Some(self.page).filter(|p| *p > 0); + let page_size = Some(self.page_size).filter(|p| *p > 0); + + let envelopes = + client.list_envelopes(&self.mailbox_id.inner, page, page_size, self.has_attachment)?; + + let envelopes = Envelopes { + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), + datetime_fmt: client.account.datetime_fmt().to_string(), + datetime_local_tz: client.account.datetime_local_tz(), + recipient: self.recipient, + with_attachment: self.has_attachment, + envelopes, + }; + + printer.out(envelopes) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct Envelopes { + #[serde(skip)] + pub preset: String, + #[serde(skip)] + pub arrangement: ContentArrangement, + #[serde(skip)] + pub datetime_fmt: String, + #[serde(skip)] + pub datetime_local_tz: bool, + #[serde(skip)] + pub recipient: bool, + #[serde(skip)] + pub with_attachment: bool, + pub envelopes: Vec, +} + +impl fmt::Display for Envelopes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + let mut header = vec![Cell::new("ID"), Cell::new("FLAGS")]; + if self.with_attachment { + header.push(Cell::new("ATT")); + } + header.push(Cell::new("SUBJECT")); + header.push(Cell::new(if self.recipient { "TO" } else { "FROM" })); + header.push(Cell::new("DATE")); + header.push(Cell::new("SIZE")); + + table + .load_preset(&self.preset) + .set_content_arrangement(self.arrangement.clone()) + .set_header(Row::from(header)) + .add_rows(self.envelopes.iter().map(|env| { + let mut row = Row::new(); + row.max_height(1); + row.add_cell(Cell::new(&env.id)); + row.add_cell(Cell::new(format_flags(&env.flags))); + if self.with_attachment { + row.add_cell(Cell::new(format_attachment(env.has_attachment))); + } + row.add_cell(Cell::new(&env.subject)); + + let addresses = if self.recipient { &env.to } else { &env.from }; + row.add_cell(Cell::new(format_addresses(addresses))); + + row.add_cell(Cell::new(format_date( + env.date, + &self.datetime_fmt, + self.datetime_local_tz, + ))); + row.add_cell(Cell::new(format_size(env.size, BINARY))); + row + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} + +/// 4-character flag widget — one slot per LCD variant. Unread (no +/// `Seen`) shows `N` in the first slot since unread is the +/// attention-grabbing case. +fn format_flags(flags: &BTreeSet) -> String { + let mut out = String::with_capacity(4); + out.push(if flags.contains(&Flag::Seen) { + ' ' + } else { + 'N' + }); + out.push(if flags.contains(&Flag::Answered) { + 'r' + } else { + ' ' + }); + out.push(if flags.contains(&Flag::Flagged) { + '*' + } else { + ' ' + }); + out.push(if flags.contains(&Flag::Draft) { + 'D' + } else { + ' ' + }); + out +} + +fn format_attachment(has: Option) -> &'static str { + match has { + Some(true) => "@", + Some(false) => "", + None => "?", + } +} + +fn format_addresses(addrs: &[Address]) -> String { + addrs + .iter() + .map(|a| match &a.name { + Some(name) if !name.is_empty() => name.clone(), + _ => a.email.clone(), + }) + .collect::>() + .join(", ") +} + +fn format_date(date: Option>, fmt: &str, local_tz: bool) -> String { + let Some(date) = date else { + return String::new(); + }; + if local_tz { + date.with_timezone(&Local).format(fmt).to_string() + } else { + date.format(fmt).to_string() + } +} diff --git a/src/envelopes/mod.rs b/src/shared/envelopes/mod.rs similarity index 64% rename from src/envelopes/mod.rs rename to src/shared/envelopes/mod.rs index 0cf8a1a4..ae4d743d 100644 --- a/src/envelopes/mod.rs +++ b/src/shared/envelopes/mod.rs @@ -1,3 +1,2 @@ pub mod cli; pub mod list; -pub mod table; diff --git a/src/shared/flags/add.rs b/src/shared/flags/add.rs new file mode 100644 index 00000000..2908f5e1 --- /dev/null +++ b/src/shared/flags/add.rs @@ -0,0 +1,46 @@ +use std::fmt; + +use anyhow::Result; +use clap::Parser; +use io_email::flag::Flag; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::shared::{ + client::EmailClient, + flags::arg::{FlagsArg, MailboxIdArg, MessageIdsArg}, +}; + +/// Add flag(s) to message(s) for the active account. +#[derive(Debug, Parser)] +pub struct FlagAddCommand { + #[command(flatten)] + pub mailbox_id: MailboxIdArg, + #[command(flatten)] + pub message_ids: MessageIdsArg, + #[command(flatten)] + pub flags: FlagsArg, +} + +impl FlagAddCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let ids: Vec<&str> = self.message_ids.inner.iter().map(String::as_str).collect(); + let flags: Vec = self.flags.inner.iter().map(Into::into).collect(); + + client.add_flags(&self.mailbox_id.inner, &ids, &flags)?; + + let flags: Vec = self.flags.inner.iter().map(ToString::to_string).collect(); + printer.out(AddedFlags { flags }) + } +} + +#[derive(Debug, Serialize)] +struct AddedFlags { + flags: Vec, +} + +impl fmt::Display for AddedFlags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Successfully added flags: {}", self.flags.join(", ")) + } +} diff --git a/src/flags/arg.rs b/src/shared/flags/arg.rs similarity index 65% rename from src/flags/arg.rs rename to src/shared/flags/arg.rs index 4bdab0db..ebbfd848 100644 --- a/src/flags/arg.rs +++ b/src/shared/flags/arg.rs @@ -1,15 +1,33 @@ +use std::fmt; + use clap::{Parser, ValueEnum}; +/// Shared CLI flag argument for the cross-protocol `flags` and +/// `messages add` commands. The variant set is the strict +/// least-common-denominator across IMAP, JMAP and Maildir; backend +/// extras (`\Deleted`, Maildir `Trashed`/`Passed`, JMAP custom +/// keywords) live on the protocol-specific commands. #[derive(Clone, Debug, ValueEnum)] #[clap(rename_all = "kebab-case")] pub enum FlagArg { Seen, Answered, Flagged, - Deleted, Draft, } +impl fmt::Display for FlagArg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::Seen => "seen", + Self::Answered => "answered", + Self::Flagged => "flagged", + Self::Draft => "draft", + }; + f.write_str(name) + } +} + #[cfg(feature = "imap")] impl FlagArg { pub fn imap(&self) -> io_imap::types::flag::Flag<'static> { @@ -19,7 +37,6 @@ impl FlagArg { Self::Seen => Flag::Seen, Self::Answered => Flag::Answered, Self::Flagged => Flag::Flagged, - Self::Deleted => Flag::Deleted, Self::Draft => Flag::Draft, } } @@ -32,7 +49,6 @@ impl FlagArg { Self::Seen => "$seen", Self::Answered => "$answered", Self::Flagged => "$flagged", - Self::Deleted => "$deleted", Self::Draft => "$draft", } } @@ -47,7 +63,6 @@ impl From<&FlagArg> for io_maildir::flag::Flag { FlagArg::Seen => Flag::Seen, FlagArg::Answered => Flag::Replied, FlagArg::Flagged => Flag::Flagged, - FlagArg::Deleted => Flag::Trashed, FlagArg::Draft => Flag::Draft, } } @@ -61,7 +76,6 @@ impl From<&FlagArg> for io_email::flag::Flag { FlagArg::Seen => Flag::Seen, FlagArg::Answered => Flag::Answered, FlagArg::Flagged => Flag::Flagged, - FlagArg::Deleted => Flag::Deleted, FlagArg::Draft => Flag::Draft, } } @@ -69,27 +83,23 @@ impl From<&FlagArg> for io_email::flag::Flag { #[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")] + /// Message Identifier(s). + #[arg(name = "message_ids", value_name = "MESSAGE-IDS")] #[arg(num_args = 1..)] pub inner: Vec, } #[derive(Debug, Parser)] pub struct FlagsArg { - /// Flag(s) to apply. + /// Flag(s) to add on message(s). + #[arg(name = "flags", value_name = "FLAG")] #[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 struct MailboxIdArg { + /// Mailbox identifier. + #[arg(name = "mailbox_id", value_name = "MAILBOX-ID")] pub inner: String, } diff --git a/src/shared/flags/cli.rs b/src/shared/flags/cli.rs new file mode 100644 index 00000000..1b93142c --- /dev/null +++ b/src/shared/flags/cli.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + client::EmailClient, + flags::{add::FlagAddCommand, remove::FlagRemoveCommand, set::FlagSetCommand}, +}; + +/// Shared API to manage flags for the active account. +/// +/// A flag is acting like a tag, giving information about message state or kind. +#[derive(Debug, Subcommand)] +pub enum FlagCommand { + Add(FlagAddCommand), + Set(FlagSetCommand), + #[command(visible_alias = "rm")] + Remove(FlagRemoveCommand), +} + +impl FlagCommand { + pub fn execute(self, printer: &mut impl Printer, client: EmailClient) -> Result<()> { + match self { + Self::Add(cmd) => cmd.execute(printer, client), + Self::Set(cmd) => cmd.execute(printer, client), + Self::Remove(cmd) => cmd.execute(printer, client), + } + } +} diff --git a/src/flags/mod.rs b/src/shared/flags/mod.rs similarity index 76% rename from src/flags/mod.rs rename to src/shared/flags/mod.rs index e4941f3c..5456bff4 100644 --- a/src/flags/mod.rs +++ b/src/shared/flags/mod.rs @@ -1,5 +1,5 @@ pub mod add; pub mod arg; pub mod cli; -pub mod delete; +pub mod remove; pub mod set; diff --git a/src/shared/flags/remove.rs b/src/shared/flags/remove.rs new file mode 100644 index 00000000..b2749c5c --- /dev/null +++ b/src/shared/flags/remove.rs @@ -0,0 +1,46 @@ +use std::fmt; + +use anyhow::Result; +use clap::Parser; +use io_email::flag::Flag; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::shared::{ + client::EmailClient, + flags::arg::{FlagsArg, MailboxIdArg, MessageIdsArg}, +}; + +/// Remove flag(s) from message(s) for the active account. +#[derive(Debug, Parser)] +pub struct FlagRemoveCommand { + #[command(flatten)] + pub mailbox_id: MailboxIdArg, + #[command(flatten)] + pub message_ids: MessageIdsArg, + #[command(flatten)] + pub flags: FlagsArg, +} + +impl FlagRemoveCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let ids: Vec<&str> = self.message_ids.inner.iter().map(String::as_str).collect(); + let flags: Vec = self.flags.inner.iter().map(Into::into).collect(); + + client.delete_flags(&self.mailbox_id.inner, &ids, &flags)?; + + let flags: Vec = self.flags.inner.iter().map(ToString::to_string).collect(); + printer.out(RemovedFlags { flags }) + } +} + +#[derive(Debug, Serialize)] +struct RemovedFlags { + flags: Vec, +} + +impl fmt::Display for RemovedFlags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Successfully removed flags: {}", self.flags.join(", ")) + } +} diff --git a/src/shared/flags/set.rs b/src/shared/flags/set.rs new file mode 100644 index 00000000..63507833 --- /dev/null +++ b/src/shared/flags/set.rs @@ -0,0 +1,46 @@ +use std::fmt; + +use anyhow::Result; +use clap::Parser; +use io_email::flag::Flag; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::shared::{ + client::EmailClient, + flags::arg::{FlagsArg, MailboxIdArg, MessageIdsArg}, +}; + +/// Replace flag(s) of message(s) for the active account. +#[derive(Debug, Parser)] +pub struct FlagSetCommand { + #[command(flatten)] + pub mailbox_id: MailboxIdArg, + #[command(flatten)] + pub message_ids: MessageIdsArg, + #[command(flatten)] + pub flags: FlagsArg, +} + +impl FlagSetCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let ids: Vec<&str> = self.message_ids.inner.iter().map(String::as_str).collect(); + let flags: Vec = self.flags.inner.iter().map(Into::into).collect(); + + client.set_flags(&self.mailbox_id.inner, &ids, &flags)?; + + let flags: Vec = self.flags.inner.iter().map(ToString::to_string).collect(); + printer.out(SetFlags { flags }) + } +} + +#[derive(Debug, Serialize)] +struct SetFlags { + flags: Vec, +} + +impl fmt::Display for SetFlags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Successfully set flags: {}", self.flags.join(", ")) + } +} diff --git a/src/shared/mailboxes/cli.rs b/src/shared/mailboxes/cli.rs new file mode 100644 index 00000000..5497524e --- /dev/null +++ b/src/shared/mailboxes/cli.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::shared::{client::EmailClient, mailboxes::list::MailboxListCommand}; + +/// Shared API to manage mailboxes for the active account. +/// +/// A mailbox is a message container. +#[derive(Debug, Subcommand)] +pub enum MailboxCommand { + #[command(visible_alias = "ls")] + List(MailboxListCommand), +} + +impl MailboxCommand { + pub fn execute(self, printer: &mut impl Printer, client: EmailClient) -> Result<()> { + match self { + Self::List(cmd) => cmd.execute(printer, client), + } + } +} diff --git a/src/shared/mailboxes/list.rs b/src/shared/mailboxes/list.rs new file mode 100644 index 00000000..eb87129b --- /dev/null +++ b/src/shared/mailboxes/list.rs @@ -0,0 +1,86 @@ +use std::fmt; + +use anyhow::Result; +use clap::Parser; +use comfy_table::{Cell, ContentArrangement, Row, Table}; +use io_email::mailbox::Mailbox; +use pimalaya_cli::printer::Printer; +use serde::Serialize; + +use crate::shared::client::EmailClient; + +/// Shared API to list mailboxes for the active account. +#[derive(Debug, Parser)] +pub struct MailboxListCommand { + /// Populate per-mailbox message counts (TOTAL and UNREAD columns). + /// + /// JMAP returns counts in the same response. IMAP issues an + /// extra `STATUS` per mailbox, which can be slow on accounts + /// with many mailboxes. Maildir does not implement counts yet. + #[arg(long)] + pub counts: bool, +} + +impl MailboxListCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let mailboxes = client.list_mailboxes(self.counts)?; + + let mailboxes = Mailboxes { + preset: client.account.table_preset().to_string(), + arrangement: client.account.table_arrangement(), + with_counts: self.counts, + mailboxes, + }; + + printer.out(mailboxes) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct Mailboxes { + #[serde(skip)] + pub preset: String, + #[serde(skip)] + pub arrangement: ContentArrangement, + #[serde(skip)] + pub with_counts: bool, + pub mailboxes: Vec, +} + +impl fmt::Display for Mailboxes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut table = Table::new(); + + let mut header = vec![Cell::new("ID"), Cell::new("NAME")]; + if self.with_counts { + header.push(Cell::new("TOTAL")); + header.push(Cell::new("UNREAD")); + } + + table + .load_preset(&self.preset) + .set_content_arrangement(self.arrangement.clone()) + .set_header(Row::from(header)) + .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)); + if self.with_counts { + row.add_cell(count_cell(m.total)); + row.add_cell(count_cell(m.unread)); + } + row + })); + + writeln!(f)?; + writeln!(f, "{table}") + } +} + +fn count_cell(value: Option) -> Cell { + match value { + Some(n) => Cell::new(n), + None => Cell::new(""), + } +} diff --git a/src/mailboxes/mod.rs b/src/shared/mailboxes/mod.rs similarity index 64% rename from src/mailboxes/mod.rs rename to src/shared/mailboxes/mod.rs index 0cf8a1a4..ae4d743d 100644 --- a/src/mailboxes/mod.rs +++ b/src/shared/mailboxes/mod.rs @@ -1,3 +1,2 @@ pub mod cli; pub mod list; -pub mod table; diff --git a/src/shared/messages/add.rs b/src/shared/messages/add.rs new file mode 100644 index 00000000..902332b7 --- /dev/null +++ b/src/shared/messages/add.rs @@ -0,0 +1,61 @@ +use std::{ + io::{stdin, IsTerminal, Read}, + path::PathBuf, +}; + +use anyhow::{bail, Result}; +use clap::Parser; +use io_email::flag::Flag; +use pimalaya_cli::printer::Message; +use pimalaya_cli::printer::Printer; + +use crate::shared::{client::EmailClient, flags::arg::FlagArg}; + +/// 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 MessageAddCommand { + /// 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 MessageAddCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let raw = read_raw(&self.file)?; + let flags: Vec = self.flag.iter().map(Into::into).collect(); + client.add_message(&self.mailbox, &flags, raw)?; + printer.out(Message::new("Message successfully added")) + } +} + +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/shared/messages/builder.rs b/src/shared/messages/builder.rs new file mode 100644 index 00000000..dc69c58d --- /dev/null +++ b/src/shared/messages/builder.rs @@ -0,0 +1,371 @@ +//! Shared MIME-building helpers for the built-in `compose`, `reply` +//! and `forward` subcommands. +//! +//! Each subcommand has its own clap struct (different positional / +//! optional args), but they all collapse into the same set of fields +//! once the source message — if any — is fetched. The helpers here +//! accept those fields and assemble an RFC 5322 message with +//! `mail_builder` (plus reply/forward header derivation via +//! `mail_parser`). +//! +//! The `-with` subcommands delegate composition entirely to an +//! external command and never go through this module. + +use std::{ + io::{stdin, IsTerminal, Read as _}, + path::{Path, PathBuf}, +}; + +use anyhow::{anyhow, Result}; +use clap::ValueEnum; +use mail_builder::{ + headers::{address::Address, raw::Raw}, + MessageBuilder, +}; +use mail_parser::{HeaderValue, MessageParser}; + +/// How a quoted source body is laid out relative to the user's body +/// when replying or forwarding. +#[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, +} + +/// All the fields the built-in MIME assembler needs. Each subcommand +/// populates these from its own clap struct. +pub struct BuilderArgs<'a> { + pub from: Option<&'a str>, + pub to: &'a [String], + pub cc: &'a [String], + pub bcc: &'a [String], + pub subject: Option<&'a str>, + pub body: Option<&'a str>, + pub body_file: Option<&'a Path>, + pub attach: &'a [PathBuf], + pub signature: Option<&'a str>, + pub signature_file: Option<&'a Path>, +} + +/// Source-message metadata, populated for reply/forward subcommands. +pub struct SourceArgs<'a> { + pub raw: &'a [u8], + pub mode: SourceMode, + pub posting_style: PostingStyle, + pub quote_headline: &'a str, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SourceMode { + Reply, + Forward, +} + +/// Assembles a MIME message from `args` and an optional reply/forward +/// `source`. Returns the raw RFC 5322 bytes. +pub fn build(args: BuilderArgs<'_>, source: Option>) -> Result> { + let mut builder = MessageBuilder::new(); + + if let Some(from) = args.from { + builder = builder.from(from); + } + if !args.to.is_empty() { + builder = builder.to(addresses(args.to)); + } + if !args.cc.is_empty() { + builder = builder.cc(addresses(args.cc)); + } + if !args.bcc.is_empty() { + builder = builder.bcc(addresses(args.bcc)); + } + + let parsed_source = source + .as_ref() + .and_then(|s| MessageParser::new().parse(s.raw)); + + let mut subject = args.subject.map(str::to_owned); + let mut source_text = String::new(); + + if let (Some(source), Some(parsed)) = (source.as_ref(), parsed_source.as_ref()) { + let prefix = match source.mode { + SourceMode::Reply => "Re: ", + SourceMode::Forward => "Fwd: ", + }; + let src_subject = parsed.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 source.mode == SourceMode::Reply && args.to.is_empty() { + if let Some(addrs) = reply_recipients(parsed) { + builder = builder.to(addrs); + } + } + + if let Some(message_id) = parsed.message_id() { + if source.mode == SourceMode::Reply { + builder = builder.in_reply_to(vec![message_id.to_string()]); + } + let refs = compute_references(parsed, message_id); + if !refs.is_empty() { + builder = builder.header("References", Raw::new(refs)); + } + } + + source_text = parsed + .body_text(0) + .map(|c| c.into_owned()) + .unwrap_or_default(); + } + + if let Some(s) = subject { + builder = builder.subject(s); + } + + let user_body = read_body(args.body, args.body_file)?; + let signature = read_signature(args.signature, args.signature_file)?; + let (style, headline) = match source.as_ref() { + Some(s) => (s.posting_style, s.quote_headline), + None => (PostingStyle::Top, ""), + }; + let body = compose_body( + &user_body, + &source_text, + headline, + signature.as_deref().unwrap_or(""), + style, + ); + builder = builder.text_body(body); + + for path in args.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); + } + + builder + .write_to_vec() + .map_err(|err| anyhow!("serialize composed message: {err}")) +} + +fn addresses(values: &[String]) -> Address<'static> { + Address::new_list( + values + .iter() + .map(|s| Address::new_address(None::<&str>, s.clone())) + .collect(), + ) +} + +fn read_body(body: Option<&str>, body_file: Option<&Path>) -> Result { + if let Some(body) = body { + return Ok(body.to_owned()); + } + + if let Some(path) = 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( + signature: Option<&str>, + signature_file: Option<&Path>, +) -> Result> { + if let Some(sig) = signature { + return Ok(Some(sig.to_owned())); + } + + if let Some(path) = 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 quoted +/// source text, 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'); + } + 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 +} + +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) +} + +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")) + .cloned(); + + 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)) + } +} + +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 +} + +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: &Path) -> &'static str { + #[cfg(feature = "maildir")] + { + let guess = mime_guess::from_path(path).first_or_octet_stream(); + let s = guess.essence_str().to_string(); + return Box::leak(s.into_boxed_str()); + } + #[cfg(not(feature = "maildir"))] + { + let _ = path; + "application/octet-stream" + } +} diff --git a/src/shared/messages/cli.rs b/src/shared/messages/cli.rs new file mode 100644 index 00000000..ad4b9b58 --- /dev/null +++ b/src/shared/messages/cli.rs @@ -0,0 +1,59 @@ +use anyhow::Result; +use clap::Subcommand; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + client::EmailClient, + messages::{ + add::MessageAddCommand, compose::MessageComposeCommand, + compose_with::MessageComposeWithCommand, copy::MessageCopyCommand, + forward::MessageForwardCommand, forward_with::MessageForwardWithCommand, + mv::MessageMoveCommand, read::MessageReadCommand, read_with::MessageReadWithCommand, + reply::MessageReplyCommand, reply_with::MessageReplyWithCommand, send::MessageSendCommand, + }, +}; + +/// Shared API to manage messages for the active account. +/// +/// A message is composed of headers (key-value properties) and a body +/// (suite of MIME parts). The built-in `compose` / `reply` / `forward` +/// / `read` subcommands cover simple cases via CLI flags. For +/// non-default workflows, the `-with` variants delegate composition +/// and rendering to a user-defined command from +/// `[message.composer.*]` / `[message.reader.*]`. +#[derive(Debug, Subcommand)] +pub enum MessageCommand { + Add(MessageAddCommand), + Compose(MessageComposeCommand), + ComposeWith(MessageComposeWithCommand), + #[command(alias = "cp")] + Copy(MessageCopyCommand), + Forward(MessageForwardCommand), + ForwardWith(MessageForwardWithCommand), + #[command(alias = "mv")] + Move(MessageMoveCommand), + Read(MessageReadCommand), + ReadWith(MessageReadWithCommand), + Reply(MessageReplyCommand), + ReplyWith(MessageReplyWithCommand), + Send(MessageSendCommand), +} + +impl MessageCommand { + pub fn execute(self, printer: &mut impl Printer, client: EmailClient) -> Result<()> { + match self { + Self::Add(cmd) => cmd.execute(printer, client), + Self::Compose(cmd) => cmd.execute(printer, client), + Self::ComposeWith(cmd) => cmd.execute(printer, client), + Self::Copy(cmd) => cmd.execute(printer, client), + Self::Forward(cmd) => cmd.execute(printer, client), + Self::ForwardWith(cmd) => cmd.execute(printer, client), + Self::Move(cmd) => cmd.execute(printer, client), + Self::Read(cmd) => cmd.execute(printer, client), + Self::ReadWith(cmd) => cmd.execute(printer, client), + Self::Reply(cmd) => cmd.execute(printer, client), + Self::ReplyWith(cmd) => cmd.execute(printer, client), + Self::Send(cmd) => cmd.execute(printer, client), + } + } +} diff --git a/src/shared/messages/compose.rs b/src/shared/messages/compose.rs new file mode 100644 index 00000000..f575b8e6 --- /dev/null +++ b/src/shared/messages/compose.rs @@ -0,0 +1,104 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + client::EmailClient, + messages::{ + builder::{self, BuilderArgs}, + output, + }, +}; + +/// Compose a new message from CLI arguments (built-in flag composer). +/// +/// Use this for the simple case: pass `--from`, `--to`, `--body`, +/// etc., and the message is assembled with `mail_builder`. The +/// produced RFC 5322 bytes are written to stdout by default; pass +/// `--save ` to append a copy, `--send` to push through the +/// account's SMTP/JMAP send path, or both. For richer composition +/// (multipart MIME, MML directives, signing/encryption, TUI editing, +/// …) use `compose-with ` instead. +#[derive(Debug, Parser)] +pub struct MessageComposeCommand { + /// Sender address (`From` header). + #[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, + + /// 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, + + /// Append a copy of the composed message to this mailbox. + #[arg(long, value_name = "MAILBOX")] + pub save: Option, + + /// Send the composed message through the account's SMTP/JMAP path. + /// Combines with `--save` to also keep a copy. + #[arg(long)] + pub send: bool, +} + +impl MessageComposeCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let raw = builder::build( + BuilderArgs { + from: self.from.as_deref(), + to: &self.to, + cc: &self.cc, + bcc: &self.bcc, + subject: self.subject.as_deref(), + body: self.body.as_deref(), + body_file: self.body_file.as_deref(), + attach: &self.attach, + signature: self.signature.as_deref(), + signature_file: self.signature_file.as_deref(), + }, + None, + )?; + + output::route(printer, &mut client, raw, self.save.as_deref(), self.send) + } +} diff --git a/src/shared/messages/compose_with.rs b/src/shared/messages/compose_with.rs new file mode 100644 index 00000000..eff71f17 --- /dev/null +++ b/src/shared/messages/compose_with.rs @@ -0,0 +1,57 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + client::EmailClient, + messages::{output, runner}, +}; + +/// Compose a new message by delegating to a user-defined composer. +/// +/// Looks `` up in `[message.composer.]` and runs its +/// `command` via `sh -c`. With no ``, falls back to the entry +/// flagged `default = true`. The escape hatch `--command ""` +/// lets you run an ad-hoc command without editing the config. +/// +/// The composer takes the terminal: stdin is left empty (new +/// message — no source), stderr is inherited (composer prompts/ +/// errors). The composer's stdout must be a valid RFC 5322 message, +/// which himalaya then routes through `--save` / `--send`, or to +/// stdout if neither is set. +#[derive(Debug, Parser)] +pub struct MessageComposeWithCommand { + /// Name of an entry in `[message.composer.*]`. Optional — when + /// omitted, the composer flagged `default = true` is used. + #[arg(value_name = "NAME", conflicts_with = "command")] + pub name: Option, + + /// Ad-hoc shell command, mutually exclusive with ``. + /// Useful for trying the feature before editing the config. + #[arg(long, value_name = "SHELL")] + pub command: Option, + + #[arg(long, value_name = "MAILBOX")] + pub save: Option, + + #[arg(long)] + pub send: bool, +} + +impl MessageComposeWithCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let command = match self.command.as_deref() { + Some(cmd) => cmd.to_owned(), + None => { + runner::resolve_composer(&client.account.composer, self.name.as_deref())?.to_owned() + } + }; + + let raw = runner::run(&command, &[])?; + if raw.is_empty() { + bail!("composer `{command}` produced no output"); + } + + output::route(printer, &mut client, raw, self.save.as_deref(), self.send) + } +} diff --git a/src/shared/messages/copy.rs b/src/shared/messages/copy.rs new file mode 100644 index 00000000..9b78eb10 --- /dev/null +++ b/src/shared/messages/copy.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use clap::Parser; +use pimalaya_cli::printer::{Message, Printer}; + +use crate::shared::{client::EmailClient, flags::arg::MessageIdsArg}; + +/// 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 MessageCopyCommand { + #[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 MessageCopyCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let ids: Vec<&str> = self.ids.inner.iter().map(String::as_str).collect(); + client.copy_messages(&self.from, &self.to, &ids)?; + printer.out(Message::new("Message(s) successfully copied")) + } +} diff --git a/src/shared/messages/forward.rs b/src/shared/messages/forward.rs new file mode 100644 index 00000000..fee7a2cb --- /dev/null +++ b/src/shared/messages/forward.rs @@ -0,0 +1,113 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + client::EmailClient, + messages::{ + builder::{self, BuilderArgs, PostingStyle, SourceArgs, SourceMode}, + output, + }, +}; + +/// Forward a message using the built-in flag composer. +/// +/// Fetches the source, pre-fills `Fwd:` on the subject and the +/// `References` header, and quotes the source body. The produced +/// MIME is written to stdout, or routed via `--save` / `--send`. +/// For non-default composition, use `forward-with `. +#[derive(Debug, Parser)] +pub struct MessageForwardCommand { + #[arg(value_name = "ID")] + pub id: String, + + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + #[arg(long, value_name = "ADDR")] + pub from: Option, + + #[arg(long, short = 't', value_name = "ADDR", value_delimiter = ',')] + pub to: Vec, + + #[arg(long, value_name = "ADDR", value_delimiter = ',')] + pub cc: Vec, + + #[arg(long, value_name = "ADDR", value_delimiter = ',')] + pub bcc: Vec, + + #[arg(long, short = 's', value_name = "TEXT")] + pub subject: Option, + + #[arg(long, value_name = "TEXT", conflicts_with = "body_file")] + pub body: Option, + + #[arg(long = "body-file", value_name = "PATH")] + pub body_file: Option, + + #[arg(long = "attach", value_name = "PATH")] + pub attach: Vec, + + #[arg(long, value_name = "TEXT")] + pub signature: Option, + + #[arg( + long = "signature-file", + value_name = "PATH", + conflicts_with = "signature" + )] + pub signature_file: Option, + + #[arg( + long = "posting-style", + short = 'P', + value_name = "STYLE", + default_value = "top" + )] + pub posting_style: PostingStyle, + + #[arg(long = "quote-headline", short = 'Q', value_name = "TEXT")] + pub quote_headline: Option, + + #[arg(long, value_name = "MAILBOX")] + pub save: Option, + + #[arg(long)] + pub send: bool, +} + +impl MessageForwardCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let source = client.get_message(&self.mailbox, &self.id)?; + + let raw = builder::build( + BuilderArgs { + from: self.from.as_deref(), + to: &self.to, + cc: &self.cc, + bcc: &self.bcc, + subject: self.subject.as_deref(), + body: self.body.as_deref(), + body_file: self.body_file.as_deref(), + attach: &self.attach, + signature: self.signature.as_deref(), + signature_file: self.signature_file.as_deref(), + }, + Some(SourceArgs { + raw: &source, + mode: SourceMode::Forward, + posting_style: self.posting_style, + quote_headline: self.quote_headline.as_deref().unwrap_or(""), + }), + )?; + + output::route(printer, &mut client, raw, self.save.as_deref(), self.send) + } +} diff --git a/src/shared/messages/forward_with.rs b/src/shared/messages/forward_with.rs new file mode 100644 index 00000000..f69d8257 --- /dev/null +++ b/src/shared/messages/forward_with.rs @@ -0,0 +1,59 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + client::EmailClient, + messages::{output, runner}, +}; + +/// Forward a message by delegating to a user-defined composer. +/// +/// Same shape as `reply-with`: fetches the source, pipes it on +/// stdin to the named (or default) composer, captures stdout as the +/// MIME draft. +#[derive(Debug, Parser)] +pub struct MessageForwardWithCommand { + #[arg(value_name = "ID")] + pub id: String, + + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + #[arg(value_name = "NAME", conflicts_with = "command")] + pub name: Option, + + #[arg(long, value_name = "SHELL")] + pub command: Option, + + #[arg(long, value_name = "MAILBOX")] + pub save: Option, + + #[arg(long)] + pub send: bool, +} + +impl MessageForwardWithCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let source = client.get_message(&self.mailbox, &self.id)?; + + let command = match self.command.as_deref() { + Some(cmd) => cmd.to_owned(), + None => { + runner::resolve_composer(&client.account.composer, self.name.as_deref())?.to_owned() + } + }; + + let raw = runner::run(&command, &source)?; + if raw.is_empty() { + bail!("composer `{command}` produced no output"); + } + + output::route(printer, &mut client, raw, self.save.as_deref(), self.send) + } +} diff --git a/src/shared/messages/mod.rs b/src/shared/messages/mod.rs new file mode 100644 index 00000000..231c682e --- /dev/null +++ b/src/shared/messages/mod.rs @@ -0,0 +1,16 @@ +pub mod add; +pub mod builder; +pub mod cli; +pub mod compose; +pub mod compose_with; +pub mod copy; +pub mod forward; +pub mod forward_with; +pub mod mv; +pub mod output; +pub mod read; +pub mod read_with; +pub mod reply; +pub mod reply_with; +pub mod runner; +pub mod send; diff --git a/src/shared/messages/mv.rs b/src/shared/messages/mv.rs new file mode 100644 index 00000000..ef6ea741 --- /dev/null +++ b/src/shared/messages/mv.rs @@ -0,0 +1,40 @@ +use anyhow::Result; +use clap::Parser; +use pimalaya_cli::printer::{Message, Printer}; + +use crate::shared::{client::EmailClient, flags::arg::MessageIdsArg}; + +/// 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 MessageMoveCommand { + #[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 MessageMoveCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let ids: Vec<&str> = self.ids.inner.iter().map(String::as_str).collect(); + client.move_messages(&self.from, &self.to, &ids)?; + printer.out(Message::new("Message(s) successfully moved")) + } +} diff --git a/src/shared/messages/output.rs b/src/shared/messages/output.rs new file mode 100644 index 00000000..476fc01d --- /dev/null +++ b/src/shared/messages/output.rs @@ -0,0 +1,44 @@ +//! Post-composer routing: where the produced MIME bytes go. +//! +//! Used by `compose` / `reply` / `forward` (and their `-with` +//! variants). The same `--save ` / `--send` flags can combine: +//! `--save Sent --send` sends the message *and* appends a copy to the +//! `Sent` mailbox. With neither flag, the raw bytes are written to +//! stdout — same shape as a manual `mml compile > out.eml`. + +use std::io::{stdout, Write}; + +use anyhow::Result; +use pimalaya_cli::printer::{Message, Printer}; + +use crate::shared::client::EmailClient; + +/// Routes `raw` through the requested combination of side-effects. +/// `save` writes a copy to the named mailbox before sending; `send` +/// pushes the message through the configured SMTP / JMAP send path. +/// With neither set, dumps `raw` to stdout and returns. +pub fn route( + printer: &mut impl Printer, + client: &mut EmailClient, + raw: Vec, + save: Option<&str>, + send: bool, +) -> Result<()> { + if !send && save.is_none() { + let mut out = stdout().lock(); + out.write_all(&raw)?; + return Ok(()); + } + + if let Some(mailbox) = save { + client.add_message(mailbox, &[], raw.clone())?; + } + + if send { + let opts = client.send_opts.clone(); + client.send_message(raw, opts)?; + return printer.out(Message::new("Message successfully sent")); + } + + printer.out(Message::new("Message saved")) +} diff --git a/src/messages/get.rs b/src/shared/messages/read.rs similarity index 72% rename from src/messages/get.rs rename to src/shared/messages/read.rs index 4bcd629c..f85f0fd2 100644 --- a/src/messages/get.rs +++ b/src/shared/messages/read.rs @@ -9,20 +9,17 @@ use mail_parser::{Message, MessageParser}; use pimalaya_cli::printer::Printer; use serde::Serialize; -use crate::{ - cli::BackendArg, - config::{AccountConfig, Config}, - messages::fetch::fetch_raw, -}; +use crate::shared::client::EmailClient; -/// Get a message from the active account. +/// Read a message from the active account (built-in flag reader). /// -/// 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. +/// Fetches the message and renders headers + text bodies. Pass +/// `--raw` to dump the original RFC 5322 bytes to stdout instead, +/// or `--json` to emit the parsed message as JSON. For a custom +/// pretty-printer (mml interpret, w3m, your own viewer, …) use +/// `read-with `. #[derive(Debug, Parser)] -pub struct MessagesGetCommand { +pub struct MessageReadCommand { /// Identifier of the message (IMAP UID, JMAP email id, or Maildir /// filename id). #[arg(value_name = "ID")] @@ -44,19 +41,13 @@ pub struct MessagesGetCommand { pub raw: bool, } -impl MessagesGetCommand { - pub fn execute( - self, - printer: &mut impl Printer, - config: Config, - account_config: AccountConfig, - backend: BackendArg, - ) -> Result<()> { +impl MessageReadCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { if self.raw && printer.is_json() { bail!("`--raw` and `--json` cannot be combined"); } - let raw = fetch_raw(&config, &account_config, backend, &self.mailbox, &self.id)?; + let raw = client.get_message(&self.mailbox, &self.id)?; if self.raw { let mut out = stdout().lock(); diff --git a/src/shared/messages/read_with.rs b/src/shared/messages/read_with.rs new file mode 100644 index 00000000..6883e0b9 --- /dev/null +++ b/src/shared/messages/read_with.rs @@ -0,0 +1,60 @@ +use std::io::{stdout, Write}; + +use anyhow::Result; +use clap::Parser; +use pimalaya_cli::printer::Printer; + +use crate::shared::{client::EmailClient, messages::runner}; + +/// Read a message by delegating to a user-defined reader. +/// +/// Fetches the source and pipes it on stdin to the named (or +/// default) reader. The reader's stdout is forwarded to the +/// terminal — zero bytes is fine (the reader may have spawned its +/// own UI), non-empty bytes are written as-is. +#[derive(Debug, Parser)] +pub struct MessageReadWithCommand { + /// Identifier of the message. + #[arg(value_name = "ID")] + pub id: String, + + /// Mailbox the message lives in. Ignored for JMAP. + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + /// Name of an entry in `[message.reader.*]`. Optional — when + /// omitted, the reader flagged `default = true` is used. + #[arg(value_name = "NAME", conflicts_with = "command")] + pub name: Option, + + /// Ad-hoc shell command, mutually exclusive with ``. + #[arg(long, value_name = "SHELL")] + pub command: Option, +} + +impl MessageReadWithCommand { + pub fn execute(self, _printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let source = client.get_message(&self.mailbox, &self.id)?; + + let command = match self.command.as_deref() { + Some(cmd) => cmd.to_owned(), + None => { + runner::resolve_reader(&client.account.reader, self.name.as_deref())?.to_owned() + } + }; + + let bytes = runner::run(&command, &source)?; + + if !bytes.is_empty() { + let mut out = stdout().lock(); + out.write_all(&bytes)?; + } + + Ok(()) + } +} diff --git a/src/shared/messages/reply.rs b/src/shared/messages/reply.rs new file mode 100644 index 00000000..dffb13dd --- /dev/null +++ b/src/shared/messages/reply.rs @@ -0,0 +1,124 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + client::EmailClient, + messages::{ + builder::{self, BuilderArgs, PostingStyle, SourceArgs, SourceMode}, + output, + }, +}; + +/// Reply to a message using the built-in flag composer. +/// +/// Fetches the source message, pre-fills `In-Reply-To` / `References` +/// and the `Re:` subject, optionally derives recipients from +/// `Reply-To`/`From`, and quotes the source text body. The produced +/// MIME is written to stdout, or routed via `--save` / `--send`. +/// For non-default composition, use `reply-with `. +#[derive(Debug, Parser)] +pub struct MessageReplyCommand { + /// Identifier of the source message (IMAP UID, JMAP id, Maildir + /// filename id). + #[arg(value_name = "ID")] + pub id: String, + + /// Mailbox the source message lives in. Ignored for JMAP, which + /// addresses messages by id directly. + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + #[arg(long, value_name = "ADDR")] + pub from: Option, + + #[arg(long, short = 't', value_name = "ADDR", value_delimiter = ',')] + pub to: Vec, + + #[arg(long, value_name = "ADDR", value_delimiter = ',')] + pub cc: Vec, + + #[arg(long, value_name = "ADDR", value_delimiter = ',')] + pub bcc: Vec, + + #[arg(long, short = 's', value_name = "TEXT")] + pub subject: Option, + + #[arg(long, value_name = "TEXT", conflicts_with = "body_file")] + pub body: Option, + + #[arg(long = "body-file", value_name = "PATH")] + pub body_file: Option, + + #[arg(long = "attach", value_name = "PATH")] + pub attach: Vec, + + #[arg(long, value_name = "TEXT")] + pub signature: Option, + + #[arg( + long = "signature-file", + value_name = "PATH", + conflicts_with = "signature" + )] + pub signature_file: Option, + + /// How to lay out the quoted source body relative to the user's + /// body. Interleaved posting is left to the user — write your + /// reply 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:"`). No substitution is + /// performed; pass the literal string you want. + #[arg(long = "quote-headline", short = 'Q', value_name = "TEXT")] + pub quote_headline: Option, + + #[arg(long, value_name = "MAILBOX")] + pub save: Option, + + #[arg(long)] + pub send: bool, +} + +impl MessageReplyCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let source = client.get_message(&self.mailbox, &self.id)?; + + let raw = builder::build( + BuilderArgs { + from: self.from.as_deref(), + to: &self.to, + cc: &self.cc, + bcc: &self.bcc, + subject: self.subject.as_deref(), + body: self.body.as_deref(), + body_file: self.body_file.as_deref(), + attach: &self.attach, + signature: self.signature.as_deref(), + signature_file: self.signature_file.as_deref(), + }, + Some(SourceArgs { + raw: &source, + mode: SourceMode::Reply, + posting_style: self.posting_style, + quote_headline: self.quote_headline.as_deref().unwrap_or(""), + }), + )?; + + output::route(printer, &mut client, raw, self.save.as_deref(), self.send) + } +} diff --git a/src/shared/messages/reply_with.rs b/src/shared/messages/reply_with.rs new file mode 100644 index 00000000..c3ea8713 --- /dev/null +++ b/src/shared/messages/reply_with.rs @@ -0,0 +1,64 @@ +use anyhow::{bail, Result}; +use clap::Parser; +use pimalaya_cli::printer::Printer; + +use crate::shared::{ + client::EmailClient, + messages::{output, runner}, +}; + +/// Reply to a message by delegating to a user-defined composer. +/// +/// Fetches the source message, then runs the named (or default) +/// composer with the source MIME piped on stdin. The composer must +/// consume stdin first if it wants user interaction — TUI composers +/// can re-open `/dev/tty` once stdin is drained (vim/less/fzf all do +/// this). The produced MIME is routed through `--save` / `--send`, +/// or stdout if neither is set. +#[derive(Debug, Parser)] +pub struct MessageReplyWithCommand { + /// Identifier of the source message. + #[arg(value_name = "ID")] + pub id: String, + + /// Mailbox the source message lives in. Ignored for JMAP. + #[arg( + long = "mailbox", + short = 'm', + value_name = "NAME", + default_value = "Inbox" + )] + pub mailbox: String, + + #[arg(value_name = "NAME", conflicts_with = "command")] + pub name: Option, + + #[arg(long, value_name = "SHELL")] + pub command: Option, + + #[arg(long, value_name = "MAILBOX")] + pub save: Option, + + #[arg(long)] + pub send: bool, +} + +impl MessageReplyWithCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> { + let source = client.get_message(&self.mailbox, &self.id)?; + + let command = match self.command.as_deref() { + Some(cmd) => cmd.to_owned(), + None => { + runner::resolve_composer(&client.account.composer, self.name.as_deref())?.to_owned() + } + }; + + let raw = runner::run(&command, &source)?; + if raw.is_empty() { + bail!("composer `{command}` produced no output"); + } + + output::route(printer, &mut client, raw, self.save.as_deref(), self.send) + } +} diff --git a/src/shared/messages/runner.rs b/src/shared/messages/runner.rs new file mode 100644 index 00000000..b15eaa49 --- /dev/null +++ b/src/shared/messages/runner.rs @@ -0,0 +1,105 @@ +//! Spawns user-defined composer and reader commands. +//! +//! A composer/reader is just a shell command line invoked via +//! `sh -c`. Himalaya pipes source MIME bytes (empty for new messages) +//! into the child's stdin, captures stdout (the produced MIME draft +//! or the interpreted text), and inherits stderr so the spawned +//! command can prompt the user or print errors directly to the +//! terminal. TUI composers that need interactive input can re-open +//! `/dev/tty` once they've consumed stdin — standard Unix practice. + +use std::{ + collections::HashMap, + io::Write, + process::{Command, Stdio}, +}; + +use anyhow::{anyhow, bail, Result}; + +use crate::config::{ComposerConfig, ReaderConfig}; + +/// Resolves a composer entry to its shell command line. When `name` +/// is given, looks up the corresponding entry and bails if missing. +/// When `name` is `None`, returns the entry with `default = true`, +/// or bails with a hint if no default is set. +pub fn resolve_composer<'a>( + composers: &'a HashMap, + name: Option<&str>, +) -> Result<&'a str> { + match name { + Some(name) => match composers.get(name) { + Some(entry) => Ok(entry.command.as_str()), + None => bail!("no composer named `{name}` in [message.composer]"), + }, + None => default_composer(composers).map(|entry| entry.command.as_str()), + } +} + +/// Same as [`resolve_composer`] but for readers. +pub fn resolve_reader<'a>( + readers: &'a HashMap, + name: Option<&str>, +) -> Result<&'a str> { + match name { + Some(name) => match readers.get(name) { + Some(entry) => Ok(entry.command.as_str()), + None => bail!("no reader named `{name}` in [message.reader]"), + }, + None => default_reader(readers).map(|entry| entry.command.as_str()), + } +} + +fn default_composer(composers: &HashMap) -> Result<&ComposerConfig> { + composers.values().find(|c| c.default).ok_or_else(|| { + anyhow!( + "no composer specified and no default in [message.composer.*]; \ + pass a or set `default = true` on one entry" + ) + }) +} + +fn default_reader(readers: &HashMap) -> Result<&ReaderConfig> { + readers.values().find(|c| c.default).ok_or_else(|| { + anyhow!( + "no reader specified and no default in [message.reader.*]; \ + pass a or set `default = true` on one entry" + ) + }) +} + +/// Spawns `command` through `sh -c`, writes `stdin_bytes` to its +/// stdin, and returns the captured stdout bytes. Stderr is inherited. +/// Bails on a non-zero exit status. +pub fn run(command: &str, stdin_bytes: &[u8]) -> Result> { + let mut child = Command::new("sh") + .arg("-c") + .arg(command) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|err| anyhow!("spawn `{command}`: {err}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(stdin_bytes) + .map_err(|err| anyhow!("write stdin to `{command}`: {err}"))?; + } + + let output = child + .wait_with_output() + .map_err(|err| anyhow!("wait `{command}`: {err}"))?; + + if !output.status.success() { + bail!( + "`{command}` exited with status {}", + output + .status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "?".to_string()) + ); + } + + Ok(output.stdout) +} diff --git a/src/shared/messages/send.rs b/src/shared/messages/send.rs new file mode 100644 index 00000000..063582ae --- /dev/null +++ b/src/shared/messages/send.rs @@ -0,0 +1,41 @@ +use std::io::{stdin, BufRead, IsTerminal}; + +use anyhow::Result; +use clap::Parser; +use pimalaya_cli::printer::{Message, Printer}; + +use crate::shared::client::EmailClient; + +/// Send a message via the active account. +/// +/// Supported over JMAP. JMAP requires `identity-id` and +/// `drafts-mailbox-id` to be set on the account's `[jmap]` config block. +#[derive(Debug, Parser)] +pub struct MessageSendCommand { + /// The raw message, including headers and body. + #[arg(trailing_var_arg = true)] + #[arg(name = "message", value_name = "MESSAGE")] + pub message: Vec, +} + +impl MessageSendCommand { + pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> 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") + }; + + let opts = client.send_opts.clone(); + client.send_message(raw.into_bytes(), opts)?; + printer.out(Message::new("Message successfully sent")) + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs new file mode 100644 index 00000000..148f98a6 --- /dev/null +++ b/src/shared/mod.rs @@ -0,0 +1,7 @@ +#[cfg(any(feature = "imap", feature = "jmap", feature = "maildir"))] +pub mod attachments; +pub mod client; +pub mod envelopes; +pub mod flags; +pub mod mailboxes; +pub mod messages; diff --git a/src/smtp/account.rs b/src/smtp/account.rs deleted file mode 100644 index 47a89e13..00000000 --- a/src/smtp/account.rs +++ /dev/null @@ -1,23 +0,0 @@ -use anyhow::Result; -use io_smtp::client::SmtpClient; -use pimalaya_stream::std::smtp::SmtpSession; - -use crate::{account::Account, config::SmtpConfig}; - -pub type SmtpAccount = Account; - -impl SmtpAccount { - /// Opens the SMTP connection (TCP/TLS/STARTTLS, greeting, EHLO, - /// SASL), then hands the established stream off to a fresh - /// [`SmtpClient`]. SMTP send is stateless after auth, so no - /// session context needs to follow the stream. - pub fn new_smtp_client(&self) -> Result { - let session = SmtpSession::new( - self.backend.url.clone(), - self.backend.tls.clone().try_into()?, - self.backend.starttls, - self.backend.sasl.clone().try_into()?, - )?; - Ok(SmtpClient::new(session.stream)) - } -} diff --git a/src/smtp/cli.rs b/src/smtp/cli.rs index 8431f289..d62d639b 100644 --- a/src/smtp/cli.rs +++ b/src/smtp/cli.rs @@ -2,9 +2,9 @@ use anyhow::Result; use clap::Subcommand; use pimalaya_cli::printer::Printer; -use crate::smtp::{account::SmtpAccount, message::cli::SmtpMessageCommand}; +use crate::smtp::{client::SmtpClient, message::cli::SmtpMessageCommand}; -/// SMTP CLI (requires `smtp` cargo feature). +/// SMTP CLI. /// /// This command gives you access to the SMTP CLI API, and allows /// you to manage SMTP mailboxes: list mailboxes, read messages, @@ -18,9 +18,9 @@ pub enum SmtpCommand { } impl SmtpCommand { - pub fn execute(self, printer: &mut impl Printer, account: SmtpAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: SmtpClient) -> Result<()> { match self { - Self::Messages(cmd) => cmd.execute(printer, account), + Self::Messages(cmd) => cmd.execute(printer, client), } } } diff --git a/src/smtp/client.rs b/src/smtp/client.rs new file mode 100644 index 00000000..c6607fc0 --- /dev/null +++ b/src/smtp/client.rs @@ -0,0 +1,74 @@ +//! Himalaya wrapper around [`io_smtp::client::SmtpClient`] that +//! bundles the merged [`Account`] alongside the live SMTP client. +//! +//! Built up front by the dispatch layer (`crate::cli`) via +//! [`build_smtp_client`] and handed down to every SMTP-specific +//! subcommand. SMTP send is stateless after auth, so no session +//! context needs to follow the stream. + +use std::{ + ops::{Deref, DerefMut}, + path::PathBuf, +}; + +use anyhow::{anyhow, Result}; +use io_smtp::client::SmtpClient as Inner; +use pimalaya_config::toml::TomlConfig; + +use crate::{ + account::context::Account, cli::load_or_wizard, config::SmtpConfig, smtp::session::SmtpSession, +}; + +pub struct SmtpClient { + inner: Inner, + #[allow(dead_code)] + pub account: Account, +} + +impl SmtpClient { + /// Opens the SMTP connection (TCP/TLS/STARTTLS, greeting, EHLO, + /// SASL) then wraps the resulting stream alongside `account`. + pub fn new(config: SmtpConfig, account: Account) -> Result { + let session = SmtpSession::new( + config.url, + config.tls.try_into()?, + config.starttls, + config.sasl.try_into()?, + )?; + let inner = Inner::new(session.stream); + Ok(Self { inner, account }) + } +} + +impl Deref for SmtpClient { + type Target = Inner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for SmtpClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + +/// Loads the configuration, picks the active account, builds the +/// merged [`Account`] then opens the SMTP session. Bails when the +/// account has no `[smtp]` block. +pub fn build_smtp_client( + config_paths: &[PathBuf], + account_name: Option<&str>, +) -> Result { + let mut config = load_or_wizard(config_paths)?; + let (name, mut ac) = config + .take_account(account_name)? + .ok_or_else(|| anyhow!("Cannot find account"))?; + let smtp_config = ac + .smtp + .take() + .ok_or_else(|| anyhow!("SMTP config is missing for account `{name}`"))?; + let account = Account::from(config).merge(Account::from(ac)); + SmtpClient::new(smtp_config, account) +} diff --git a/src/smtp/message/cli.rs b/src/smtp/message/cli.rs index 7d73c5e0..5da6a839 100644 --- a/src/smtp/message/cli.rs +++ b/src/smtp/message/cli.rs @@ -2,7 +2,7 @@ use anyhow::Result; use clap::Subcommand; use pimalaya_cli::printer::Printer; -use crate::smtp::{account::SmtpAccount, message::send::SmtpMessageSendCommand}; +use crate::smtp::{client::SmtpClient, message::send::SmtpMessageSendCommand}; /// Manage messages. /// @@ -15,9 +15,9 @@ pub enum SmtpMessageCommand { } impl SmtpMessageCommand { - pub fn execute(self, printer: &mut impl Printer, account: SmtpAccount) -> Result<()> { + pub fn execute(self, printer: &mut impl Printer, client: SmtpClient) -> Result<()> { match self { - Self::Send(cmd) => cmd.execute(printer, account), + Self::Send(cmd) => cmd.execute(printer, client), } } } diff --git a/src/smtp/message/send.rs b/src/smtp/message/send.rs index d1e617f2..354a0bcc 100644 --- a/src/smtp/message/send.rs +++ b/src/smtp/message/send.rs @@ -13,7 +13,7 @@ use io_smtp::rfc5321::types::{ use mail_parser::{Addr, Address, HeaderName, HeaderValue, MessageParser}; use pimalaya_cli::printer::{Message, Printer}; -use crate::smtp::account::SmtpAccount; +use crate::smtp::client::SmtpClient; /// Send a message to a mailbox. /// @@ -28,9 +28,7 @@ pub struct SmtpMessageSendCommand { } impl SmtpMessageSendCommand { - pub fn execute(self, printer: &mut impl Printer, account: SmtpAccount) -> Result<()> { - let mut client = account.new_smtp_client()?; - + pub fn execute(self, printer: &mut impl Printer, mut client: SmtpClient) -> Result<()> { let message = if stdin().is_terminal() || printer.is_json() { self.message .join(" ") diff --git a/src/smtp/mod.rs b/src/smtp/mod.rs index d29d63d8..813e0c6d 100644 --- a/src/smtp/mod.rs +++ b/src/smtp/mod.rs @@ -1,3 +1,4 @@ -pub mod account; pub mod cli; +pub mod client; pub mod message; +pub mod session; diff --git a/src/smtp/session.rs b/src/smtp/session.rs new file mode 100644 index 00000000..8177381f --- /dev/null +++ b/src/smtp/session.rs @@ -0,0 +1,216 @@ +//! Transitional SMTP session helper ported from `pimalaya-stream`. +//! +//! Will be replaced by `io_smtp::client::SmtpClient` once the +//! protocol-specific subcommands switch over. + +#[cfg(unix)] +use std::os::unix::net::UnixStream; +use std::{ + io::{Read, Write}, + net::{Ipv4Addr, TcpStream}, +}; + +use anyhow::{bail, Result}; +use io_smtp::{ + login::{SmtpLogin, SmtpLoginResult}, + rfc3207::starttls::{SmtpStartTls, SmtpStartTlsResult}, + rfc4616::plain::{SmtpPlain, SmtpPlainResult}, + rfc5321::{ + ehlo::{SmtpEhlo, SmtpEhloResult}, + greeting::{GetSmtpGreeting, GetSmtpGreetingResult}, + types::ehlo_domain::EhloDomain, + }, +}; +use log::info; +use pimalaya_stream::{ + sasl::{Sasl, SaslMechanism}, + std::{ + stream::Stream, + tls::{upgrade_tls, Tls}, + }, +}; +#[cfg(windows)] +use uds_windows::UnixStream; +use url::Url; + +const READ_BUFFER_SIZE: usize = 8 * 1024; + +#[derive(Debug)] +pub struct SmtpSession { + pub stream: Stream, +} + +fn drive_greeting(stream: &mut S) -> Result<()> { + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut coroutine = GetSmtpGreeting::new(); + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + GetSmtpGreetingResult::Ok { .. } => return Ok(()), + GetSmtpGreetingResult::WantsRead => { + let n = stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + GetSmtpGreetingResult::Err(err) => bail!(err), + } + } +} + +fn drive_ehlo(stream: &mut S, domain: EhloDomain<'_>) -> Result<()> { + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut coroutine = SmtpEhlo::new(domain); + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + SmtpEhloResult::Ok { .. } => return Ok(()), + SmtpEhloResult::WantsRead => { + let n = stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + SmtpEhloResult::WantsWrite(bytes) => { + stream.write_all(&bytes)?; + arg = None; + } + SmtpEhloResult::Err(err) => bail!(err), + } + } +} + +fn drive_starttls(stream: &mut S) -> Result<()> { + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut coroutine = SmtpStartTls::new(); + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + SmtpStartTlsResult::WantsStartTls(_) => return Ok(()), + SmtpStartTlsResult::WantsRead => { + let n = stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + SmtpStartTlsResult::WantsWrite(bytes) => { + stream.write_all(&bytes)?; + arg = None; + } + SmtpStartTlsResult::Err(err) => bail!(err), + } + } +} + +impl SmtpSession { + pub fn new(url: Url, tls: Tls, starttls: bool, mut sasl: Sasl) -> Result { + info!("connecting to SMTP server using {url}"); + + let host = url.host_str().unwrap_or("127.0.0.1"); + let domain: EhloDomain<'static> = Ipv4Addr::new(127, 0, 0, 1).into(); + + let mut stream = match url.scheme() { + scheme if scheme.eq_ignore_ascii_case("smtp") => { + let port = url.port().unwrap_or(25); + let mut tcp = TcpStream::connect((host, port))?; + + drive_greeting(&mut tcp)?; + drive_ehlo(&mut tcp, domain.clone())?; + + Stream::Tcp(tcp) + } + scheme if scheme.eq_ignore_ascii_case("smtps") => { + let default_port = if starttls { 587 } else { 465 }; + let port = url.port().unwrap_or(default_port); + let mut tcp = TcpStream::connect((host, port))?; + + if starttls { + drive_greeting(&mut tcp)?; + drive_ehlo(&mut tcp, domain.clone())?; + drive_starttls(&mut tcp)?; + } + + let mut stream = upgrade_tls(host, tcp, &tls, &[b"smtp"])?; + + if !starttls { + drive_greeting(&mut stream)?; + } + + drive_ehlo(&mut stream, domain.clone())?; + + stream + } + scheme if scheme.eq_ignore_ascii_case("unix") => { + let sock_path = url.path(); + let mut unix = UnixStream::connect(sock_path)?; + + drive_greeting(&mut unix)?; + drive_ehlo(&mut unix, domain.clone())?; + + Stream::Unix(unix) + } + scheme => { + bail!("Unknown scheme {scheme}, expected smtp, smtps or unix"); + } + }; + + let mechanism = sasl + .mechanism + .or(Some(SaslMechanism::Plain).filter(|_| sasl.plain.is_some())) + .or(Some(SaslMechanism::Login).filter(|_| sasl.login.is_some())); + + match mechanism { + None => bail!("no SASL mechanism configured"), + Some(SaslMechanism::Login) => { + let Some(auth) = sasl.login.take() else { + bail!("missing SASL LOGIN configuration"); + }; + + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut coroutine = SmtpLogin::new(&auth.username, &auth.password, domain.clone()); + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + SmtpLoginResult::Ok => break, + SmtpLoginResult::WantsRead => { + let n = stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + SmtpLoginResult::WantsWrite(bytes) => { + stream.write_all(&bytes)?; + arg = None; + } + SmtpLoginResult::Err(err) => bail!(err), + } + } + } + Some(SaslMechanism::Plain) => { + let Some(auth) = sasl.plain.take() else { + bail!("missing SASL PLAIN configuration"); + }; + + let mut buf = [0u8; READ_BUFFER_SIZE]; + let mut coroutine = SmtpPlain::new(&auth.authcid, &auth.passwd, domain.clone()); + let mut arg: Option<&[u8]> = None; + + loop { + match coroutine.resume(arg.take()) { + SmtpPlainResult::Ok => break, + SmtpPlainResult::WantsRead => { + let n = stream.read(&mut buf)?; + arg = Some(&buf[..n]); + } + SmtpPlainResult::WantsWrite(bytes) => { + stream.write_all(&bytes)?; + arg = None; + } + SmtpPlainResult::Err(err) => bail!(err), + } + } + } + Some(SaslMechanism::Anonymous) => { + unimplemented!("ANONYMOUS SASL mechanism not yet implemented") + } + } + + Ok(Self { stream }) + } +} diff --git a/src/wizard.rs b/src/wizard.rs index 7229e4fb..036f488c 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -8,41 +8,54 @@ //! //! 1. Confirm with the user. Exit if they decline. //! 2. Ask for an account name and email address. -//! 3. Run discovery — currently PACC only; Mozilla Autoconfig will -//! join it once `io-discovery`'s `autoconfig` feature builds again. -//! Both probes will then run in parallel via `std::thread::scope`, -//! PACC results preferred. -//! 4. Convert any discovery hit into [`WizardImapConfig`] / -//! [`WizardSmtpConfig`] defaults, hand them to the per-protocol -//! wizards in [`pimalaya_cli::wizard`]. +//! 3. Run discovery sequentially — PACC first, then Mozilla +//! Autoconfig — with one spinner per method. Sub-step messages +//! track which URL is being probed. +//! 4. If PACC returned a JMAP endpoint, ask the user whether to use +//! it instead of IMAP+SMTP and run the matching protocol wizard(s). //! 5. Build a [`Config`], write it to `target`, return it. -use std::{collections::HashMap, path::Path, process::exit, thread}; +use std::{collections::HashMap, path::Path, process::exit, process::Command}; use anyhow::{anyhow, bail, Result}; -use io_discovery::pacc::{ - client::{DiscoveryPaccClient, DiscoveryPaccClientError}, - types::PaccConfig, +use io_discovery::{ + autoconfig::{ + client::DiscoveryAutoconfigClient, + coroutines::{dns_mx::mx_parent_domain, isp::DiscoveryIsp}, + types::{Autoconfig, SecurityType, Server, ServerType}, + }, + pacc::{ + client::{DiscoveryPaccClient, DiscoveryPaccClientError}, + types::PaccConfig, + }, }; -use io_process::command::Command; use log::{debug, info}; -use pimalaya_cli::wizard::{ - imap::{ - self as imap_wizard, Encryption as ImapEncryption, ImapAuth, ImapSecret, WizardImapConfig, - }, - smtp::{ - self as smtp_wizard, Encryption as SmtpEncryption, SmtpAuth, SmtpSecret, WizardSmtpConfig, +use pimalaya_cli::{ + prompt, + spinner::Spinner, + wizard::{ + imap::{ + self as imap_wizard, Encryption as ImapEncryption, ImapAuth, ImapSecret, + WizardImapConfig, + }, + jmap::{self as jmap_wizard, JmapAuth, JmapSecret, WizardJmapConfig}, + smtp::{ + self as smtp_wizard, Encryption as SmtpEncryption, SmtpAuth, SmtpSecret, + WizardSmtpConfig, + }, }, }; -use pimalaya_config::secret::Secret; +use pimalaya_config::{command::shell, secret::Secret}; use url::Url; use crate::config::{ - AccountConfig, Config, ImapConfig, SaslConfig, SaslMechanismConfig, SaslPlainConfig, SmtpConfig, + AccountConfig, Config, ImapConfig, JmapAuthConfig, JmapConfig, SaslConfig, SaslMechanismConfig, + SaslPlainConfig, SmtpConfig, }; -/// DNS resolver used by PACC discovery. Cloudflare's `1.1.1.1` is a -/// reasonable default; we'll make this configurable later. +/// DNS resolver used by PACC and Autoconfig discovery. Cloudflare's +/// `1.1.1.1` is a reasonable default; we'll make this configurable +/// later. const DEFAULT_RESOLVER: &str = "tcp://1.1.1.1:53"; pub fn run_or_exit(target: &Path) -> Result { @@ -51,38 +64,27 @@ pub fn run_or_exit(target: &Path) -> Result { target.display(), ); - if !pimalaya_cli::prompt::bool(&prompt, true)? { + if !prompt::bool(&prompt, true)? { exit(0); } - let account_name = pimalaya_cli::prompt::text("Account name:", Some("default"))?; - let email = pimalaya_cli::prompt::text::<&str>("Email address:", None)?; + let account_name = prompt::text("Account name:", Some("default"))?; + let email = prompt::text::<&str>("Email address:", None)?; let (local_part, domain) = email - .split_once('@') + .rsplit_once('@') .ok_or_else(|| anyhow!("Invalid email address `{email}`: missing `@`"))?; - info!("Discovering provider settings for {domain}…"); - let (imap_defaults, smtp_defaults) = discover(domain); + let discovery = discover(local_part, domain); - let imap = imap_wizard::run(&account_name, local_part, domain, imap_defaults.as_ref())?; - let smtp = smtp_wizard::run(&account_name, local_part, domain, smtp_defaults.as_ref())?; - - let account = AccountConfig { - default: true, - downloads_dir: None, - table_preset: None, - table_arrangement: None, - imap: Some(imap_to_config(imap)?), - jmap: None, - maildir: None, - smtp: Some(smtp_to_config(smtp)?), - }; + let account = build_account_from_discovery(&account_name, local_part, domain, discovery)?; let config = Config { downloads_dir: None, table_preset: None, table_arrangement: None, + envelope: Default::default(), + message: Default::default(), accounts: HashMap::from([(account_name, account)]), }; @@ -92,56 +94,275 @@ pub fn run_or_exit(target: &Path) -> Result { Ok(config) } -/// Runs configured discovery probes in parallel and returns the -/// merged IMAP/SMTP defaults. Currently PACC-only; Mozilla Autoconfig -/// will join once io-discovery's `autoconfig` feature compiles. -fn discover(domain: &str) -> (Option, Option) { - thread::scope(|scope| { - let pacc = scope.spawn(|| run_pacc(domain)); - - let pacc = pacc.join().unwrap_or_else(|_| { - debug!("PACC discovery thread panicked"); - None - }); - - match pacc { - Some(config) => pacc_defaults(&config), - None => (None, None), - } - }) +struct DiscoveryResult { + imap: Option, + smtp: Option, + jmap: Option, } -fn run_pacc(domain: &str) -> Option { +/// Drives PACC then Mozilla Autoconfig sequentially, each with its +/// own spinner. PACC values win on overlap; Autoconfig only fills +/// IMAP/SMTP fields PACC didn't yield. JMAP is PACC-only. +fn discover(local_part: &str, domain: &str) -> DiscoveryResult { + let pacc = run_pacc_with_spinner(domain); + let autoconfig = run_autoconfig_with_spinner(local_part, domain); + + let (pacc_imap, pacc_smtp, pacc_jmap) = pacc + .as_ref() + .map(pacc_defaults) + .unwrap_or((None, None, None)); + let (ac_imap, ac_smtp) = autoconfig + .as_ref() + .map(autoconfig_defaults) + .unwrap_or((None, None)); + + DiscoveryResult { + imap: pacc_imap.or(ac_imap), + smtp: pacc_smtp.or(ac_smtp), + jmap: pacc_jmap, + } +} + +fn run_pacc_with_spinner(domain: &str) -> Option { + let spinner = Spinner::start(format!("Probing PACC for {domain}…")); + let resolver: Url = match DEFAULT_RESOLVER.parse() { Ok(url) => url, Err(err) => { debug!("PACC: invalid default resolver `{DEFAULT_RESOLVER}`: {err}"); + spinner.failure(format!("PACC: invalid resolver `{DEFAULT_RESOLVER}`")); return None; } }; + spinner.set_message(format!( + "PACC: fetching .well-known config from ua-auto-config.{domain} and verifying digest…" + )); + let mut client = DiscoveryPaccClient::new(resolver); match client.discover(domain) { - Ok(config) => Some(config), + Ok(config) => { + spinner.success(pacc_summary(domain, &config)); + Some(config) + } Err(DiscoveryPaccClientError::Discovery(err)) => { debug!("PACC discovery for {domain} failed: {err}"); + spinner.failure(format!("PACC: no valid configuration for {domain}")); None } Err(err) => { debug!("PACC transport error for {domain}: {err}"); + spinner.failure(format!("PACC: endpoint unreachable for {domain}")); None } } } -fn pacc_defaults(config: &PaccConfig) -> (Option, Option) { +fn pacc_summary(domain: &str, config: &PaccConfig) -> String { + let p = &config.protocols; + let mut protos = Vec::with_capacity(3); + if p.jmap.is_some() { + protos.push("JMAP"); + } + if p.imap.is_some() { + protos.push("IMAP"); + } + if p.smtp.is_some() { + protos.push("SMTP"); + } + if protos.is_empty() { + format!("PACC: configuration found for {domain} (no IMAP/SMTP/JMAP fields)") + } else { + format!("PACC: discovered {} for {domain}", protos.join(" + ")) + } +} + +/// Tries the Mozilla Autoconfig chain — direct ISP URLs, then +/// MX-derived parent domain ISP URLs. The TXT mailconf and SRV +/// fallbacks from the autoconfig CLI are skipped here; we keep the +/// wizard fast and let manual entry handle the long tail. The +/// spinner message is updated for every sub-attempt so the user sees +/// which URL is currently being probed. +fn run_autoconfig_with_spinner(local_part: &str, domain: &str) -> Option { + let spinner = Spinner::start(format!("Probing Mozilla Autoconfig for {domain}…")); + + let resolver: Url = match DEFAULT_RESOLVER.parse() { + Ok(url) => url, + Err(err) => { + debug!("Autoconfig: invalid default resolver `{DEFAULT_RESOLVER}`: {err}"); + spinner.failure(format!("Autoconfig: invalid resolver `{DEFAULT_RESOLVER}`")); + return None; + } + }; + + let mut client = DiscoveryAutoconfigClient::new(resolver); + + if let Some(ac) = try_isp_urls_with_spinner(&spinner, &mut client, local_part, domain) { + spinner.success(autoconfig_summary(domain, &ac)); + return Some(ac); + } + + spinner.set_message(format!("Autoconfig: looking up MX records for {domain}…")); + + let mx_parent = match client.mx(domain) { + Ok(records) => records + .first() + .map(|r| r.rdata.exchange.to_string()) + .and_then(|t| mx_parent_domain(&t)) + .filter(|d| d != domain), + Err(err) => { + debug!("Autoconfig MX lookup for {domain} failed: {err}"); + None + } + }; + + if let Some(parent) = mx_parent { + debug!("Autoconfig: re-trying ISPs against MX parent {parent}"); + if let Some(ac) = try_isp_urls_with_spinner(&spinner, &mut client, local_part, &parent) { + spinner.success(autoconfig_summary(domain, &ac)); + return Some(ac); + } + } + + spinner.failure(format!( + "Autoconfig: no provider configuration found for {domain}" + )); + None +} + +const ISP_LABELS: [&str; 5] = [ + "ISP main URL (HTTPS)", + "ISP main URL (HTTP)", + "ISP well-known URL (HTTPS)", + "ISP well-known URL (HTTP)", + "Thunderbird ISPDB", +]; + +fn try_isp_urls_with_spinner( + spinner: &Spinner, + client: &mut DiscoveryAutoconfigClient, + local_part: &str, + domain: &str, +) -> Option { + let urls = match DiscoveryIsp::all_urls(local_part, domain) { + Ok(urls) => urls, + Err(err) => { + debug!("Autoconfig: cannot build ISP URLs for {domain}: {err}"); + return None; + } + }; + + for (url, label) in urls.iter().zip(ISP_LABELS.iter()) { + spinner.set_message(format!("Autoconfig: trying {label} for {domain}…")); + match client.isp(url.clone()) { + Ok(ac) => return Some(ac), + Err(err) => debug!("Autoconfig ISP attempt at {url} failed: {err}"), + } + } + + None +} + +fn autoconfig_summary(domain: &str, ac: &Autoconfig) -> String { + let has_imap = ac + .email_provider + .incoming_server + .iter() + .any(|s| matches!(s.r#type, ServerType::Imap)); + let has_smtp = ac + .email_provider + .outgoing_server + .iter() + .any(|s| matches!(s.r#type, ServerType::Smtp)); + let mut protos = Vec::with_capacity(2); + if has_imap { + protos.push("IMAP"); + } + if has_smtp { + protos.push("SMTP"); + } + if protos.is_empty() { + format!("Autoconfig: configuration found for {domain} (no IMAP/SMTP fields)") + } else { + format!("Autoconfig: discovered {} for {domain}", protos.join(" + ")) + } +} + +fn autoconfig_defaults(ac: &Autoconfig) -> (Option, Option) { + let imap = ac + .email_provider + .incoming_server + .iter() + .find(|s| matches!(s.r#type, ServerType::Imap)) + .and_then(autoconfig_imap); + + let smtp = ac + .email_provider + .outgoing_server + .iter() + .find(|s| matches!(s.r#type, ServerType::Smtp)) + .and_then(autoconfig_smtp); + + (imap, smtp) +} + +fn autoconfig_imap(server: &Server) -> Option { + let host = server.hostname.clone()?; + let encryption = match server.socket_type { + Some(SecurityType::Tls) => ImapEncryption::Tls, + Some(SecurityType::Starttls) => ImapEncryption::StartTls, + _ => ImapEncryption::None, + }; + let port = server.port.unwrap_or(match encryption { + ImapEncryption::Tls => 993, + _ => 143, + }); + + Some(WizardImapConfig { + host, + port, + encryption, + login: String::new(), + auth: ImapAuth::Password(ImapSecret::Raw(String::new().into())), + }) +} + +fn autoconfig_smtp(server: &Server) -> Option { + let host = server.hostname.clone()?; + let encryption = match server.socket_type { + Some(SecurityType::Tls) => SmtpEncryption::Tls, + Some(SecurityType::Starttls) => SmtpEncryption::StartTls, + _ => SmtpEncryption::None, + }; + let port = server.port.unwrap_or(match encryption { + SmtpEncryption::Tls => 465, + SmtpEncryption::StartTls => 587, + SmtpEncryption::None => 25, + }); + + Some(WizardSmtpConfig { + host, + port, + encryption, + login: String::new(), + auth: SmtpAuth::Password(SmtpSecret::Raw(String::new().into())), + }) +} + +fn pacc_defaults( + config: &PaccConfig, +) -> ( + Option, + Option, + Option, +) { let imap = config.protocols.imap.as_ref().map(|p| WizardImapConfig { host: p.host.clone(), port: 993, encryption: ImapEncryption::Tls, login: String::new(), - // Placeholder; the user picks their real auth in the wizard. - // Only the host/port/encryption fields are read as defaults. + // Placeholder; only host/port/encryption are read as defaults. + // The user picks their real auth in the wizard. auth: ImapAuth::Password(ImapSecret::Raw(String::new().into())), }); @@ -153,7 +374,246 @@ fn pacc_defaults(config: &PaccConfig) -> (Option, Option WizardImapConfig { + let scheme = c.url.scheme(); + let encryption = match scheme { + "imaps" => ImapEncryption::Tls, + _ if c.starttls => ImapEncryption::StartTls, + _ => ImapEncryption::None, + }; + let host = c.url.host_str().unwrap_or("").to_string(); + let port = c.url.port_or_known_default().unwrap_or(match encryption { + ImapEncryption::Tls => 993, + _ => 143, + }); + let login = c + .sasl + .plain + .as_ref() + .map(|p| p.authcid.clone()) + .or_else(|| c.sasl.login.as_ref().map(|l| l.username.clone())) + .unwrap_or_default(); + + WizardImapConfig { + host, + port, + encryption, + login, + auth: ImapAuth::Password(ImapSecret::Raw(String::new().into())), + } +} + +/// Same as [`imap_to_wizard`] but for SMTP. +pub(crate) fn smtp_to_wizard(c: &SmtpConfig) -> WizardSmtpConfig { + let scheme = c.url.scheme(); + let encryption = match scheme { + "smtps" => SmtpEncryption::Tls, + _ if c.starttls => SmtpEncryption::StartTls, + _ => SmtpEncryption::None, + }; + let host = c.url.host_str().unwrap_or("").to_string(); + let port = c.url.port_or_known_default().unwrap_or(match encryption { + SmtpEncryption::Tls => 465, + SmtpEncryption::StartTls => 587, + SmtpEncryption::None => 25, + }); + let login = c + .sasl + .plain + .as_ref() + .map(|p| p.authcid.clone()) + .or_else(|| c.sasl.login.as_ref().map(|l| l.username.clone())) + .unwrap_or_default(); + + WizardSmtpConfig { + host, + port, + encryption, + login, + auth: SmtpAuth::Password(SmtpSecret::Raw(String::new().into())), + } +} + +/// Same as [`imap_to_wizard`] but for JMAP. Auth is reset to a +/// placeholder — the wizard re-prompts the user for it. +pub(crate) fn jmap_to_wizard(c: &JmapConfig) -> WizardJmapConfig { + let auth = match &c.auth { + JmapAuthConfig::Basic { username, .. } => JmapAuth::Basic { + login: username.clone(), + secret: JmapSecret::Raw(String::new().into()), + }, + JmapAuthConfig::Bearer { .. } | JmapAuthConfig::Header(_) => JmapAuth::Bearer { + secret: JmapSecret::Raw(String::new().into()), + }, + }; + + WizardJmapConfig { + server: c.server.clone(), + auth, + } +} + +/// Decides whether to run the JMAP wizard or the IMAP+SMTP wizard +/// pair and builds an [`AccountConfig`] from the answers. The JMAP +/// branch fires when PACC discovered a JMAP endpoint and either the +/// user opted into it (when IMAP+SMTP defaults were also present) or +/// nothing else is available. +fn build_account_from_discovery( + account_name: &str, + local_part: &str, + domain: &str, + discovery: DiscoveryResult, +) -> Result { + let DiscoveryResult { imap, smtp, jmap } = discovery; + + let prefer_jmap = match (&jmap, imap.is_some() || smtp.is_some()) { + (Some(_), true) => prompt::bool( + "A JMAP server was discovered. Use it instead of IMAP+SMTP?", + true, + )?, + (Some(_), false) => true, + (None, _) => false, + }; + + if prefer_jmap { + let jmap_defaults = jmap.as_ref(); + let jmap = jmap_wizard::run(account_name, local_part, domain, jmap_defaults)?; + + Ok(AccountConfig { + default: true, + downloads_dir: None, + table_preset: None, + table_arrangement: None, + envelope: Default::default(), + imap: None, + jmap: Some(jmap_to_config(jmap)?), + maildir: None, + smtp: None, + }) + } else { + let imap = imap_wizard::run(account_name, local_part, domain, imap.as_ref())?; + let smtp = smtp_wizard::run(account_name, local_part, domain, smtp.as_ref())?; + + Ok(AccountConfig { + default: true, + downloads_dir: None, + table_preset: None, + table_arrangement: None, + envelope: Default::default(), + imap: Some(imap_to_config(imap)?), + jmap: None, + maildir: None, + smtp: Some(smtp_to_config(smtp)?), + }) + } +} + +/// Edits (or creates) the account named `account_name`. Uses the +/// account's current `jmap` or `imap`/`smtp` blocks as defaults; an +/// existing JMAP block routes to the JMAP wizard, otherwise the +/// IMAP+SMTP wizards run. Skips provider discovery entirely — this is +/// meant for accounts the user already configured. Writes the +/// updated config to `target` before returning. +pub fn edit_account(target: &Path, mut config: Config, account_name: &str) -> Result { + let existing = config.accounts.remove(account_name); + + let jmap_defaults = existing + .as_ref() + .and_then(|a| a.jmap.as_ref()) + .map(jmap_to_wizard); + let imap_defaults = existing + .as_ref() + .and_then(|a| a.imap.as_ref()) + .map(imap_to_wizard); + let smtp_defaults = existing + .as_ref() + .and_then(|a| a.smtp.as_ref()) + .map(smtp_to_wizard); + + let default_email = imap_defaults + .as_ref() + .map(|c| c.login.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| { + smtp_defaults + .as_ref() + .map(|c| c.login.clone()) + .filter(|s| !s.is_empty()) + }) + .or_else(|| match jmap_defaults.as_ref().map(|c| &c.auth) { + Some(JmapAuth::Basic { login, .. }) if !login.is_empty() => Some(login.clone()), + _ => None, + }); + + let email = prompt::text("Email address:", default_email.as_deref())?; + let (local_part, domain) = email + .rsplit_once('@') + .ok_or_else(|| anyhow!("Invalid email address `{email}`: missing `@`"))?; + + let is_first_account = config.accounts.is_empty() && existing.is_none(); + let default = existing + .as_ref() + .map(|a| a.default) + .unwrap_or(is_first_account); + let downloads_dir = existing.as_ref().and_then(|a| a.downloads_dir.clone()); + let table_preset = existing.as_ref().and_then(|a| a.table_preset.clone()); + let table_arrangement = existing.as_ref().and_then(|a| a.table_arrangement.clone()); + let envelope = existing + .as_ref() + .map(|a| a.envelope.clone()) + .unwrap_or_default(); + let maildir = existing.as_ref().and_then(|a| a.maildir.clone()); + + let account = if jmap_defaults.is_some() { + let jmap = jmap_wizard::run(account_name, local_part, domain, jmap_defaults.as_ref())?; + AccountConfig { + default, + downloads_dir, + table_preset, + table_arrangement, + envelope, + imap: None, + jmap: Some(jmap_to_config(jmap)?), + maildir, + smtp: None, + } + } else { + let imap = imap_wizard::run(account_name, local_part, domain, imap_defaults.as_ref())?; + let smtp = smtp_wizard::run(account_name, local_part, domain, smtp_defaults.as_ref())?; + AccountConfig { + default, + downloads_dir, + table_preset, + table_arrangement, + envelope, + imap: Some(imap_to_config(imap)?), + jmap: None, + maildir, + smtp: Some(smtp_to_config(smtp)?), + } + }; + + config.accounts.insert(account_name.to_owned(), account); + config.write(target)?; + info!("Configuration written to {}.", target.display()); + + Ok(config) } fn imap_to_config(w: WizardImapConfig) -> Result { @@ -190,6 +650,26 @@ fn smtp_to_config(w: WizardSmtpConfig) -> Result { }) } +fn jmap_to_config(w: WizardJmapConfig) -> Result { + let auth = match w.auth { + JmapAuth::Basic { login, secret } => JmapAuthConfig::Basic { + username: login, + password: jmap_secret_to_secret(secret)?, + }, + JmapAuth::Bearer { secret } => JmapAuthConfig::Bearer { + token: jmap_secret_to_secret(secret)?, + }, + }; + + Ok(JmapConfig { + server: w.server, + tls: Default::default(), + auth, + identity_id: None, + drafts_mailbox_id: None, + }) +} + fn build_sasl_imap(login: &str, auth: ImapAuth) -> Result { let ImapAuth::Password(secret) = auth; let passwd = match secret { @@ -210,6 +690,13 @@ fn build_sasl_smtp(login: &str, auth: SmtpAuth) -> Result { Ok(plain_sasl(login, passwd)) } +fn jmap_secret_to_secret(secret: JmapSecret) -> Result { + Ok(match secret { + JmapSecret::Raw(s) => Secret::Raw(s), + JmapSecret::Command(cmd) => Secret::Command(parse_cmd(&cmd)?), + }) +} + fn plain_sasl(login: &str, passwd: Secret) -> SaslConfig { SaslConfig { mechanism: Some(SaslMechanismConfig::Plain), @@ -224,8 +711,9 @@ fn plain_sasl(login: &str, passwd: Secret) -> SaslConfig { } fn parse_cmd(cmd: &str) -> Result { - if cmd.trim().is_empty() { + let line = cmd.trim(); + if line.is_empty() { bail!("Empty shell command for secret"); } - Ok(Command::new(cmd)) + Ok(shell(line)) } diff --git a/tests/common/shared.rs b/tests/common/shared.rs new file mode 100644 index 00000000..1576cca4 --- /dev/null +++ b/tests/common/shared.rs @@ -0,0 +1,403 @@ +use std::{ + fs, + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +use assert_cmd::Command; +use serde_json::Value; +use tempfile::TempDir; + +/// Cleanup resources allocated by the shared API test flow. The shared +/// API has no `mailboxes create/delete` of its own, so scaffolding goes +/// through the protocol-specific `imap mailboxes` commands. The shared +/// surface under test is everything else (`mailboxes list`, +/// `envelopes`, `messages`, `flags`, `attachments`). +struct Cleanup<'a> { + config: &'a Path, + mbox_name: Option, + mbox_name_2: Option, +} + +impl Drop for Cleanup<'_> { + fn drop(&mut self) { + if let Some(name) = &self.mbox_name { + let _ = imap_scaffold(self.config) + .args(["mailboxes", "delete", name]) + .output(); + } + + if let Some(name) = &self.mbox_name_2 { + let _ = imap_scaffold(self.config) + .args(["mailboxes", "delete", name]) + .output(); + } + } +} + +/// `himalaya ` — invokes a shared subcommand. No `imap` / +/// `jmap` / `maildir` prefix; dispatch is driven by `--backend auto` +/// (configured account has IMAP only, so the shared command resolves +/// to the IMAP backend). +fn shared(config: &Path) -> Command { + let mut cmd = Command::cargo_bin("himalaya").unwrap(); + cmd.args(["-c", config.to_str().unwrap()]); + cmd +} + +/// Same as `shared` but with `--json` so we can parse output. +fn shared_json(config: &Path) -> Command { + let mut cmd = Command::cargo_bin("himalaya").unwrap(); + cmd.args(["--json", "-c", config.to_str().unwrap()]); + cmd +} + +/// Protocol-specific `himalaya imap …` used only for scaffolding +/// (create/delete mailboxes). The shared API has no equivalents. +fn imap_scaffold(config: &Path) -> Command { + let mut cmd = Command::cargo_bin("himalaya").unwrap(); + cmd.args(["-c", config.to_str().unwrap(), "imap"]); + cmd +} + +/// Shared API integration test suite. +/// +/// Exercises every command in the shared surface in a single ordered +/// flow against a backend that already works (here: Fastmail over +/// IMAP). The shared API has no mailbox create/delete, so the test +/// scaffolds setup/teardown via `imap mailboxes …` and exercises +/// every other shared subcommand on top of those mailboxes. +/// +/// `messages send` is intentionally skipped: it needs SMTP or JMAP +/// and is covered by the dedicated `fastmail-smtp` / `fastmail-jmap` +/// test suites. +pub fn run(config: &Path, email: impl ToString) { + let email = email.to_string(); + + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + + let mbox_name = format!("himalaya-shared-{ts}"); + let mbox_name_2 = format!("himalaya-shared-{ts}-copy"); + + let mut cleanup = Cleanup { + config, + mbox_name: None, + mbox_name_2: None, + }; + + // ── scaffold: two test mailboxes via the IMAP protocol-specific + // commands; the shared API has no create/delete. + imap_scaffold(config) + .args(["mailboxes", "create", &mbox_name]) + .assert() + .success(); + cleanup.mbox_name = Some(mbox_name.clone()); + + imap_scaffold(config) + .args(["mailboxes", "create", &mbox_name_2]) + .assert() + .success(); + cleanup.mbox_name_2 = Some(mbox_name_2.clone()); + + // ── 1. mailboxes list ──────────────────────────────────────────── + + shared(config) + .args(["mailboxes", "list"]) + .assert() + .success(); + + // `--counts` issues an extra STATUS per mailbox on IMAP (slow on + // big accounts, but the test account is small). + shared(config) + .args(["mailboxes", "list", "--counts"]) + .assert() + .success(); + + // ── 2. messages add (with attachment) ──────────────────────────── + + let eml = build_eml_with_attachment(&email); + + shared(config) + .args([ + "messages", + "add", + "--mailbox", + &mbox_name, + "--flag", + "draft", + ]) + .write_stdin(eml.as_bytes()) + .assert() + .success(); + + // ── 3. envelopes list (plain, JSON for UID extraction) ─────────── + + let stdout = shared_json(config) + .args(["envelopes", "list", &mbox_name]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let envelopes: Vec = serde_json::from_slice::(&stdout) + .unwrap_or_else(|e| { + panic!( + "failed to parse envelope list output: {e}\nstdout: {}", + String::from_utf8_lossy(&stdout) + ) + }) + .get("envelopes") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_else(|| { + panic!( + "missing `envelopes` key in output: {}", + String::from_utf8_lossy(&stdout) + ) + }); + + assert_eq!( + envelopes.len(), + 1, + "expected exactly one envelope after `messages add`" + ); + + let uid = envelopes[0]["id"] + .as_str() + .map(|s| s.to_owned()) + .or_else(|| envelopes[0]["uid"].as_u64().map(|n| n.to_string())) + .expect("envelope should expose an id"); + + // ── 4. envelopes list flag variants ────────────────────────────── + + shared(config) + .args(["envelopes", "list", &mbox_name, "--has-attachment"]) + .assert() + .success(); + + shared(config) + .args(["envelopes", "list", &mbox_name, "--recipient"]) + .assert() + .success(); + + shared(config) + .args([ + "envelopes", + "list", + &mbox_name, + "--page", + "1", + "--page-size", + "10", + ]) + .assert() + .success(); + + // ── 5. messages get (default + --raw) ──────────────────────────── + + shared(config) + .args(["messages", "get", "--mailbox", &mbox_name, &uid]) + .assert() + .success(); + + let raw_stdout = shared(config) + .args(["messages", "get", "--mailbox", &mbox_name, &uid, "--raw"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let raw_body = String::from_utf8_lossy(&raw_stdout); + assert!( + raw_body.contains("Subject:"), + "`messages get --raw` should emit RFC 5322 bytes incl. Subject header, got: {raw_body}" + ); + + // ── 6. messages compose (stdout, no --send) ────────────────────── + + let compose_stdout = shared(config) + .args([ + "messages", + "compose", + "--from", + &email, + "--to", + &email, + "--subject", + "Himalaya shared compose", + "--body", + "compose body", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let compose_body = String::from_utf8_lossy(&compose_stdout); + assert!( + compose_body.contains("Subject: Himalaya shared compose"), + "`messages compose` stdout should contain the assembled headers, got: {compose_body}" + ); + + // ── 7. flags add / set / remove ────────────────────────────────── + + shared(config) + .args(["flags", "add", &mbox_name, &uid, "--flag", "seen"]) + .assert() + .success(); + + shared(config) + .args([ + "flags", "set", &mbox_name, &uid, "--flag", "seen", "--flag", "flagged", + ]) + .assert() + .success(); + + shared(config) + .args(["flags", "remove", &mbox_name, &uid, "--flag", "flagged"]) + .assert() + .success(); + + // ── 8. attachments list (default + --inline) ───────────────────── + + let att_stdout = shared_json(config) + .args(["attachments", "list", &mbox_name, &uid]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let att_body = String::from_utf8_lossy(&att_stdout); + assert!( + att_body.contains("hello.txt"), + "`attachments list` should surface the test attachment filename, got: {att_body}" + ); + + shared(config) + .args(["attachments", "list", &mbox_name, &uid, "--inline"]) + .assert() + .success(); + + // ── 9. attachments download ────────────────────────────────────── + + let dl_dir = TempDir::new().unwrap(); + shared(config) + .args([ + "attachments", + "download", + &mbox_name, + &uid, + "--dir", + dl_dir.path().to_str().unwrap(), + ]) + .assert() + .success(); + let downloaded = dl_dir.path().join("hello.txt"); + assert!( + downloaded.exists(), + "expected attachment to land at {}, dir listing: {:?}", + downloaded.display(), + fs::read_dir(dl_dir.path()) + .unwrap() + .map(|e| e.unwrap().path()) + .collect::>() + ); + + // ── 10. messages copy ──────────────────────────────────────────── + + shared(config) + .args([ + "messages", + "copy", + "--from", + &mbox_name, + "--to", + &mbox_name_2, + &uid, + ]) + .assert() + .success(); + + let stdout = shared_json(config) + .args(["envelopes", "list", &mbox_name_2]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let dest_envelopes: Vec = serde_json::from_slice::(&stdout) + .unwrap_or_else(|e| { + panic!( + "failed to parse destination envelope list: {e}\nstdout: {}", + String::from_utf8_lossy(&stdout) + ) + }) + .get("envelopes") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_else(|| { + panic!( + "missing `envelopes` key in destination output: {}", + String::from_utf8_lossy(&stdout) + ) + }); + assert_eq!( + dest_envelopes.len(), + 1, + "expected one message in copy destination" + ); + let dest_uid = dest_envelopes[0]["id"] + .as_str() + .map(|s| s.to_owned()) + .or_else(|| dest_envelopes[0]["uid"].as_u64().map(|n| n.to_string())) + .expect("destination envelope should expose an id"); + + // ── 11. messages move ──────────────────────────────────────────── + + shared(config) + .args([ + "messages", + "move", + "--from", + &mbox_name_2, + "--to", + &mbox_name, + &dest_uid, + ]) + .assert() + .success(); + + // cleanup via Drop + let _ = cleanup; +} + +/// Build a small multipart/mixed RFC 5322 message with a plain-text +/// body and a single named text attachment (`hello.txt`). Used to +/// exercise `attachments list` and `attachments download`. +fn build_eml_with_attachment(email: &str) -> String { + let boundary = "HIMALAYA-SHARED-BOUNDARY"; + [ + &format!("From: Himalaya Shared <{email}>"), + &format!("To: Himalaya Shared <{email}>"), + "Subject: Himalaya shared API integration test", + "Date: Thu, 01 Jan 2026 00:00:00 +0000", + "MIME-Version: 1.0", + &format!(r#"Content-Type: multipart/mixed; boundary="{boundary}""#), + "", + &format!("--{boundary}"), + "Content-Type: text/plain; charset=utf-8", + "", + "This is the body for the shared API integration test.", + "", + &format!("--{boundary}"), + r#"Content-Type: text/plain; charset=utf-8; name="hello.txt""#, + r#"Content-Disposition: attachment; filename="hello.txt""#, + "", + "Attachment contents.", + "", + &format!("--{boundary}--"), + ] + .join("\r\n") +} diff --git a/tests/fastmail-imap.rs b/tests/fastmail-imap.rs index d8b01062..abd1dfb7 100644 --- a/tests/fastmail-imap.rs +++ b/tests/fastmail-imap.rs @@ -2,14 +2,14 @@ #[path = "common/imap.rs"] mod imap; +#[path = "common/shared.rs"] +mod shared; use std::{env, io::Write}; use tempfile::NamedTempFile; -#[test] -#[ignore = "requires FASTMAIL_{EMAIL,APP_PASSWORD} env vars and --ignored"] -fn fastmail_imap() { +fn write_imap_config() -> (NamedTempFile, String) { let email = env::var("FASTMAIL_EMAIL").expect("FASTMAIL_EMAIL not set"); let app_password = env::var("FASTMAIL_APP_PASSWORD").expect("FASTMAIL_APP_PASSWORD not set"); @@ -24,5 +24,19 @@ imap.sasl.plain.passwd.raw = "{app_password}""# config.write_all(config_tpl.as_bytes()).unwrap(); + (config, email) +} + +#[test] +#[ignore = "requires FASTMAIL_{EMAIL,APP_PASSWORD} env vars and --ignored"] +fn fastmail_imap() { + let (config, email) = write_imap_config(); imap::run(config.path(), email); } + +#[test] +#[ignore = "requires FASTMAIL_{EMAIL,APP_PASSWORD} env vars and --ignored"] +fn fastmail_shared_imap() { + let (config, email) = write_imap_config(); + shared::run(config.path(), email); +}