From b7d068c7293e3c86f3d7eeb1c0dd64d79ed4fc81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Sun, 10 Oct 2021 22:58:57 +0200 Subject: [PATCH] improve folder struct + msg management (#217) * make imap list and search return msg instead of fetch * move imap logouts to main fn * improve list command * improve search command * improve flags command * improve template reply * improve tpl forward command * refactor tpl and msg reply/forward * refactor copy, move and write commands * refactor attachment command * fix attachment part of copy and move commands * fix send, save, read and mbox * put back notify and watch commands * fix msg encoding * refactor edit choices, clean dead code * fix attachment for write, reply and forward commands * refactor config mod struct * refactor project folder struct * fix vim plugin (#215) --- Cargo.lock | 368 ++++- Cargo.toml | 5 +- src/compl/{arg.rs => compl_arg.rs} | 0 src/compl/{handler.rs => compl_handler.rs} | 0 src/compl/mod.rs | 4 +- src/config/account_entity.rs | 162 ++ src/config/{arg.rs => config_arg.rs} | 0 src/config/config_entity.rs | 158 ++ src/config/entity.rs | 398 ----- src/config/mod.rs | 9 +- src/domain/imap/{arg.rs => imap_arg.rs} | 0 .../imap/{handler.rs => imap_handler.rs} | 10 +- .../imap/{service.rs => imap_service.rs} | 255 +-- src/domain/imap/mod.rs | 8 +- src/domain/mbox/{arg.rs => mbox_arg.rs} | 0 src/domain/mbox/{entity.rs => mbox_entity.rs} | 4 +- .../mbox/{handler.rs => mbox_handler.rs} | 7 +- src/domain/mbox/mod.rs | 8 +- src/domain/mod.rs | 7 + src/domain/msg/attachment/arg.rs | 25 - src/domain/msg/attachment/entity.rs | 120 -- src/domain/msg/attachment/mod.rs | 4 - src/domain/msg/body/entity.rs | 92 -- src/domain/msg/body/mod.rs | 1 - src/domain/msg/entity.rs | 1450 ----------------- src/domain/msg/envelope_entity.rs | 145 ++ src/domain/msg/envelopes_entity.rs | 42 + src/domain/msg/flag/entity.rs | 280 ---- src/domain/msg/flag/handler.rs | 82 - src/domain/msg/flag/mod.rs | 5 - src/domain/msg/{flag/arg.rs => flag_arg.rs} | 65 +- src/domain/msg/flag_entity.rs | 26 + src/domain/msg/flag_handler.rs | 58 + src/domain/msg/flags_entity.rs | 239 +++ src/domain/msg/handler.rs | 454 ------ src/domain/msg/header/entity.rs | 608 ------- src/domain/msg/header/mod.rs | 1 - src/domain/msg/mod.rs | 46 +- src/domain/msg/{arg.rs => msg_arg.rs} | 166 +- src/domain/msg/msg_entity.rs | 814 +++++++++ src/domain/msg/msg_handler.rs | 321 ++++ src/domain/msg/msg_utils.rs | 15 + src/domain/msg/parts_entity.rs | 117 ++ src/domain/msg/tpl/handler.rs | 158 -- src/domain/msg/tpl/mod.rs | 4 - src/domain/msg/{tpl/arg.rs => tpl_arg.rs} | 90 +- src/domain/msg/tpl_entity.rs | 118 ++ src/domain/msg/tpl_handler.rs | 52 + src/domain/msg/utils.rs | 15 - src/domain/smtp/mod.rs | 3 +- .../smtp/{service.rs => smtp_service.rs} | 18 +- src/main.rs | 133 +- src/output/mod.rs | 10 +- src/output/{arg.rs => output_arg.rs} | 0 src/output/{service.rs => output_service.rs} | 0 src/output/output_utils.rs | 12 + src/output/utils.rs | 32 - src/ui/choice.rs | 14 +- src/ui/editor.rs | 72 +- src/ui/mod.rs | 2 + src/ui/table.rs | 4 +- vim/autoload/himalaya/mbox.vim | 8 +- vim/autoload/himalaya/msg.vim | 34 +- vim/ftplugin/himalaya-msg-write.vim | 2 +- 64 files changed, 3100 insertions(+), 4260 deletions(-) rename src/compl/{arg.rs => compl_arg.rs} (100%) rename src/compl/{handler.rs => compl_handler.rs} (100%) create mode 100644 src/config/account_entity.rs rename src/config/{arg.rs => config_arg.rs} (100%) create mode 100644 src/config/config_entity.rs delete mode 100644 src/config/entity.rs rename src/domain/imap/{arg.rs => imap_arg.rs} (100%) rename src/domain/imap/{handler.rs => imap_handler.rs} (66%) rename src/domain/imap/{service.rs => imap_service.rs} (64%) rename src/domain/mbox/{arg.rs => mbox_arg.rs} (100%) rename src/domain/mbox/{entity.rs => mbox_entity.rs} (98%) rename src/domain/mbox/{handler.rs => mbox_handler.rs} (71%) delete mode 100644 src/domain/msg/attachment/arg.rs delete mode 100644 src/domain/msg/attachment/entity.rs delete mode 100644 src/domain/msg/attachment/mod.rs delete mode 100644 src/domain/msg/body/entity.rs delete mode 100644 src/domain/msg/body/mod.rs delete mode 100644 src/domain/msg/entity.rs create mode 100644 src/domain/msg/envelope_entity.rs create mode 100644 src/domain/msg/envelopes_entity.rs delete mode 100644 src/domain/msg/flag/entity.rs delete mode 100644 src/domain/msg/flag/handler.rs delete mode 100644 src/domain/msg/flag/mod.rs rename src/domain/msg/{flag/arg.rs => flag_arg.rs} (63%) create mode 100644 src/domain/msg/flag_entity.rs create mode 100644 src/domain/msg/flag_handler.rs create mode 100644 src/domain/msg/flags_entity.rs delete mode 100644 src/domain/msg/handler.rs delete mode 100644 src/domain/msg/header/entity.rs delete mode 100644 src/domain/msg/header/mod.rs rename src/domain/msg/{arg.rs => msg_arg.rs} (66%) create mode 100644 src/domain/msg/msg_entity.rs create mode 100644 src/domain/msg/msg_handler.rs create mode 100644 src/domain/msg/msg_utils.rs create mode 100644 src/domain/msg/parts_entity.rs delete mode 100644 src/domain/msg/tpl/handler.rs delete mode 100644 src/domain/msg/tpl/mod.rs rename src/domain/msg/{tpl/arg.rs => tpl_arg.rs} (64%) create mode 100644 src/domain/msg/tpl_entity.rs create mode 100644 src/domain/msg/tpl_handler.rs delete mode 100644 src/domain/msg/utils.rs rename src/domain/smtp/{service.rs => smtp_service.rs} (72%) rename src/output/{arg.rs => output_arg.rs} (100%) rename src/output/{service.rs => output_service.rs} (100%) create mode 100644 src/output/output_utils.rs delete mode 100644 src/output/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 2c5c4c1..1682f75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,11 +4,26 @@ version = 3 [[package]] name = "aho-corasick" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" dependencies = [ - "memchr 2.3.4", + "memchr 2.4.1", +] + +[[package]] +name = "ammonia" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e445c26125ff80316eaea16e812d717b147b82a68682bd4730f74d4845c8b35" +dependencies = [ + "html5ever", + "lazy_static", + "maplit", + "markup5ever_rcdom", + "matches", + "tendril", + "url", ] [[package]] @@ -90,9 +105,9 @@ checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" [[package]] name = "cc" -version = "1.0.69" +version = "1.0.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70cc2f62c6ce1868963827bd677764c62d07c3d9a3e1fb1177ee1a9ab199eb2" +checksum = "79c2681d6594606957bbb8631c4b90a7fcaaa72cdb714743a437b156d6a7eedd" [[package]] name = "cfg-if" @@ -263,6 +278,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" +[[package]] +name = "futf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9c1ce3fa9336301af935ab852c437817d14cd33690446569392e65170aac3b" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures-core" version = "0.3.17" @@ -291,12 +316,23 @@ dependencies = [ "futures-core", "futures-io", "futures-task", - "memchr 2.3.4", + "memchr 2.4.1", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.3" @@ -305,7 +341,7 @@ checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" dependencies = [ "cfg-if 1.0.0", "libc", - "wasi", + "wasi 0.10.0+wasi-snapshot-preview1", ] [[package]] @@ -327,17 +363,20 @@ dependencies = [ name = "himalaya" version = "0.4.0" dependencies = [ + "ammonia", "anyhow", "atty", "chrono", "clap", "env_logger", + "htmlescape", "imap", "imap-proto", "lettre", "log", "mailparse", "native-tls", + "regex", "rfc2047-decoder", "serde", "serde_json", @@ -361,6 +400,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "html5ever" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafcf38a1a36118242d29b92e1b08ef84e67e4a5ed06e0a80be20e6a32bfed6b" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + [[package]] name = "httpdate" version = "1.0.1" @@ -396,7 +455,7 @@ dependencies = [ "imap-proto", "lazy_static", "native-tls", - "nom 6.2.1", + "nom 6.1.2", "regex", ] @@ -406,7 +465,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad9b46a79efb6078e578ae04e51463d7c3e8767864687f7e63095b3cbefafbb" dependencies = [ - "nom 6.2.1", + "nom 6.1.2", ] [[package]] @@ -421,9 +480,9 @@ dependencies = [ [[package]] name = "instant" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee0328b1209d157ef001c94dd85b4f8f64139adb0eac2659f4b08382b2f474d" +checksum = "716d3d89f35ac6a34fd0eed635395f4c3b76fa889338a4632e5231a8684216bd" dependencies = [ "cfg-if 1.0.0", ] @@ -463,9 +522,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.101" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb00336871be5ed2c8ed44b60ae9959dc5b9f08539422ed43f09e34ecaeba21" +checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6" [[package]] name = "lock_api" @@ -494,6 +553,12 @@ dependencies = [ "cfg-if 1.0.0", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mailparse" version = "0.13.6" @@ -505,6 +570,38 @@ dependencies = [ "quoted_printable", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f015da43bcd8d4f144559a3423f4591d69b8ce0652c905374da7205df336ae2b" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "match_cfg" version = "0.1.0" @@ -528,9 +625,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.3.4" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" [[package]] name = "mime" @@ -540,9 +637,9 @@ checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" [[package]] name = "minimal-lexical" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6595bb28ed34f43c3fe088e48f6cfb2e033cab45f25a5384d5fdf564fbc8c4b2" +checksum = "9c64630dcdd71f1a64c435f54885086a0de5d6a12d104d69b165fb7d5286d677" [[package]] name = "native-tls" @@ -562,6 +659,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + [[package]] name = "nom" version = "3.2.1" @@ -573,13 +676,13 @@ dependencies = [ [[package]] name = "nom" -version = "6.2.1" +version = "6.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5c51b9083a3c620fa67a2a635d1ce7d95b897e957d6b28ff9a5da960a103a6" +checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2" dependencies = [ "bitvec", "funty", - "memchr 2.3.4", + "memchr 2.4.1", "version_check", ] @@ -589,7 +692,7 @@ version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffd9d26838a953b4af82cbeb9f1592c6798916983959be223a7124e992742c1" dependencies = [ - "memchr 2.3.4", + "memchr 2.4.1", "minimal-lexical", "version_check", ] @@ -641,9 +744,9 @@ checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" [[package]] name = "openssl-sys" -version = "0.9.66" +version = "0.9.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1996d2d305e561b70d1ee0c53f1542833f4e1ac6ce9a6708b6ff2738ca67dc82" +checksum = "69df2d8dfc6ce3aaf44b40dec6f487d5a886516cf6879c49e98e0710f310a058" dependencies = [ "autocfg", "cc", @@ -717,6 +820,44 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared", + "rand 0.7.3", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.7" @@ -731,9 +872,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" +checksum = "7c9b1041b4387893b91ee6746cddfc28516aff326a3519fb2adf820932c5e6cb" [[package]] name = "ppv-lite86" @@ -741,6 +882,12 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro2" version = "1.0.29" @@ -752,9 +899,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05" dependencies = [ "proc-macro2", ] @@ -782,6 +929,20 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc 0.2.0", + "rand_pcg", +] + [[package]] name = "rand" version = "0.8.4" @@ -789,9 +950,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e7573632e6454cf6b99d7aac4ccca54be06da05aca2ef7423d22d27d4d4bcd8" dependencies = [ "libc", - "rand_chacha", - "rand_core", - "rand_hc", + "rand_chacha 0.3.1", + "rand_core 0.6.3", + "rand_hc 0.3.1", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", ] [[package]] @@ -801,7 +972,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.3", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", ] [[package]] @@ -810,7 +990,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" dependencies = [ - "getrandom", + "getrandom 0.2.3", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", ] [[package]] @@ -819,7 +1008,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d51e9f596de227fda2ea6c84607f5558e196eeaf43c986b724ba4fb8fdf497e7" dependencies = [ - "rand_core", + "rand_core 0.6.3", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", ] [[package]] @@ -843,18 +1041,18 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" dependencies = [ - "getrandom", + "getrandom 0.2.3", "redox_syscall 0.2.10", ] [[package]] name = "regex" -version = "1.4.6" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a26af418b574bd56588335b3a3659a65725d4e636eb1016c2f9e3b38c7cc759" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" dependencies = [ "aho-corasick", - "memchr 2.3.4", + "memchr 2.4.1", "regex-syntax", ] @@ -960,9 +1158,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.67" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f9e390c27c3c0ce8bc5d725f6e4d30a29d26659494aa4b17535f7522c5c950" +checksum = "0f690853975602e1bfe1ccbf50504d67174e3bcf340f23b5ea9992e0587a52d8" dependencies = [ "itoa", "ryu", @@ -978,6 +1176,12 @@ dependencies = [ "dirs-next", ] +[[package]] +name = "siphasher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533494a8f9b724d33625ab53c6c4800f7cc445895924a8ef649222dcb76e938b" + [[package]] name = "slab" version = "0.4.4" @@ -986,9 +1190,34 @@ checksum = "c307a32c1c5c437f38c7fd45d753050587732ba8628319fbdf12a7e289ccc590" [[package]] name = "smallvec" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" +checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309" + +[[package]] +name = "string_cache" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb1139b5353f96e429e1a5e19fbaf663bddedaa06d1dbd49f82e352601209a" +dependencies = [ + "lazy_static", + "new_debug_unreachable", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24c8e5e19d22a726626f1a5e16fe15b132dcf21d10177fa5a45ce7962996b97" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] [[package]] name = "strsim" @@ -998,9 +1227,9 @@ checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" [[package]] name = "syn" -version = "1.0.75" +version = "1.0.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f58f7e8eaa0009c5fec437aabf511bd9933e4b2d7407bd05273c01a8906ea7" +checksum = "d010a1623fbd906d51d650a9916aaefc05ffa0e4053ff7fe601167f3e715d194" dependencies = [ "proc-macro2", "quote", @@ -1021,12 +1250,23 @@ checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" dependencies = [ "cfg-if 1.0.0", "libc", - "rand", + "rand 0.8.4", "redox_syscall 0.2.10", "remove_dir_all", "winapi", ] +[[package]] +name = "tendril" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ef557cb397a4f0a5a3a628f06515f78563f2209e64d47055d9dc6052bf5e33" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "termcolor" version = "1.1.2" @@ -1062,15 +1302,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" dependencies = [ "libc", - "wasi", + "wasi 0.10.0+wasi-snapshot-preview1", "winapi", ] [[package]] name = "tinyvec" -version = "1.3.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "848a1e1181b9f6753b5e96a092749e29b11d19ede67dfbbd6c7dc7e0f49b5338" +checksum = "f83b2a3d4d9091d0abd7eba4dc2710b1718583bd4d8992e2190720ea38f391f7" dependencies = [ "tinyvec_macros", ] @@ -1105,9 +1345,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "246f4c42e67e7a4e3c6106ff716a5d067d4132a642840b242e357e468a2a0085" +checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" [[package]] name = "unicode-normalization" @@ -1120,9 +1360,9 @@ dependencies = [ [[package]] name = "unicode-width" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" [[package]] name = "unicode-xid" @@ -1142,13 +1382,19 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "uuid" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" dependencies = [ - "getrandom", + "getrandom 0.2.3", ] [[package]] @@ -1163,6 +1409,12 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.10.0+wasi-snapshot-preview1" @@ -1205,3 +1457,15 @@ name = "wyz" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" + +[[package]] +name = "xml5ever" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1b52e6e8614d4a58b8e70cf51ec0cc21b256ad8206708bcff8139b5bbd6a59" +dependencies = [ + "log", + "mac", + "markup5ever", + "time", +] diff --git a/Cargo.toml b/Cargo.toml index 52706f9..fd21257 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,19 +6,22 @@ authors = ["soywod "] edition = "2018" [dependencies] +ammonia = "3.1.2" anyhow = "1.0.44" atty = "0.2.14" chrono = "0.4.19" clap = { version = "2.33.3", default-features = false, features = ["suggestions", "color"] } env_logger = "0.8.3" +htmlescape = "0.3.1" imap = "3.0.0-alpha.4" imap-proto = "0.14.3" # This commit includes the de/serialization of the ContentType # lettre = { version = "0.10.0-rc.1", features = ["serde"] } lettre = {git = "https://github.com/TornaxO7/lettre/", branch = "master", features = ["serde"] } log = "0.4.14" -mailparse = "0.13.4" +mailparse = "0.13.6" native-tls = "0.2" +regex = "1.5.4" rfc2047-decoder = "0.1.2" serde = { version = "1.0.118", features = ["derive"] } serde_json = "1.0.61" diff --git a/src/compl/arg.rs b/src/compl/compl_arg.rs similarity index 100% rename from src/compl/arg.rs rename to src/compl/compl_arg.rs diff --git a/src/compl/handler.rs b/src/compl/compl_handler.rs similarity index 100% rename from src/compl/handler.rs rename to src/compl/compl_handler.rs diff --git a/src/compl/mod.rs b/src/compl/mod.rs index a333e6c..35328b6 100644 --- a/src/compl/mod.rs +++ b/src/compl/mod.rs @@ -5,5 +5,5 @@ //! //! [clap's docs.rs website]: https://docs.rs/clap/2.33.3/clap/enum.Shell.html -pub mod arg; -pub mod handler; +pub mod compl_arg; +pub mod compl_handler; diff --git a/src/config/account_entity.rs b/src/config/account_entity.rs new file mode 100644 index 0000000..b3c2d66 --- /dev/null +++ b/src/config/account_entity.rs @@ -0,0 +1,162 @@ +use anyhow::{anyhow, Context, Error, Result}; +use lettre::transport::smtp::authentication::Credentials as SmtpCredentials; +use log::{debug, trace}; +use std::{convert::TryFrom, env, fs, path::PathBuf}; + +use crate::{ + config::{Config, DEFAULT_PAGE_SIZE, DEFAULT_SIG_DELIM}, + output::run_cmd, +}; + +/// Represent a user account. +#[derive(Debug, Default)] +pub struct Account { + pub name: String, + pub from: String, + pub downloads_dir: PathBuf, + pub sig: Option, + pub default_page_size: usize, + pub watch_cmds: Vec, + pub default: bool, + pub email: String, + + pub imap_host: String, + pub imap_port: u16, + pub imap_starttls: bool, + pub imap_insecure: bool, + pub imap_login: String, + pub imap_passwd_cmd: String, + + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_starttls: bool, + pub smtp_insecure: bool, + pub smtp_login: String, + pub smtp_passwd_cmd: String, +} + +impl Account { + pub fn address(&self) -> String { + let name = &self.from; + let has_special_chars = "()<>[]:;@.,".contains(|special_char| name.contains(special_char)); + + if name.is_empty() { + format!("{}", self.email) + } else if has_special_chars { + // so the name has special characters => Wrap it with '"' + format!("\"{}\" <{}>", name, self.email) + } else { + format!("{} <{}>", name, self.email) + } + } + + pub fn imap_passwd(&self) -> Result { + let passwd = run_cmd(&self.imap_passwd_cmd).context("cannot run IMAP passwd cmd")?; + let passwd = passwd + .trim_end_matches(|c| c == '\r' || c == '\n') + .to_owned(); + + Ok(passwd) + } + + pub fn smtp_creds(&self) -> Result { + let passwd = run_cmd(&self.smtp_passwd_cmd).context("cannot run SMTP passwd cmd")?; + let passwd = passwd + .trim_end_matches(|c| c == '\r' || c == '\n') + .to_owned(); + + Ok(SmtpCredentials::new(self.smtp_login.to_owned(), passwd)) + } +} + +impl<'a> TryFrom<(&'a Config, Option<&str>)> for Account { + type Error = Error; + + fn try_from((config, account_name): (&'a Config, Option<&str>)) -> Result { + debug!("init account `{}`", account_name.unwrap_or("default")); + let (name, account) = match account_name.map(|name| name.trim()) { + Some("default") | Some("") | None => config + .accounts + .iter() + .find(|(_, account)| account.default.unwrap_or(false)) + .map(|(name, account)| (name.to_owned(), account)) + .ok_or_else(|| anyhow!("cannot find default account")), + Some(name) => config + .accounts + .get(name) + .map(|account| (name.to_owned(), account)) + .ok_or_else(|| anyhow!(r#"cannot find account "{}""#, name)), + }?; + + let downloads_dir = account + .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(|| { + 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())) + }) + .unwrap_or_else(|| env::temp_dir()); + + let default_page_size = account + .default_page_size + .as_ref() + .or_else(|| config.default_page_size.as_ref()) + .unwrap_or(&DEFAULT_PAGE_SIZE) + .to_owned(); + + let default_sig_delim = DEFAULT_SIG_DELIM.to_string(); + let sig_delim = account + .signature_delimiter + .as_ref() + .or_else(|| config.signature_delimiter.as_ref()) + .unwrap_or(&default_sig_delim); + let sig = account + .signature + .as_ref() + .or_else(|| config.signature.as_ref()); + let sig = sig + .and_then(|sig| shellexpand::full(sig).ok()) + .map(String::from) + .and_then(|sig| fs::read_to_string(sig).ok()) + .or_else(|| sig.map(|sig| sig.to_owned())) + .map(|sig| format!("{}{}", sig_delim, sig.trim_end())); + + let account = Account { + name, + from: account.name.as_ref().unwrap_or(&config.name).to_owned(), + downloads_dir, + sig, + default_page_size, + watch_cmds: account + .watch_cmds + .as_ref() + .or_else(|| config.watch_cmds.as_ref()) + .unwrap_or(&vec![]) + .to_owned(), + default: account.default.unwrap_or(false), + email: account.email.to_owned(), + imap_host: account.imap_host.to_owned(), + imap_port: account.imap_port, + imap_starttls: account.imap_starttls.unwrap_or_default(), + imap_insecure: account.imap_insecure.unwrap_or_default(), + imap_login: account.imap_login.to_owned(), + imap_passwd_cmd: account.imap_passwd_cmd.to_owned(), + smtp_host: account.smtp_host.to_owned(), + smtp_port: account.smtp_port, + smtp_starttls: account.smtp_starttls.unwrap_or_default(), + smtp_insecure: account.smtp_insecure.unwrap_or_default(), + smtp_login: account.smtp_login.to_owned(), + smtp_passwd_cmd: account.smtp_passwd_cmd.to_owned(), + }; + + trace!("{:#?}", account); + Ok(account) + } +} diff --git a/src/config/arg.rs b/src/config/config_arg.rs similarity index 100% rename from src/config/arg.rs rename to src/config/config_arg.rs diff --git a/src/config/config_entity.rs b/src/config/config_entity.rs new file mode 100644 index 0000000..b4ce176 --- /dev/null +++ b/src/config/config_entity.rs @@ -0,0 +1,158 @@ +use anyhow::{Context, Error, Result}; +use log::{debug, trace}; +use serde::Deserialize; +use std::{collections::HashMap, convert::TryFrom, env, fs, path::PathBuf, thread}; +use toml; + +use crate::output::run_cmd; + +pub const DEFAULT_PAGE_SIZE: usize = 10; +pub const DEFAULT_SIG_DELIM: &str = "-- \n"; + +/// Represent the user config. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Config { + /// Define the full display name of the user. + pub name: String, + /// Define the downloads directory (eg. for attachments). + pub downloads_dir: Option, + /// Override the default signature delimiter "`--\n `". + pub signature_delimiter: Option, + /// Define the signature. + pub signature: Option, + /// Define the default page size for listings. + pub default_page_size: Option, + pub notify_cmd: Option, + pub watch_cmds: Option>, + #[serde(flatten)] + pub accounts: ConfigAccountsMap, +} + +/// Represent the accounts section of the config. +pub type ConfigAccountsMap = HashMap; + +/// Represent an account in the accounts section. +#[derive(Debug, Default, Clone, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct ConfigAccountEntry { + pub name: Option, + pub downloads_dir: Option, + pub signature_delimiter: Option, + pub signature: Option, + pub default_page_size: Option, + pub watch_cmds: Option>, + pub default: Option, + pub email: String, + pub imap_host: String, + pub imap_port: u16, + pub imap_starttls: Option, + pub imap_insecure: Option, + pub imap_login: String, + pub imap_passwd_cmd: String, + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_starttls: Option, + pub smtp_insecure: Option, + pub smtp_login: String, + pub smtp_passwd_cmd: String, +} + +impl Config { + fn path_from_xdg() -> Result { + let path = env::var("XDG_CONFIG_HOME").context("cannot find `XDG_CONFIG_HOME` env var")?; + let mut path = PathBuf::from(path); + path.push("himalaya"); + path.push("config.toml"); + + Ok(path) + } + + fn path_from_xdg_alt() -> Result { + let home_var = if cfg!(target_family = "windows") { + "USERPROFILE" + } else { + "HOME" + }; + let mut path: PathBuf = env::var(home_var) + .context(format!("cannot find `{}` env var", home_var))? + .into(); + path.push(".config"); + path.push("himalaya"); + path.push("config.toml"); + + Ok(path) + } + + fn path_from_home() -> Result { + let home_var = if cfg!(target_family = "windows") { + "USERPROFILE" + } else { + "HOME" + }; + let mut path: PathBuf = env::var(home_var) + .context(format!("cannot find `{}` env var", home_var))? + .into(); + path.push(".himalayarc"); + + Ok(path) + } + + pub fn path() -> Result { + let path = Self::path_from_xdg() + .or_else(|_| Self::path_from_xdg_alt()) + .or_else(|_| Self::path_from_home()) + .context("cannot find config path")?; + + Ok(path) + } + + pub fn run_notify_cmd>(&self, subject: S, sender: S) -> Result<()> { + let subject = subject.as_ref(); + let sender = sender.as_ref(); + + let default_cmd = format!(r#"notify-send "📫 {}" "{}""#, sender, subject); + let cmd = self + .notify_cmd + .as_ref() + .map(|cmd| format!(r#"{} {:?} {:?}"#, cmd, subject, sender)) + .unwrap_or(default_cmd); + + run_cmd(&cmd).context("cannot run notify cmd")?; + + Ok(()) + } + + pub fn _exec_watch_cmds(&self, account: &ConfigAccountEntry) -> Result<()> { + let cmds = account + .watch_cmds + .as_ref() + .or_else(|| self.watch_cmds.as_ref()) + .map(|cmds| cmds.to_owned()) + .unwrap_or_default(); + + thread::spawn(move || { + debug!("batch execution of {} cmd(s)", cmds.len()); + cmds.iter().for_each(|cmd| { + debug!("running command {:?}…", cmd); + let res = run_cmd(cmd); + debug!("{:?}", res); + }) + }); + + Ok(()) + } +} + +impl TryFrom> for Config { + type Error = Error; + + fn try_from(path: Option<&str>) -> Result { + debug!("init config from `{:?}`", path); + let path = path.map(|s| s.into()).unwrap_or(Config::path()?); + let content = fs::read_to_string(path).context("cannot read config file")?; + let config = toml::from_str(&content).context("cannot parse config file")?; + trace!("{:#?}", config); + Ok(config) + } +} diff --git a/src/config/entity.rs b/src/config/entity.rs deleted file mode 100644 index 62d45d0..0000000 --- a/src/config/entity.rs +++ /dev/null @@ -1,398 +0,0 @@ -use anyhow::{anyhow, Context, Error, Result}; -use lettre::transport::smtp::authentication::Credentials as SmtpCredentials; -use log::{debug, trace}; -use serde::Deserialize; -use shellexpand; -use std::{collections::HashMap, convert::TryFrom, env, fs, path::PathBuf, thread}; -use toml; - -use crate::output::utils::run_cmd; - -const DEFAULT_PAGE_SIZE: usize = 10; -const DEFAULT_SIG_DELIM: &str = "-- \n"; - -/// Represents the whole config file. -#[derive(Debug, Default, Clone, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct Config { - // TODO: rename with `from` - pub name: String, - pub downloads_dir: Option, - pub notify_cmd: Option, - /// Option to override the default signature delimiter "`--\n `". - pub signature_delimiter: Option, - pub signature: Option, - pub default_page_size: Option, - pub watch_cmds: Option>, - #[serde(flatten)] - pub accounts: ConfigAccountsMap, -} - -impl Config { - fn path_from_xdg() -> Result { - let path = env::var("XDG_CONFIG_HOME").context("cannot find `XDG_CONFIG_HOME` env var")?; - let mut path = PathBuf::from(path); - path.push("himalaya"); - path.push("config.toml"); - - Ok(path) - } - - fn path_from_xdg_alt() -> Result { - let home_var = if cfg!(target_family = "windows") { - "USERPROFILE" - } else { - "HOME" - }; - let mut path: PathBuf = env::var(home_var) - .context(format!("cannot find `{}` env var", home_var))? - .into(); - path.push(".config"); - path.push("himalaya"); - path.push("config.toml"); - - Ok(path) - } - - fn path_from_home() -> Result { - let home_var = if cfg!(target_family = "windows") { - "USERPROFILE" - } else { - "HOME" - }; - let mut path: PathBuf = env::var(home_var) - .context(format!("cannot find `{}` env var", home_var))? - .into(); - path.push(".himalayarc"); - - Ok(path) - } - - pub fn path() -> Result { - let path = Self::path_from_xdg() - .or_else(|_| Self::path_from_xdg_alt()) - .or_else(|_| Self::path_from_home()) - .context("cannot find config path")?; - - Ok(path) - } - - pub fn run_notify_cmd>(&self, subject: S, sender: S) -> Result<()> { - let subject = subject.as_ref(); - let sender = sender.as_ref(); - - let default_cmd = format!(r#"notify-send "📫 {}" "{}""#, sender, subject); - let cmd = self - .notify_cmd - .as_ref() - .map(|cmd| format!(r#"{} {:?} {:?}"#, cmd, subject, sender)) - .unwrap_or(default_cmd); - - run_cmd(&cmd).context("cannot run notify cmd")?; - - Ok(()) - } - - pub fn _exec_watch_cmds(&self, account: &ConfigAccountEntry) -> Result<()> { - let cmds = account - .watch_cmds - .as_ref() - .or_else(|| self.watch_cmds.as_ref()) - .map(|cmds| cmds.to_owned()) - .unwrap_or_default(); - - thread::spawn(move || { - debug!("batch execution of {} cmd(s)", cmds.len()); - cmds.iter().for_each(|cmd| { - debug!("running command {:?}…", cmd); - let res = run_cmd(cmd); - debug!("{:?}", res); - }) - }); - - Ok(()) - } -} - -impl TryFrom> for Config { - type Error = Error; - - fn try_from(path: Option<&str>) -> Result { - debug!("init config from `{:?}`", path); - let path = path.map(|s| s.into()).unwrap_or(Config::path()?); - let content = fs::read_to_string(path).context("cannot read config file")?; - let config = toml::from_str(&content).context("cannot parse config file")?; - trace!("{:#?}", config); - Ok(config) - } -} - -pub type ConfigAccountsMap = HashMap; - -#[derive(Debug, Default, Clone, PartialEq, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct ConfigAccountEntry { - // TODO: rename with `from` - pub name: Option, - pub downloads_dir: Option, - pub signature_delimiter: Option, - pub signature: Option, - pub default_page_size: Option, - pub watch_cmds: Option>, - pub default: Option, - pub email: String, - pub imap_host: String, - pub imap_port: u16, - pub imap_starttls: Option, - pub imap_insecure: Option, - pub imap_login: String, - pub imap_passwd_cmd: String, - pub smtp_host: String, - pub smtp_port: u16, - pub smtp_starttls: Option, - pub smtp_insecure: Option, - pub smtp_login: String, - pub smtp_passwd_cmd: String, -} - -/// Representation of a user account. -#[derive(Debug, Default)] -pub struct Account { - pub name: String, - pub from: String, - pub downloads_dir: PathBuf, - pub signature: String, - pub default_page_size: usize, - pub watch_cmds: Vec, - - pub default: bool, - pub email: String, - - pub imap_host: String, - pub imap_port: u16, - pub imap_starttls: bool, - pub imap_insecure: bool, - pub imap_login: String, - pub imap_passwd_cmd: String, - - pub smtp_host: String, - pub smtp_port: u16, - pub smtp_starttls: bool, - pub smtp_insecure: bool, - pub smtp_login: String, - pub smtp_passwd_cmd: String, -} - -impl Account { - /// This is a little helper-function like which uses the the name and email - /// of the account to create a valid address for the header of the headers - /// of a msg. - /// - /// # Hint - /// If the name includes some special characters like a whitespace, comma or semicolon, then - /// the name will be automatically wrapped between two `"`. - /// - /// # Exapmle - /// ``` - /// use himalaya::config::model::{Account, Config}; - /// - /// fn main() { - /// let config = Config::default(); - /// - /// let normal_account = Account::new(Some("Acc1"), "acc1@mail.com"); - /// // notice the semicolon in the name! - /// let special_account = Account::new(Some("TL;DR"), "acc2@mail.com"); - /// - /// // -- Expeced outputs -- - /// let expected_normal = Account { - /// name: Some("Acc1".to_string()), - /// email: "acc1@mail.com".to_string(), - /// .. Account::default() - /// }; - /// - /// let expected_special = Account { - /// name: Some("\"TL;DR\"".to_string()), - /// email: "acc2@mail.com".to_string(), - /// .. Account::default() - /// }; - /// - /// assert_eq!(config.address(&normal_account), "Acc1 "); - /// assert_eq!(config.address(&special_account), "\"TL;DR\" "); - /// } - /// ``` - pub fn address(&self) -> String { - let name = &self.from; - let has_special_chars = "()<>[]:;@.,".contains(|special_char| name.contains(special_char)); - - if name.is_empty() { - format!("{}", self.email) - } else if has_special_chars { - // so the name has special characters => Wrap it with '"' - format!("\"{}\" <{}>", name, self.email) - } else { - format!("{} <{}>", name, self.email) - } - } - - /// Runs the given command in your password string and returns it. - pub fn imap_passwd(&self) -> Result { - let passwd = run_cmd(&self.imap_passwd_cmd).context("cannot run IMAP passwd cmd")?; - let passwd = passwd - .trim_end_matches(|c| c == '\r' || c == '\n') - .to_owned(); - - Ok(passwd) - } - - pub fn smtp_creds(&self) -> Result { - let passwd = run_cmd(&self.smtp_passwd_cmd).context("cannot run SMTP passwd cmd")?; - let passwd = passwd - .trim_end_matches(|c| c == '\r' || c == '\n') - .to_owned(); - - Ok(SmtpCredentials::new(self.smtp_login.to_owned(), passwd)) - } -} - -impl<'a> TryFrom<(&'a Config, Option<&str>)> for Account { - type Error = Error; - - fn try_from((config, account_name): (&'a Config, Option<&str>)) -> Result { - debug!("init account `{}`", account_name.unwrap_or("default")); - let (name, account) = match account_name { - Some("") | None => config - .accounts - .iter() - .find(|(_, account)| account.default.unwrap_or(false)) - .map(|(name, account)| (name.to_owned(), account)) - .ok_or_else(|| anyhow!("cannot find default account")), - Some(name) => config - .accounts - .get(name) - .map(|account| (name.to_owned(), account)) - .ok_or_else(|| anyhow!("cannot find account `{}`", name)), - }?; - - let downloads_dir = account - .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(|| { - 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())) - }) - .unwrap_or_else(|| env::temp_dir()); - - let default_page_size = account - .default_page_size - .as_ref() - .or_else(|| config.default_page_size.as_ref()) - .unwrap_or(&DEFAULT_PAGE_SIZE) - .to_owned(); - - let default_sig_delim = DEFAULT_SIG_DELIM.to_string(); - let signature_delim = account - .signature_delimiter - .as_ref() - .or_else(|| config.signature_delimiter.as_ref()) - .unwrap_or(&default_sig_delim); - let signature = account - .signature - .as_ref() - .or_else(|| config.signature.as_ref()); - let signature = signature - .and_then(|sig| shellexpand::full(sig).ok()) - .map(String::from) - .and_then(|sig| fs::read_to_string(sig).ok()) - .or_else(|| signature.map(|sig| sig.to_owned())) - .map(|sig| format!("\n\n{}{}", signature_delim, sig.trim_end())) - .unwrap_or_default(); - - let account = Account { - name, - from: account.name.as_ref().unwrap_or(&config.name).to_owned(), - downloads_dir, - signature, - default_page_size, - watch_cmds: account - .watch_cmds - .as_ref() - .or_else(|| config.watch_cmds.as_ref()) - .unwrap_or(&vec![]) - .to_owned(), - default: account.default.unwrap_or(false), - email: account.email.to_owned(), - imap_host: account.imap_host.to_owned(), - imap_port: account.imap_port, - imap_starttls: account.imap_starttls.unwrap_or_default(), - imap_insecure: account.imap_insecure.unwrap_or_default(), - imap_login: account.imap_login.to_owned(), - imap_passwd_cmd: account.imap_passwd_cmd.to_owned(), - smtp_host: account.smtp_host.to_owned(), - smtp_port: account.smtp_port, - smtp_starttls: account.smtp_starttls.unwrap_or_default(), - smtp_insecure: account.smtp_insecure.unwrap_or_default(), - smtp_login: account.smtp_login.to_owned(), - smtp_passwd_cmd: account.smtp_passwd_cmd.to_owned(), - }; - - trace!("{:#?}", account); - Ok(account) - } -} -// FIXME: tests -// #[cfg(test)] -// mod tests { -// use crate::domain::{account::entity::Account, config::entity::Config}; - -// // a quick way to get a config instance for testing -// fn get_config() -> Config { -// Config { -// name: String::from("Config Name"), -// ..Config::default() -// } -// } - -// #[test] -// fn test_find_account_by_name() { -// let mut config = get_config(); - -// let account1 = Account::new(None, "one@mail.com"); -// let account2 = Account::new(Some("Two"), "two@mail.com"); - -// // add some accounts -// config.accounts.insert("One".to_string(), account1.clone()); -// config.accounts.insert("Two".to_string(), account2.clone()); - -// let ret1 = config.find_account_by_name(Some("One")).unwrap(); -// let ret2 = config.find_account_by_name(Some("Two")).unwrap(); - -// assert_eq!(*ret1, account1); -// assert_eq!(*ret2, account2); -// } - -// #[test] -// fn test_address() { -// let config = get_config(); - -// let account1 = Account::new(None, "one@mail.com"); -// let account2 = Account::new(Some("Two"), "two@mail.com"); -// let account3 = Account::new(Some("TL;DR"), "three@mail.com"); -// let account4 = Account::new(Some("TL,DR"), "lol@mail.com"); -// let account5 = Account::new(Some("TL:DR"), "rofl@mail.com"); -// let account6 = Account::new(Some("TL.DR"), "rust@mail.com"); - -// assert_eq!(&config.address(&account1), "Config Name "); -// assert_eq!(&config.address(&account2), "Two "); -// assert_eq!(&config.address(&account3), "\"TL;DR\" "); -// assert_eq!(&config.address(&account4), "\"TL,DR\" "); -// assert_eq!(&config.address(&account5), "\"TL:DR\" "); -// assert_eq!(&config.address(&account6), "\"TL.DR\" "); -// } -// } diff --git a/src/config/mod.rs b/src/config/mod.rs index 313f7c7..a033da2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,9 @@ //! Module related to the user's configuration. -pub mod arg; -pub mod entity; +pub mod config_arg; + +pub mod account_entity; +pub use account_entity::*; + +pub mod config_entity; +pub use config_entity::*; diff --git a/src/domain/imap/arg.rs b/src/domain/imap/imap_arg.rs similarity index 100% rename from src/domain/imap/arg.rs rename to src/domain/imap/imap_arg.rs diff --git a/src/domain/imap/handler.rs b/src/domain/imap/imap_handler.rs similarity index 66% rename from src/domain/imap/handler.rs rename to src/domain/imap/imap_handler.rs index c756085..a543600 100644 --- a/src/domain/imap/handler.rs +++ b/src/domain/imap/imap_handler.rs @@ -4,7 +4,7 @@ use anyhow::Result; -use crate::{config::entity::Config, domain::imap::service::ImapServiceInterface}; +use crate::{config::Config, domain::imap::ImapServiceInterface}; /// Notify handler. pub fn notify( @@ -12,9 +12,7 @@ pub fn notify( config: &Config, imap: &mut ImapService, ) -> Result<()> { - imap.notify(&config, keepalive)?; - imap.logout()?; - Ok(()) + imap.notify(&config, keepalive) } /// Watch handler. @@ -22,7 +20,5 @@ pub fn watch( keepalive: u64, imap: &mut ImapService, ) -> Result<()> { - imap.watch(keepalive)?; - imap.logout()?; - Ok(()) + imap.watch(keepalive) } diff --git a/src/domain/imap/service.rs b/src/domain/imap/imap_service.rs similarity index 64% rename from src/domain/imap/service.rs rename to src/domain/imap/imap_service.rs index ff49dc0..9ef7604 100644 --- a/src/domain/imap/service.rs +++ b/src/domain/imap/imap_service.rs @@ -3,47 +3,45 @@ //! This module exposes a service that can interact with IMAP servers. use anyhow::{anyhow, Context, Result}; -use imap; use log::{debug, trace}; -use native_tls::{self, TlsConnector, TlsStream}; -use std::{collections::HashSet, convert::TryFrom, iter::FromIterator, net::TcpStream}; +use native_tls::{TlsConnector, TlsStream}; +use std::{ + collections::HashSet, + convert::{TryFrom, TryInto}, + iter::FromIterator, + net::TcpStream, +}; use crate::{ - config::entity::{Account, Config}, + config::{Account, Config}, domain::{ - mbox::entity::Mbox, - msg::{entity::Msg, flag::entity::Flags}, + mbox::Mbox, + msg::{Envelopes, Flags, Msg}, }, }; type ImapSession = imap::Session>; -type ImapMsgs = imap::types::ZeroCopy>; type ImapMboxes = imap::types::ZeroCopy>; pub trait ImapServiceInterface { fn notify(&mut self, config: &Config, keepalive: u64) -> Result<()>; fn watch(&mut self, keepalive: u64) -> Result<()>; - fn list_mboxes(&mut self) -> Result; - fn list_msgs(&mut self, page_size: &usize, page: &usize) -> Result>; - fn search_msgs( - &mut self, - query: &str, - page_size: &usize, - page: &usize, - ) -> Result>; - fn get_msg(&mut self, uid: &str) -> Result; - fn append_msg(&mut self, mbox: &Mbox, msg: &mut Msg) -> Result<()>; - /// Add flags to the given message UID sequence. - /// - /// ```ignore - /// let flags = Flags::from(vec![Flag::Seen, Flag::Deleted]); - /// add_flags("5:10", flags) - /// ``` - fn add_flags(&mut self, uid_seq: &str, flags: &Flags) -> Result<()>; - fn set_flags(&mut self, uid_seq: &str, flags: &Flags) -> Result<()>; - fn remove_flags(&mut self, uid_seq: &str, flags: &Flags) -> Result<()>; + fn get_mboxes(&mut self) -> Result; + fn get_msgs(&mut self, page_size: &usize, page: &usize) -> Result; + fn find_msgs(&mut self, query: &str, page_size: &usize, page: &usize) -> Result; + fn find_msg(&mut self, seq: &str) -> Result; + fn find_raw_msg(&mut self, seq: &str) -> Result>; + fn append_msg(&mut self, mbox: &Mbox, msg: Msg) -> Result<()>; + fn append_raw_msg_with_flags(&mut self, mbox: &Mbox, msg: &[u8], flags: Flags) -> Result<()>; fn expunge(&mut self) -> Result<()>; fn logout(&mut self) -> Result<()>; + + /// Add flags to all messages within the given sequence range. + fn add_flags(&mut self, seq_range: &str, flags: &Flags) -> Result<()>; + /// Replace flags of all messages within the given sequence range. + fn set_flags(&mut self, seq_range: &str, flags: &Flags) -> Result<()>; + /// Remove flags from all messages within the given sequence range. + fn remove_flags(&mut self, seq_range: &str, flags: &Flags) -> Result<()>; } pub struct ImapService<'a> { @@ -108,7 +106,7 @@ impl<'a> ImapService<'a> { } impl<'a> ImapServiceInterface for ImapService<'a> { - fn list_mboxes(&mut self) -> Result { + fn get_mboxes(&mut self) -> Result { let mboxes = self .sess()? .list(Some(""), Some("*")) @@ -116,20 +114,20 @@ impl<'a> ImapServiceInterface for ImapService<'a> { Ok(mboxes) } - fn list_msgs(&mut self, page_size: &usize, page: &usize) -> Result> { + fn get_msgs(&mut self, page_size: &usize, page: &usize) -> Result { let mbox = self.mbox.to_owned(); let last_seq = self .sess()? .select(&mbox.name) - .context(format!("cannot select mailbox `{}`", self.mbox.name))? + .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))? .exists as i64; if last_seq == 0 { - return Ok(None); + return Ok(Envelopes::default()); } // TODO: add tests, improve error management when empty page - let range = if page_size > &0 { + let range = if *page_size > 0 { let cursor = (page * page_size) as i64; let begin = 1.max(last_seq - cursor); let end = begin - begin.min(*page_size as i64) + 1; @@ -140,138 +138,94 @@ impl<'a> ImapServiceInterface for ImapService<'a> { let fetches = self .sess()? - .fetch(range, "(UID FLAGS ENVELOPE INTERNALDATE)") - .context("cannot fetch messages")?; + .fetch(range, "(ENVELOPE FLAGS INTERNALDATE)") + .context(r#"cannot fetch messages within range "{}""#)?; - Ok(Some(fetches)) + Ok(Envelopes::try_from(fetches)?) } - fn search_msgs( - &mut self, - query: &str, - page_size: &usize, - page: &usize, - ) -> Result> { + fn find_msgs(&mut self, query: &str, page_size: &usize, page: &usize) -> Result { let mbox = self.mbox.to_owned(); self.sess()? .select(&mbox.name) - .context(format!("cannot select mailbox `{}`", self.mbox.name))?; + .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))?; let begin = page * page_size; let end = begin + (page_size - 1); - let uids: Vec = self + let seqs: Vec = self .sess()? .search(query) .context(format!( - "cannot search in `{}` with query `{}`", + r#"cannot search in "{}" with query: "{}""#, self.mbox.name, query ))? .iter() .map(|seq| seq.to_string()) .collect(); - if uids.is_empty() { - return Ok(None); + if seqs.is_empty() { + return Ok(Envelopes::default()); } - let range = uids[begin..end.min(uids.len())].join(","); + // FIXME: panic if begin > end + let range = seqs[begin..end.min(seqs.len())].join(","); let fetches = self .sess()? - .fetch(&range, "(UID FLAGS ENVELOPE INTERNALDATE)") - .context(format!("cannot fetch range `{}`", &range))?; + .fetch(&range, "(ENVELOPE FLAGS INTERNALDATE)") + .context(r#"cannot fetch messages within range "{}""#)?; - Ok(Some(fetches)) + Ok(Envelopes::try_from(fetches)?) } - /// Get the message according to the given `mbox` and `uid`. - fn get_msg(&mut self, uid: &str) -> Result { + + /// Find a message by sequence number. + fn find_msg(&mut self, seq: &str) -> Result { let mbox = self.mbox.to_owned(); - self.sess()? - .select(&mbox.name) - .context(format!("cannot select mbox `{}`", self.mbox.name))?; - match self - .sess()? - .uid_fetch(uid, "(FLAGS BODY[] ENVELOPE INTERNALDATE)") - .context("cannot fetch bodies")? - .first() - { - None => Err(anyhow!("cannot find message `{}`", uid)), - Some(fetch) => Ok(Msg::try_from(fetch)?), - } - } - - fn append_msg(&mut self, mbox: &Mbox, msg: &mut Msg) -> Result<()> { - let body = msg.into_bytes()?; - let flags: HashSet> = (*msg.flags).clone(); - self.sess()? - .append(&mbox.name, &body) - .flags(flags) - .finish() - .context(format!("cannot append message to `{}`", mbox.name))?; - Ok(()) - } - - fn add_flags(&mut self, uid_seq: &str, flags: &Flags) -> Result<()> { - let mbox = self.mbox.to_owned(); - let flags: String = flags.to_string(); self.sess()? .select(&mbox.name) .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))?; - self.sess()? - .uid_store(uid_seq, format!("+FLAGS ({})", flags)) - .context(format!(r#"cannot add flags "{}""#, &flags))?; - Ok(()) + let fetches = self + .sess()? + .fetch(seq, "(ENVELOPE FLAGS INTERNALDATE BODY[])") + .context(r#"cannot fetch messages "{}""#)?; + let fetch = fetches + .first() + .ok_or(anyhow!(r#"cannot find message "{}"#, seq))?; + + Ok(Msg::try_from(fetch)?) } - /// Applies the given flags to the msg. - /// - /// # Example - /// ```no_run - /// use himalaya::imap::model::ImapConnector; - /// use himalaya::config::model::Account; - /// use himalaya::flag::model::Flags; - /// use imap::types::Flag; - /// - /// fn main() { - /// let account = Account::default(); - /// let mut imap_conn = ImapConnector::new(&account).unwrap(); - /// let flags = Flags::from(vec![Flag::Seen]); - /// - /// // Mark the message with the UID 42 in the mailbox "rofl" as "Seen" and wipe all other - /// // flags - /// imap_conn.set_flags("rofl", "42", flags).unwrap(); - /// - /// imap_conn.logout(); - /// } - /// ``` - fn set_flags(&mut self, uid_seq: &str, flags: &Flags) -> Result<()> { + fn find_raw_msg(&mut self, seq: &str) -> Result> { let mbox = self.mbox.to_owned(); self.sess()? .select(&mbox.name) - .context(format!("cannot select mailbox `{}`", self.mbox.name))?; + .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))?; + let fetches = self + .sess()? + .fetch(seq, "BODY[]") + .context(r#"cannot fetch raw messages "{}""#)?; + let fetch = fetches + .first() + .ok_or(anyhow!(r#"cannot find raw message "{}"#, seq))?; + + Ok(fetch.body().map(Vec::from).unwrap_or_default()) + } + + fn append_raw_msg_with_flags(&mut self, mbox: &Mbox, msg: &[u8], flags: Flags) -> Result<()> { self.sess()? - .uid_store(uid_seq, format!("FLAGS ({})", flags)) - .context(format!("cannot set flags `{}`", &flags))?; + .append(&mbox.name, &msg) + .flags(flags.0) + .finish() + .context(format!(r#"cannot append message to "{}""#, mbox.name))?; Ok(()) } - /// Remove the flags to the message by the given information. Take a look on the example above. - /// It's pretty similar. - fn remove_flags(&mut self, uid_seq: &str, flags: &Flags) -> Result<()> { - let mbox = self.mbox.to_owned(); - let flags = flags.to_string(); + fn append_msg(&mut self, mbox: &Mbox, msg: Msg) -> Result<()> { + let msg_raw: Vec = (&msg).try_into()?; self.sess()? - .select(&mbox.name) - .context(format!("cannot select mailbox `{}`", self.mbox.name))?; - self.sess()? - .uid_store(uid_seq, format!("-FLAGS ({})", flags)) - .context(format!("cannot remove flags `{}`", &flags))?; - Ok(()) - } - - fn expunge(&mut self) -> Result<()> { - self.sess()? - .expunge() - .context(format!("cannot expunge `{}`", self.mbox.name))?; + .append(&mbox.name, &msg_raw) + .flags(msg.flags.0) + .finish() + .context(format!(r#"cannot append message to "{}""#, mbox.name))?; Ok(()) } @@ -327,8 +281,13 @@ impl<'a> ImapServiceInterface for ImapService<'a> { anyhow!("cannot retrieve message {}'s UID", fetch.message) })?; - let subject = msg.headers.subject.clone().unwrap_or_default(); - config.run_notify_cmd(&subject, &msg.headers.from[0])?; + let from = msg + .from + .as_ref() + .and_then(|addrs| addrs.iter().next()) + .map(|addr| addr.to_string()) + .unwrap_or(String::from("unknown")); + config.run_notify_cmd(&msg.subject, &from)?; debug!("notify message: {}", uid); trace!("message: {:?}", msg); @@ -377,6 +336,48 @@ impl<'a> ImapServiceInterface for ImapService<'a> { } Ok(()) } + + fn add_flags(&mut self, seq_range: &str, flags: &Flags) -> Result<()> { + let mbox = self.mbox.to_owned(); + let flags: String = flags.to_string(); + self.sess()? + .select(&mbox.name) + .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))?; + self.sess()? + .store(seq_range, format!("+FLAGS ({})", flags)) + .context(format!(r#"cannot add flags "{}""#, &flags))?; + Ok(()) + } + + fn set_flags(&mut self, uid_seq: &str, flags: &Flags) -> Result<()> { + let mbox = self.mbox.to_owned(); + self.sess()? + .select(&mbox.name) + .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))?; + self.sess()? + .store(uid_seq, format!("FLAGS ({})", flags)) + .context(format!(r#"cannot set flags "{}""#, &flags))?; + Ok(()) + } + + fn remove_flags(&mut self, uid_seq: &str, flags: &Flags) -> Result<()> { + let mbox = self.mbox.to_owned(); + let flags = flags.to_string(); + self.sess()? + .select(&mbox.name) + .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))?; + self.sess()? + .store(uid_seq, format!("-FLAGS ({})", flags)) + .context(format!(r#"cannot remove flags "{}""#, &flags))?; + Ok(()) + } + + fn expunge(&mut self) -> Result<()> { + self.sess()? + .expunge() + .context(format!(r#"cannot expunge mailbox "{}""#, self.mbox.name))?; + Ok(()) + } } impl<'a> From<(&'a Account, &'a Mbox)> for ImapService<'a> { diff --git a/src/domain/imap/mod.rs b/src/domain/imap/mod.rs index c2bd990..55a198c 100644 --- a/src/domain/imap/mod.rs +++ b/src/domain/imap/mod.rs @@ -1,5 +1,7 @@ //! Module related to IMAP. -pub mod arg; -pub mod handler; -pub mod service; +pub mod imap_arg; +pub mod imap_handler; + +pub mod imap_service; +pub use imap_service::*; diff --git a/src/domain/mbox/arg.rs b/src/domain/mbox/mbox_arg.rs similarity index 100% rename from src/domain/mbox/arg.rs rename to src/domain/mbox/mbox_arg.rs diff --git a/src/domain/mbox/entity.rs b/src/domain/mbox/mbox_entity.rs similarity index 98% rename from src/domain/mbox/entity.rs rename to src/domain/mbox/mbox_entity.rs index b87e650..8c7b294 100644 --- a/src/domain/mbox/entity.rs +++ b/src/domain/mbox/mbox_entity.rs @@ -5,9 +5,7 @@ use serde::{ ser::{self, SerializeSeq}, Serialize, }; -use std::collections::HashSet; -use std::fmt; -use std::{borrow::Cow, convert::TryFrom}; +use std::{borrow::Cow, collections::HashSet, convert::TryFrom, fmt}; use crate::ui::table::{Cell, Row, Table}; diff --git a/src/domain/mbox/handler.rs b/src/domain/mbox/mbox_handler.rs similarity index 71% rename from src/domain/mbox/handler.rs rename to src/domain/mbox/mbox_handler.rs index a2e4f50..995ee46 100644 --- a/src/domain/mbox/handler.rs +++ b/src/domain/mbox/mbox_handler.rs @@ -6,8 +6,8 @@ use anyhow::Result; use log::{debug, trace}; use crate::{ - domain::{imap::service::ImapServiceInterface, mbox::entity::Mboxes}, - output::service::{OutputService, OutputServiceInterface}, + domain::{imap::ImapServiceInterface, mbox::Mboxes}, + output::{OutputService, OutputServiceInterface}, }; /// List all mailboxes. @@ -15,11 +15,10 @@ pub fn list( output: &OutputService, imap: &mut ImapService, ) -> Result<()> { - let names = imap.list_mboxes()?; + let names = imap.get_mboxes()?; let mboxes = Mboxes::from(&names); debug!("mailboxes len: {}", mboxes.0.len()); trace!("mailboxes: {:#?}", mboxes); output.print(mboxes)?; - imap.logout()?; Ok(()) } diff --git a/src/domain/mbox/mod.rs b/src/domain/mbox/mod.rs index 93e2889..eba5425 100644 --- a/src/domain/mbox/mod.rs +++ b/src/domain/mbox/mod.rs @@ -1,5 +1,7 @@ //! Module related to mailbox. -pub mod arg; -pub mod entity; -pub mod handler; +pub mod mbox_arg; +pub mod mbox_handler; + +pub mod mbox_entity; +pub use mbox_entity::*; diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 3d97d85..e90834f 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,6 +1,13 @@ //! Domain-specific modules. pub mod imap; +pub use self::imap::*; + pub mod mbox; +pub use mbox::*; + pub mod msg; +pub use msg::*; + pub mod smtp; +pub use smtp::*; diff --git a/src/domain/msg/attachment/arg.rs b/src/domain/msg/attachment/arg.rs deleted file mode 100644 index b2d2fee..0000000 --- a/src/domain/msg/attachment/arg.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Module related to message attachment CLI. -//! -//! This module provides arguments related to message attachment. - -use clap::{App, Arg, SubCommand}; - -use crate::domain::msg; - -/// Message attachment subcommands. -pub(crate) fn subcmds<'a>() -> Vec> { - vec![SubCommand::with_name("attachments") - .aliases(&["attachment", "att", "a"]) - .about("Downloads all message attachments") - .arg(msg::arg::uid_arg())] -} - -/// Message attachment path argument. -pub(crate) fn path_arg<'a>() -> Arg<'a, 'a> { - Arg::with_name("attachments") - .help("Adds attachment to the message") - .short("a") - .long("attachment") - .value_name("PATH") - .multiple(true) -} diff --git a/src/domain/msg/attachment/entity.rs b/src/domain/msg/attachment/entity.rs deleted file mode 100644 index 346d9ae..0000000 --- a/src/domain/msg/attachment/entity.rs +++ /dev/null @@ -1,120 +0,0 @@ -use anyhow::{Error, Result}; -use lettre::message::header::ContentType; -use mailparse::{DispositionType, ParsedMail}; -use serde::Serialize; -use std::{convert::TryFrom, fs, path::Path}; - -/// This struct represents an attachment. -#[derive(Debug, Serialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Attachment { - /// Holds the filename of an attachment. - pub filename: String, - - /// Holds the mime-type of the attachment. For example `text/plain`. - pub content_type: ContentType, - - /// Holds the data of the attachment. - #[serde(skip_serializing)] - pub body_raw: Vec, -} - -impl Attachment { - /// This from function extracts one attachment of a parsed msg. - /// If it couldn't create an attachment with the given parsed msg, than it will - /// return `None`. - /// - /// # Example - /// ``` - /// use himalaya::msg::attachment::Attachment; - /// - /// let parsed = mailparse::parse_mail(concat![ - /// "Content-Type: text/plain; charset=utf-8\n", - /// "Content-Transfer-Encoding: quoted-printable\n", - /// "\n", - /// "A plaintext attachment.", - /// ].as_bytes()).unwrap(); - /// - /// let attachment = Attachment::from_parsed_mail(&parsed); - /// ``` - pub fn from_parsed_mail(parsed_mail: &ParsedMail) -> Option { - if parsed_mail.get_content_disposition().disposition == DispositionType::Attachment { - let disposition = parsed_mail.get_content_disposition(); - let filename = disposition.params.get("filename").unwrap().to_string(); - let body_raw = parsed_mail.get_body_raw().unwrap_or(Vec::new()); - let content_type: ContentType = tree_magic::from_u8(&body_raw).parse().unwrap(); - - return Some(Self { - filename, - content_type, - body_raw, - }); - } - - None - } -} - -// == Traits == -/// Creates an Attachment with the follwing values: -/// -/// ```no_run -/// # use himalaya::msg::attachment::Attachment; -/// use lettre::message::header::ContentType; -/// -/// let attachment = Attachment { -/// filename: String::new(), -/// content_type: ContentType::TEXT_PLAIN, -/// body_raw: Vec::new(), -/// }; -/// ``` -impl Default for Attachment { - fn default() -> Self { - Self { - filename: String::new(), - content_type: ContentType::TEXT_PLAIN, - body_raw: Vec::new(), - } - } -} - -// -- From Implementations -- -/// Tries to convert the given file (by the given path) into an attachment. -/// It'll try to detect the mime-type/data-type automatically. -/// -/// # Example -/// ```no_run -/// use himalaya::msg::attachment::Attachment; -/// use std::convert::TryFrom; -/// -/// let attachment = Attachment::try_from("/some/path.png"); -/// ``` -impl<'from> TryFrom<&'from str> for Attachment { - type Error = Error; - - fn try_from(path: &'from str) -> Result { - let path = Path::new(path); - - // -- Get attachment information -- - let filename = if let Some(filename) = path.file_name() { - filename - // `&OsStr` -> `Option<&str>` - .to_str() - // get rid of the `Option` wrapper - .unwrap_or(&String::new()) - .to_string() - } else { - // use an empty string - String::new() - }; - - let file_content = fs::read(&path)?; - let content_type: ContentType = tree_magic::from_filepath(&path).parse()?; - - Ok(Self { - filename, - content_type, - body_raw: file_content, - }) - } -} diff --git a/src/domain/msg/attachment/mod.rs b/src/domain/msg/attachment/mod.rs deleted file mode 100644 index 809996c..0000000 --- a/src/domain/msg/attachment/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Module related to message attachment. - -pub mod arg; -pub mod entity; diff --git a/src/domain/msg/body/entity.rs b/src/domain/msg/body/entity.rs deleted file mode 100644 index 59b5e32..0000000 --- a/src/domain/msg/body/entity.rs +++ /dev/null @@ -1,92 +0,0 @@ -use serde::Serialize; -use std::fmt; - -/// This struct represents the body/content of a msg. For example: -/// -/// ```text -/// Dear Mr. Boss, -/// I like rust. It's an awesome language. *Change my mind*.... -/// -/// Sincerely -/// ``` -/// -/// This part of the msg/msg would be stored in this struct. -#[derive(Clone, Serialize, Debug, PartialEq, Eq)] -pub struct Body { - /// The plain version of a body (if available) - pub plain: Option, - - /// The html version of a body (if available) - pub html: Option, -} - -impl Body { - /// Returns a new instance of `Body` without any attributes set. (Same as `Body::default()`) - /// - /// # Example - /// ```rust - /// use himalaya::msg::body::Body; - /// - /// fn main() { - /// let body = Body::new(); - /// - /// let expected_body = Body { - /// text: None, - /// html: None, - /// }; - /// - /// assert_eq!(body, expected_body); - /// } - /// ``` - pub fn new() -> Self { - Self::default() - } - - /// Returns a new instance of `Body` with `text` set. - /// - /// # Example - /// ```rust - /// use himalaya::msg::body::Body; - /// - /// fn main() { - /// let body = Body::new_with_text("Text body"); - /// - /// let expected_body = Body { - /// text: Some("Text body".to_string()), - /// html: None, - /// }; - /// - /// assert_eq!(body, expected_body); - /// } - /// ``` - pub fn new_with_text(text: S) -> Self { - Self { - plain: Some(text.to_string()), - html: None, - } - } -} - -// == Traits == -impl Default for Body { - fn default() -> Self { - Self { - plain: None, - html: None, - } - } -} - -impl fmt::Display for Body { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - let content = if let Some(text) = self.plain.clone() { - text - } else if let Some(html) = self.html.clone() { - html - } else { - String::new() - }; - - write!(formatter, "{}", content) - } -} diff --git a/src/domain/msg/body/mod.rs b/src/domain/msg/body/mod.rs deleted file mode 100644 index e8c3d6a..0000000 --- a/src/domain/msg/body/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod entity; diff --git a/src/domain/msg/entity.rs b/src/domain/msg/entity.rs deleted file mode 100644 index 44eb8c1..0000000 --- a/src/domain/msg/entity.rs +++ /dev/null @@ -1,1450 +0,0 @@ -use anyhow::{anyhow, Context, Error, Result}; -use imap::types::{Fetch, Flag, ZeroCopy}; -use lettre::message::{ - header::ContentType, Attachment as lettre_Attachment, Mailbox, Message, MultiPart, SinglePart, -}; -use log::debug; -use mailparse; -use serde::Serialize; -use std::{ - convert::{From, TryFrom}, - fmt, -}; - -use crate::{ - config::entity::Account, - domain::msg::{ - attachment::entity::Attachment, body::entity::Body, flag::entity::Flags, - header::entity::Headers, - }, - ui::{ - editor, - table::{Cell, Row, Table}, - }, -}; - -/// Represents the msg in a serializeable form with additional values. -/// This struct-type makes it also possible to print the msg in a serialized form or in a normal -/// form. -#[derive(Serialize, Clone, Debug, Eq, PartialEq)] -pub struct MsgSerialized { - /// First of all, the messge in general - #[serde(flatten)] - pub msg: Msg, - - /// A bool which indicates if the current msg includes attachments or not. - pub has_attachment: bool, - - /// The raw mail as a string - pub raw: String, -} - -impl TryFrom<&Msg> for MsgSerialized { - type Error = Error; - - fn try_from(msg: &Msg) -> Result { - let has_attachment = msg.attachments.is_empty(); - let raw = msg.get_raw_as_string()?; - - Ok(Self { - msg: msg.clone(), - has_attachment, - raw, - }) - } -} - -impl fmt::Display for MsgSerialized { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - write!(formatter, "{}", self.msg) - } -} - -/// This struct represents a whole msg with its attachments, body-content -/// and its headers. -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] -pub struct Msg { - /// All added attachments are listed in this vector. - pub attachments: Vec, - - /// The flags of this msg. - pub flags: Flags, - - /// All information of the headers (sender, from, to and so on) - // headers: HashMap>, - pub headers: Headers, - - /// This variable stores the body of the msg. - /// This includes the general content text and the signature. - pub body: Body, - - /// The signature of the message. - pub sig: String, - - /// The UID of the msg. In general, a message should already have one, unless you're writing a - /// new message, then we're generating it. - uid: Option, - - /// The origination date field. Read [the RFC here] here for more - /// information. - /// - /// [the RFC here]: - /// https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.1 - date: Option, - - /// The msg but in raw. - #[serde(skip_serializing)] - raw: Vec, -} - -impl Msg { - /// Creates a completely new msg where two header fields are set: - /// - [`from`] - /// - and [`signature`] - /// - /// [`from`]: struct.Headers.html#structfield.from - /// [`signature`]: struct.Headers.html#structfield.signature - /// - /// # Example - /// - ///
- /// - /// ``` - /// # use himalaya::msg::model::Msg; - /// # use himalaya::msg::headers::Headers; - /// # use himalaya::config::model::Account; - /// # use himalaya::ctx::Ctx; - /// - /// # fn main() { - /// // -- Accounts -- - /// let ctx1 = Ctx { - /// account: Account::new_with_signature(Some("Soywod"), "clement.douin@posteo.net", - /// Some("Account Signature") - /// ), - /// .. Ctx::default() - /// }; - /// let ctx2 = Ctx { - /// account: Account::new(None, "tornax07@gmail.com"), - /// .. Ctx::default() - /// }; - /// - /// // Creating messages - /// let msg1 = Msg::new(&ctx1); - /// let msg2 = Msg::new(&ctx2); - /// - /// let expected_headers1 = Headers { - /// from: vec![String::from("Soywod ")], - /// // the signature of the account is stored as well - /// signature: Some(String::from("\n-- \nAccount Signature")), - /// ..Headers::default() - /// }; - /// - /// let expected_headers2 = Headers { - /// from: vec![String::from("tornax07@gmail.com")], - /// ..Headers::default() - /// }; - /// - /// assert_eq!(msg1.headers, expected_headers1, - /// "{:#?}, {:#?}", - /// msg1.headers, expected_headers1); - /// assert_eq!(msg2.headers, expected_headers2, - /// "{:#?}, {:#?}", - /// msg2.headers, expected_headers2); - /// # } - /// ``` - /// - ///
- pub fn new(account: &Account) -> Self { - Self::new_with_headers(&account, Headers::default()) - } - - /// This function does the same as [`Msg::new`] but you can apply a custom - /// [`headers`] when calling the function instead of using the default one - /// from the [`Msg::new`] function. - /// - /// [`Msg::new`]: struct.Msg.html#method.new - /// [`headers`]: struct.Headers.html - pub fn new_with_headers(account: &Account, mut headers: Headers) -> Self { - if headers.from.is_empty() { - headers.from = vec![account.address()]; - } - - Self { - headers, - body: Body::new_with_text(""), - sig: account.signature.to_owned(), - ..Self::default() - } - } - - /// Converts the message into a Reply message. - /// An [`Account`] struct is needed to set the `From:` field. - /// - /// # Changes - /// The value on the left side, represents the header *after* the function - /// call, while the value on the right side shows the data *before* the - /// function call. So if we pick up the first example of `reply_all = - /// false`, then we can see, that the value of `ReplyTo:` is moved into the - /// `To:` header field in this function call. - /// - /// - `reply_all = false`: - /// - `To:` = `ReplyTo:` otherwise from `From:` - /// - attachments => cleared - /// - `From:` = Emailaddress of the current user account - /// - `Subject:` = "Re:" + `Subject` - /// - `in_reply_to` = Old Message ID - /// - `Cc:` = cleared - /// - /// - `reply_all = true`: - /// - `To:` = `ReplyTo:` + Addresses in `To:` - /// - `Cc:` = All CC-Addresses - /// - The rest: Same as in `reply_all = false` - /// - /// It'll add for each line in the body the `>` character in the beginning - /// of each line. - /// - /// # Example - /// [Here] you can see an example how a discussion with replies could look - /// like. - /// - /// [Here]: https://www.rfc-editor.org/rfc/rfc5322.html#page-46 - /// [`Account`]: struct.Account.html - /// - // TODO: References field is missing, but the imap-crate can't implement it - // currently. - pub fn change_to_reply(&mut self, account: &Account, reply_all: bool) -> Result<()> { - let subject = self - .headers - .subject - .as_ref() - .map(|sub| { - if sub.starts_with("Re:") { - sub.to_owned() - } else { - format!("Re: {}", sub) - } - }) - .unwrap_or_default(); - - // The new fields - let mut to: Vec = Vec::new(); - let mut cc = None; - - if reply_all { - let email_addr: lettre::Address = account.email.parse()?; - - for addr in self.headers.to.iter() { - let addr_parsed: Mailbox = addr.parse()?; - - // we don't want to receive the msg which we have just sent, - // don't we? - if addr_parsed.email != email_addr { - to.push(addr.to_string()); - } - } - - // Also use the addresses in the "Cc:" field - cc = self.headers.cc.clone(); - } - - // Now add the addresses in the `Reply-To:` Field or from the `From:` - // field. - if let Some(reply_to) = &self.headers.reply_to { - to.append(&mut reply_to.clone()); - } else { - // if the "Reply-To" wasn't set from the sender, then we're just - // replying to the addresses in the "From:" field - to.append(&mut self.headers.from.clone()); - }; - - let new_headers = Headers { - from: vec![account.address()], - to, - cc, - subject: Some(subject), - in_reply_to: self.headers.message_id.clone(), - // and clear the rest of the fields - ..Headers::default() - }; - - // comment "out" the body of the msg, by adding the `>` characters to - // each line which includes a string. - let new_body = self - .body - .plain - .clone() - .unwrap_or_default() - .lines() - .map(|line| { - let space = if line.starts_with(">") { "" } else { " " }; - format!(">{}{}", space, line) - }) - .collect::>() - .join("\n"); - - self.body = Body::new_with_text(new_body); - self.headers = new_headers; - self.attachments.clear(); - self.sig = account.signature.to_owned(); - - Ok(()) - } - - /// Changes the msg/msg to a forwarding msg/msg. - /// - /// # Changes - /// Calling this function will change apply the following to the current - /// message: - /// - /// - `Subject:`: `"Fwd: "` will be added in front of the "old" subject - /// - `"---------- Forwarded Message ----------"` will be added on top of - /// the body. - /// - /// # Example - /// ```text - /// Subject: Test subject - /// ... - /// - /// Hi, - /// I use Himalaya - /// - /// Sincerely - /// ``` - /// - /// will be changed to - /// - /// ```text - /// Subject: Fwd: Test subject - /// Sender: - /// ... - /// - /// > Hi, - /// > I use Himalaya - /// > - /// > Sincerely - /// ``` - pub fn change_to_forwarding(&mut self, account: &Account) { - // -- Header -- - let subject = self - .headers - .subject - .as_ref() - .map(|sub| { - if sub.starts_with("Fwd:") { - sub.to_owned() - } else { - format!("Fwd: {}", sub) - } - }) - .unwrap_or_default(); - - self.headers = Headers { - subject: Some(subject), - sender: None, - reply_to: None, - message_id: None, - from: vec![account.address()], - to: vec![], - // and use the rest of the headers - ..self.headers.clone() - }; - - // TODO: add Subject, Date, From and To headers after "Forwarded Message" - self.body = Body::new_with_text(format!( - "\n\n---------- Forwarded Message ----------\n{}", - self.body - .plain - .to_owned() - .unwrap_or_default() - .replace("\r", ""), - )); - self.sig = account.signature.to_owned(); - } - - /// Returns the bytes of the *sendable message* of the struct! - pub fn into_bytes(&mut self) -> Result> { - // parse the whole msg first - let parsed = self.to_sendable_msg()?; - - return Ok(parsed.formatted()); - } - - /// Let the user edit the body of the msg. - /// - /// It'll enter the headers of the headers into the draft-file *if they're - /// not [`None`]!*. - /// - /// # Example - /// ```no_run - /// use himalaya::config::model::Account; - /// use himalaya::msg::model::Msg; - /// use himalaya::ctx::Ctx; - /// - /// fn main() { - /// let ctx = Ctx { - /// account: Account::new(Some("Name"), "some@msg.asdf"), - /// .. Ctx::default() - /// }; - /// let mut msg = Msg::new(&ctx); - /// - /// // In this case, only the header fields "From:" and "To:" are gonna - /// // be editable, because the other headers fields are set to "None" - /// // per default! - /// msg.edit_body().unwrap(); - /// } - /// ``` - /// - /// Now enable some headers: - /// - /// ```no_run - /// use himalaya::config::model::Account; - /// use himalaya::msg::{headers::Headers, model::Msg}; - /// use himalaya::ctx::Ctx; - /// - /// fn main() { - /// let ctx = Ctx { - /// account: Account::new(Some("Name"), "some@msg.asdf"), - /// .. Ctx::default() - /// }; - /// - /// let mut msg = Msg::new_with_headers( - /// &ctx, - /// Headers { - /// bcc: Some(Vec::new()), - /// cc: Some(Vec::new()), - /// ..Headers::default() - /// }, - /// ); - /// - /// // The "Bcc:" and "Cc:" header fields are gonna be editable as well - /// msg.edit_body().unwrap(); - /// } - /// ``` - /// - /// # Errors - /// In generel an error should appear if - /// - The draft or changes couldn't be saved - /// - The changed msg can't be parsed! (You wrote some things wrong...) - pub fn edit_body(&mut self) -> Result<()> { - // First of all, we need to create our template for the user. This - // means, that the header needs to be added as well! - let msg = self.to_string(); - - // We don't let this line compile, if we're doing - // tests, because we just need to look, if the headers are set - // correctly - let msg = editor::open_editor_with_tpl(msg.as_bytes())?; - - // refresh the state of the msg - self.parse_from_str(&msg)?; - - Ok(()) - } - - /// Read the string of the argument `content` and store it's values into the - /// struct. It stores the headers-fields and the body of the msg. - /// - /// **Hint: The signature can't be fetched of the content at the moment!** - /// - /// # Example - /// ``` - /// use himalaya::config::model::Account; - /// use himalaya::msg::model::Msg; - /// use himalaya::ctx::Ctx; - /// - /// fn main() { - /// let content = concat![ - /// "Subject: Himalaya is nice\n", - /// "To: Soywod \n", - /// "From: TornaxO7 \n", - /// "Bcc: third_person@msg.com,rofl@yeet.com\n", - /// "\n", - /// "You should use himalaya, it's a nice program :D\n", - /// "\n", - /// "Sincerely\n", - /// ]; - /// - /// let ctx = Ctx { - /// account: Account::new(Some("Username"), "some@msg.com"), - /// .. Ctx::default() - /// }; - /// - /// // create the message - /// let mut msg = Msg::new(&ctx); - /// - /// // store the information given by the `content` variable which - /// // represents our current msg - /// msg.parse_from_str(content); - /// } - /// ``` - pub fn parse_from_str(&mut self, content: &str) -> Result<()> { - let parsed = mailparse::parse_mail(content.as_bytes()) - .context(format!("How the message looks like currently:\n{}", self))?; - - self.headers = Headers::from(&parsed); - - match parsed.get_body() { - Ok(body) => self.body = Body::new_with_text(body), - Err(err) => return Err(anyhow!(err.to_string())), - }; - - Ok(()) - } - - /// Add an attachment to the msg from the local machine by the given path. - /// - /// # Example - /// ``` - /// use himalaya::config::model::Account; - /// use himalaya::msg::headers::Headers; - /// use himalaya::msg::model::Msg; - /// use himalaya::ctx::Ctx; - /// - /// fn main() { - /// let ctx = Ctx { - /// account: Account::new(Some("Name"), "address@msg.com"), - /// .. Ctx::default() - /// }; - /// let mut msg = Msg::new(&ctx); - /// - /// // suppose we have a Screenshot saved in our home directory - /// // Remember: Currently himalaya can't expand tilde ('~') and shell variables - /// msg.add_attachment("/home/bruh/Screenshot.png"); - /// } - /// ``` - /// - // THOUGHT: Error handling? - pub fn add_attachment(&mut self, path: &str) { - if let Ok(new_attachment) = Attachment::try_from(path) { - self.attachments.push(new_attachment); - } - } - - /// This function will use the information of the `Msg` struct and creates - /// a sendable msg with it. It uses the `Msg.headers` and - /// `Msg.attachments` fields for that. - /// - /// # Example - /// ```no_run - /// use himalaya::config::model::Account; - /// use himalaya::smtp; - /// - /// use himalaya::msg::{body::Body, headers::Headers, model::Msg}; - /// - /// use himalaya::imap::model::ImapConnector; - /// - /// use himalaya::ctx::Ctx; - /// - /// use imap::types::Flag; - /// - /// fn main() { - /// let ctx = Ctx { - /// account: Account::new(Some("Name"), "name@msg.net"), - /// .. Ctx::default() - /// }; - /// - /// let mut imap_conn = ImapConnector::new(&ctx.account).unwrap(); - /// let mut msg = Msg::new_with_headers( - /// &ctx, - /// Headers { - /// to: vec!["someone ".to_string()], - /// ..Headers::default() - /// }, - /// ); - /// - /// msg.body = Body::new_with_text("A little text."); - /// let sendable_msg = msg.to_sendable_msg().unwrap(); - /// - /// // now send the msg. Hint: Do the appropriate error handling here! - /// smtp::send(&ctx.account, &sendable_msg).unwrap(); - /// - /// // also say to the server of the account user, that we've just sent - /// // new message - /// msg.flags.insert(Flag::Seen); - /// imap_conn.append_msg("Sent", &mut msg).unwrap(); - /// - /// imap_conn.logout(); - /// } - /// ``` - pub fn to_sendable_msg(&mut self) -> Result { - // == Header of Msg == - // This variable will hold all information of our msg - let mut msg = Message::builder(); - - // -- Must-have-fields -- - // add "from" - for mailaddress in &self.headers.from { - msg = msg.from( - match mailaddress.parse().context("cannot parse `From` header") { - Ok(from) => from, - Err(err) => return Err(anyhow!(err.to_string())), - }, - ); - } - - // add "to" - for mailaddress in &self.headers.to { - msg = msg.to( - match mailaddress.parse().context("cannot parse `To` header") { - Ok(from) => from, - Err(err) => return Err(anyhow!(err.to_string())), - }, - ); - } - - // -- Optional fields -- - // add "bcc" - if let Some(bcc) = &self.headers.bcc { - for mailaddress in bcc { - msg = msg.bcc( - match mailaddress.parse().context("cannot parse `Bcc` header") { - Ok(from) => from, - Err(err) => return Err(anyhow!(err.to_string())), - }, - ); - } - } - - // add "cc" - if let Some(cc) = &self.headers.cc { - for mailaddress in cc { - msg = msg.cc( - match mailaddress.parse().context("cannot parse `Cc` header") { - Ok(from) => from, - Err(err) => return Err(anyhow!(err.to_string())), - }, - ); - } - } - - // add "in_reply_to" - if let Some(in_reply_to) = &self.headers.in_reply_to { - msg = msg.in_reply_to( - match in_reply_to - .parse() - .context("cannot parse `In-Reply-To` header") - { - Ok(from) => from, - Err(err) => return Err(anyhow!(err.to_string())), - }, - ); - } - - // add message-id if it exists - msg = match self.headers.message_id.clone() { - Some(message_id) => msg.message_id(Some(message_id)), - None => { - // extract the domain like "gmail.com" - let mailbox: lettre::message::Mailbox = self.headers.from[0].parse()?; - let domain = mailbox.email.domain(); - - // generate a new UUID - let new_msg_id = format!("{}@{}", uuid::Uuid::new_v4().to_string(), domain); - - msg.message_id(Some(new_msg_id)) - } - }; - - // add "reply-to" - if let Some(reply_to) = &self.headers.reply_to { - for mailaddress in reply_to { - msg = msg.reply_to( - match mailaddress - .parse() - .context("cannot parse `Reply-To` header") - { - Ok(from) => from, - Err(err) => return Err(anyhow!(err.to_string())), - }, - ); - } - } - - // add "sender" - if let Some(sender) = &self.headers.sender { - msg = msg.sender( - match sender.parse().context("cannot parse `Sender` header") { - Ok(from) => from, - Err(err) => return Err(anyhow!(err.to_string())), - }, - ); - } - - // add subject - if let Some(subject) = &self.headers.subject { - msg = msg.subject(subject); - } - - // -- Body + Attachments -- - // In this part, we'll add the content of the msg. This means the body - // and the attachments of the msg. - - // this variable will store all "sections" or attachments of the msg - let mut msg_parts = MultiPart::mixed().build(); - - // -- Body -- - if self.body.plain.is_some() && self.body.html.is_some() { - msg_parts = msg_parts.multipart(MultiPart::alternative_plain_html( - self.body.plain.clone().unwrap(), - self.body.html.clone().unwrap(), - )); - } else { - let msg_body = SinglePart::builder() - .header(ContentType::TEXT_PLAIN) - .header(self.headers.encoding) - .body(self.body.plain.clone().unwrap_or_default()); - - msg_parts = msg_parts.singlepart(msg_body); - } - - // -- Attachments -- - for attachment in self.attachments.iter() { - let msg_attachment = lettre_Attachment::new(attachment.filename.clone()); - let msg_attachment = - msg_attachment.body(attachment.body_raw.clone(), attachment.content_type.clone()); - - msg_parts = msg_parts.singlepart(msg_attachment); - } - - Ok(msg - .multipart(msg_parts) - // whenever an error appears, print out the messge as well to see what might be the - // error - .context(format!("-- Current Message --\n{}", self))?) - } - - /// Returns the uid of the msg. - /// - /// # Hint - /// The uid is set if you *send* a *new* message or if you receive a message of the server. So - /// in general you can only get a `Some(...)` from this function, if it's a fetched msg - /// otherwise you'll get `None`. - pub fn get_uid(&self) -> Option { - self.uid - } - - /// Returns the raw mail as a string instead of a Vector of bytes. - pub fn get_raw_as_string(&self) -> Result { - let raw_message = String::from_utf8(self.raw.clone()) - .context(format!("cannot parse raw message as string"))?; - - Ok(raw_message) - } -} - -impl fmt::Display for Msg { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - write!( - formatter, - "{}\n{}{}", - self.headers.get_header_as_string(), - self.body, - self.sig - ) - } -} - -impl Table for Msg { - fn head() -> Row { - Row::new() - .cell(Cell::new("UID").bold().underline().white()) - .cell(Cell::new("FLAGS").bold().underline().white()) - .cell(Cell::new("SUBJECT").shrinkable().bold().underline().white()) - .cell(Cell::new("FROM").bold().underline().white()) - .cell(Cell::new("DATE").bold().underline().white()) - } - - fn row(&self) -> Row { - let is_seen = !self.flags.contains(&Flag::Seen); - - // The data which will be shown in the row - let uid = self.get_uid().unwrap_or(0); - let flags = self.flags.get_signs(); - let subject = self.headers.subject.clone().unwrap_or_default(); - let mut from = String::new(); - let date = self.date.clone().unwrap_or(String::new()); - - for from_addr in self.headers.from.iter() { - let mut address_iter = from_addr.split_ascii_whitespace(); - - if let Some(name) = address_iter.next() { - from.push_str(&format!("{}, ", name)); - } else if let Some(address) = address_iter.next() { - from.push_str(&format!("{}, ", address)); - } else { - from.push_str("UNKNWON"); - } - } - - // remove trailing whitespace + the ',' - let mut from = from.trim_end().to_string(); - from.pop(); - - Row::new() - .cell(Cell::new(&uid.to_string()).bold_if(is_seen).red()) - .cell(Cell::new(&flags).bold_if(is_seen).white()) - .cell(Cell::new(&subject).shrinkable().bold_if(is_seen).green()) - .cell(Cell::new(&from).bold_if(is_seen).blue()) - .cell(Cell::new(&date).bold_if(is_seen).yellow()) - } -} - -// -- From's -- -/// Load the data from a fetched msg and store them in the msg-struct. -/// Please make sure that the fetch includes the following query: -/// -/// - UID (optional) -/// - FLAGS (optional) -/// - ENVELOPE (optional) -/// - INTERNALDATE -/// - BODY[] (optional) -impl TryFrom<&Fetch> for Msg { - type Error = Error; - - fn try_from(fetch: &Fetch) -> Result { - // -- Preparations -- - // We're preparing the variables first, which will hold the data of the - // fetched msg. - - let mut attachments = Vec::new(); - let flags = Flags::from(fetch.flags()); - let headers = Headers::try_from(fetch.envelope())?; - let uid = fetch.uid; - - let date = fetch - .internal_date() - .map(|date| date.naive_local().to_string()); - - let raw = match fetch.body() { - Some(body) => body.to_vec(), - None => Vec::new(), - }; - - // Get the content of the msg. Here we have to look (important!) if - // the fetch even includes a body or not, since the `BODY[]` query is - // only *optional*! - let parsed = - // the empty array represents an invalid body, so we can enter the - // `Err` arm if the body-query wasn't applied - match mailparse::parse_mail(raw.as_slice()) { - Ok(parsed) => { - debug!("Fetch has a body to parse."); - Some(parsed) - }, - Err(_) => { - debug!("Fetch hasn't a body to parse."); - None - }, - }; - - // -- Storing the information (body) -- - let mut body = Body::new(); - if let Some(parsed) = parsed { - // Ok, so some mails have their mody wrapped in a multipart, some - // don't. This condition hits, if the body isn't in a multipart, so we can - // immediately fetch the body from the first part of the mail. - match parsed.ctype.mimetype.as_ref() { - "text/plain" => body.plain = parsed.get_body().ok(), - "text/html" => body.html = parsed.get_body().ok(), - _ => (), - }; - - for subpart in &parsed.subparts { - // now it might happen, that the body is *in* a multipart, if - // that's the case, look, if we've already applied a body - // (body.is_empty()) and set it, if needed - if body.plain.is_none() && subpart.ctype.mimetype == "text/plain" { - body.plain = subpart.get_body().ok(); - } else if body.html.is_none() && subpart.ctype.mimetype == "text/html" { - body.html = subpart.get_body().ok(); - } - // otherise it's a normal attachment, like a PNG file or - // something like that - else if let Some(attachment) = Attachment::from_parsed_mail(subpart) { - attachments.push(attachment); - } - // this shouldn't happen, since this would mean, that's neither an attachment nor - // the body of the mail but something else. Log that! - else { - println!( - "Unknown attachment with the following mime-type: {}", - subpart.ctype.mimetype, - ); - } - } - } - - Ok(Self { - attachments, - flags, - headers, - body: Body::new_with_text(body), - uid, - date, - raw, - ..Self::default() - }) - } -} - -impl TryFrom<&str> for Msg { - type Error = Error; - - fn try_from(content: &str) -> Result { - let mut msg = Msg::default(); - msg.parse_from_str(content)?; - - Ok(msg) - } -} - -// == Msgs == -/// A Type-Safety struct which stores a vector of Messages. -#[derive(Debug, Serialize)] -pub struct Msgs(pub Vec); - -impl Msgs { - pub fn new() -> Self { - Self(Vec::new()) - } -} - -// -- From's -- -impl<'mails> TryFrom<&'mails ZeroCopy>> for Msgs { - type Error = Error; - - fn try_from(fetches: &'mails ZeroCopy>) -> Result { - // the content of the Msgs-struct - let mut mails = Vec::new(); - - for fetch in fetches.iter().rev() { - mails.push(Msg::try_from(fetch)?); - } - - Ok(Self(mails)) - } -} - -// -- Traits -- -impl fmt::Display for Msgs { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - writeln!(formatter, "\n{}", Table::render(&self.0)) - } -} - -// FIXME: fix tests -// #[cfg(test)] -// mod tests { -// use crate::{ -// ctx::Ctx, -// domain::{account::entity::Account, config::entity::Config}, -// msg::{body::Body, headers::Headers, model::Msg}, -// }; - -// #[test] -// fn test_new() { -// let ctx = Ctx { -// account: Account::new_with_signature(None, "test@mail.com", None), -// config: Config { -// name: String::from("Config Name"), -// ..Config::default() -// }, -// ..Ctx::default() -// }; - -// let msg = Msg::new(&ctx); -// let expected_headers = Headers { -// from: vec![String::from("Config Name ")], -// ..Headers::default() -// }; - -// assert_eq!( -// msg.headers, expected_headers, -// "{:#?}, {:#?}", -// msg.headers, expected_headers -// ); -// assert!(msg.get_raw_as_string().unwrap().is_empty()); -// } - -// #[test] -// fn test_new_with_account_name() { -// let ctx = Ctx { -// account: Account::new_with_signature(Some("Account Name"), "test@mail.com", None), -// config: Config { -// name: String::from("Config Name"), -// ..Config::default() -// }, -// mbox: String::from("INBOX"), -// ..Ctx::default() -// }; - -// let msg = Msg::new(&ctx); -// let expected_headers = Headers { -// from: vec![String::from("Account Name ")], -// ..Headers::default() -// }; - -// assert_eq!( -// msg.headers, expected_headers, -// "{:#?}, {:#?}", -// msg.headers, expected_headers -// ); -// assert!(msg.get_raw_as_string().unwrap().is_empty()); -// } - -// #[test] -// fn test_new_with_headers() { -// let ctx = Ctx { -// account: Account::new(Some("Account Name"), "test@mail.com"), -// config: Config { -// name: String::from("Config Name"), -// ..Config::default() -// }, -// mbox: String::from("INBOX"), -// ..Ctx::default() -// }; - -// let msg_with_custom_from = Msg::new_with_headers( -// &ctx, -// Headers { -// from: vec![String::from("Account Name ")], -// ..Headers::default() -// }, -// ); -// let expected_with_custom_from = Msg { -// headers: Headers { -// // the Msg::new_with_headers function should use the from -// // address in the headers struct, not the from address of the -// // account -// from: vec![String::from("Account Name ")], -// ..Headers::default() -// }, -// // The signature should be added automatically -// body: Body::new_with_text("\n"), -// ..Msg::default() -// }; - -// assert_eq!( -// msg_with_custom_from, expected_with_custom_from, -// "Left: {:#?}, Right: {:#?}", -// msg_with_custom_from, expected_with_custom_from -// ); -// } - -// #[test] -// fn test_new_with_headers_and_signature() { -// let ctx = Ctx { -// account: Account::new_with_signature( -// Some("Account Name"), -// "test@mail.com", -// Some("Signature"), -// ), -// config: Config { -// name: String::from("Config Name"), -// ..Config::default() -// }, -// mbox: String::from("INBOX"), -// ..Ctx::default() -// }; - -// let msg_with_custom_signature = Msg::new_with_headers(&ctx, Headers::default()); - -// let expected_with_custom_signature = Msg { -// headers: Headers { -// from: vec![String::from("Account Name ")], -// signature: Some(String::from("\n-- \nSignature")), -// ..Headers::default() -// }, -// body: Body::new_with_text("\n\n-- \nSignature"), -// ..Msg::default() -// }; - -// assert_eq!( -// msg_with_custom_signature, -// expected_with_custom_signature, -// "Left: {:?}, Right: {:?}", -// dbg!(&msg_with_custom_signature), -// dbg!(&expected_with_custom_signature) -// ); -// } - -// #[test] -// fn test_change_to_reply() { -// // in this test, we are gonna reproduce the same situation as shown -// // here: https://datatracker.ietf.org/doc/html/rfc5322#appendix-A.2 - -// // == Preparations == -// // -- rfc test -- -// // accounts for the rfc test -// let config = Config { -// name: String::from("Config Name"), -// ..Config::default() -// }; - -// let john_doe = Ctx { -// account: Account::new(Some("John Doe"), "jdoe@machine.example"), -// config: config.clone(), -// mbox: String::from("INBOX"), -// ..Ctx::default() -// }; - -// let mary_smith = Ctx { -// account: Account::new(Some("Mary Smith"), "mary@example.net"), -// config: config.clone(), -// mbox: String::from("INBOX"), -// ..Ctx::default() -// }; - -// let msg_rfc_test = Msg { -// headers: Headers { -// from: vec!["John Doe ".to_string()], -// to: vec!["Mary Smith ".to_string()], -// subject: Some("Saying Hello".to_string()), -// message_id: Some("<1234@local.machine.example>".to_string()), -// ..Headers::default() -// }, -// body: Body::new_with_text(concat![ -// "This is a message just to say hello.\n", -// "So, \"Hello\".", -// ]), -// ..Msg::default() -// }; - -// // -- for general tests -- -// let ctx = Ctx { -// account: Account::new(Some("Name"), "some@address.asdf"), -// config, -// mbox: String::from("INBOX"), -// ..Ctx::default() -// }; - -// // -- for reply_all -- -// // a custom test to look what happens, if we want to reply to all addresses. -// // Take a look into the doc of the "change_to_reply" what should happen, if we -// // set "reply_all" to "true". -// let mut msg_reply_all = Msg { -// headers: Headers { -// from: vec!["Boss ".to_string()], -// to: vec![ -// "msg@1.asdf".to_string(), -// "msg@2.asdf".to_string(), -// "Name ".to_string(), -// ], -// cc: Some(vec![ -// "test@testing".to_string(), -// "test2@testing".to_string(), -// ]), -// message_id: Some("RandomID123".to_string()), -// reply_to: Some(vec!["Reply@Mail.rofl".to_string()]), -// subject: Some("Have you heard of himalaya?".to_string()), -// ..Headers::default() -// }, -// body: Body::new_with_text(concat!["A body test\n", "\n", "Sincerely",]), -// ..Msg::default() -// }; - -// // == Expected output(s) == -// // -- rfc test -- -// // the first step -// let expected_rfc1 = Msg { -// headers: Headers { -// from: vec!["Mary Smith ".to_string()], -// to: vec!["John Doe ".to_string()], -// reply_to: Some(vec![ -// "\"Mary Smith: Personal Account\" ".to_string(), -// ]), -// subject: Some("Re: Saying Hello".to_string()), -// message_id: Some("<3456@example.net>".to_string()), -// in_reply_to: Some("<1234@local.machine.example>".to_string()), -// ..Headers::default() -// }, -// body: Body::new_with_text(concat![ -// "> This is a message just to say hello.\n", -// "> So, \"Hello\".", -// ]), -// ..Msg::default() -// }; - -// // then the response the the first respone above -// let expected_rfc2 = Msg { -// headers: Headers { -// to: vec!["\"Mary Smith: Personal Account\" ".to_string()], -// from: vec!["John Doe ".to_string()], -// subject: Some("Re: Saying Hello".to_string()), -// message_id: Some("".to_string()), -// in_reply_to: Some("<3456@example.net>".to_string()), -// ..Headers::default() -// }, -// body: Body::new_with_text(concat![ -// ">> This is a message just to say hello.\n", -// ">> So, \"Hello\".", -// ]), -// ..Msg::default() -// }; - -// // -- reply all -- -// let expected_reply_all = Msg { -// headers: Headers { -// from: vec!["Name ".to_string()], -// to: vec![ -// "msg@1.asdf".to_string(), -// "msg@2.asdf".to_string(), -// "Reply@Mail.rofl".to_string(), -// ], -// cc: Some(vec![ -// "test@testing".to_string(), -// "test2@testing".to_string(), -// ]), -// in_reply_to: Some("RandomID123".to_string()), -// subject: Some("Re: Have you heard of himalaya?".to_string()), -// ..Headers::default() -// }, -// body: Body::new_with_text(concat!["> A body test\n", "> \n", "> Sincerely"]), -// ..Msg::default() -// }; - -// // == Testing == -// // -- rfc test -- -// // represents the message for the first reply -// let mut rfc_reply_1 = msg_rfc_test.clone(); -// rfc_reply_1.change_to_reply(&mary_smith, false).unwrap(); - -// // the user would enter this normally -// rfc_reply_1.headers = Headers { -// message_id: Some("<3456@example.net>".to_string()), -// reply_to: Some(vec![ -// "\"Mary Smith: Personal Account\" ".to_string(), -// ]), -// ..rfc_reply_1.headers.clone() -// }; - -// // represents the message for the reply to the reply -// let mut rfc_reply_2 = rfc_reply_1.clone(); -// rfc_reply_2.change_to_reply(&john_doe, false).unwrap(); -// rfc_reply_2.headers = Headers { -// message_id: Some("".to_string()), -// ..rfc_reply_2.headers.clone() -// }; - -// assert_eq!( -// rfc_reply_1, -// expected_rfc1, -// "Left: {:?}, Right: {:?}", -// dbg!(&rfc_reply_1), -// dbg!(&expected_rfc1) -// ); - -// assert_eq!( -// rfc_reply_2, -// expected_rfc2, -// "Left: {:?}, Right: {:?}", -// dbg!(&rfc_reply_2), -// dbg!(&expected_rfc2) -// ); - -// // -- custom tests -— -// msg_reply_all.change_to_reply(&ctx, true).unwrap(); -// assert_eq!( -// msg_reply_all, -// expected_reply_all, -// "Left: {:?}, Right: {:?}", -// dbg!(&msg_reply_all), -// dbg!(&expected_reply_all) -// ); -// } - -// #[test] -// fn test_change_to_forwarding() { -// // == Preparations == -// let ctx = Ctx { -// account: Account::new_with_signature(Some("Name"), "some@address.asdf", Some("lol")), -// config: Config { -// name: String::from("Config Name"), -// ..Config::default() -// }, -// mbox: String::from("INBOX"), -// ..Ctx::default() -// }; - -// let mut msg = Msg::new_with_headers( -// &ctx, -// Headers { -// from: vec![String::from("ThirdPerson ")], -// subject: Some(String::from("Test subject")), -// ..Headers::default() -// }, -// ); - -// msg.body = Body::new_with_text(concat!["The body text, nice!\n", "Himalaya is nice!",]); - -// // == Expected Results == -// let expected_msg = Msg { -// headers: Headers { -// from: vec![String::from("ThirdPerson ")], -// sender: Some(String::from("Name ")), -// signature: Some(String::from("\n-- \nlol")), -// subject: Some(String::from("Fwd: Test subject")), -// ..Headers::default() -// }, -// body: Body::new_with_text(concat![ -// "\n", -// "---------- Forwarded Message ----------\n", -// "The body text, nice!\n", -// "Himalaya is nice!\n", -// "\n-- \nlol" -// ]), -// ..Msg::default() -// }; - -// // == Tests == -// msg.change_to_forwarding(&ctx); -// assert_eq!( -// msg, -// expected_msg, -// "Left: {:?}, Right: {:?}", -// dbg!(&msg), -// dbg!(&expected_msg) -// ); -// } - -// #[test] -// fn test_edit_body() { -// // == Preparations == -// let ctx = Ctx { -// account: Account::new_with_signature(Some("Name"), "some@address.asdf", None), -// ..Ctx::default() -// }; - -// let mut msg = Msg::new_with_headers( -// &ctx, -// Headers { -// bcc: Some(vec![String::from("bcc ")]), -// cc: Some(vec![String::from("cc ")]), -// subject: Some(String::from("Subject")), -// ..Headers::default() -// }, -// ); - -// // == Expected Results == -// let expected_msg = Msg { -// headers: Headers { -// from: vec![String::from("Name ")], -// to: vec![String::new()], -// // these fields should exist now -// subject: Some(String::from("Subject")), -// bcc: Some(vec![String::from("bcc ")]), -// cc: Some(vec![String::from("cc ")]), -// ..Headers::default() -// }, -// body: Body::new_with_text("\n"), -// ..Msg::default() -// }; - -// // == Tests == -// msg.edit_body().unwrap(); - -// assert_eq!( -// msg, expected_msg, -// "Left: {:#?}, Right: {:#?}", -// msg, expected_msg -// ); -// } - -// #[test] -// fn test_parse_from_str() { -// use std::collections::HashMap; - -// // == Preparations == -// let ctx = Ctx { -// account: Account::new_with_signature(Some("Name"), "some@address.asdf", None), -// config: Config { -// name: String::from("Config Name"), -// ..Config::default() -// }, -// mbox: String::from("INBOX"), -// ..Ctx::default() -// }; - -// let msg_template = Msg::new(&ctx); - -// let normal_content = concat![ -// "From: Some \n", -// "Subject: Awesome Subject\n", -// "Bcc: mail1@rofl.lol,name \n", -// "To: To \n", -// "\n", -// "Account Signature\n", -// ]; - -// let content_with_custom_headers = concat![ -// "From: Some \n", -// "Subject: Awesome Subject\n", -// "Bcc: mail1@rofl.lol,name \n", -// "To: To \n", -// "CustomHeader1: Value1\n", -// "CustomHeader2: Value2\n", -// "\n", -// "Account Signature\n", -// ]; - -// // == Expected outputs == -// let expect = Msg { -// headers: Headers { -// from: vec![String::from("Some ")], -// subject: Some(String::from("Awesome Subject")), -// bcc: Some(vec![ -// String::from("name "), -// String::from("mail1@rofl.lol"), -// ]), -// to: vec![String::from("To ")], -// ..Headers::default() -// }, -// body: Body::new_with_text("Account Signature\n"), -// ..Msg::default() -// }; - -// // -- with custom headers -- -// let mut custom_headers: HashMap> = HashMap::new(); -// custom_headers.insert("CustomHeader1".to_string(), vec!["Value1".to_string()]); -// custom_headers.insert("CustomHeader2".to_string(), vec!["Value2".to_string()]); - -// let expect_custom_header = Msg { -// headers: Headers { -// from: vec![String::from("Some ")], -// subject: Some(String::from("Awesome Subject")), -// bcc: Some(vec![ -// String::from("name "), -// String::from("mail1@rofl.lol"), -// ]), -// to: vec![String::from("To ")], -// custom_headers: Some(custom_headers), -// ..Headers::default() -// }, -// body: Body::new_with_text("Account Signature\n"), -// ..Msg::default() -// }; - -// // == Testing == -// let mut msg1 = msg_template.clone(); -// let mut msg2 = msg_template.clone(); - -// msg1.parse_from_str(normal_content).unwrap(); -// msg2.parse_from_str(content_with_custom_headers).unwrap(); - -// assert_eq!( -// msg1, -// expect, -// "Left: {:?}, Right: {:?}", -// dbg!(&msg1), -// dbg!(&expect) -// ); - -// assert_eq!( -// msg2, -// expect_custom_header, -// "Left: {:?}, Right: {:?}", -// dbg!(&msg2), -// dbg!(&expect_custom_header) -// ); -// } -// } diff --git a/src/domain/msg/envelope_entity.rs b/src/domain/msg/envelope_entity.rs new file mode 100644 index 0000000..d1ffebf --- /dev/null +++ b/src/domain/msg/envelope_entity.rs @@ -0,0 +1,145 @@ +use anyhow::{anyhow, Context, Error, Result}; +use serde::Serialize; +use std::convert::TryFrom; + +use crate::{ + domain::msg::{Flag, Flags}, + ui::table::{Cell, Row, Table}, +}; + +/// Representation of an envelope. An envelope gathers basic information related to a message. It +/// is mostly used for listings. +#[derive(Debug, Default, Serialize)] +pub struct Envelope { + /// The sequence number of the message. + /// + /// [RFC3501]: https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.1.2 + pub id: u32, + + /// The flags attached to the message. + pub flags: Flags, + + /// The subject of the message. + pub subject: String, + + /// The sender of the message. + pub sender: String, + + /// The internal date of the message. + /// + /// [RFC3501]: https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.3 + pub date: Option, +} + +impl<'a> TryFrom<&'a imap::types::Fetch> for Envelope { + type Error = Error; + + fn try_from(fetch: &'a imap::types::Fetch) -> Result { + let envelope = fetch + .envelope() + .ok_or(anyhow!("cannot get envelope of message {}", fetch.message))?; + + // Get the sequence number + let id = fetch.message; + + // Get the flags + let flags = Flags::try_from(fetch.flags())?; + + // Get the subject + let subject = envelope + .subject + .as_ref() + .ok_or(anyhow!("cannot get subject of message {}", fetch.message)) + .and_then(|subj| { + rfc2047_decoder::decode(subj).context(format!( + "cannot decode subject of message {}", + fetch.message + )) + })?; + + // Get the sender + let sender = envelope + .sender + .as_ref() + .and_then(|addrs| addrs.get(0)) + .or_else(|| envelope.from.as_ref().and_then(|addrs| addrs.get(0))) + .ok_or(anyhow!("cannot get sender of message {}", fetch.message))?; + let sender = if let Some(ref name) = sender.name { + rfc2047_decoder::decode(&name.to_vec()).context(format!( + "cannot decode sender's name of message {}", + fetch.message, + ))? + } else { + let mbox = sender + .mailbox + .as_ref() + .ok_or(anyhow!( + "cannot get sender's mailbox of message {}", + fetch.message + )) + .and_then(|mbox| { + rfc2047_decoder::decode(&mbox.to_vec()).context(format!( + "cannot decode sender's mailbox of message {}", + fetch.message, + )) + })?; + let host = sender + .host + .as_ref() + .ok_or(anyhow!( + "cannot get sender's host of message {}", + fetch.message + )) + .and_then(|host| { + rfc2047_decoder::decode(&host.to_vec()).context(format!( + "cannot decode sender's host of message {}", + fetch.message, + )) + })?; + format!("{}@{}", mbox, host) + }; + + // Get the internal date + let date = fetch + .internal_date() + .map(|date| date.naive_local().to_string()); + + Ok(Self { + id, + flags, + subject, + sender, + date, + }) + } +} + +impl Table for Envelope { + fn head() -> Row { + Row::new() + .cell(Cell::new("ID").bold().underline().white()) + .cell(Cell::new("FLAGS").bold().underline().white()) + .cell(Cell::new("SUBJECT").shrinkable().bold().underline().white()) + .cell(Cell::new("SENDER").bold().underline().white()) + .cell(Cell::new("DATE").bold().underline().white()) + } + + fn row(&self) -> Row { + let id = self.id.to_string(); + let flags = self.flags.to_symbols_string(); + let unseen = !self.flags.contains(&Flag::Seen); + let subject = &self.subject; + let sender = &self.sender; + let date = self + .date + .as_ref() + .map(|date| date.as_str()) + .unwrap_or_default(); + Row::new() + .cell(Cell::new(id).bold_if(unseen).red()) + .cell(Cell::new(flags).bold_if(unseen).white()) + .cell(Cell::new(subject).shrinkable().bold_if(unseen).green()) + .cell(Cell::new(sender).bold_if(unseen).blue()) + .cell(Cell::new(date).bold_if(unseen).yellow()) + } +} diff --git a/src/domain/msg/envelopes_entity.rs b/src/domain/msg/envelopes_entity.rs new file mode 100644 index 0000000..139505e --- /dev/null +++ b/src/domain/msg/envelopes_entity.rs @@ -0,0 +1,42 @@ +use anyhow::{Error, Result}; +use imap::types::{Fetch, ZeroCopy}; +use serde::Serialize; +use std::{ + convert::TryFrom, + fmt::{self, Display}, + ops::Deref, +}; + +use crate::{domain::msg::Envelope, ui::Table}; + +/// Representation of a list of envelopes. +#[derive(Debug, Default, Serialize)] +pub struct Envelopes(pub Vec); + +impl Deref for Envelopes { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl TryFrom>> for Envelopes { + type Error = Error; + + fn try_from(fetches: ZeroCopy>) -> Result { + let mut envelopes = vec![]; + + for fetch in fetches.iter().rev() { + envelopes.push(Envelope::try_from(fetch)?); + } + + Ok(Self(envelopes)) + } +} + +impl Display for Envelopes { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + writeln!(f, "\n{}", Table::render(&self)) + } +} diff --git a/src/domain/msg/flag/entity.rs b/src/domain/msg/flag/entity.rs deleted file mode 100644 index 4c7fba1..0000000 --- a/src/domain/msg/flag/entity.rs +++ /dev/null @@ -1,280 +0,0 @@ -pub(crate) use imap::types::Flag; -use serde::ser::{Serialize, SerializeSeq, Serializer}; - -use std::borrow::Cow; -use std::collections::HashSet; -use std::fmt; -use std::ops::{Deref, DerefMut}; - -use std::convert::From; - -/// Serializable wrapper for `imap::types::Flag` -#[derive(Debug, PartialEq, Eq, Clone)] -struct SerializableFlag<'flag>(&'flag imap::types::Flag<'flag>); - -impl<'flag> Serialize for SerializableFlag<'flag> { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(match self.0 { - Flag::Seen => "Seen", - Flag::Answered => "Answered", - Flag::Flagged => "Flagged", - Flag::Deleted => "Deleted", - Flag::Draft => "Draft", - Flag::Recent => "Recent", - Flag::MayCreate => "MayCreate", - Flag::Custom(cow) => cow, - _ => "Unknown", - }) - } -} - -/// This struct type includes all flags which belong to a given mail. -/// It's used in the [`Msg.flags`] attribute field of the `Msg` struct. To be more clear: It's just -/// a wrapper for the [`imap::types::Flag`] but without a lifetime. -/// -/// [`Msg.flags`]: struct.Msg.html#structfield.flags -/// [`imap::types::Flag`]: https://docs.rs/imap/2.4.1/imap/types/enum.Flag.html -#[derive(Debug, PartialEq, Eq, Clone, Default)] -pub struct Flags(pub HashSet>); - -impl Flags { - /// Returns the flags of their respective flag value in the following order: - /// - /// 1. Seen - /// 2. Answered - /// 3. Flagged - pub fn get_signs(&self) -> String { - let mut flags = String::new(); - - flags.push_str(if self.0.contains(&Flag::Seen) { - " " - } else { - "✷" - }); - - flags.push_str(if self.0.contains(&Flag::Answered) { - "↵" - } else { - " " - }); - - flags.push_str(if self.0.contains(&Flag::Flagged) { - "!" - } else { - " " - }); - - flags - } -} - -impl fmt::Display for Flags { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut glue = ""; - for flag in &self.0 { - write!(f, "{}", glue)?; - match flag { - Flag::Seen => write!(f, "\\Seen")?, - Flag::Answered => write!(f, "\\Answered")?, - Flag::Flagged => write!(f, "\\Flagged")?, - Flag::Deleted => write!(f, "\\Deleted")?, - Flag::Draft => write!(f, "\\Draft")?, - Flag::Recent => write!(f, "\\Recent")?, - Flag::MayCreate => write!(f, "\\MayCreate")?, - Flag::Custom(cow) => write!(f, "{}", cow)?, - _ => (), - } - glue = " "; - } - Ok(()) - } -} - -impl<'a> From<&[imap::types::Flag<'a>]> for Flags { - fn from(flags: &[imap::types::Flag<'a>]) -> Self { - Self( - flags - .iter() - .map(|flag| convert_to_static(flag).unwrap()) - .collect::>>(), - ) - } -} - -impl<'a> From>> for Flags { - fn from(flags: Vec>) -> Self { - Self( - flags - .iter() - .map(|flag| convert_to_static(flag).unwrap()) - .collect::>>(), - ) - } -} - -/// Converst a string of flags into their appropriate flag representation. For example `"Seen"` is -/// gonna be convertred to `Flag::Seen`. -/// -/// # Example -/// ```rust -/// use himalaya::flag::model::Flags; -/// use imap::types::Flag; -/// use std::collections::HashSet; -/// -/// fn main() { -/// let flags = "Seen Answered"; -/// -/// let mut expected = HashSet::new(); -/// expected.insert(Flag::Seen); -/// expected.insert(Flag::Answered); -/// -/// let output = Flags::from(flags); -/// -/// assert_eq!(output.0, expected); -/// } -/// ``` -impl From<&str> for Flags { - fn from(flags: &str) -> Self { - let mut content: HashSet> = HashSet::new(); - - for flag in flags.split_ascii_whitespace() { - match flag { - "Answered" => content.insert(Flag::Answered), - "Deleted" => content.insert(Flag::Deleted), - "Draft" => content.insert(Flag::Draft), - "Flagged" => content.insert(Flag::Flagged), - "MayCreate" => content.insert(Flag::MayCreate), - "Recent" => content.insert(Flag::Recent), - "Seen" => content.insert(Flag::Seen), - custom => content.insert(Flag::Custom(Cow::Owned(custom.to_string()))), - }; - } - - Self(content) - } -} - -impl<'a> From> for Flags { - fn from(flags: Vec<&'a str>) -> Self { - let mut map: HashSet> = HashSet::new(); - - for f in flags { - match f { - "Answered" | _ if f.eq_ignore_ascii_case("answered") => map.insert(Flag::Answered), - "Deleted" | _ if f.eq_ignore_ascii_case("deleted") => map.insert(Flag::Deleted), - "Draft" | _ if f.eq_ignore_ascii_case("draft") => map.insert(Flag::Draft), - "Flagged" | _ if f.eq_ignore_ascii_case("flagged") => map.insert(Flag::Flagged), - "MayCreate" | _ if f.eq_ignore_ascii_case("maycreate") => { - map.insert(Flag::MayCreate) - } - "Recent" | _ if f.eq_ignore_ascii_case("recent") => map.insert(Flag::Recent), - "Seen" | _ if f.eq_ignore_ascii_case("seen") => map.insert(Flag::Seen), - custom => map.insert(Flag::Custom(Cow::Owned(custom.into()))), - }; - } - - Self(map) - } -} - -impl Deref for Flags { - type Target = HashSet>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for Flags { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl Serialize for Flags { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut seq = serializer.serialize_seq(Some(self.0.len()))?; - - for flag in &self.0 { - seq.serialize_element(&SerializableFlag(flag))?; - } - - seq.end() - } -} - -// == Helper Functions == -/// HINT: This function is only needed as long this pull request hasn't been -/// merged yet: https://github.com/jonhoo/rust-imap/pull/206 -fn convert_to_static<'func>(flag: &'func Flag) -> Result, ()> { - match flag { - Flag::Seen => Ok(Flag::Seen), - Flag::Answered => Ok(Flag::Answered), - Flag::Flagged => Ok(Flag::Flagged), - Flag::Deleted => Ok(Flag::Deleted), - Flag::Draft => Ok(Flag::Draft), - Flag::Recent => Ok(Flag::Recent), - Flag::MayCreate => Ok(Flag::MayCreate), - Flag::Custom(cow) => Ok(Flag::Custom(Cow::Owned(cow.to_string()))), - &_ => Err(()), - } -} - -#[cfg(test)] -mod tests { - use crate::domain::msg::flag::entity::Flags; - use imap::types::Flag; - use std::collections::HashSet; - - #[test] - fn test_get_signs() { - let flags = Flags::from(vec![Flag::Seen, Flag::Answered]); - - assert_eq!(flags.get_signs(), " ↵ ".to_string()); - } - - #[test] - fn test_from_string() { - let flags = Flags::from("Seen Answered"); - - let expected = Flags::from(vec![Flag::Seen, Flag::Answered]); - - assert_eq!(flags, expected); - } - - #[test] - fn test_to_string() { - let flags = Flags::from(vec![Flag::Seen, Flag::Answered]); - - // since we can't influence the order in the HashSet, we're gonna convert it into a vec, - // sort it according to the names and compare it aftwards. - let flag_string = flags.to_string(); - let mut flag_vec: Vec = flag_string - .split_ascii_whitespace() - .map(|word| word.to_string()) - .collect(); - flag_vec.sort(); - - assert_eq!( - flag_vec, - vec!["\\Answered".to_string(), "\\Seen".to_string()] - ); - } - - #[test] - fn test_from_vec() { - let flags = Flags::from(vec![Flag::Seen, Flag::Answered]); - - let mut expected = HashSet::new(); - expected.insert(Flag::Seen); - expected.insert(Flag::Answered); - - assert_eq!(flags.0, expected); - } -} diff --git a/src/domain/msg/flag/handler.rs b/src/domain/msg/flag/handler.rs deleted file mode 100644 index e69d44b..0000000 --- a/src/domain/msg/flag/handler.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Module related to message flag handling. -//! -//! This module gathers all message flag commands. - -use anyhow::Result; - -use crate::{ - domain::{imap::service::ImapServiceInterface, msg::flag::entity::Flags}, - output::service::OutputServiceInterface, -}; - -/// Add flags from the given message UID sequence. -/// Flags do not need to be prefixed with `\` and they are not case-sensitive. -/// -/// ```ignore -/// add("21", "\\Seen", &output, &mut imap)?; -/// add("42", "recent", &output, &mut imap)?; -/// add("1:10", "Answered custom", &output, &mut imap)?; -/// ``` -pub fn add<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( - uid: &'a str, - flags: Vec<&'a str>, - output: &'a OutputService, - imap: &'a mut ImapService, -) -> Result<()> { - let flags = Flags::from(flags); - imap.add_flags(uid, &flags)?; - output.print(format!( - r#"Flag(s) "{}" successfully added to message {}"#, - flags, uid - ))?; - imap.logout()?; - Ok(()) -} - -/// Remove flags from the given message UID sequence. -/// Flags do not need to be prefixed with `\` and they are not case-sensitive. -/// -/// ```ignore -/// remove("21", "\\Seen", &output, &mut imap)?; -/// remove("42", "recent", &output, &mut imap)?; -/// remove("1:10", "Answered custom", &output, &mut imap)?; -/// ``` -pub fn remove<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( - uid: &'a str, - flags: Vec<&'a str>, - output: &'a OutputService, - imap: &'a mut ImapService, -) -> Result<()> { - let flags = Flags::from(flags); - imap.remove_flags(uid, &flags)?; - output.print(format!( - r#"Flag(s) "{}" successfully removed from message {}"#, - flags, uid - ))?; - imap.logout()?; - Ok(()) -} - -/// Replace flags from the given message UID sequence. -/// Flags do not need to be prefixed with `\` and they are not case-sensitive. -/// -/// ```ignore -/// set("21", "\\Seen", &output, &mut imap)?; -/// set("42", "recent", &output, &mut imap)?; -/// set("1:10", "Answered custom", &output, &mut imap)?; -/// ``` -pub fn set<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( - uid: &'a str, - flags: Vec<&'a str>, - output: &'a OutputService, - imap: &'a mut ImapService, -) -> Result<()> { - let flags = Flags::from(flags); - imap.set_flags(uid, &flags)?; - output.print(format!( - r#"Flag(s) "{}" successfully set for message {}"#, - flags, uid - ))?; - imap.logout()?; - Ok(()) -} diff --git a/src/domain/msg/flag/mod.rs b/src/domain/msg/flag/mod.rs deleted file mode 100644 index d0da5b4..0000000 --- a/src/domain/msg/flag/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Module related to messages flag. - -pub mod arg; -pub mod entity; -pub mod handler; diff --git a/src/domain/msg/flag/arg.rs b/src/domain/msg/flag_arg.rs similarity index 63% rename from src/domain/msg/flag/arg.rs rename to src/domain/msg/flag_arg.rs index 157a661..e849a0a 100644 --- a/src/domain/msg/flag/arg.rs +++ b/src/domain/msg/flag_arg.rs @@ -4,47 +4,47 @@ use anyhow::Result; use clap::{self, App, AppSettings, Arg, ArgMatches, SubCommand}; -use log::debug; +use log::{debug, trace}; -use crate::domain::msg; +use crate::domain::msg::msg_arg; -type Uid<'a> = &'a str; +type SeqRange<'a> = &'a str; type Flags<'a> = Vec<&'a str>; /// Message flag commands. pub enum Command<'a> { - Set(Uid<'a>, Flags<'a>), - Add(Uid<'a>, Flags<'a>), - Remove(Uid<'a>, Flags<'a>), + Set(SeqRange<'a>, Flags<'a>), + Add(SeqRange<'a>, Flags<'a>), + Remove(SeqRange<'a>, Flags<'a>), } /// Message flag command matcher. pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { - if let Some(m) = m.subcommand_matches("set") { - debug!("set command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); - let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect(); - debug!("flags: `{:?}`", flags); - return Ok(Some(Command::Set(uid, flags))); - } - if let Some(m) = m.subcommand_matches("add") { debug!("add command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); + let seq_range = m.value_of("seq-range").unwrap(); + trace!(r#"seq range: "{:?}""#, seq_range); let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect(); - debug!("flags: `{:?}`", flags); - return Ok(Some(Command::Add(uid, flags))); + trace!(r#"flags: "{:?}""#, flags); + return Ok(Some(Command::Add(seq_range, flags))); + } + + if let Some(m) = m.subcommand_matches("set") { + debug!("set command matched"); + let seq_range = m.value_of("seq-range").unwrap(); + trace!(r#"seq range: "{:?}""#, seq_range); + let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect(); + trace!(r#"flags: "{:?}""#, flags); + return Ok(Some(Command::Set(seq_range, flags))); } if let Some(m) = m.subcommand_matches("remove") { debug!("remove command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); + let seq_range = m.value_of("seq-range").unwrap(); + trace!(r#"seq range: "{:?}""#, seq_range); let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect(); - debug!("flags: `{:?}`", flags); - return Ok(Some(Command::Remove(uid, flags))); + trace!(r#"flags: "{:?}""#, flags); + return Ok(Some(Command::Remove(seq_range, flags))); } Ok(None) @@ -53,9 +53,8 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { /// Message flag flags argument. fn flags_arg<'a>() -> Arg<'a, 'a> { Arg::with_name("flags") - .help( - "IMAP flags (they do not need to be prefixed with `\\` and they are case-insensitive)", - ) + .help("IMAP flags") + .long_help("IMAP flags. Flags are case-insensitive, and they do not need to be prefixed with `\\`.") .value_name("FLAGS…") .multiple(true) .required(true) @@ -68,22 +67,22 @@ pub fn subcmds<'a>() -> Vec> { .about("Handles flags") .setting(AppSettings::SubcommandRequiredElseHelp) .subcommand( - SubCommand::with_name("set") - .about("Replaces all message flags") - .arg(msg::arg::uid_arg()) + SubCommand::with_name("add") + .about("Adds flags to a message") + .arg(msg_arg::seq_range_arg()) .arg(flags_arg()), ) .subcommand( - SubCommand::with_name("add") - .about("Adds flags to a message") - .arg(msg::arg::uid_arg()) + SubCommand::with_name("set") + .about("Replaces all message flags") + .arg(msg_arg::seq_range_arg()) .arg(flags_arg()), ) .subcommand( SubCommand::with_name("remove") .aliases(&["rm"]) .about("Removes flags from a message") - .arg(msg::arg::uid_arg()) + .arg(msg_arg::seq_range_arg()) .arg(flags_arg()), )] } diff --git a/src/domain/msg/flag_entity.rs b/src/domain/msg/flag_entity.rs new file mode 100644 index 0000000..b8474c8 --- /dev/null +++ b/src/domain/msg/flag_entity.rs @@ -0,0 +1,26 @@ +pub use imap::types::Flag; +use serde::ser::{Serialize, Serializer}; + +/// Serializable wrapper arround [`imap::types::Flag`]. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct SerializableFlag<'a>(pub &'a Flag<'a>); + +impl<'a> Serialize for SerializableFlag<'a> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(match self.0 { + Flag::Seen => "Seen", + Flag::Answered => "Answered", + Flag::Flagged => "Flagged", + Flag::Deleted => "Deleted", + Flag::Draft => "Draft", + Flag::Recent => "Recent", + Flag::MayCreate => "MayCreate", + Flag::Custom(cow) => cow, + // TODO: find a way to return an error + _ => "Unknown", + }) + } +} diff --git a/src/domain/msg/flag_handler.rs b/src/domain/msg/flag_handler.rs new file mode 100644 index 0000000..d62792c --- /dev/null +++ b/src/domain/msg/flag_handler.rs @@ -0,0 +1,58 @@ +//! Module related to message flag handling. +//! +//! This module gathers all message flag commands. + +use anyhow::Result; + +use crate::{ + domain::{imap::ImapServiceInterface, msg::Flags}, + output::OutputServiceInterface, +}; + +/// Add flags to all messages within the given sequence range. +/// Flags are case-insensitive, and they do not need to be prefixed with `\`. +pub fn add<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( + seq_range: &'a str, + flags: Vec<&'a str>, + output: &'a OutputService, + imap: &'a mut ImapService, +) -> Result<()> { + let flags = Flags::from(flags); + imap.add_flags(seq_range, &flags)?; + output.print(format!( + r#"Flag(s) "{}" successfully added to message(s) "{}""#, + flags, seq_range + )) +} + +/// Remove flags from all messages within the given sequence range. +/// Flags are case-insensitive, and they do not need to be prefixed with `\`. +pub fn remove<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( + seq_range: &'a str, + flags: Vec<&'a str>, + output: &'a OutputService, + imap: &'a mut ImapService, +) -> Result<()> { + let flags = Flags::from(flags); + imap.remove_flags(seq_range, &flags)?; + output.print(format!( + r#"Flag(s) "{}" successfully removed from message(s) "{}""#, + flags, seq_range + )) +} + +/// Replace flags of all messages within the given sequence range. +/// Flags are case-insensitive, and they do not need to be prefixed with `\`. +pub fn set<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( + seq_range: &'a str, + flags: Vec<&'a str>, + output: &'a OutputService, + imap: &'a mut ImapService, +) -> Result<()> { + let flags = Flags::from(flags); + imap.set_flags(seq_range, &flags)?; + output.print(format!( + r#"Flag(s) "{}" successfully set for message(s) "{}""#, + flags, seq_range + )) +} diff --git a/src/domain/msg/flags_entity.rs b/src/domain/msg/flags_entity.rs new file mode 100644 index 0000000..2c746e9 --- /dev/null +++ b/src/domain/msg/flags_entity.rs @@ -0,0 +1,239 @@ +use anyhow::{anyhow, Error, Result}; +use serde::ser::{Serialize, SerializeSeq, Serializer}; +use std::{ + borrow::Cow, + collections::HashSet, + convert::{TryFrom, TryInto}, + fmt::{self, Display}, + ops::{Deref, DerefMut}, +}; + +use crate::domain::msg::{Flag, SerializableFlag}; + +/// Wrapper arround [`imap::types::Flag`]s. +#[derive(Debug, Clone, Default)] +pub struct Flags(pub HashSet>); + +impl Flags { + /// Build a symbols string based on flags contained in the hashset. + pub fn to_symbols_string(&self) -> String { + let mut flags = String::new(); + flags.push_str(if self.contains(&Flag::Seen) { + " " + } else { + "✷" + }); + flags.push_str(if self.contains(&Flag::Answered) { + "↵" + } else { + " " + }); + flags.push_str(if self.contains(&Flag::Flagged) { + "⚑" + } else { + " " + }); + flags + } +} + +impl Display for Flags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut glue = ""; + + for flag in &self.0 { + write!(f, "{}", glue)?; + match flag { + Flag::Seen => write!(f, "\\Seen")?, + Flag::Answered => write!(f, "\\Answered")?, + Flag::Flagged => write!(f, "\\Flagged")?, + Flag::Deleted => write!(f, "\\Deleted")?, + Flag::Draft => write!(f, "\\Draft")?, + Flag::Recent => write!(f, "\\Recent")?, + Flag::MayCreate => write!(f, "\\MayCreate")?, + Flag::Custom(cow) => write!(f, "{}", cow)?, + _ => (), + } + glue = " "; + } + + Ok(()) + } +} + +impl<'a> TryFrom>> for Flags { + type Error = Error; + + fn try_from(flags: Vec>) -> Result { + let mut set: HashSet> = HashSet::new(); + + for flag in flags { + set.insert(match flag { + Flag::Seen => Flag::Seen, + Flag::Answered => Flag::Answered, + Flag::Flagged => Flag::Flagged, + Flag::Deleted => Flag::Deleted, + Flag::Draft => Flag::Draft, + Flag::Recent => Flag::Recent, + Flag::MayCreate => Flag::MayCreate, + Flag::Custom(cow) => Flag::Custom(Cow::Owned(cow.to_string())), + flag => return Err(anyhow!(r#"cannot parse flag "{}""#, flag)), + }); + } + + Ok(Self(set)) + } +} + +impl<'a> TryFrom<&'a [Flag<'a>]> for Flags { + type Error = Error; + + fn try_from(flags: &'a [Flag<'a>]) -> Result { + flags.to_vec().try_into() + } +} + +impl Deref for Flags { + type Target = HashSet>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Flags { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Serialize for Flags { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut seq = serializer.serialize_seq(Some(self.0.len()))?; + for flag in &self.0 { + seq.serialize_element(&SerializableFlag(flag))?; + } + seq.end() + } +} + +///// Converst a string of flags into their appropriate flag representation. For example `"Seen"` is +///// gonna be convertred to `Flag::Seen`. +///// +///// # Example +///// ```rust +///// use himalaya::flag::model::Flags; +///// use imap::types::Flag; +///// use std::collections::HashSet; +///// +///// fn main() { +///// let flags = "Seen Answered"; +///// +///// let mut expected = HashSet::new(); +///// expected.insert(Flag::Seen); +///// expected.insert(Flag::Answered); +///// +///// let output = Flags::from(flags); +///// +///// assert_eq!(output.0, expected); +///// } +///// ``` +//impl From<&str> for Flags { +// fn from(flags: &str) -> Self { +// let mut content: HashSet> = HashSet::new(); + +// for flag in flags.split_ascii_whitespace() { +// match flag { +// "Answered" => content.insert(Flag::Answered), +// "Deleted" => content.insert(Flag::Deleted), +// "Draft" => content.insert(Flag::Draft), +// "Flagged" => content.insert(Flag::Flagged), +// "MayCreate" => content.insert(Flag::MayCreate), +// "Recent" => content.insert(Flag::Recent), +// "Seen" => content.insert(Flag::Seen), +// custom => content.insert(Flag::Custom(Cow::Owned(custom.to_string()))), +// }; +// } + +// Self(content) +// } +//} + +impl<'a> From> for Flags { + fn from(flags: Vec<&'a str>) -> Self { + let mut map: HashSet> = HashSet::new(); + + for f in flags { + match f { + "Answered" | _ if f.eq_ignore_ascii_case("answered") => map.insert(Flag::Answered), + "Deleted" | _ if f.eq_ignore_ascii_case("deleted") => map.insert(Flag::Deleted), + "Draft" | _ if f.eq_ignore_ascii_case("draft") => map.insert(Flag::Draft), + "Flagged" | _ if f.eq_ignore_ascii_case("flagged") => map.insert(Flag::Flagged), + "MayCreate" | _ if f.eq_ignore_ascii_case("maycreate") => { + map.insert(Flag::MayCreate) + } + "Recent" | _ if f.eq_ignore_ascii_case("recent") => map.insert(Flag::Recent), + "Seen" | _ if f.eq_ignore_ascii_case("seen") => map.insert(Flag::Seen), + custom => map.insert(Flag::Custom(Cow::Owned(custom.into()))), + }; + } + + Self(map) + } +} + +//#[cfg(test)] +//mod tests { +// use crate::domain::msg::flag::entity::Flags; +// use imap::types::Flag; +// use std::collections::HashSet; + +// #[test] +// fn test_get_signs() { +// let flags = Flags::from(vec![Flag::Seen, Flag::Answered]); + +// assert_eq!(flags.to_symbols_string(), " ↵ ".to_string()); +// } + +// #[test] +// fn test_from_string() { +// let flags = Flags::from("Seen Answered"); + +// let expected = Flags::from(vec![Flag::Seen, Flag::Answered]); + +// assert_eq!(flags, expected); +// } + +// #[test] +// fn test_to_string() { +// let flags = Flags::from(vec![Flag::Seen, Flag::Answered]); + +// // since we can't influence the order in the HashSet, we're gonna convert it into a vec, +// // sort it according to the names and compare it aftwards. +// let flag_string = flags.to_string(); +// let mut flag_vec: Vec = flag_string +// .split_ascii_whitespace() +// .map(|word| word.to_string()) +// .collect(); +// flag_vec.sort(); + +// assert_eq!( +// flag_vec, +// vec!["\\Answered".to_string(), "\\Seen".to_string()] +// ); +// } + +// #[test] +// fn test_from_vec() { +// let flags = Flags::from(vec![Flag::Seen, Flag::Answered]); + +// let mut expected = HashSet::new(); +// expected.insert(Flag::Seen); +// expected.insert(Flag::Answered); + +// assert_eq!(flags.0, expected); +// } +//} diff --git a/src/domain/msg/handler.rs b/src/domain/msg/handler.rs deleted file mode 100644 index 99cf256..0000000 --- a/src/domain/msg/handler.rs +++ /dev/null @@ -1,454 +0,0 @@ -//! Module related to message handling. -//! -//! This module gathers all message commands. - -use anyhow::{Context, Result}; -use atty::Stream; -use imap::types::Flag; -use lettre::message::header::ContentTransferEncoding; -use log::{debug, trace}; -use std::{ - borrow::Cow, - convert::TryFrom, - fs, - io::{self, BufRead}, -}; -use url::Url; - -use crate::{ - config::entity::Account, - domain::{ - imap::service::ImapServiceInterface, - mbox::entity::Mbox, - msg::{ - self, - body::entity::Body, - entity::{Msg, MsgSerialized, Msgs}, - flag::entity::Flags, - header::entity::Headers, - }, - smtp::service::SmtpServiceInterface, - }, - output::service::OutputServiceInterface, - ui::{ - choice::{self, PostEditChoice}, - editor, - }, -}; - -// TODO: move this function to the right folder -fn msg_interaction< - OutputService: OutputServiceInterface, - ImapService: ImapServiceInterface, - SmtpService: SmtpServiceInterface, ->( - output: &OutputService, - msg: &mut Msg, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result { - // let the user change the body a little bit first, before opening the prompt - msg.edit_body()?; - - loop { - match choice::post_edit()? { - PostEditChoice::Send => { - debug!("sending message…"); - - // prepare the msg to be send - let sendable = match msg.to_sendable_msg() { - Ok(sendable) => sendable, - // In general if an error occured, then this is normally - // due to a missing value of a header. So let's give the - // user another try and give him/her the chance to fix - // that :) - Err(err) => { - println!("{}", err); - println!("Please reedit your msg to make it to a sendable message!"); - continue; - } - }; - smtp.send(&sendable)?; - - // TODO: Gmail sent mailboxes are called `[Gmail]/Sent` - // which creates a conflict, fix this! - - // let the server know, that the user sent a msg - msg.flags.insert(Flag::Seen); - let mbox = Mbox::from("Sent"); - imap.append_msg(&mbox, msg)?; - - // remove the draft, since we sent it - msg::utils::remove_draft()?; - output.print("Message successfully sent")?; - break; - } - // edit the body of the msg - PostEditChoice::Edit => { - Msg::parse_from_str(msg, &editor::open_editor_with_draft()?)?; - continue; - } - PostEditChoice::LocalDraft => break, - PostEditChoice::RemoteDraft => { - debug!("saving to draft…"); - - msg.flags.insert(Flag::Seen); - - let mbox = Mbox::from("Drafts"); - match imap.append_msg(&mbox, msg) { - Ok(_) => { - msg::utils::remove_draft()?; - output.print("Message successfully saved to Drafts")?; - } - Err(err) => { - output.print("Cannot save draft to the server")?; - return Err(err.into()); - } - }; - break; - } - PostEditChoice::Discard => { - msg::utils::remove_draft()?; - break; - } - } - } - - Ok(true) -} - -/// Download all attachments from the given message UID to the user account downloads directory. -pub fn attachments( - uid: &str, - account: &Account, - output: &OutputService, - imap: &mut ImapService, -) -> Result<()> { - let msg = imap.get_msg(&uid)?; - let attachments = msg.attachments.clone(); - - debug!( - "{} attachment(s) found for message {}", - attachments.len(), - uid - ); - - for attachment in &attachments { - let filepath = account.downloads_dir.join(&attachment.filename); - debug!("downloading {}…", attachment.filename); - fs::write(&filepath, &attachment.body_raw) - .context(format!("cannot download attachment {:?}", filepath))?; - } - - output.print(format!( - "{} attachment(s) successfully downloaded to {:?}", - attachments.len(), - account.downloads_dir - ))?; - - imap.logout()?; - Ok(()) -} - -/// Copy the given message UID from the selected mailbox to the targetted mailbox. -pub fn copy( - uid: &str, - mbox: Option<&str>, - output: &OutputService, - imap: &mut ImapService, -) -> Result<()> { - let target = Mbox::try_from(mbox)?; - let mut msg = imap.get_msg(&uid)?; - msg.flags.insert(Flag::Seen); - imap.append_msg(&target, &mut msg)?; - output.print(format!( - r#"Message {} successfully copied to folder "{}""#, - uid, target - ))?; - imap.logout()?; - Ok(()) -} - -/// Delete the given message UID from the selected mailbox. -pub fn delete( - uid: &str, - output: &OutputService, - imap: &mut ImapService, -) -> Result<()> { - let flags = Flags::from(vec![Flag::Seen, Flag::Deleted]); - imap.add_flags(uid, &flags)?; - imap.expunge()?; - output.print(format!("Message(s) {} successfully deleted", uid))?; - imap.logout()?; - Ok(()) -} - -/// Forward the given message UID from the selected mailbox. -pub fn forward< - OutputService: OutputServiceInterface, - ImapService: ImapServiceInterface, - SmtpService: SmtpServiceInterface, ->( - uid: &str, - attachments_paths: Vec<&str>, - account: &Account, - output: &OutputService, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - let mut msg = imap.get_msg(&uid)?; - msg.change_to_forwarding(&account); - attachments_paths - .iter() - .for_each(|path| msg.add_attachment(path)); - debug!("found {} attachments", attachments_paths.len()); - trace!("attachments: {:?}", attachments_paths); - msg_interaction(output, &mut msg, imap, smtp)?; - imap.logout()?; - Ok(()) -} - -/// List messages with pagination from the selected mailbox. -pub fn list( - page_size: Option, - page: usize, - account: &Account, - output: &OutputService, - imap: &mut ImapService, -) -> Result<()> { - let page_size = page_size.unwrap_or(account.default_page_size); - let msgs = imap.list_msgs(&page_size, &page)?; - let msgs = if let Some(ref fetches) = msgs { - Msgs::try_from(fetches)? - } else { - Msgs::new() - }; - trace!("messages: {:#?}", msgs); - output.print(msgs)?; - imap.logout()?; - Ok(()) -} - -/// Parse and edit a message from a [mailto] URL string. -/// -/// [mailto]: https://en.wikipedia.org/wiki/Mailto -pub fn mailto< - OutputService: OutputServiceInterface, - ImapService: ImapServiceInterface, - SmtpService: SmtpServiceInterface, ->( - url: &Url, - account: &Account, - output: &OutputService, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - let mut cc = Vec::new(); - let mut bcc = Vec::new(); - let mut subject = Cow::default(); - let mut body = Cow::default(); - - for (key, val) in url.query_pairs() { - match key.as_bytes() { - b"cc" => { - cc.push(val.into()); - } - b"bcc" => { - bcc.push(val.into()); - } - b"subject" => { - subject = val; - } - b"body" => { - body = val; - } - _ => (), - } - } - - let headers = Headers { - from: vec![account.address()], - to: vec![url.path().to_string()], - encoding: ContentTransferEncoding::Base64, - cc: if cc.is_empty() { None } else { Some(cc) }, - bcc: if bcc.is_empty() { None } else { Some(bcc) }, - subject: Some(subject.into()), - ..Headers::default() - }; - - let mut msg = Msg::new_with_headers(&account, headers); - msg.body = Body::new_with_text(body); - msg_interaction(output, &mut msg, imap, smtp)?; - imap.logout()?; - Ok(()) -} - -/// Move the given message UID from the selected mailbox to the targetted mailbox. -pub fn move_( - uid: &str, - mbox: Option<&str>, - output: &OutputService, - imap: &mut ImapService, -) -> Result<()> { - let target = Mbox::try_from(mbox)?; - let mut msg = imap.get_msg(&uid)?; - // create the msg in the target-msgbox - msg.flags.insert(Flag::Seen); - imap.append_msg(&target, &mut msg)?; - output.print(format!( - r#"Message {} successfully moved to folder "{}""#, - uid, target - ))?; - // delete the msg in the old mailbox - let flags = Flags::from(vec![Flag::Seen, Flag::Deleted]); - imap.add_flags(uid, &flags)?; - imap.expunge()?; - imap.logout()?; - Ok(()) -} - -/// Read a message from the given UID. -pub fn read( - uid: &str, - // TODO: use the mime to select the right body - _mime: String, - raw: bool, - output: &OutputService, - imap: &mut ImapService, -) -> Result<()> { - let msg = imap.get_msg(&uid)?; - if raw { - output.print(msg.get_raw_as_string()?)?; - } else { - output.print(MsgSerialized::try_from(&msg)?)?; - } - imap.logout()?; - Ok(()) -} - -/// Reply to the given message UID. -pub fn reply< - OutputService: OutputServiceInterface, - ImapService: ImapServiceInterface, - SmtpService: SmtpServiceInterface, ->( - uid: &str, - all: bool, - attachments_paths: Vec<&str>, - account: &Account, - output: &OutputService, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - let mut msg = imap.get_msg(&uid)?; - // Change the msg to a reply-msg. - msg.change_to_reply(&account, all)?; - // Apply the given attachments to the reply-msg. - attachments_paths - .iter() - .for_each(|path| msg.add_attachment(path)); - debug!("found {} attachments", attachments_paths.len()); - trace!("attachments: {:#?}", attachments_paths); - msg_interaction(output, &mut msg, imap, smtp)?; - imap.logout()?; - Ok(()) -} - -/// Save a raw message to the targetted mailbox. -pub fn save( - mbox: Option<&str>, - msg: &str, - imap: &mut ImapService, -) -> Result<()> { - let mbox = Mbox::try_from(mbox)?; - let mut msg = Msg::try_from(msg)?; - msg.flags.insert(Flag::Seen); - imap.append_msg(&mbox, &mut msg)?; - imap.logout()?; - Ok(()) -} - -/// Search messages from the given IMAP query. -pub fn search( - page_size: Option, - page: usize, - query: String, - account: &Account, - output: &OutputService, - imap: &mut ImapService, -) -> Result<()> { - let page_size = page_size.unwrap_or(account.default_page_size); - let msgs = imap.search_msgs(&query, &page_size, &page)?; - let msgs = if let Some(ref fetches) = msgs { - Msgs::try_from(fetches)? - } else { - Msgs::new() - }; - trace!("messages: {:?}", msgs); - output.print(msgs)?; - imap.logout()?; - Ok(()) -} - -/// Send a raw message. -pub fn send< - OutputService: OutputServiceInterface, - ImapService: ImapServiceInterface, - SmtpService: SmtpServiceInterface, ->( - msg: &str, - output: &OutputService, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - let msg = if atty::is(Stream::Stdin) || output.is_json() { - msg.replace("\r", "").replace("\n", "\r\n") - } else { - io::stdin() - .lock() - .lines() - .filter_map(|ln| ln.ok()) - .map(|ln| ln.to_string()) - .collect::>() - .join("\r\n") - }; - let mut msg = Msg::try_from(msg.as_str())?; - // send the message/msg - let sendable = msg.to_sendable_msg()?; - smtp.send(&sendable)?; - debug!("message sent!"); - // add the message/msg to the Sent-Mailbox of the user - msg.flags.insert(Flag::Seen); - let mbox = Mbox::from("Sent"); - imap.append_msg(&mbox, &mut msg)?; - imap.logout()?; - Ok(()) -} - -/// Compose a new message. -pub fn write< - OutputService: OutputServiceInterface, - ImapService: ImapServiceInterface, - SmtpService: SmtpServiceInterface, ->( - attachments_paths: Vec<&str>, - account: &Account, - output: &OutputService, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - let mut msg = Msg::new_with_headers( - &account, - Headers { - subject: Some(String::new()), - to: Vec::new(), - ..Headers::default() - }, - ); - attachments_paths - .iter() - .for_each(|path| msg.add_attachment(path)); - msg_interaction(output, &mut msg, imap, smtp)?; - imap.logout()?; - Ok(()) -} diff --git a/src/domain/msg/header/entity.rs b/src/domain/msg/header/entity.rs deleted file mode 100644 index 95e8006..0000000 --- a/src/domain/msg/header/entity.rs +++ /dev/null @@ -1,608 +0,0 @@ -use anyhow::{anyhow, Error, Result}; -use lettre::message::header::ContentTransferEncoding; -use log::{debug, warn}; -use rfc2047_decoder; -use serde::Serialize; -use std::{borrow::Cow, collections::HashMap, convert::TryFrom, fmt}; - -/// This struct is a wrapper for the [Envelope struct] of the [imap_proto] -/// crate. It's should mainly help to interact with the mails by using more -/// common data types like `Vec` or `String` since a `[u8]` array is a little -/// bit limited to use. -/// -/// # Usage -/// The general idea is, that you create a new instance like that: -/// -/// ``` -/// use himalaya::msg::headers::Headers; -/// # fn main() { -/// -/// let headers = Headers { -/// from: vec![String::from("From ")], -/// to: vec![String::from("To ")], -/// ..Headers::default() -/// }; -/// -/// # } -/// ``` -/// -/// We don't have a build-pattern here, because this is easy as well and we -/// don't need a dozens of functions, just to set some values. -/// -/// [Envelope struct]: https://docs.rs/imap-proto/0.14.3/imap_proto/types/struct.Headers.html -/// [imap_proto]: https://docs.rs/imap-proto/0.14.3/imap_proto/index.html -#[derive(Debug, Serialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Headers { - // -- Must-Fields -- - // These fields are the mininum needed to send a msg. - pub from: Vec, - pub to: Vec, - pub encoding: ContentTransferEncoding, - - // -- Optional fields -- - pub bcc: Option>, - pub cc: Option>, - pub custom_headers: Option>>, - pub in_reply_to: Option, - pub message_id: Option, - pub reply_to: Option>, - pub sender: Option, - pub subject: Option, -} - -impl Headers { - /// This method works similiar to the [`Display Trait`] but it will only - /// convert the header into a string **without** the signature. - /// - /// # Example - /// - ///
- /// - /// ``` - /// # use himalaya::msg::headers::Headers; - /// # use std::collections::HashMap; - /// # use lettre::message::header::ContentTransferEncoding; - /// # fn main() { - /// // our headers - /// let headers = Headers { - /// from: vec!["TornaxO7 ".to_string()], - /// to: vec!["Soywod ".to_string()], - /// encoding: ContentTransferEncoding::Base64, - /// bcc: Some(vec!["ThirdOne ".to_string()]), - /// cc: Some(vec!["CcAccount ".to_string()]), - /// custom_headers: None, - /// in_reply_to: Some("1234@local.machine.example".to_string()), - /// message_id: Some("123456789".to_string()), - /// reply_to: Some(vec!["reply@msg.net".to_string()]), - /// sender: Some("himalaya@secretary.net".to_string()), - /// signature: Some("Signature of Headers".to_string()), - /// subject: Some("Himalaya is cool".to_string()), - /// }; - /// - /// // get the header - /// let headers_string = headers.get_header_as_string(); - /// - /// // how the header part should look like - /// let expected_output = concat![ - /// "From: TornaxO7 \n", - /// "To: Soywod \n", - /// "In-Reply-To: 1234@local.machine.example\n", - /// "Sender: himalaya@secretary.net\n", - /// "Message-ID: 123456789\n", - /// "Reply-To: reply@msg.net\n", - /// "Cc: CcAccount \n", - /// "Bcc: ThirdOne \n", - /// "Subject: Himalaya is cool\n", - /// ]; - /// - /// assert_eq!(headers_string, expected_output, - /// "{}, {}", - /// headers_string, expected_output); - /// # } - /// ``` - /// - ///
- /// - /// [`Display Trait`]: https://doc.rust-lang.org/std/fmt/trait.Display.html - pub fn get_header_as_string(&self) -> String { - let mut header = String::new(); - - // -- Must-Have-Fields -- - // the "From: " header - header.push_str(&merge_addresses_to_one_line("From", &self.from, ',')); - - // the "To: " header - header.push_str(&merge_addresses_to_one_line("To", &self.to, ',')); - - // -- Optional fields -- - // Here we are adding only the header parts which have a value (are not - // None). That's why we are always checking here with "if let Some()". - - // in reply to - if let Some(in_reply_to) = &self.in_reply_to { - header.push_str(&format!("In-Reply-To: {}\n", in_reply_to)); - } - - // Sender - if let Some(sender) = &self.sender { - header.push_str(&format!("Sender: {}\n", sender)); - } - - // Message-ID - if let Some(message_id) = &self.message_id { - header.push_str(&format!("Message-ID: {}\n", message_id)); - } - - // reply_to - if let Some(reply_to) = &self.reply_to { - header.push_str(&merge_addresses_to_one_line("Reply-To", &reply_to, ',')); - } - - // cc - if let Some(cc) = &self.cc { - header.push_str(&merge_addresses_to_one_line("Cc", &cc, ',')); - } - - // bcc - if let Some(bcc) = &self.bcc { - header.push_str(&merge_addresses_to_one_line("Bcc", &bcc, ',')); - } - - // custom headers - if let Some(custom_headers) = &self.custom_headers { - for (key, value) in custom_headers.iter() { - header.push_str(&merge_addresses_to_one_line(key, &value, ',')); - } - } - - // Subject - if let Some(subject) = &self.subject { - header.push_str(&format!("Subject: {}\n", subject)); - } - - header - } -} - -/// Returns a Headers with the following values: -/// -/// ```no_run -/// # use himalaya::msg::headers::Headers; -/// # use lettre::message::header::ContentTransferEncoding; -/// Headers { -/// from: Vec::new(), -/// to: Vec::new(), -/// encoding: ContentTransferEncoding::Base64, -/// bcc: None, -/// cc: None, -/// custom_headers: None, -/// in_reply_to: None, -/// message_id: None, -/// reply_to: None, -/// sender: None, -/// signature: None, -/// subject: None, -/// }; -/// ``` -impl Default for Headers { - fn default() -> Self { - Self { - // must-fields - from: Vec::new(), - to: Vec::new(), - encoding: ContentTransferEncoding::Base64, - - // optional fields - bcc: None, - cc: None, - custom_headers: None, - in_reply_to: None, - message_id: None, - reply_to: None, - sender: None, - subject: None, - } - } -} - -// == From implementations == -impl TryFrom>> for Headers { - type Error = Error; - - fn try_from(envelope: Option<&imap_proto::types::Envelope<'_>>) -> Result { - if let Some(envelope) = envelope { - debug!("Fetch has headers."); - - let subject = envelope - .subject - .as_ref() - .and_then(|subj| rfc2047_decoder::decode(subj).ok()); - - let from = match convert_vec_address_to_string(envelope.from.as_ref())? { - Some(from) => from, - None => return Err(anyhow!("cannot extract senders from envelope")), - }; - - // only the first address is used, because how should multiple machines send the same - // mail? - let sender = convert_vec_address_to_string(envelope.sender.as_ref())?; - let sender = match sender { - Some(tmp_sender) => Some( - tmp_sender - .iter() - .next() - .unwrap_or(&String::new()) - .to_string(), - ), - None => None, - }; - - let message_id = convert_cow_u8_to_string(envelope.message_id.as_ref())?; - let reply_to = convert_vec_address_to_string(envelope.reply_to.as_ref())?; - let to = match convert_vec_address_to_string(envelope.to.as_ref())? { - Some(to) => to, - None => return Err(anyhow!("cannot extract recipients from envelope")), - }; - let cc = convert_vec_address_to_string(envelope.cc.as_ref())?; - let bcc = convert_vec_address_to_string(envelope.bcc.as_ref())?; - let in_reply_to = convert_cow_u8_to_string(envelope.in_reply_to.as_ref())?; - - Ok(Self { - subject, - from, - sender, - message_id, - reply_to, - to, - cc, - bcc, - in_reply_to, - custom_headers: None, - encoding: ContentTransferEncoding::Base64, - }) - } else { - debug!("Fetch hasn't headers."); - Ok(Headers::default()) - } - } -} - -impl<'from> From<&mailparse::ParsedMail<'from>> for Headers { - fn from(parsed_mail: &mailparse::ParsedMail<'from>) -> Self { - let mut new_headers = Headers::default(); - - let header_iter = parsed_mail.headers.iter(); - for header in header_iter { - // get the value of the header. For example if we have this header: - // - // Subject: I use Arch btw - // - // than `value` would be like that: `let value = "I use Arch btw".to_string()` - let value = header.get_value().replace("\r", ""); - let header_name = header.get_key().to_lowercase(); - let header_name = header_name.as_str(); - - // now go through all headers and look which values they have. - match header_name { - "from" => { - new_headers.from = value - .rsplit(',') - .map(|addr| addr.trim().to_string()) - .collect() - } - - "to" => { - new_headers.to = value - .rsplit(',') - .map(|addr| addr.trim().to_string()) - .collect() - } - - "bcc" => { - new_headers.bcc = Some( - value - .rsplit(',') - .map(|addr| addr.trim().to_string()) - .collect(), - ) - } - - "cc" => { - new_headers.cc = Some( - value - .rsplit(',') - .map(|addr| addr.trim().to_string()) - .collect(), - ) - } - "in_reply_to" => new_headers.in_reply_to = Some(value), - "reply_to" => { - new_headers.reply_to = Some( - value - .rsplit(',') - .map(|addr| addr.trim().to_string()) - .collect(), - ) - } - - "sender" => new_headers.sender = Some(value), - "subject" => new_headers.subject = Some(value), - "message-id" => new_headers.message_id = Some(value), - "content-transfer-encoding" => { - match value.to_lowercase().as_str() { - "8bit" => new_headers.encoding = ContentTransferEncoding::EightBit, - "7bit" => new_headers.encoding = ContentTransferEncoding::SevenBit, - "quoted-printable" => { - new_headers.encoding = ContentTransferEncoding::QuotedPrintable - } - "base64" => new_headers.encoding = ContentTransferEncoding::Base64, - _ => warn!("Unsupported encoding, default to QuotedPrintable"), - }; - } - - // it's a custom header => Add it to our - // custom-header-hash-map - _ => { - let custom_header = header.get_key(); - - // If we don't have a HashMap yet => Create one! Otherwise - // we'll keep using it, because why should we reset its - // values again? - if let None = new_headers.custom_headers { - new_headers.custom_headers = Some(HashMap::new()); - } - - let mut updated_hashmap = new_headers.custom_headers.unwrap(); - - updated_hashmap.insert( - custom_header, - value - .rsplit(',') - .map(|addr| addr.trim().to_string()) - .collect(), - ); - - new_headers.custom_headers = Some(updated_hashmap); - } - } - } - - new_headers - } -} - -// -- Common Traits -- -/// This trait just returns the headers but as a string. But be careful! **The -/// signature is printed as well!!!**, so it isn't really useable to create the -/// content of a msg! Use [get_header_as_string] instead! -/// -/// # Example -/// -/// ``` -/// # use himalaya::msg::headers::Headers; -/// # fn main() { -/// let headers = Headers { -/// subject: Some(String::from("Himalaya is cool")), -/// to: vec![String::from("Soywod ")], -/// from: vec![String::from("TornaxO7 ")], -/// signature: Some(String::from("Signature of Headers")), -/// ..Headers::default() -/// }; -/// -/// // use the `fmt::Display` trait -/// let headers_output = format!("{}", headers); -/// -/// // How the output of the `fmt::Display` trait should look like -/// let expected_output = concat![ -/// "From: TornaxO7 \n", -/// "To: Soywod \n", -/// "Subject: Himalaya is cool\n", -/// "\n\n\n", -/// "Signature of Headers", -/// ]; -/// -/// assert_eq!(headers_output, expected_output, -/// "{:#?}, {:#?}", -/// headers_output, expected_output); -/// # } -/// ``` -/// -/// [get_header_as_string]: struct.Headers.html#method.get_header_as_string -impl fmt::Display for Headers { - fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - write!(formatter, "{}", self.get_header_as_string()) - } -} - -// -- Helper functions -- -/// This function is mainly used for the `imap_proto::types::Address` struct to -/// convert one field into a String. Take a look into the -/// `test_convert_cow_u8_to_string` test function to see it in action. -fn convert_cow_u8_to_string<'val>(value: Option<&Cow<'val, [u8]>>) -> Result> { - if let Some(value) = value { - // convert the `[u8]` list into a vector and try to get a string out of - // it. If everything worked fine, return the content of the list - Ok(Some(rfc2047_decoder::decode(&value.to_vec())?)) - } else { - Ok(None) - } -} - -/// This function is mainly used for the `imap_proto::types::Address` struct as -/// well to change the Address into an address-string like this: -/// `TornaxO7 `. -/// -/// If you provide two addresses as the function argument, then this functions -/// returns their "parsed" address in the same order. Take a look into the -/// `test_convert_vec_address_to_string` for an example. -fn convert_vec_address_to_string<'val>( - addresses: Option<&Vec>>, -) -> Result>> { - if let Some(addresses) = addresses { - let mut parsed_addresses: Vec = Vec::new(); - - for address in addresses.iter() { - // This variable will hold the parsed version of the Address-struct, - // like this: - // - // "Name " - let mut parsed_address = String::new(); - - // -- Get the fields -- - // add the name field (if it exists) like this: - // "Name" - if let Some(name) = convert_cow_u8_to_string(address.name.as_ref())? { - parsed_address.push_str(&name); - } - - // add the mailaddress - if let Some(mailbox) = convert_cow_u8_to_string(address.mailbox.as_ref())? { - if let Some(host) = convert_cow_u8_to_string(address.host.as_ref())? { - let mail_address = format!("{}@{}", mailbox, host); - - // some mail clients add a trailing space, after the address - let trimmed = mail_address.trim(); - - if parsed_address.is_empty() { - // parsed_address = "msg@host" - parsed_address.push_str(&trimmed); - } else { - // parsed_address = "Name " - parsed_address.push_str(&format!(" <{}>", trimmed)); - } - } - } - - parsed_addresses.push(parsed_address); - } - - Ok(Some(parsed_addresses)) - } else { - Ok(None) - } -} - -/// This function is used, in order to merge multiple msg accounts into one -/// line. Take a look into the `test_merge_addresses_to_one_line` test-function -/// to see an example how to use it. -fn merge_addresses_to_one_line(header: &str, addresses: &Vec, separator: char) -> String { - let mut output = header.to_string(); - let mut address_iter = addresses.iter(); - - // Convert the header to this (for example): `Cc: ` - output.push_str(": "); - - // the first emsg doesn't need a comma before, so we should append the msg - // to it - output.push_str(address_iter.next().unwrap_or(&String::new())); - - // add the rest of the emails. It should look like this after the for_each: - // - // Addr1, Addr2, Addr2, ... - address_iter.for_each(|address| output.push_str(&format!("{}{}", separator, address))); - - // end the header-line by using a newline character - output.push('\n'); - - output -} - -// ========== -// Tests -// ========== -/// This tests only test the helper functions. -#[cfg(test)] -mod tests { - - #[test] - fn test_merge_addresses_to_one_line() { - use super::merge_addresses_to_one_line; - // In this function, we want to create the following Cc header: - // - // Cc: TornaxO7 , Soywod - // - // by a vector of email-addresses. - - // our msg addresses for the "Cc" header - let mail_addresses = vec![ - "TornaxO7 ".to_string(), - "Soywod ".to_string(), - ]; - - let cc_header = merge_addresses_to_one_line("Cc", &mail_addresses, ','); - - let expected_output = concat![ - "Cc: TornaxO7 ", - ",", - "Soywod \n", - ]; - - assert_eq!( - cc_header, expected_output, - "{:#?}, {:#?}", - cc_header, expected_output - ); - } - - #[test] - fn test_convert_cow_u8_to_string() { - use super::convert_cow_u8_to_string; - use std::borrow::Cow; - - let output1 = convert_cow_u8_to_string(None); - let output2 = convert_cow_u8_to_string(Some(&Cow::Owned(b"Test".to_vec()))); - - // test output1 - if let Ok(output1) = output1 { - assert!(output1.is_none()); - } else { - assert!(false); - } - - // test output2 - if let Ok(output2) = output2 { - if let Some(string) = output2 { - assert_eq!(String::from("Test"), string); - } else { - assert!(false); - } - } else { - assert!(false); - } - } - - #[test] - fn test_convert_vec_address_to_string() { - use super::convert_vec_address_to_string; - use imap_proto::types::Address; - use std::borrow::Cow; - - let addresses = vec![ - Address { - name: Some(Cow::Owned(b"Name1".to_vec())), - adl: None, - mailbox: Some(Cow::Owned(b"Mailbox1".to_vec())), - host: Some(Cow::Owned(b"Host1".to_vec())), - }, - Address { - name: None, - adl: None, - mailbox: Some(Cow::Owned(b"Mailbox2".to_vec())), - host: Some(Cow::Owned(b"Host2".to_vec())), - }, - ]; - - // the expected addresses - let expected_output = vec![ - String::from("Name1 "), - String::from("Mailbox2@Host2"), - ]; - - if let Ok(converted) = convert_vec_address_to_string(Some(&addresses)) { - assert_eq!(converted, Some(expected_output)); - } else { - assert!(false); - } - } -} diff --git a/src/domain/msg/header/mod.rs b/src/domain/msg/header/mod.rs deleted file mode 100644 index e8c3d6a..0000000 --- a/src/domain/msg/header/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod entity; diff --git a/src/domain/msg/mod.rs b/src/domain/msg/mod.rs index 9d809c3..2a526d6 100644 --- a/src/domain/msg/mod.rs +++ b/src/domain/msg/mod.rs @@ -18,24 +18,36 @@ /// /// Execute `himalaya help ` where `` is one entry of this list above /// to get more information about them. -pub mod arg; +pub mod msg_arg; -/// Here are the two **main structs** of this module: `Msg` and `Msgs` which -/// represent a *Mail* or *multiple Mails* in this crate. -pub mod entity; +pub mod msg_handler; +pub mod msg_utils; -/// This module is used in the `Msg` struct, which should represent an -/// attachment of a msg. -pub mod attachment; +pub mod flag_arg; +pub mod flag_handler; -/// This module is used in the `Msg` struct, which should represent the headers -/// fields like `To:` and `From:`. -pub mod header; +pub mod flag_entity; +pub use flag_entity::*; -/// This module is used in the `Msg` struct, which should represent the body of -/// a msg; The part where you're writing some text like `Dear Mr. LMAO`. -pub mod body; -pub mod flag; -pub mod handler; -pub mod tpl; -pub mod utils; +pub mod flags_entity; +pub use flags_entity::*; + +pub mod envelope_entity; +pub use envelope_entity::*; + +pub mod envelopes_entity; +pub use envelopes_entity::*; + +pub mod tpl_arg; +pub use tpl_arg::TplOverride; + +pub mod tpl_handler; + +pub mod tpl_entity; +pub use tpl_entity::*; + +pub mod msg_entity; +pub use msg_entity::*; + +pub mod parts_entity; +pub use parts_entity::*; diff --git a/src/domain/msg/arg.rs b/src/domain/msg/msg_arg.rs similarity index 66% rename from src/domain/msg/arg.rs rename to src/domain/msg/msg_arg.rs index 6c78b29..b71d47f 100644 --- a/src/domain/msg/arg.rs +++ b/src/domain/msg/msg_arg.rs @@ -4,14 +4,17 @@ use anyhow::Result; use clap::{self, App, Arg, ArgMatches, SubCommand}; -use log::debug; +use log::{debug, trace}; -use crate::domain::{mbox, msg}; +use crate::domain::{ + mbox::mbox_arg, + msg::{flag_arg, msg_arg, tpl_arg}, +}; -type Uid<'a> = &'a str; +type Seq<'a> = &'a str; type PageSize = usize; type Page = usize; -type TargetMbox<'a> = Option<&'a str>; +type Mbox<'a> = Option<&'a str>; type Mime = String; type Raw = bool; type All = bool; @@ -21,61 +24,61 @@ type AttachmentsPaths<'a> = Vec<&'a str>; /// Message commands. pub enum Command<'a> { - Attachments(Uid<'a>), - Copy(Uid<'a>, TargetMbox<'a>), - Delete(Uid<'a>), - Forward(Uid<'a>, AttachmentsPaths<'a>), + Attachments(Seq<'a>), + Copy(Seq<'a>, Mbox<'a>), + Delete(Seq<'a>), + Forward(Seq<'a>, AttachmentsPaths<'a>), List(Option, Page), - Move(Uid<'a>, TargetMbox<'a>), - Read(Uid<'a>, Mime, Raw), - Reply(Uid<'a>, All, AttachmentsPaths<'a>), - Save(TargetMbox<'a>, RawMsg<'a>), + Move(Seq<'a>, Mbox<'a>), + Read(Seq<'a>, Mime, Raw), + Reply(Seq<'a>, All, AttachmentsPaths<'a>), + Save(Mbox<'a>, RawMsg<'a>), Search(Query, Option, Page), Send(RawMsg<'a>), Write(AttachmentsPaths<'a>), - Flag(Option>), - Tpl(Option>), + Flag(Option>), + Tpl(Option>), } /// Message command matcher. pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { if let Some(m) = m.subcommand_matches("attachments") { debug!("attachments command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); - return Ok(Some(Command::Attachments(uid))); + let seq = m.value_of("seq").unwrap(); + trace!("seq: {}", seq); + return Ok(Some(Command::Attachments(seq))); } if let Some(m) = m.subcommand_matches("copy") { debug!("copy command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); + let seq = m.value_of("seq").unwrap(); + trace!("seq: {}", seq); let target = m.value_of("target"); - debug!("target mailbox: `{:?}`", target); - return Ok(Some(Command::Copy(uid, target))); + trace!(r#"target mailbox: "{:?}""#, target); + return Ok(Some(Command::Copy(seq, target))); } if let Some(m) = m.subcommand_matches("delete") { debug!("copy command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); - return Ok(Some(Command::Delete(uid))); + let seq = m.value_of("seq").unwrap(); + trace!("seq: {}", seq); + return Ok(Some(Command::Delete(seq))); } if let Some(m) = m.subcommand_matches("forward") { debug!("forward command matched"); - let uid = m.value_of("uid").unwrap(); + let seq = m.value_of("seq").unwrap(); + trace!("seq: {}", seq); let paths: Vec<&str> = m.values_of("attachments").unwrap_or_default().collect(); - debug!("attachments paths: {:?}", paths); - debug!("uid: {}", uid); - return Ok(Some(Command::Forward(uid, paths))); + trace!("attachments paths: {:?}", paths); + return Ok(Some(Command::Forward(seq, paths))); } if let Some(m) = m.subcommand_matches("list") { debug!("list command matched"); let page_size = m.value_of("page-size").and_then(|s| s.parse().ok()); - debug!("page size: `{:?}`", page_size); + trace!(r#"page size: "{:?}""#, page_size); let page = m .value_of("page") .unwrap_or("1") @@ -83,38 +86,39 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { .ok() .map(|page| 1.max(page) - 1) .unwrap_or_default(); - debug!("page: `{:?}`", page); + trace!(r#"page: "{:?}""#, page); return Ok(Some(Command::List(page_size, page))); } if let Some(m) = m.subcommand_matches("move") { debug!("move command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); + let seq = m.value_of("seq").unwrap(); + trace!("seq: {}", seq); let target = m.value_of("target"); - debug!("target mailbox: `{:?}`", target); - return Ok(Some(Command::Move(uid, target))); + trace!(r#"target mailbox: "{:?}""#, target); + return Ok(Some(Command::Move(seq, target))); } if let Some(m) = m.subcommand_matches("read") { - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); + debug!("read command matched"); + let seq = m.value_of("seq").unwrap(); + trace!("seq: {}", seq); let mime = format!("text/{}", m.value_of("mime-type").unwrap()); - debug!("mime: {}", mime); + trace!("mime: {}", mime); let raw = m.is_present("raw"); - debug!("raw: {}", raw); - return Ok(Some(Command::Read(uid, mime, raw))); + trace!("raw: {}", raw); + return Ok(Some(Command::Read(seq, mime, raw))); } if let Some(m) = m.subcommand_matches("reply") { debug!("reply command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); + let seq = m.value_of("seq").unwrap(); + trace!("seq: {}", seq); let all = m.is_present("reply-all"); - debug!("reply all: {}", all); + trace!("reply all: {}", all); let paths: Vec<&str> = m.values_of("attachments").unwrap_or_default().collect(); - debug!("attachments paths: {:?}", paths); - return Ok(Some(Command::Reply(uid, all, paths))); + trace!("attachments paths: {:#?}", paths); + return Ok(Some(Command::Reply(seq, all, paths))); } if let Some(m) = m.subcommand_matches("save") { @@ -129,7 +133,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { if let Some(m) = m.subcommand_matches("search") { debug!("search command matched"); let page_size = m.value_of("page-size").and_then(|s| s.parse().ok()); - debug!("page size: `{:?}`", page_size); + trace!(r#"page size: "{:?}""#, page_size); let page = m .value_of("page") .unwrap() @@ -137,7 +141,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { .ok() .map(|page| 1.max(page) - 1) .unwrap_or_default(); - debug!("page: `{:?}`", page); + trace!(r#"page: "{:?}""#, page); let query = m .values_of("query") .unwrap_or_default() @@ -162,40 +166,50 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { }) .1 .join(" "); + trace!(r#"query: "{:?}""#, query); return Ok(Some(Command::Search(query, page_size, page))); } if let Some(m) = m.subcommand_matches("send") { debug!("send command matched"); let msg = m.value_of("message").unwrap_or_default(); - debug!("message: {}", msg); + trace!("message: {}", msg); return Ok(Some(Command::Send(msg))); } if let Some(m) = m.subcommand_matches("write") { debug!("write command matched"); let attachment_paths: Vec<&str> = m.values_of("attachments").unwrap_or_default().collect(); - debug!("attachments paths: {:?}", attachment_paths); + trace!("attachments paths: {:?}", attachment_paths); return Ok(Some(Command::Write(attachment_paths))); } if let Some(m) = m.subcommand_matches("template") { - return Ok(Some(Command::Tpl(msg::tpl::arg::matches(&m)?))); + return Ok(Some(Command::Tpl(tpl_arg::matches(&m)?))); } if let Some(m) = m.subcommand_matches("flag") { - return Ok(Some(Command::Flag(msg::flag::arg::matches(&m)?))); + return Ok(Some(Command::Flag(flag_arg::matches(&m)?))); } debug!("default list command matched"); Ok(Some(Command::List(None, 0))) } -/// Message UID argument. -pub(crate) fn uid_arg<'a>() -> Arg<'a, 'a> { - Arg::with_name("uid") +/// Message sequence number argument. +pub(crate) fn seq_arg<'a>() -> Arg<'a, 'a> { + Arg::with_name("seq") .help("Specifies the targetted message") - .value_name("UID") + .value_name("SEQ") + .required(true) +} + +/// Message sequence range argument. +pub(crate) fn seq_range_arg<'a>() -> Arg<'a, 'a> { + Arg::with_name("seq-range") + .help("Specifies targetted message(s)") + .long_help("Specifies a range of targetted messages. The range follows the [RFC3501](https://datatracker.ietf.org/doc/html/rfc3501#section-9) format: `1:5` matches messages with sequence number between 1 and 5, `1,5` matches messages with sequence number 1 or 5, * matches all messages.") + .value_name("SEQ") .required(true) } @@ -226,13 +240,26 @@ fn page_arg<'a>() -> Arg<'a, 'a> { .default_value("0") } +/// Message attachment argument. +fn attachment_arg<'a>() -> Arg<'a, 'a> { + Arg::with_name("attachments") + .help("Adds attachment to the message") + .short("a") + .long("attachment") + .value_name("PATH") + .multiple(true) +} + /// Message subcommands. pub fn subcmds<'a>() -> Vec> { vec![ - msg::flag::arg::subcmds(), - msg::tpl::arg::subcmds(), - msg::attachment::arg::subcmds(), + flag_arg::subcmds(), + tpl_arg::subcmds(), vec![ + SubCommand::with_name("attachments") + .aliases(&["attachment", "att", "a"]) + .about("Downloads all message attachments") + .arg(msg_arg::seq_arg()), SubCommand::with_name("list") .aliases(&["lst", "l"]) .about("Lists all messages") @@ -245,14 +272,15 @@ pub fn subcmds<'a>() -> Vec> { .arg(page_arg()) .arg( Arg::with_name("query") - .help("IMAP query (see https://tools.ietf.org/html/rfc3501#section-6.4.4)") + .help("IMAP query") + .long_help("The IMAP query format follows the [RFC3501](https://tools.ietf.org/html/rfc3501#section-6.4.4). The query is case-insensitive.") .value_name("QUERY") .multiple(true) .required(true), ), SubCommand::with_name("write") .about("Writes a new message") - .arg(msg::attachment::arg::path_arg()), + .arg(attachment_arg()), SubCommand::with_name("send") .about("Sends a raw message") .arg(Arg::with_name("message").raw(true).last(true)), @@ -261,7 +289,7 @@ pub fn subcmds<'a>() -> Vec> { .arg(Arg::with_name("message").raw(true)), SubCommand::with_name("read") .about("Reads text bodies of a message") - .arg(uid_arg()) + .arg(seq_arg()) .arg( Arg::with_name("mime-type") .help("MIME type to use") @@ -280,28 +308,28 @@ pub fn subcmds<'a>() -> Vec> { SubCommand::with_name("reply") .aliases(&["rep", "r"]) .about("Answers to a message") - .arg(uid_arg()) + .arg(seq_arg()) .arg(reply_all_arg()) - .arg(msg::attachment::arg::path_arg()), + .arg(attachment_arg()), SubCommand::with_name("forward") .aliases(&["fwd", "f"]) .about("Forwards a message") - .arg(uid_arg()) - .arg(msg::attachment::arg::path_arg()), + .arg(seq_arg()) + .arg(attachment_arg()), SubCommand::with_name("copy") .aliases(&["cp", "c"]) .about("Copies a message to the targetted mailbox") - .arg(uid_arg()) - .arg(mbox::arg::target_arg()), + .arg(seq_arg()) + .arg(mbox_arg::target_arg()), SubCommand::with_name("move") .aliases(&["mv"]) .about("Moves a message to the targetted mailbox") - .arg(uid_arg()) - .arg(mbox::arg::target_arg()), + .arg(seq_arg()) + .arg(mbox_arg::target_arg()), SubCommand::with_name("delete") .aliases(&["del", "d", "remove", "rm"]) .about("Deletes a message") - .arg(uid_arg()), + .arg(seq_arg()), ], ] .concat() diff --git a/src/domain/msg/msg_entity.rs b/src/domain/msg/msg_entity.rs new file mode 100644 index 0000000..6a98267 --- /dev/null +++ b/src/domain/msg/msg_entity.rs @@ -0,0 +1,814 @@ +use ammonia; +use anyhow::{anyhow, Context, Error, Result}; +use chrono::{DateTime, FixedOffset}; +use htmlescape; +use imap::types::Flag; +use lettre::message::{Attachment, MultiPart, SinglePart}; +use regex::Regex; +use rfc2047_decoder; +use serde::Serialize; +use std::{ + convert::{TryFrom, TryInto}, + fmt, fs, + path::PathBuf, +}; + +use crate::{ + config::Account, + domain::{ + imap::ImapServiceInterface, + mbox::Mbox, + msg::{msg_utils, Flags, Parts, TextHtmlPart, TextPlainPart, Tpl, TplOverride}, + smtp::SmtpServiceInterface, + }, + output::OutputServiceInterface, + ui::{ + choice::{self, PostEditChoice, PreEditChoice}, + editor, + }, +}; + +use super::{BinaryPart, Part}; + +type Addr = lettre::message::Mailbox; + +/// Representation of a message. +#[derive(Debug, Default)] +pub struct Msg { + /// The sequence number of the message. + /// + /// [RFC3501]: https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.1.2 + pub id: u32, + + /// The flags attached to the message. + pub flags: Flags, + + /// The subject of the message. + pub subject: String, + + pub from: Option>, + pub reply_to: Option>, + pub to: Option>, + pub cc: Option>, + pub bcc: Option>, + pub in_reply_to: Option, + pub message_id: Option, + + /// The internal date of the message. + /// + /// [RFC3501]: https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.3 + pub date: Option>, + pub parts: Parts, +} + +impl Msg { + pub fn attachments(&self) -> Vec { + self.parts + .iter() + .filter_map(|part| match part { + Part::Binary(part) => Some(part.clone()), + _ => None, + }) + .collect() + } + + pub fn join_text_plain_parts(&self) -> String { + let text_parts = self + .parts + .iter() + .filter_map(|part| match part { + Part::TextPlain(part) => Some(part.content.to_owned()), + _ => None, + }) + .collect::>() + .join("\n\n"); + let text_parts = ammonia::Builder::new() + .tags(Default::default()) + .clean(&text_parts) + .to_string(); + let text_parts = match htmlescape::decode_html(&text_parts) { + Ok(text_parts) => text_parts, + Err(_) => text_parts, + }; + text_parts + } + + pub fn join_text_html_parts(&self) -> String { + let text_parts = self + .parts + .iter() + .filter_map(|part| match part { + Part::TextPlain(part) => Some(part.content.to_owned()), + _ => None, + }) + .collect::>() + .join("\n\n"); + let text_parts = Regex::new(r"(\r?\n){2,}") + .unwrap() + .replace_all(&text_parts, "\n\n") + .to_string(); + text_parts + } + + pub fn join_text_parts(&self) -> String { + let text_parts = self.join_text_plain_parts(); + if text_parts.is_empty() { + self.join_text_html_parts() + } else { + text_parts + } + } + + pub fn into_reply(mut self, all: bool, account: &Account) -> Result { + let account_addr: Addr = account.address().parse()?; + + // Message-Id + self.message_id = None; + + // In-Reply-To + self.in_reply_to = self.message_id.to_owned(); + + // From + self.from = Some(vec![account_addr.to_owned()]); + + // To + let addrs = self + .reply_to + .as_ref() + .or_else(|| self.from.as_ref()) + .map(|addrs| { + addrs + .clone() + .into_iter() + .filter(|addr| addr != &account_addr) + }); + if all { + self.to = addrs.map(|addrs| addrs.collect()); + } else { + self.to = addrs + .and_then(|mut addrs| addrs.next()) + .map(|addr| vec![addr]); + } + + // Cc & Bcc + if !all { + self.cc = None; + self.bcc = None; + } + + // Subject + if !self.subject.starts_with("Re:") { + self.subject = format!("Re: {}", self.subject); + } + + // Text plain parts + let plain_content = { + let date = self + .date + .as_ref() + .map(|date| date.format("%d %b %Y, at %H:%M").to_string()) + .unwrap_or("unknown date".into()); + let sender = self + .reply_to + .as_ref() + .or(self.from.as_ref()) + .and_then(|addrs| addrs.first()) + .map(|addr| addr.name.to_owned().unwrap_or(addr.email.to_string())) + .unwrap_or("unknown sender".into()); + let mut content = format!("\n\nOn {}, {} wrote:\n", date, sender); + + let mut glue = ""; + for line in self.join_text_plain_parts().trim().lines() { + if line == "-- \n" { + break; + } + content.push_str(glue); + content.push_str(">"); + content.push_str(if line.starts_with(">") { "" } else { " " }); + content.push_str(line); + glue = "\n"; + } + + content + }; + + // Text HTML parts + let html_content = { + let date = self + .date + .as_ref() + .map(|date| date.format("%d %b %Y, at %H:%M").to_string()) + .unwrap_or("unknown date".into()); + let sender = self + .reply_to + .as_ref() + .or(self.from.as_ref()) + .and_then(|addrs| addrs.first()) + .map(|addr| addr.name.to_owned().unwrap_or(addr.email.to_string())) + .unwrap_or("unknown sender".into()); + let mut content = format!("\n\nOn {}, {} wrote:\n", date, sender); + + let mut glue = ""; + for line in self.join_text_html_parts().trim().lines() { + if line == "-- \n" { + break; + } + content.push_str(glue); + content.push_str(">"); + content.push_str(if line.starts_with(">") { "" } else { " " }); + content.push_str(line); + glue = "\n"; + } + + content + }; + + self.parts = Parts::default(); + + if !plain_content.is_empty() { + self.parts.push(Part::TextPlain(TextPlainPart { + content: plain_content, + })); + } + + if !html_content.is_empty() { + self.parts.push(Part::TextHtml(TextHtmlPart { + content: html_content, + })); + } + + Ok(self) + } + + pub fn into_forward(mut self, account: &Account) -> Result { + let account_addr: Addr = account.address().parse()?; + + let prev_subject = self.subject.to_owned(); + let prev_date = self.date.to_owned(); + let prev_from = self.reply_to.to_owned().or_else(|| self.from.to_owned()); + let prev_to = self.to.to_owned(); + + // Message-Id + self.message_id = None; + + // In-Reply-To + self.in_reply_to = None; + + // From + self.from = Some(vec![account_addr.to_owned()]); + + // To + self.to = Some(vec![]); + + // Cc + self.cc = None; + + // Bcc + self.bcc = None; + + // Subject + if !self.subject.starts_with("Fwd:") { + self.subject = format!("Fwd: {}", self.subject); + } + + // Text plain parts + { + let mut content = String::default(); + content.push_str("\n\n-------- Forwarded Message --------\n"); + content.push_str(&format!("Subject: {}\n", prev_subject)); + if let Some(date) = prev_date { + content.push_str(&format!("Date: {}\n", date.to_rfc2822())); + } + if let Some(addrs) = prev_from.as_ref() { + content.push_str("From: "); + let mut glue = ""; + for addr in addrs { + content.push_str(glue); + content.push_str(&addr.to_string()); + glue = ", "; + } + content.push_str("\n"); + } + if let Some(addrs) = prev_to.as_ref() { + content.push_str("To: "); + let mut glue = ""; + for addr in addrs { + content.push_str(glue); + content.push_str(&addr.to_string()); + glue = ", "; + } + content.push_str("\n"); + } + content.push_str("\n"); + content.push_str(&self.join_text_plain_parts()); + self.parts + .replace_text_plain_parts_with(TextPlainPart { content }) + } + + // Text HTML parts + { + let mut content = String::default(); + content.push_str("\n\n-------- Forwarded Message --------\n"); + content.push_str(&format!("Subject: {}\n", prev_subject)); + if let Some(date) = prev_date { + content.push_str(&format!("Date: {}\n", date.to_rfc2822())); + } + if let Some(addrs) = prev_from.as_ref() { + content.push_str("From: "); + let mut glue = ""; + for addr in addrs { + content.push_str(glue); + content.push_str(&addr.to_string()); + glue = ", "; + } + content.push_str("\n"); + } + if let Some(addrs) = prev_to.as_ref() { + content.push_str("To: "); + let mut glue = ""; + for addr in addrs { + content.push_str(glue); + content.push_str(&addr.to_string()); + glue = ", "; + } + content.push_str("\n"); + } + content.push_str("\n"); + content.push_str(&self.join_text_html_parts()); + self.parts + .replace_text_html_parts_with(TextHtmlPart { content }) + } + + Ok(self) + } + + fn _edit_with_editor(&self, account: &Account) -> Result { + let tpl = Tpl::from_msg(TplOverride::default(), self, account); + let tpl = editor::open_with_tpl(tpl)?; + Self::try_from(&tpl) + } + + pub fn edit_with_editor< + OutputService: OutputServiceInterface, + ImapService: ImapServiceInterface, + SmtpService: SmtpServiceInterface, + >( + mut self, + account: &Account, + output: &OutputService, + imap: &mut ImapService, + smtp: &mut SmtpService, + ) -> Result<()> { + let draft = msg_utils::local_draft_path(); + if draft.exists() { + loop { + match choice::pre_edit() { + Ok(choice) => match choice { + PreEditChoice::Edit => { + let tpl = editor::open_with_draft()?; + self.merge_with(Msg::try_from(&tpl)?); + break; + } + PreEditChoice::Discard => { + self.merge_with(self._edit_with_editor(account)?); + break; + } + PreEditChoice::Quit => return Ok(()), + }, + Err(err) => { + println!("{}", err); + continue; + } + } + } + } else { + self.merge_with(self._edit_with_editor(account)?); + } + + loop { + match choice::post_edit() { + Ok(PostEditChoice::Send) => { + let mbox = Mbox::from("Sent"); + let sent_msg = smtp.send_msg(&self)?; + let flags = Flags::try_from(vec![Flag::Seen])?; + imap.append_raw_msg_with_flags(&mbox, &sent_msg.formatted(), flags)?; + msg_utils::remove_local_draft()?; + output.print("Message successfully sent")?; + break; + } + Ok(PostEditChoice::Edit) => { + self.merge_with(self._edit_with_editor(account)?); + continue; + } + Ok(PostEditChoice::LocalDraft) => { + output.print("Message successfully saved locally")?; + break; + } + Ok(PostEditChoice::RemoteDraft) => { + let mbox = Mbox::from("Drafts"); + let flags = Flags::try_from(vec![Flag::Seen, Flag::Draft])?; + let tpl = Tpl::from_msg(TplOverride::default(), &self, account); + imap.append_raw_msg_with_flags(&mbox, tpl.as_bytes(), flags)?; + msg_utils::remove_local_draft()?; + output.print("Message successfully saved to Drafts")?; + break; + } + Ok(PostEditChoice::Discard) => { + msg_utils::remove_local_draft()?; + break; + } + Err(err) => { + println!("{}", err); + continue; + } + } + } + + Ok(()) + } + + pub fn add_attachments(mut self, attachments_paths: Vec<&str>) -> Result { + for path in attachments_paths { + let path = shellexpand::full(path) + .context(format!(r#"cannot expand attachment path "{}""#, path))?; + let path = PathBuf::from(path.to_string()); + let filename: String = path + .file_name() + .ok_or(anyhow!("cannot get file name of attachment {:?}", path))? + .to_string_lossy() + .into(); + let content = fs::read(&path).context(format!("cannot read attachment {:?}", path))?; + let mime = tree_magic::from_u8(&content); + + self.parts.push(Part::Binary(BinaryPart { + filename, + mime, + content, + })) + } + + Ok(self) + } + + pub fn merge_with(&mut self, msg: Msg) { + if msg.from.is_some() { + self.from = msg.from; + } + + if msg.to.is_some() { + self.to = msg.to; + } + + if msg.cc.is_some() { + self.cc = msg.cc; + } + + if msg.bcc.is_some() { + self.bcc = msg.bcc; + } + + if !msg.subject.is_empty() { + self.subject = msg.subject; + } + + for part in msg.parts.0.into_iter() { + match part { + Part::Binary(_) => self.parts.push(part), + Part::TextPlain(_) => { + self.parts.retain(|p| match p { + Part::TextPlain(_) => false, + _ => true, + }); + self.parts.push(part); + } + Part::TextHtml(_) => { + self.parts.retain(|p| match p { + Part::TextHtml(_) => false, + _ => true, + }); + self.parts.push(part); + } + } + } + } +} + +impl TryFrom<&Tpl> for Msg { + type Error = Error; + + fn try_from(tpl: &Tpl) -> Result { + let mut msg = Msg::default(); + + let parsed_msg = + mailparse::parse_mail(tpl.as_bytes()).context("cannot parse message from template")?; + + for header in parsed_msg.get_headers() { + let key = header.get_key(); + let val = String::from_utf8(header.get_value_raw().to_vec()) + .map(|val| val.trim().to_string())?; + + match key.as_str() { + "Message-Id" | _ if key.eq_ignore_ascii_case("message-id") => { + msg.message_id = Some(val.to_owned()) + } + "From" | _ if key.eq_ignore_ascii_case("from") => { + msg.from = Some( + val.split(',') + .filter_map(|addr| addr.parse().ok()) + .collect::>(), + ); + } + "To" | _ if key.eq_ignore_ascii_case("to") => { + msg.to = Some( + val.split(',') + .filter_map(|addr| addr.parse().ok()) + .collect::>(), + ); + } + "Reply-To" | _ if key.eq_ignore_ascii_case("reply-to") => { + msg.reply_to = Some( + val.split(',') + .filter_map(|addr| addr.parse().ok()) + .collect::>(), + ); + } + "In-Reply-To" | _ if key.eq_ignore_ascii_case("in-reply-to") => { + msg.in_reply_to = Some(val.to_owned()) + } + "Cc" | _ if key.eq_ignore_ascii_case("cc") => { + msg.cc = Some( + val.split(',') + .filter_map(|addr| addr.parse().ok()) + .collect::>(), + ); + } + "Bcc" | _ if key.eq_ignore_ascii_case("bcc") => { + msg.bcc = Some( + val.split(',') + .filter_map(|addr| addr.parse().ok()) + .collect::>(), + ); + } + "Subject" | _ if key.eq_ignore_ascii_case("subject") => { + msg.subject = val; + } + _ => (), + } + } + + let content = parsed_msg + .get_body_raw() + .context("cannot get body from parsed message")?; + let content = String::from_utf8(content).context("cannot decode body from utf-8")?; + msg.parts.push(Part::TextPlain(TextPlainPart { content })); + + Ok(msg) + } +} + +impl TryInto for Msg { + type Error = Error; + + fn try_into(self) -> Result { + let from: Option = self + .from + .and_then(|addrs| addrs.into_iter().next()) + .map(|addr| addr.email); + let to = self + .to + .map(|addrs| addrs.into_iter().map(|addr| addr.email).collect()) + .unwrap_or_default(); + let envelope = + lettre::address::Envelope::new(from, to).context("cannot create envelope")?; + + Ok(envelope) + } +} + +impl TryInto for &Msg { + type Error = Error; + + fn try_into(self) -> Result { + let mut msg_builder = lettre::Message::builder() + .message_id(self.message_id.to_owned()) + .subject(self.subject.to_owned()); + + if let Some(id) = self.in_reply_to.as_ref() { + msg_builder = msg_builder.in_reply_to(id.to_owned()); + }; + + if let Some(addrs) = self.from.as_ref() { + msg_builder = addrs + .iter() + .fold(msg_builder, |builder, addr| builder.from(addr.to_owned())) + }; + + if let Some(addrs) = self.to.as_ref() { + msg_builder = addrs + .iter() + .fold(msg_builder, |builder, addr| builder.to(addr.to_owned())) + }; + + if let Some(addrs) = self.reply_to.as_ref() { + msg_builder = addrs.iter().fold(msg_builder, |builder, addr| { + builder.reply_to(addr.to_owned()) + }) + }; + + if let Some(addrs) = self.cc.as_ref() { + msg_builder = addrs + .iter() + .fold(msg_builder, |builder, addr| builder.cc(addr.to_owned())) + }; + + if let Some(addrs) = self.bcc.as_ref() { + msg_builder = addrs + .iter() + .fold(msg_builder, |builder, addr| builder.bcc(addr.to_owned())) + }; + + let mut multipart = + MultiPart::mixed().singlepart(SinglePart::plain(self.join_text_plain_parts())); + + for part in self.attachments() { + let filename = part.filename; + let content = part.content; + let mime = part.mime.parse().context(format!( + r#"cannot parse content type of attachment "{}""#, + filename + ))?; + multipart = multipart.singlepart(Attachment::new(filename).body(content, mime)) + } + + msg_builder + .multipart(multipart) + .context("cannot build sendable message") + } +} + +impl TryInto> for &Msg { + type Error = Error; + + fn try_into(self) -> Result> { + let msg: lettre::Message = self.try_into()?; + Ok(msg.formatted()) + } +} + +impl<'a> TryFrom<&'a imap::types::Fetch> for Msg { + type Error = Error; + + fn try_from(fetch: &'a imap::types::Fetch) -> Result { + let envelope = fetch + .envelope() + .ok_or(anyhow!("cannot get envelope of message {}", fetch.message))?; + + // Get the sequence number + let id = fetch.message; + + // Get the flags + let flags = Flags::try_from(fetch.flags())?; + + // Get the subject + let subject = envelope + .subject + .as_ref() + .ok_or(anyhow!("cannot get subject of message {}", fetch.message)) + .and_then(|subj| { + rfc2047_decoder::decode(subj).context(format!( + "cannot decode subject of message {}", + fetch.message + )) + })?; + + // Get the sender(s) address(es) + let from = match envelope + .sender + .as_ref() + .or_else(|| envelope.from.as_ref()) + .map(parse_addrs) + { + Some(addrs) => Some(addrs?), + None => None, + }; + + // Get the "Reply-To" address(es) + let reply_to = parse_some_addrs(&envelope.reply_to).context(format!( + r#"cannot parse "reply to" address of message {}"#, + id + ))?; + + // Get the recipient(s) address(es) + let to = parse_some_addrs(&envelope.to) + .context(format!(r#"cannot parse "to" address of message {}"#, id))?; + + // Get the "Cc" recipient(s) address(es) + let cc = parse_some_addrs(&envelope.cc) + .context(format!(r#"cannot parse "cc" address of message {}"#, id))?; + + // Get the "Bcc" recipient(s) address(es) + let bcc = parse_some_addrs(&envelope.bcc) + .context(format!(r#"cannot parse "bcc" address of message {}"#, id))?; + + // Get the "In-Reply-To" message identifier + let in_reply_to = match envelope + .in_reply_to + .as_ref() + .map(|cow| String::from_utf8(cow.to_vec())) + { + Some(id) => Some(id?), + None => None, + }; + + // Get the message identifier + let message_id = match envelope + .message_id + .as_ref() + .map(|cow| String::from_utf8(cow.to_vec())) + { + Some(id) => Some(id?), + None => None, + }; + + // Get the internal date + let date = fetch.internal_date(); + + // Get all parts + let parts = Parts::from( + &mailparse::parse_mail( + fetch + .body() + .ok_or(anyhow!("cannot get body of message {}", id))?, + ) + .context(format!("cannot parse body of message {}", id))?, + ); + + Ok(Self { + id, + flags, + subject, + message_id, + from, + reply_to, + in_reply_to, + to, + cc, + bcc, + date, + parts, + }) + } +} + +pub fn parse_addr(addr: &imap_proto::Address) -> Result { + let name = addr + .name + .as_ref() + .map(|name| { + rfc2047_decoder::decode(&name.to_vec()) + .context("cannot decode address name") + .map(|name| Some(name)) + }) + .unwrap_or(Ok(None))?; + let mbox = addr + .mailbox + .as_ref() + .ok_or(anyhow!("cannot get address mailbox")) + .and_then(|mbox| { + rfc2047_decoder::decode(&mbox.to_vec()).context("cannot decode address mailbox") + })?; + let host = addr + .host + .as_ref() + .ok_or(anyhow!("cannot get address host")) + .and_then(|host| { + rfc2047_decoder::decode(&host.to_vec()).context("cannot decode address host") + })?; + + Ok(Addr::new(name, lettre::Address::new(mbox, host)?)) +} + +pub fn parse_addrs(addrs: &Vec) -> Result> { + let mut parsed_addrs = vec![]; + for addr in addrs { + parsed_addrs + .push(parse_addr(addr).context(format!(r#"cannot parse address "{:?}""#, addr))?); + } + Ok(parsed_addrs) +} + +pub fn parse_some_addrs(addrs: &Option>) -> Result>> { + Ok(match addrs.as_ref().map(parse_addrs) { + Some(addrs) => Some(addrs?), + None => None, + }) +} + +#[derive(Debug, Serialize)] +pub struct PrintableMsg(pub String); + +impl fmt::Display for PrintableMsg { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}", self.0) + } +} diff --git a/src/domain/msg/msg_handler.rs b/src/domain/msg/msg_handler.rs new file mode 100644 index 0000000..7d999e6 --- /dev/null +++ b/src/domain/msg/msg_handler.rs @@ -0,0 +1,321 @@ +//! Module related to message handling. +//! +//! This module gathers all message commands. + +use anyhow::{Context, Result}; +use atty::Stream; +use imap::types::Flag; +use log::{debug, trace}; +use std::{ + borrow::Cow, + convert::{TryFrom, TryInto}, + fs, + io::{self, BufRead}, +}; +use url::Url; + +use crate::{ + config::Account, + domain::{ + imap::ImapServiceInterface, + mbox::Mbox, + msg::{Flags, Msg, Part, TextPlainPart, Tpl}, + smtp::SmtpServiceInterface, + }, + output::OutputServiceInterface, +}; + +use super::PrintableMsg; + +/// Download all attachments from the given message sequence number to the user account downloads +/// directory. +pub fn attachments( + seq: &str, + account: &Account, + output: &OutputService, + imap: &mut ImapService, +) -> Result<()> { + let attachments = imap.find_msg(&seq)?.attachments(); + let attachments_len = attachments.len(); + debug!( + r#"{} attachment(s) found for message "{}""#, + attachments_len, seq + ); + + for attachment in attachments { + let filepath = account.downloads_dir.join(&attachment.filename); + debug!("downloading {}…", attachment.filename); + fs::write(&filepath, &attachment.content) + .context(format!("cannot download attachment {:?}", filepath))?; + } + + output.print(format!( + "{} attachment(s) successfully downloaded to {:?}", + attachments_len, account.downloads_dir + )) +} + +/// Copy a message from a mailbox to another. +pub fn copy( + seq: &str, + mbox: Option<&str>, + output: &OutputService, + imap: &mut ImapService, +) -> Result<()> { + let mbox = Mbox::try_from(mbox)?; + let msg = imap.find_raw_msg(&seq)?; + let flags = Flags::try_from(vec![Flag::Seen])?; + imap.append_raw_msg_with_flags(&mbox, &msg, flags)?; + output.print(format!( + r#"Message {} successfully copied to folder "{}""#, + seq, mbox + )) +} + +/// Delete messages matching the given sequence range. +pub fn delete( + seq: &str, + output: &OutputService, + imap: &mut ImapService, +) -> Result<()> { + let flags = Flags::try_from(vec![Flag::Seen, Flag::Deleted])?; + imap.add_flags(seq, &flags)?; + imap.expunge()?; + output.print(format!(r#"Message(s) {} successfully deleted"#, seq)) +} + +/// Forward the given message UID from the selected mailbox. +pub fn forward< + OutputService: OutputServiceInterface, + ImapService: ImapServiceInterface, + SmtpService: SmtpServiceInterface, +>( + seq: &str, + attachments_paths: Vec<&str>, + account: &Account, + output: &OutputService, + imap: &mut ImapService, + smtp: &mut SmtpService, +) -> Result<()> { + imap.find_msg(seq)? + .into_forward(account)? + .add_attachments(attachments_paths)? + .edit_with_editor(account, output, imap, smtp) +} + +/// List paginated messages from the selected mailbox. +pub fn list( + page_size: Option, + page: usize, + account: &Account, + output: &OutputService, + imap: &mut ImapService, +) -> Result<()> { + let page_size = page_size.unwrap_or(account.default_page_size); + trace!("page size: {}", page_size); + + let msgs = imap.get_msgs(&page_size, &page)?; + trace!("messages: {:#?}", msgs); + output.print(msgs) +} + +/// Parse and edit a message from a [mailto] URL string. +/// +/// [mailto]: https://en.wikipedia.org/wiki/Mailto +pub fn mailto< + OutputService: OutputServiceInterface, + ImapService: ImapServiceInterface, + SmtpService: SmtpServiceInterface, +>( + url: &Url, + account: &Account, + output: &OutputService, + imap: &mut ImapService, + smtp: &mut SmtpService, +) -> Result<()> { + let to: Vec = url + .path() + .split(";") + .filter_map(|s| s.parse().ok()) + .collect(); + let mut cc = Vec::new(); + let mut bcc = Vec::new(); + let mut subject = Cow::default(); + let mut body = Cow::default(); + + for (key, val) in url.query_pairs() { + match key.as_bytes() { + b"cc" => { + cc.push(val.parse()?); + } + b"bcc" => { + bcc.push(val.parse()?); + } + b"subject" => { + subject = val; + } + b"body" => { + body = val; + } + _ => (), + } + } + + let mut msg = Msg::default(); + + msg.from = Some(vec![account.address().parse()?]); + msg.to = if to.is_empty() { None } else { Some(to) }; + msg.cc = if cc.is_empty() { None } else { Some(cc) }; + msg.bcc = if bcc.is_empty() { None } else { Some(bcc) }; + msg.subject = subject.into(); + msg.parts.push(Part::TextPlain(TextPlainPart { + content: body.into(), + })); + msg.edit_with_editor(account, output, imap, smtp) +} + +/// Move a message from a mailbox to another. +pub fn move_( + // The sequence number of the message to move + seq: &str, + // The mailbox to move the message in + mbox: Option<&str>, + output: &OutputService, + imap: &mut ImapService, +) -> Result<()> { + // Copy the message to targetted mailbox + let mbox = Mbox::try_from(mbox)?; + let msg = imap.find_raw_msg(&seq)?; + let flags = Flags::try_from(vec![Flag::Seen])?; + imap.append_raw_msg_with_flags(&mbox, &msg, flags)?; + + // Delete the original message + let flags = Flags::try_from(vec![Flag::Seen, Flag::Deleted])?; + imap.add_flags(seq, &flags)?; + imap.expunge()?; + + output.print(format!( + r#"Message {} successfully moved to folder "{}""#, + seq, mbox + )) +} + +/// Read a message by its sequence number. +pub fn read( + seq: &str, + // TODO: use the mime to select the right body + _mime: String, + raw: bool, + output: &OutputService, + imap: &mut ImapService, +) -> Result<()> { + if raw { + let msg = String::from_utf8(imap.find_raw_msg(&seq)?)?; + output.print(PrintableMsg(msg)) + } else { + let msg = imap.find_msg(&seq)?.join_text_parts(); + output.print(PrintableMsg(msg)) + } +} + +/// Reply to the given message UID. +pub fn reply< + OutputService: OutputServiceInterface, + ImapService: ImapServiceInterface, + SmtpService: SmtpServiceInterface, +>( + seq: &str, + all: bool, + attachments_paths: Vec<&str>, + account: &Account, + output: &OutputService, + imap: &mut ImapService, + smtp: &mut SmtpService, +) -> Result<()> { + imap.find_msg(seq)? + .into_reply(all, account)? + .add_attachments(attachments_paths)? + .edit_with_editor(account, output, imap, smtp)?; + let flags = Flags::try_from(vec![Flag::Answered])?; + imap.add_flags(seq, &flags) +} + +/// Save a raw message to the targetted mailbox. +pub fn save( + mbox: Option<&str>, + msg: &str, + imap: &mut ImapService, +) -> Result<()> { + let mbox = Mbox::try_from(mbox)?; + let flags = Flags::try_from(vec![Flag::Seen])?; + imap.append_raw_msg_with_flags(&mbox, msg.as_bytes(), flags) +} + +/// Paginate messages from the selected mailbox matching the specified query. +pub fn search( + query: String, + page_size: Option, + page: usize, + account: &Account, + output: &OutputService, + imap: &mut ImapService, +) -> Result<()> { + let page_size = page_size.unwrap_or(account.default_page_size); + trace!("page size: {}", page_size); + + let msgs = imap.find_msgs(&query, &page_size, &page)?; + trace!("messages: {:#?}", msgs); + output.print(msgs) +} + +/// Send a raw message. +pub fn send< + OutputService: OutputServiceInterface, + ImapService: ImapServiceInterface, + SmtpService: SmtpServiceInterface, +>( + raw_msg: &str, + output: &OutputService, + imap: &mut ImapService, + smtp: &mut SmtpService, +) -> Result<()> { + let raw_msg = if atty::is(Stream::Stdin) || output.is_json() { + raw_msg.replace("\r", "").replace("\n", "\r\n") + } else { + io::stdin() + .lock() + .lines() + .filter_map(|ln| ln.ok()) + .map(|ln| ln.to_string()) + .collect::>() + .join("\r\n") + }; + + let tpl = Tpl(raw_msg.to_string()); + let msg = Msg::try_from(&tpl)?; + let envelope: lettre::address::Envelope = msg.try_into()?; + smtp.send_raw_msg(&envelope, raw_msg.as_bytes())?; + debug!("message sent!"); + + // Save message to sent folder + let mbox = Mbox::from("Sent"); + let flags = Flags::try_from(vec![Flag::Seen])?; + imap.append_raw_msg_with_flags(&mbox, raw_msg.as_bytes(), flags) +} + +/// Compose a new message. +pub fn write< + OutputService: OutputServiceInterface, + ImapService: ImapServiceInterface, + SmtpService: SmtpServiceInterface, +>( + attachments_paths: Vec<&str>, + account: &Account, + output: &OutputService, + imap: &mut ImapService, + smtp: &mut SmtpService, +) -> Result<()> { + Msg::default() + .add_attachments(attachments_paths)? + .edit_with_editor(account, output, imap, smtp) +} diff --git a/src/domain/msg/msg_utils.rs b/src/domain/msg/msg_utils.rs new file mode 100644 index 0000000..27d4b37 --- /dev/null +++ b/src/domain/msg/msg_utils.rs @@ -0,0 +1,15 @@ +use anyhow::{Context, Result}; +use log::{debug, trace}; +use std::{env, fs, path::PathBuf}; + +pub fn local_draft_path() -> PathBuf { + let path = env::temp_dir().join("himalaya-draft.mail"); + trace!("local draft path: {:?}", path); + path +} + +pub fn remove_local_draft() -> Result<()> { + let path = local_draft_path(); + debug!("remove draft path at {:?}", path); + fs::remove_file(&path).context(format!("cannot remove local draft at {:?}", path)) +} diff --git a/src/domain/msg/parts_entity.rs b/src/domain/msg/parts_entity.rs new file mode 100644 index 0000000..b183747 --- /dev/null +++ b/src/domain/msg/parts_entity.rs @@ -0,0 +1,117 @@ +use mailparse::MailHeaderMap; +use serde::Serialize; +use std::ops::{Deref, DerefMut}; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct TextPlainPart { + pub content: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct TextHtmlPart { + pub content: String, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct BinaryPart { + pub filename: String, + pub mime: String, + pub content: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Part { + TextPlain(TextPlainPart), + TextHtml(TextHtmlPart), + Binary(BinaryPart), +} + +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Parts(pub Vec); + +impl Parts { + pub fn replace_text_plain_parts_with(&mut self, part: TextPlainPart) { + self.retain(|part| { + if let Part::TextPlain(_) = part { + false + } else { + true + } + }); + self.push(Part::TextPlain(part)); + } + + pub fn replace_text_html_parts_with(&mut self, part: TextHtmlPart) { + self.retain(|part| { + if let Part::TextHtml(_) = part { + false + } else { + true + } + }); + self.push(Part::TextHtml(part)); + } +} + +impl Deref for Parts { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Parts { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl<'a> From<&'a mailparse::ParsedMail<'a>> for Parts { + fn from(part: &'a mailparse::ParsedMail<'a>) -> Self { + let mut parts = vec![]; + build_parts_map_rec(part, &mut parts); + Self(parts) + } +} + +fn build_parts_map_rec(part: &mailparse::ParsedMail, parts: &mut Vec) { + if part.subparts.is_empty() { + let content_disp = part.get_content_disposition(); + match content_disp.disposition { + mailparse::DispositionType::Attachment => { + let filename = content_disp + .params + .get("filename") + .map(String::from) + .unwrap_or(String::from("noname")); + let content = part.get_body_raw().unwrap_or_default(); + let mime = tree_magic::from_u8(&content); + parts.push(Part::Binary(BinaryPart { + filename, + mime, + content, + })); + } + // TODO: manage other use cases + _ => { + part.get_headers() + .get_first_value("content-type") + .map(|ctype| { + let content = part.get_body().unwrap_or_default(); + if ctype.starts_with("text/plain") { + parts.push(Part::TextPlain(TextPlainPart { content })) + } else if ctype.starts_with("text/html") { + parts.push(Part::TextHtml(TextHtmlPart { content })) + } + }); + } + }; + } else { + part.subparts + .iter() + .for_each(|part| build_parts_map_rec(part, parts)); + } +} diff --git a/src/domain/msg/tpl/handler.rs b/src/domain/msg/tpl/handler.rs deleted file mode 100644 index 94eaf9d..0000000 --- a/src/domain/msg/tpl/handler.rs +++ /dev/null @@ -1,158 +0,0 @@ -use std::{ - collections::HashMap, - convert::TryFrom, - io::{self, BufRead}, -}; - -use anyhow::Result; -use atty::Stream; -use log::{debug, trace}; - -use crate::{ - config::entity::Account, - domain::{ - imap::service::ImapServiceInterface, - msg::{ - body::entity::Body, - entity::{Msg, MsgSerialized}, - header::entity::Headers, - tpl::arg::Tpl, - }, - }, - output::service::OutputServiceInterface, -}; - -pub fn new<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( - tpl: Tpl<'a>, - account: &'a Account, - output: &'a OutputService, - imap: &'a mut ImapService, -) -> Result<()> { - let mut msg = Msg::new(&account); - override_msg_with_args(&mut msg, tpl); - trace!("message: {:#?}", msg); - output.print(MsgSerialized::try_from(&msg)?)?; - imap.logout()?; - Ok(()) -} - -pub fn reply<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( - uid: &str, - all: bool, - tpl: Tpl<'a>, - account: &'a Account, - output: &'a OutputService, - imap: &'a mut ImapService, -) -> Result<()> { - let mut msg = imap.get_msg(uid)?; - msg.change_to_reply(account, all)?; - override_msg_with_args(&mut msg, tpl); - trace!("Message: {:?}", msg); - output.print(MsgSerialized::try_from(&msg)?)?; - imap.logout()?; - Ok(()) -} - -pub fn forward<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( - uid: &str, - tpl: Tpl<'a>, - account: &'a Account, - output: &'a OutputService, - imap: &'a mut ImapService, -) -> Result<()> { - let mut msg = imap.get_msg(&uid)?; - msg.sig = account.signature.to_owned(); - msg.change_to_forwarding(&account); - override_msg_with_args(&mut msg, tpl); - trace!("Message: {:?}", msg); - output.print(MsgSerialized::try_from(&msg)?)?; - imap.logout()?; - Ok(()) -} - -// == Helper functions == -// -- Template Subcommands -- -// These functions are more used for the "template" subcommand -fn override_msg_with_args<'a>(msg: &mut Msg, tpl: Tpl<'a>) { - // -- Collecting credentials -- - let from: Vec = match tpl.from { - Some(from) => from.map(|arg| arg.to_string()).collect(), - None => msg.headers.from.clone(), - }; - let to: Vec = match tpl.to { - Some(to) => to.map(|arg| arg.to_string()).collect(), - None => Vec::new(), - }; - let subject = tpl - .subject - .map(String::from) - .or_else(|| msg.headers.subject.clone()) - .or_else(|| Some(String::new())); - let cc: Option> = tpl - .cc - .map(|cc| cc.map(|arg| arg.to_string()).collect()) - .or_else(|| msg.headers.cc.clone()); - let bcc: Option> = tpl - .bcc - .map(|bcc| bcc.map(|arg| arg.to_string()).collect()) - .or_else(|| msg.headers.bcc.clone()); - - let custom_headers: Option>> = { - if let Some(matched_headers) = tpl.headers { - let mut custom_headers: HashMap> = HashMap::new(); - - // collect the custom headers - for header in matched_headers { - let mut header = header.split(":"); - let key = header.next().unwrap_or_default(); - let val = header.next().unwrap_or_default().trim_start(); - - custom_headers.insert(key.to_string(), vec![val.to_string()]); - } - - Some(custom_headers) - } else { - None - } - }; - - let body = { - if atty::isnt(Stream::Stdin) { - let body = io::stdin() - .lock() - .lines() - .filter_map(|line| line.ok()) - .map(|line| line.to_string()) - .collect::>() - .join("\n"); - debug!("overriden body from stdin: {:?}", body); - body - } else if let Some(body) = tpl.body { - debug!("overriden body: {:?}", body); - body.to_string() - } else { - msg.body - .plain - .as_ref() - .map(String::from) - .unwrap_or_default() - } - }; - - let body = Body::new_with_text(body); - - // -- Creating and printing -- - let headers = Headers { - from, - subject, - to, - cc, - bcc, - custom_headers, - ..msg.headers.clone() - }; - - msg.headers = headers; - msg.body = body; - msg.sig = tpl.sig.map(String::from).unwrap_or(msg.sig.to_owned()); -} diff --git a/src/domain/msg/tpl/mod.rs b/src/domain/msg/tpl/mod.rs deleted file mode 100644 index bf7874f..0000000 --- a/src/domain/msg/tpl/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Module related to messages template. - -pub mod arg; -pub mod handler; diff --git a/src/domain/msg/tpl/arg.rs b/src/domain/msg/tpl_arg.rs similarity index 64% rename from src/domain/msg/tpl/arg.rs rename to src/domain/msg/tpl_arg.rs index 49d1b82..432ce19 100644 --- a/src/domain/msg/tpl/arg.rs +++ b/src/domain/msg/tpl_arg.rs @@ -3,87 +3,87 @@ //! This module provides subcommands, arguments and a command matcher related to message template. use anyhow::Result; -use clap::{self, App, AppSettings, Arg, ArgMatches, SubCommand, Values}; -use log::debug; +use clap::{self, App, AppSettings, Arg, ArgMatches, SubCommand}; +use log::{debug, trace}; -use crate::domain::msg::{self, arg::uid_arg}; +use crate::domain::msg::msg_arg; -type Uid<'a> = &'a str; +type Seq<'a> = &'a str; type All = bool; -#[derive(Debug)] -pub struct Tpl<'a> { +#[derive(Debug, Default)] +pub struct TplOverride<'a> { pub subject: Option<&'a str>, - pub from: Option>, - pub to: Option>, - pub cc: Option>, - pub bcc: Option>, - pub headers: Option>, + pub from: Option>, + pub to: Option>, + pub cc: Option>, + pub bcc: Option>, + pub headers: Option>, pub body: Option<&'a str>, pub sig: Option<&'a str>, } /// Message template commands. pub enum Command<'a> { - New(Tpl<'a>), - Reply(Uid<'a>, All, Tpl<'a>), - Forward(Uid<'a>, Tpl<'a>), + New(TplOverride<'a>), + Reply(Seq<'a>, All, TplOverride<'a>), + Forward(Seq<'a>, TplOverride<'a>), } /// Message template command matcher. pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { if let Some(m) = m.subcommand_matches("new") { debug!("new command matched"); - let tpl = Tpl { + let tpl = TplOverride { subject: m.value_of("subject"), - from: m.values_of("from"), - to: m.values_of("to"), - cc: m.values_of("cc"), - bcc: m.values_of("bcc"), - headers: m.values_of("headers"), + from: m.values_of("from").map(|v| v.collect()), + to: m.values_of("to").map(|v| v.collect()), + cc: m.values_of("cc").map(|v| v.collect()), + bcc: m.values_of("bcc").map(|v| v.collect()), + headers: m.values_of("headers").map(|v| v.collect()), body: m.value_of("body"), sig: m.value_of("signature"), }; - debug!("template: `{:?}`", tpl); + trace!(r#"template args: "{:?}""#, tpl); return Ok(Some(Command::New(tpl))); } if let Some(m) = m.subcommand_matches("reply") { debug!("reply command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); + let seq = m.value_of("seq").unwrap(); + trace!(r#"seq: "{}""#, seq); let all = m.is_present("reply-all"); - debug!("reply all: {}", all); - let tpl = Tpl { + trace!("reply all: {}", all); + let tpl = TplOverride { subject: m.value_of("subject"), - from: m.values_of("from"), - to: m.values_of("to"), - cc: m.values_of("cc"), - bcc: m.values_of("bcc"), - headers: m.values_of("headers"), + from: m.values_of("from").map(|v| v.collect()), + to: m.values_of("to").map(|v| v.collect()), + cc: m.values_of("cc").map(|v| v.collect()), + bcc: m.values_of("bcc").map(|v| v.collect()), + headers: m.values_of("headers").map(|v| v.collect()), body: m.value_of("body"), sig: m.value_of("signature"), }; - debug!("template: `{:?}`", tpl); - return Ok(Some(Command::Reply(uid, all, tpl))); + trace!(r#"template args: "{:?}""#, tpl); + return Ok(Some(Command::Reply(seq, all, tpl))); } if let Some(m) = m.subcommand_matches("forward") { debug!("forward command matched"); - let uid = m.value_of("uid").unwrap(); - debug!("uid: {}", uid); - let tpl = Tpl { + let seq = m.value_of("seq").unwrap(); + trace!(r#"seq: "{}""#, seq); + let tpl = TplOverride { subject: m.value_of("subject"), - from: m.values_of("from"), - to: m.values_of("to"), - cc: m.values_of("cc"), - bcc: m.values_of("bcc"), - headers: m.values_of("headers"), + from: m.values_of("from").map(|v| v.collect()), + to: m.values_of("to").map(|v| v.collect()), + cc: m.values_of("cc").map(|v| v.collect()), + bcc: m.values_of("bcc").map(|v| v.collect()), + headers: m.values_of("headers").map(|v| v.collect()), body: m.value_of("body"), sig: m.value_of("signature"), }; - debug!("template: `{:?}`", tpl); - return Ok(Some(Command::Forward(uid, tpl))); + trace!(r#"template args: "{:?}""#, tpl); + return Ok(Some(Command::Forward(seq, tpl))); } Ok(None) @@ -156,15 +156,15 @@ pub fn subcmds<'a>() -> Vec> { SubCommand::with_name("reply") .aliases(&["rep", "r"]) .about("Generates a reply message template") - .arg(uid_arg()) - .arg(msg::arg::reply_all_arg()) + .arg(msg_arg::seq_arg()) + .arg(msg_arg::reply_all_arg()) .args(&tpl_args()), ) .subcommand( SubCommand::with_name("forward") .aliases(&["fwd", "fw", "f"]) .about("Generates a forward message template") - .arg(uid_arg()) + .arg(msg_arg::seq_arg()) .args(&tpl_args()), )] } diff --git a/src/domain/msg/tpl_entity.rs b/src/domain/msg/tpl_entity.rs new file mode 100644 index 0000000..2dc9768 --- /dev/null +++ b/src/domain/msg/tpl_entity.rs @@ -0,0 +1,118 @@ +use log::trace; +use serde::Serialize; +use std::{ + fmt::{self, Display}, + ops::Deref, +}; + +use crate::{ + config::Account, + domain::msg::{Msg, TplOverride}, +}; + +#[derive(Debug, Default, Clone, Serialize)] +pub struct Tpl(pub String); + +impl Tpl { + pub fn from_msg(opts: TplOverride, msg: &Msg, account: &Account) -> Tpl { + let mut tpl = String::default(); + + tpl.push_str("Content-Type: text/plain; charset=utf-8\n"); + + if let Some(in_reply_to) = msg.in_reply_to.as_ref() { + tpl.push_str(&format!("In-Reply-To: {}\n", in_reply_to)) + } + + // From + tpl.push_str(&format!( + "From: {}\n", + opts.from + .map(|addrs| addrs.join(", ")) + .unwrap_or_else(|| account.address()) + )); + + // To + tpl.push_str(&format!( + "To: {}\n", + opts.to + .map(|addrs| addrs.join(", ")) + .or_else(|| msg.to.clone().map(|addrs| addrs + .iter() + .map(|addr| addr.to_string()) + .collect::>() + .join(", "))) + .unwrap_or_default() + )); + + // Cc + if let Some(addrs) = opts.cc.map(|addrs| addrs.join(", ")).or_else(|| { + msg.cc.clone().map(|addrs| { + addrs + .iter() + .map(|addr| addr.to_string()) + .collect::>() + .join(", ") + }) + }) { + tpl.push_str(&format!("Cc: {}\n", addrs)); + } + + // Bcc + if let Some(addrs) = opts.bcc.map(|addrs| addrs.join(", ")).or_else(|| { + msg.bcc.clone().map(|addrs| { + addrs + .iter() + .map(|addr| addr.to_string()) + .collect::>() + .join(", ") + }) + }) { + tpl.push_str(&format!("Bcc: {}\n", addrs)); + } + + // Subject + tpl.push_str(&format!( + "Subject: {}\n", + opts.subject.unwrap_or(&msg.subject) + )); + + // Headers <=> body separator + tpl.push_str("\n"); + + // Body + if let Some(body) = opts.body { + tpl.push_str(body); + } else { + tpl.push_str(&msg.join_text_plain_parts()) + } + + // Signature + if let Some(sig) = opts.sig { + tpl.push_str("\n\n"); + tpl.push_str(sig); + } else if let Some(ref sig) = account.sig { + tpl.push_str("\n\n"); + tpl.push_str(sig); + } + + tpl.push_str("\n"); + + let tpl = Tpl(tpl); + trace!("template: {:#?}", tpl); + tpl + } +} + +impl Deref for Tpl { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Display for Tpl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.deref()) + } +} diff --git a/src/domain/msg/tpl_handler.rs b/src/domain/msg/tpl_handler.rs new file mode 100644 index 0000000..b7c557e --- /dev/null +++ b/src/domain/msg/tpl_handler.rs @@ -0,0 +1,52 @@ +//! Module related to message template handling. +//! +//! This module gathers all message template commands. + +use anyhow::Result; + +use crate::{ + config::Account, + domain::{ + imap::ImapServiceInterface, + msg::{Msg, Tpl, TplOverride}, + }, + output::OutputServiceInterface, +}; + +/// Generate a new message template. +pub fn new<'a, OutputService: OutputServiceInterface>( + opts: TplOverride<'a>, + account: &'a Account, + output: &'a OutputService, +) -> Result<()> { + let msg = Msg::default(); + let tpl = Tpl::from_msg(opts, &msg, account); + output.print(tpl) +} + +/// Generate a reply message template. +pub fn reply<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( + seq: &str, + all: bool, + opts: TplOverride<'a>, + account: &'a Account, + output: &'a OutputService, + imap: &'a mut ImapService, +) -> Result<()> { + let msg = imap.find_msg(seq)?.into_reply(all, account)?; + let tpl = Tpl::from_msg(opts, &msg, account); + output.print(tpl) +} + +/// Generate a forward message template. +pub fn forward<'a, OutputService: OutputServiceInterface, ImapService: ImapServiceInterface>( + seq: &str, + opts: TplOverride<'a>, + account: &'a Account, + output: &'a OutputService, + imap: &'a mut ImapService, +) -> Result<()> { + let msg = imap.find_msg(seq)?.into_forward(account)?; + let tpl = Tpl::from_msg(opts, &msg, account); + output.print(tpl) +} diff --git a/src/domain/msg/utils.rs b/src/domain/msg/utils.rs deleted file mode 100644 index 67f9ca9..0000000 --- a/src/domain/msg/utils.rs +++ /dev/null @@ -1,15 +0,0 @@ -use anyhow::{Context, Result}; -use log::debug; -use std::{env, fs, path::PathBuf}; - -pub fn draft_path() -> PathBuf { - let path = env::temp_dir().join("himalaya-draft.mail"); - debug!("draft path: `{:?}`", path); - path -} - -pub fn remove_draft() -> Result<()> { - let path = draft_path(); - debug!("remove draft path: `{:?}`", path); - fs::remove_file(&path).context(format!("cannot delete draft file at `{:?}`", path)) -} diff --git a/src/domain/smtp/mod.rs b/src/domain/smtp/mod.rs index fed0ac5..b593f23 100644 --- a/src/domain/smtp/mod.rs +++ b/src/domain/smtp/mod.rs @@ -1,3 +1,4 @@ //! Module related to SMTP. -pub mod service; +pub mod smtp_service; +pub use smtp_service::*; diff --git a/src/domain/smtp/service.rs b/src/domain/smtp/smtp_service.rs similarity index 72% rename from src/domain/smtp/service.rs rename to src/domain/smtp/smtp_service.rs index 5ed85df..eae8aa0 100644 --- a/src/domain/smtp/service.rs +++ b/src/domain/smtp/smtp_service.rs @@ -8,11 +8,13 @@ use lettre::{ Transport, }; use log::debug; +use std::convert::TryInto; -use crate::config::entity::Account; +use crate::{config::Account, domain::msg::Msg}; pub trait SmtpServiceInterface { - fn send(&mut self, msg: &lettre::Message) -> Result<()>; + fn send_msg(&mut self, msg: &Msg) -> Result; + fn send_raw_msg(&mut self, envelope: &lettre::address::Envelope, msg: &[u8]) -> Result<()>; } pub struct SmtpService<'a> { @@ -55,8 +57,16 @@ impl<'a> SmtpService<'a> { } impl<'a> SmtpServiceInterface for SmtpService<'a> { - fn send(&mut self, msg: &lettre::Message) -> Result<()> { - self.transport()?.send(msg)?; + fn send_msg(&mut self, msg: &Msg) -> Result { + debug!("sending message…"); + let sendable_msg: lettre::Message = msg.try_into()?; + self.transport()?.send(&sendable_msg)?; + Ok(sendable_msg) + } + + fn send_raw_msg(&mut self, envelope: &lettre::address::Envelope, msg: &[u8]) -> Result<()> { + debug!("sending raw message…"); + self.transport()?.send_raw(envelope, msg)?; Ok(()) } } diff --git a/src/main.rs b/src/main.rs index c8d20ab..2b28463 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,14 +10,14 @@ mod domain; mod output; mod ui; -use config::entity::{Account, Config}; +use config::{Account, Config}; use domain::{ - imap::{self, service::ImapService}, - mbox::{self, entity::Mbox}, - msg, - smtp::service::SmtpService, + imap::{imap_arg, imap_handler, ImapService, ImapServiceInterface}, + mbox::{mbox_arg, mbox_handler, Mbox}, + msg::{flag_arg, flag_handler, msg_arg, msg_handler, tpl_arg, tpl_handler}, + smtp::SmtpService, }; -use output::service::OutputService; +use output::OutputService; fn create_app<'a>() -> clap::App<'a, 'a> { clap::App::new(env!("CARGO_PKG_NAME")) @@ -25,13 +25,13 @@ fn create_app<'a>() -> clap::App<'a, 'a> { .about(env!("CARGO_PKG_DESCRIPTION")) .author(env!("CARGO_PKG_AUTHORS")) .setting(AppSettings::GlobalVersion) - .args(&config::arg::args()) - .args(&output::arg::args()) - .arg(mbox::arg::source_arg()) - .subcommands(compl::arg::subcmds()) - .subcommands(imap::arg::subcmds()) - .subcommands(mbox::arg::subcmds()) - .subcommands(msg::arg::subcmds()) + .args(&config::config_arg::args()) + .args(&output::output_arg::args()) + .arg(mbox_arg::source_arg()) + .subcommands(compl::compl_arg::subcmds()) + .subcommands(imap_arg::subcmds()) + .subcommands(mbox_arg::subcmds()) + .subcommands(msg_arg::subcmds()) } fn main() -> Result<()> { @@ -50,17 +50,17 @@ fn main() -> Result<()> { let url = Url::parse(&raw_args[1])?; let mut imap = ImapService::from((&account, &mbox)); let mut smtp = SmtpService::from(&account); - return msg::handler::mailto(&url, &account, &output, &mut imap, &mut smtp); + return msg_handler::mailto(&url, &account, &output, &mut imap, &mut smtp); } let app = create_app(); let m = app.get_matches(); // Check completion match BEFORE entities and services initialization. - // See https://github.com/soywod/himalaya/issues/115. - match compl::arg::matches(&m)? { - Some(compl::arg::Command::Generate(shell)) => { - return compl::handler::generate(create_app(), shell); + // Linked issue: https://github.com/soywod/himalaya/issues/115. + match compl::compl_arg::matches(&m)? { + Some(compl::compl_arg::Command::Generate(shell)) => { + return compl::compl_handler::generate(create_app(), shell); } _ => (), } @@ -73,89 +73,88 @@ fn main() -> Result<()> { let mut smtp = SmtpService::from(&account); // Check IMAP matches. - match imap::arg::matches(&m)? { - Some(imap::arg::Command::Notify(keepalive)) => { - return imap::handler::notify(keepalive, &config, &mut imap); + match imap_arg::matches(&m)? { + Some(imap_arg::Command::Notify(keepalive)) => { + return imap_handler::notify(keepalive, &config, &mut imap); } - Some(imap::arg::Command::Watch(keepalive)) => { - return imap::handler::watch(keepalive, &mut imap); + Some(imap_arg::Command::Watch(keepalive)) => { + return imap_handler::watch(keepalive, &mut imap); } _ => (), } // Check mailbox matches. - match mbox::arg::matches(&m)? { - Some(mbox::arg::Command::List) => { - return mbox::handler::list(&output, &mut imap); + match mbox_arg::matches(&m)? { + Some(mbox_arg::Command::List) => { + return mbox_handler::list(&output, &mut imap); } _ => (), } // Check message matches. - match msg::arg::matches(&m)? { - Some(msg::arg::Command::Attachments(uid)) => { - return msg::handler::attachments(uid, &account, &output, &mut imap); + match msg_arg::matches(&m)? { + Some(msg_arg::Command::Attachments(seq)) => { + return msg_handler::attachments(seq, &account, &output, &mut imap); } - Some(msg::arg::Command::Copy(uid, mbox)) => { - return msg::handler::copy(uid, mbox, &output, &mut imap); + Some(msg_arg::Command::Copy(seq, target)) => { + return msg_handler::copy(seq, target, &output, &mut imap); } - Some(msg::arg::Command::Delete(uid)) => { - return msg::handler::delete(uid, &output, &mut imap); + Some(msg_arg::Command::Delete(seq)) => { + return msg_handler::delete(seq, &output, &mut imap); } - Some(msg::arg::Command::Forward(uid, paths)) => { - return msg::handler::forward(uid, paths, &account, &output, &mut imap, &mut smtp); + Some(msg_arg::Command::Forward(seq, atts)) => { + return msg_handler::forward(seq, atts, &account, &output, &mut imap, &mut smtp); } - Some(msg::arg::Command::List(page_size, page)) => { - return msg::handler::list(page_size, page, &account, &output, &mut imap); + Some(msg_arg::Command::List(page_size, page)) => { + return msg_handler::list(page_size, page, &account, &output, &mut imap); } - Some(msg::arg::Command::Move(uid, mbox)) => { - return msg::handler::move_(uid, mbox, &output, &mut imap); + Some(msg_arg::Command::Move(seq, target)) => { + return msg_handler::move_(seq, target, &output, &mut imap); } - Some(msg::arg::Command::Read(uid, mime, raw)) => { - return msg::handler::read(uid, mime, raw, &output, &mut imap); + Some(msg_arg::Command::Read(seq, mime, raw)) => { + return msg_handler::read(seq, mime, raw, &output, &mut imap); } - Some(msg::arg::Command::Reply(uid, all, paths)) => { - return msg::handler::reply(uid, all, paths, &account, &output, &mut imap, &mut smtp); + Some(msg_arg::Command::Reply(seq, all, atts)) => { + return msg_handler::reply(seq, all, atts, &account, &output, &mut imap, &mut smtp); } - Some(msg::arg::Command::Save(mbox, msg)) => { - return msg::handler::save(mbox, msg, &mut imap); + Some(msg_arg::Command::Save(target, msg)) => { + return msg_handler::save(target, msg, &mut imap); } - Some(msg::arg::Command::Search(query, page_size, page)) => { - return msg::handler::search(page_size, page, query, &account, &output, &mut imap); + Some(msg_arg::Command::Search(query, page_size, page)) => { + return msg_handler::search(query, page_size, page, &account, &output, &mut imap); } - Some(msg::arg::Command::Send(msg)) => { - return msg::handler::send(msg, &output, &mut imap, &mut smtp); + Some(msg_arg::Command::Send(raw_msg)) => { + return msg_handler::send(raw_msg, &output, &mut imap, &mut smtp); } - Some(msg::arg::Command::Write(paths)) => { - return msg::handler::write(paths, &account, &output, &mut imap, &mut smtp); + Some(msg_arg::Command::Write(atts)) => { + return msg_handler::write(atts, &account, &output, &mut imap, &mut smtp); } - - Some(msg::arg::Command::Flag(m)) => match m { - Some(msg::flag::arg::Command::Set(uid, flags)) => { - return msg::flag::handler::set(uid, flags, &output, &mut imap); + Some(msg_arg::Command::Flag(m)) => match m { + Some(flag_arg::Command::Set(seq_range, flags)) => { + return flag_handler::set(seq_range, flags, &output, &mut imap); } - Some(msg::flag::arg::Command::Add(uid, flags)) => { - return msg::flag::handler::add(uid, flags, &output, &mut imap); + Some(flag_arg::Command::Add(seq_range, flags)) => { + return flag_handler::add(seq_range, flags, &output, &mut imap); } - Some(msg::flag::arg::Command::Remove(uid, flags)) => { - return msg::flag::handler::remove(uid, flags, &output, &mut imap); + Some(flag_arg::Command::Remove(seq_range, flags)) => { + return flag_handler::remove(seq_range, flags, &output, &mut imap); } _ => (), }, - Some(msg::arg::Command::Tpl(m)) => match m { - Some(msg::tpl::arg::Command::New(tpl)) => { - return msg::tpl::handler::new(tpl, &account, &output, &mut imap); + Some(msg_arg::Command::Tpl(m)) => match m { + Some(tpl_arg::Command::New(tpl)) => { + return tpl_handler::new(tpl, &account, &output); } - Some(msg::tpl::arg::Command::Reply(uid, all, tpl)) => { - return msg::tpl::handler::reply(uid, all, tpl, &account, &output, &mut imap); + Some(tpl_arg::Command::Reply(seq, all, tpl)) => { + return tpl_handler::reply(seq, all, tpl, &account, &output, &mut imap); } - Some(msg::tpl::arg::Command::Forward(uid, tpl)) => { - return msg::tpl::handler::forward(uid, tpl, &account, &output, &mut imap); + Some(tpl_arg::Command::Forward(seq, tpl)) => { + return tpl_handler::forward(seq, tpl, &account, &output, &mut imap); } _ => (), }, _ => (), } - Ok(()) + imap.logout() } diff --git a/src/output/mod.rs b/src/output/mod.rs index ba9f953..201236b 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,5 +1,9 @@ //! Module related to output formatting and printing. -pub mod arg; -pub mod service; -pub mod utils; +pub mod output_arg; + +pub mod output_utils; +pub use output_utils::*; + +pub mod output_service; +pub use output_service::*; diff --git a/src/output/arg.rs b/src/output/output_arg.rs similarity index 100% rename from src/output/arg.rs rename to src/output/output_arg.rs diff --git a/src/output/service.rs b/src/output/output_service.rs similarity index 100% rename from src/output/service.rs rename to src/output/output_service.rs diff --git a/src/output/output_utils.rs b/src/output/output_utils.rs new file mode 100644 index 0000000..cedc17d --- /dev/null +++ b/src/output/output_utils.rs @@ -0,0 +1,12 @@ +use anyhow::Result; +use std::process::Command; + +pub fn run_cmd(cmd: &str) -> Result { + let output = if cfg!(target_os = "windows") { + Command::new("cmd").args(&["/C", cmd]).output() + } else { + Command::new("sh").arg("-c").arg(cmd).output() + }?; + + Ok(String::from_utf8(output.stdout)?) +} diff --git a/src/output/utils.rs b/src/output/utils.rs deleted file mode 100644 index feb25d1..0000000 --- a/src/output/utils.rs +++ /dev/null @@ -1,32 +0,0 @@ -use anyhow::Result; -use serde::ser::{self, SerializeStruct}; -use std::{fmt, process::Command, result}; - -pub struct Info(pub String); - -impl fmt::Display for Info { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl ser::Serialize for Info { - fn serialize(&self, serializer: S) -> result::Result - where - S: ser::Serializer, - { - let mut state = serializer.serialize_struct("Info", 1)?; - state.serialize_field("info", &self.0)?; - state.end() - } -} - -pub fn run_cmd(cmd: &str) -> Result { - let output = if cfg!(target_os = "windows") { - Command::new("cmd").args(&["/C", cmd]).output() - } else { - Command::new("sh").arg("-c").arg(cmd).output() - }?; - - Ok(String::from_utf8(output.stdout)?) -} diff --git a/src/ui/choice.rs b/src/ui/choice.rs index 78a0a93..1fb8993 100644 --- a/src/ui/choice.rs +++ b/src/ui/choice.rs @@ -1,5 +1,5 @@ use anyhow::{anyhow, Context, Result}; -use log::debug; +use log::{debug, error}; use std::io::{self, Write}; pub enum PreEditChoice { @@ -32,11 +32,11 @@ pub fn pre_edit() -> Result { Ok(PreEditChoice::Quit) } Some(choice) => { - debug!("invalid choice `{}`", choice); - Err(anyhow!("invalid choice `{}`", choice)) + error!(r#"invalid choice "{}""#, choice); + Err(anyhow!(r#"invalid choice "{}""#, choice)) } None => { - debug!("empty choice"); + error!("empty choice"); Err(anyhow!("empty choice")) } } @@ -81,11 +81,11 @@ pub fn post_edit() -> Result { Ok(PostEditChoice::Discard) } Some(choice) => { - debug!("invalid choice `{}`", choice); - Err(anyhow!("invalid choice `{}`", choice)) + error!(r#"invalid choice "{}""#, choice); + Err(anyhow!(r#"invalid choice "{}""#, choice)) } None => { - debug!("empty choice"); + error!("empty choice"); Err(anyhow!("empty choice")) } } diff --git a/src/ui/editor.rs b/src/ui/editor.rs index aa35288..ac37783 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -1,70 +1,32 @@ -use anyhow::{anyhow, Context, Result}; -use log::{debug, error}; -use std::{ - env, - fs::File, - io::{Read, Write}, - process::Command, -}; +use anyhow::{Context, Result}; +use log::debug; +use std::{env, fs, process::Command}; -use crate::{ - domain::msg, - ui::choice::{self, PreEditChoice}, -}; +use crate::domain::msg::{msg_utils, Tpl}; -pub fn open_editor_with_tpl(tpl: &[u8]) -> Result { - let path = msg::utils::draft_path(); - if path.exists() { - debug!("draft found"); - loop { - match choice::pre_edit() { - Ok(choice) => match choice { - PreEditChoice::Edit => return open_editor_with_draft(), - PreEditChoice::Discard => break, - PreEditChoice::Quit => return Err(anyhow!("edition aborted")), - }, - Err(err) => error!("{}", err), - } - } - } +pub fn open_with_tpl(tpl: Tpl) -> Result { + let path = msg_utils::local_draft_path(); debug!("create draft"); - File::create(&path) - .context(format!("cannot create draft file `{:?}`", path))? - .write(tpl) - .context(format!("cannot write draft file `{:?}`", path))?; + fs::write(&path, tpl.as_bytes()).context(format!("cannot write local draft at {:?}", path))?; debug!("open editor"); - Command::new(env::var("EDITOR").context("cannot find `$EDITOR` env var")?) + Command::new(env::var("EDITOR").context(r#"cannot find "$EDITOR" env var"#)?) .arg(&path) .status() .context("cannot launch editor")?; debug!("read draft"); - let mut draft = String::new(); - File::open(&path) - .context(format!("cannot open draft file `{:?}`", path))? - .read_to_string(&mut draft) - .context(format!("cannot read draft file `{:?}`", path))?; + let content = + fs::read_to_string(&path).context(format!("cannot read local draft at {:?}", path))?; - Ok(draft) + Ok(Tpl(content)) } -pub fn open_editor_with_draft() -> Result { - let path = msg::utils::draft_path(); - - // Opens editor and saves user input to draft file - Command::new(env::var("EDITOR").context("cannot find `EDITOR` env var")?) - .arg(&path) - .status() - .context("cannot launch editor")?; - - // Extracts draft file content - let mut draft = String::new(); - File::open(&path) - .context(format!("cannot open file `{:?}`", path))? - .read_to_string(&mut draft) - .context(format!("cannot read file `{:?}`", path))?; - - Ok(draft) +pub fn open_with_draft() -> Result { + let path = msg_utils::local_draft_path(); + let content = + fs::read_to_string(&path).context(format!("cannot read local draft at {:?}", path))?; + let tpl = Tpl(content); + open_with_tpl(tpl) } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2174ede..7016534 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,4 +2,6 @@ pub mod choice; pub mod editor; + pub mod table; +pub use table::*; diff --git a/src/ui/table.rs b/src/ui/table.rs index 54bab6f..b3c7a6e 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -11,11 +11,11 @@ use unicode_width::UnicodeWidthStr; /// Define the default terminal size. /// It is used when the size cannot be determined by the `terminal_size` crate. -const DEFAULT_TERM_WIDTH: usize = 80; +pub const DEFAULT_TERM_WIDTH: usize = 80; /// Define the minimum size of a shrinked cell. /// TODO: make this customizable. -const MAX_SHRINK_WIDTH: usize = 5; +pub const MAX_SHRINK_WIDTH: usize = 5; /// Wrapper around [ANSI escape codes] for styling cells. /// diff --git a/vim/autoload/himalaya/mbox.vim b/vim/autoload/himalaya/mbox.vim index 99de826..e1b76d2 100644 --- a/vim/autoload/himalaya/mbox.vim +++ b/vim/autoload/himalaya/mbox.vim @@ -10,7 +10,7 @@ endfunction function! s:fzf_picker(cb, mboxes) call fzf#run({ \"source": a:mboxes, - \"sink": a:cb, + \"sink": function(a:cb), \"down": "25%", \}) endfunction @@ -24,14 +24,14 @@ endfunction " Pagination -let s:curr_page = 0 +let s:curr_page = 1 function! himalaya#mbox#curr_page() return s:curr_page endfunction function! himalaya#mbox#prev_page() - let s:curr_page = max([0, s:curr_page - 1]) + let s:curr_page = max([1, s:curr_page - 1]) call himalaya#msg#list() endfunction @@ -79,6 +79,6 @@ endfunction function! himalaya#mbox#_change(mbox) let s:curr_mbox = a:mbox - let s:curr_page = 0 + let s:curr_page = 1 call himalaya#msg#list() endfunction diff --git a/vim/autoload/himalaya/msg.vim b/vim/autoload/himalaya/msg.vim index 83cc8b8..a505144 100644 --- a/vim/autoload/himalaya/msg.vim +++ b/vim/autoload/himalaya/msg.vim @@ -8,16 +8,11 @@ let s:draft = "" " Message function! s:format_msg_for_list(msg) - let msg = {} - let msg.uid = a:msg.uid let flag_new = index(a:msg.flags, "Seen") == -1 ? "✷" : " " let flag_flagged = index(a:msg.flags, "Flagged") == -1 ? " " : "!" let flag_replied = index(a:msg.flags, "Answered") == -1 ? " " : "↵" - let msg.flags = printf("%s %s %s", flag_new, flag_replied, flag_flagged) - let msg.subject = a:msg.subject - let msg.sender = a:msg.sender - let msg.date = a:msg.date - return msg + let a:msg.flags = printf("%s %s %s", flag_new, flag_replied, flag_flagged) + return a:msg endfunction function! himalaya#msg#list_with(account, mbox, page, should_throw) @@ -30,7 +25,7 @@ function! himalaya#msg#list_with(account, mbox, page, should_throw) \) let msgs = map(msgs, "s:format_msg_for_list(v:val)") let buftype = stridx(bufname("%"), "Himalaya messages") == 0 ? "file" : "edit" - execute printf("silent! %s Himalaya messages [%s] [page %d]", buftype, a:mbox, a:page + 1) + execute printf("silent! %s Himalaya messages [%s] [page %d]", buftype, a:mbox, a:page) setlocal modifiable silent execute "%d" call append(0, s:render("list", msgs)) @@ -43,7 +38,9 @@ endfunction function! himalaya#msg#list(...) try - call himalaya#account#set(a:0 > 0 ? a:1 : "") + if a:0 > 0 + call himalaya#account#set(a:1) + endif let account = himalaya#account#curr() let mbox = himalaya#mbox#curr_mbox() let page = himalaya#mbox#curr_page() @@ -67,11 +64,10 @@ function! himalaya#msg#read() \printf("Fetching message %d", s:msg_id), \1, \) - let attachment = msg.hasAttachment ? " []" : "" - execute printf("silent! edit Himalaya read message [%d]%s", s:msg_id, attachment) + execute printf("silent! botright new Himalaya read message [%d]", s:msg_id) setlocal modifiable silent execute "%d" - call append(0, split(substitute(msg.content, "\r", "", "g"), "\n")) + call append(0, split(substitute(msg, "\r", "", "g"), "\n")) silent execute "$d" setlocal filetype=himalaya-msg-read let &modified = 0 @@ -90,7 +86,7 @@ function! himalaya#msg#write() let account = himalaya#account#curr() let msg = s:cli("--account %s template new", [shellescape(account)], "Fetching new template", 0) silent! edit Himalaya write - call append(0, split(substitute(msg.raw, "\r", "", "g"), "\n")) + call append(0, split(substitute(msg, "\r", "", "g"), "\n")) silent execute "$d" setlocal filetype=himalaya-msg-write let &modified = 0 @@ -116,7 +112,7 @@ function! himalaya#msg#reply() \0, \) execute printf("silent! edit Himalaya reply [%d]", msg_id) - call append(0, split(substitute(msg.raw, "\r", "", "g"), "\n")) + call append(0, split(substitute(msg, "\r", "", "g"), "\n")) silent execute "$d" setlocal filetype=himalaya-msg-write let &modified = 0 @@ -142,7 +138,7 @@ function! himalaya#msg#reply_all() \0 \) execute printf("silent! edit Himalaya reply all [%d]", msg_id) - call append(0, split(substitute(msg.raw, "\r", "", "g"), "\n")) + call append(0, split(substitute(msg, "\r", "", "g"), "\n")) silent execute "$d" setlocal filetype=himalaya-msg-write let &modified = 0 @@ -168,7 +164,7 @@ function! himalaya#msg#forward() \0 \) execute printf("silent! edit Himalaya forward [%d]", msg_id) - call append(0, split(substitute(msg.raw, "\r", "", "g"), "\n")) + call append(0, split(substitute(msg, "\r", "", "g"), "\n")) silent execute "$d" setlocal filetype=himalaya-msg-write let &modified = 0 @@ -259,7 +255,7 @@ function! himalaya#msg#delete() range endfunction function! himalaya#msg#draft_save() - let s:draft = join(getline(1, "$"), "\n") + let s:draft = join(getline(1, "$"), "\n") . "\n" redraw | call s:log("Save draft [OK]") let &modified = 0 endfunction @@ -322,10 +318,10 @@ endfunction let s:config = { \"list": { - \"columns": ["uid", "flags", "subject", "sender", "date"], + \"columns": ["id", "flags", "subject", "sender", "date"], \}, \"labels": { - \"uid": "UID", + \"id": "ID", \"flags": "FLAGS", \"subject": "SUBJECT", \"sender": "SENDER", diff --git a/vim/ftplugin/himalaya-msg-write.vim b/vim/ftplugin/himalaya-msg-write.vim index c5e5c1b..f9b1bee 100644 --- a/vim/ftplugin/himalaya-msg-write.vim +++ b/vim/ftplugin/himalaya-msg-write.vim @@ -6,5 +6,5 @@ setlocal startofline augroup himalaya_write autocmd! * autocmd BufWriteCmd call himalaya#msg#draft_save() - autocmd BufUnload call himalaya#msg#draft_handle() + autocmd BufLeave call himalaya#msg#draft_handle() augroup end