From 158bc86cfaa1ad3ff2b9fb9ec842c7f941e61042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Tue, 22 Feb 2022 16:54:39 +0100 Subject: [PATCH] release v0.5.6 (#301) * make use of mailparse::MailAddr * move addr logic to a dedicated file * update changelog * add suffix to downoalded attachments with same name (#204) * implement sort command (#34) * introduce backends structure (#296) * implement backend structure poc * improve config namings * improve account namings and structure * rename imap vars to backend * maildir backend (#299) * refactor config system, preparing maildir backend * rename deserializable by deserialized * wrap backend in a Box * reword backend trait methods * merge list envelopes functions * remove find_raw_msg from backend trait * remove expunge fn from backend trait * rename add_msg from backend trait * init maildir integration tests, start impl maildir backend fns * implement remaining methods maildir backend, refactor trait * improve backend trait, add copy and move fns * remove usage of Mbox in handlers * reorganize backends folder structure * move mbox out of domain folder * rename mbox entities * improve mbox structure * remove unused files, move smtp module * improve envelope, impl get_envelopes for maildir * link maildir mail entry id to envelope id * use erased-serde to make backend get_mboxes return a trait object * remove unused mbox files * rename Output trait * make get_envelopes return a trait object * remove unused impl for imap envelope * update backend return signature with Box * replace impl from imap::Fetch to mailparse::ParsedMail * split flags by backends * remove unused flags from msg * remove remaining flags from domain * impl maildir copy and move, improve maildir e2e tests * set up imap backend e2e tests * move domain/msg to msg * repair broken tests * fix maildir envelopes encoding issues * add date column to maildir envelopes * implement maildir list pagination * improve maildir subdir path management * add pgp and maildir features to readme * update changelog * bump version v0.5.6 --- .github/workflows/tests.yaml | 13 +- CHANGELOG.md | 20 +- Cargo.lock | 33 +- Cargo.toml | 6 +- README.md | 37 +- src/backends/backend.rs | 41 ++ src/{domain => backends}/imap/imap_arg.rs | 0 src/backends/imap/imap_backend.rs | 369 +++++++++++++ .../imap/imap_envelope.rs} | 140 +++-- src/backends/imap/imap_flag.rs | 147 +++++ src/backends/imap/imap_handler.rs | 15 + src/backends/imap/imap_mbox.rs | 148 ++++++ src/backends/imap/imap_mbox_attr.rs | 119 +++++ src/backends/imap/msg_sort_criterion.rs | 61 +++ src/backends/maildir/maildir_backend.rs | 185 +++++++ src/backends/maildir/maildir_envelope.rs | 187 +++++++ src/backends/maildir/maildir_flag.rs | 129 +++++ src/backends/maildir/maildir_mbox.rs | 141 +++++ src/config/account_args.rs | 13 + src/config/account_config.rs | 402 ++++++++++++++ src/config/account_entity.rs | 230 -------- src/config/config_arg.rs | 21 - src/config/config_args.rs | 13 + src/config/config_entity.rs | 162 ------ src/config/deserialized_account_config.rs | 124 +++++ src/config/deserialized_config.rs | 103 ++++ src/config/mod.rs | 16 +- src/domain/imap/imap_handler.rs | 27 - src/domain/imap/imap_service.rs | 416 --------------- src/domain/imap/mod.rs | 7 - src/domain/mbox/attr_entity.rs | 70 --- src/domain/mbox/attrs_entity.rs | 70 --- src/domain/mbox/mbox_entity.rs | 116 ---- src/domain/mbox/mboxes_entity.rs | 46 -- src/domain/mbox/mod.rs | 18 - src/domain/mod.rs | 13 - src/domain/msg/envelopes_entity.rs | 46 -- src/domain/msg/flag_entity.rs | 31 -- src/domain/msg/flag_handler.rs | 58 -- src/domain/msg/flags_entity.rs | 197 ------- src/domain/msg/mod.rs | 50 -- src/domain/msg/msg_handler.rs | 365 ------------- src/domain/msg/tpl_handler.rs | 120 ----- src/domain/smtp/mod.rs | 4 - src/lib.rs | 88 +++ src/main.rs | 230 +++++--- src/mbox/mbox.rs | 7 + src/{domain => }/mbox/mbox_arg.rs | 0 src/{domain => }/mbox/mbox_handler.rs | 93 ++-- src/msg/addr_entity.rs | 133 +++++ src/msg/envelope.rs | 13 + src/{domain => }/msg/flag_arg.rs | 39 +- src/msg/flag_handler.rs | 55 ++ src/{domain => }/msg/msg_arg.rs | 132 ++++- src/{domain => }/msg/msg_entity.rs | 500 ++++++------------ src/msg/msg_handler.rs | 348 ++++++++++++ src/{domain => }/msg/msg_utils.rs | 0 src/{domain => }/msg/parts_entity.rs | 8 +- src/{domain => }/msg/tpl_arg.rs | 19 +- src/msg/tpl_handler.rs | 109 ++++ src/output/output_entity.rs | 14 +- src/output/printer_service.rs | 26 +- src/smtp/mod.rs | 1 + src/{domain => }/smtp/smtp_service.rs | 20 +- src/ui/editor.rs | 2 +- tests/emails/alice-to-patrick.eml | 6 + tests/test_imap_backend.rs | 90 ++++ tests/test_maildir_backend.rs | 68 +++ 68 files changed, 3834 insertions(+), 2696 deletions(-) create mode 100644 src/backends/backend.rs rename src/{domain => backends}/imap/imap_arg.rs (100%) create mode 100644 src/backends/imap/imap_backend.rs rename src/{domain/msg/envelope_entity.rs => backends/imap/imap_envelope.rs} (61%) create mode 100644 src/backends/imap/imap_flag.rs create mode 100644 src/backends/imap/imap_handler.rs create mode 100644 src/backends/imap/imap_mbox.rs create mode 100644 src/backends/imap/imap_mbox_attr.rs create mode 100644 src/backends/imap/msg_sort_criterion.rs create mode 100644 src/backends/maildir/maildir_backend.rs create mode 100644 src/backends/maildir/maildir_envelope.rs create mode 100644 src/backends/maildir/maildir_flag.rs create mode 100644 src/backends/maildir/maildir_mbox.rs create mode 100644 src/config/account_args.rs create mode 100644 src/config/account_config.rs delete mode 100644 src/config/account_entity.rs delete mode 100644 src/config/config_arg.rs create mode 100644 src/config/config_args.rs delete mode 100644 src/config/config_entity.rs create mode 100644 src/config/deserialized_account_config.rs create mode 100644 src/config/deserialized_config.rs delete mode 100644 src/domain/imap/imap_handler.rs delete mode 100644 src/domain/imap/imap_service.rs delete mode 100644 src/domain/imap/mod.rs delete mode 100644 src/domain/mbox/attr_entity.rs delete mode 100644 src/domain/mbox/attrs_entity.rs delete mode 100644 src/domain/mbox/mbox_entity.rs delete mode 100644 src/domain/mbox/mboxes_entity.rs delete mode 100644 src/domain/mbox/mod.rs delete mode 100644 src/domain/mod.rs delete 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/flags_entity.rs delete mode 100644 src/domain/msg/mod.rs delete mode 100644 src/domain/msg/msg_handler.rs delete mode 100644 src/domain/msg/tpl_handler.rs delete mode 100644 src/domain/smtp/mod.rs create mode 100644 src/lib.rs create mode 100644 src/mbox/mbox.rs rename src/{domain => }/mbox/mbox_arg.rs (100%) rename src/{domain => }/mbox/mbox_handler.rs (58%) create mode 100644 src/msg/addr_entity.rs create mode 100644 src/msg/envelope.rs rename src/{domain => }/msg/flag_arg.rs (74%) create mode 100644 src/msg/flag_handler.rs rename src/{domain => }/msg/msg_arg.rs (73%) rename src/{domain => }/msg/msg_entity.rs (61%) create mode 100644 src/msg/msg_handler.rs rename src/{domain => }/msg/msg_utils.rs (100%) rename src/{domain => }/msg/parts_entity.rs (95%) rename src/{domain => }/msg/tpl_arg.rs (93%) create mode 100644 src/msg/tpl_handler.rs create mode 100644 src/smtp/mod.rs rename src/{domain => }/smtp/smtp_service.rs (79%) create mode 100644 tests/emails/alice-to-patrick.eml create mode 100644 tests/test_imap_backend.rs create mode 100644 tests/test_maildir_backend.rs diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f8d6eee..4692490 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -14,8 +14,17 @@ jobs: uses: actions/checkout@v2 - name: Start GreenMail testing server run: | - docker run --rm -d -e GREENMAIL_OPTS='-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose' -p 3025:3025 -p 3110:3110 -p 3143:3143 -p 3465:3465 -p 3993:3993 -p 3995:3995 greenmail/standalone:1.6.2 - + docker run \ + --rm \ + -d \ + -e GREENMAIL_OPTS='-Dgreenmail.setup.test.all -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose' \ + -p 3025:3025 \ + -p 3110:3110 \ + -p 3143:3143 \ + -p 3465:3465 \ + -p 3993:3993 \ + -p 3995:3995 \ + greenmail/standalone:1.6.2 - name: Install rust uses: actions-rs/toolchain@v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 464e644..ecc87fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.6] - 2022-02-22 + +### Added + +- Sort command [#34] +- Maildir support [#43] + +### Fixed + +- Suffix to downloaded attachments with same name [#204] + ## [0.5.5] - 2022-02-08 ### Added - [Contributing guide](https://github.com/soywod/himalaya/blob/master/CONTRIBUTING.md) [#256] - Notify query config option [#289] -- End-to-end encryption *(EXPERIMENTAL)* [#54] +- End-to-end encryption [#54] ### Fixed - Multiple recipients issue [#288] +- Cannot parse address [#227] ## [0.5.4] - 2022-02-05 @@ -292,7 +304,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Password from command [#22] - Set up README [#20] -[unreleased]: https://github.com/soywod/himalaya/compare/v0.5.5...HEAD +[unreleased]: https://github.com/soywod/himalaya/compare/v0.5.6...HEAD +[0.5.6]: https://github.com/soywod/himalaya/compare/v0.5.5...v0.5.6 [0.5.5]: https://github.com/soywod/himalaya/compare/v0.5.4...v0.5.5 [0.5.4]: https://github.com/soywod/himalaya/compare/v0.5.3...v0.5.4 [0.5.3]: https://github.com/soywod/himalaya/compare/v0.5.2...v0.5.3 @@ -346,6 +359,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#39]: https://github.com/soywod/himalaya/issues/39 [#40]: https://github.com/soywod/himalaya/issues/40 [#41]: https://github.com/soywod/himalaya/issues/41 +[#43]: https://github.com/soywod/himalaya/issues/43 [#47]: https://github.com/soywod/himalaya/issues/47 [#48]: https://github.com/soywod/himalaya/issues/48 [#50]: https://github.com/soywod/himalaya/issues/50 @@ -400,9 +414,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#193]: https://github.com/soywod/himalaya/issues/193 [#196]: https://github.com/soywod/himalaya/issues/196 [#199]: https://github.com/soywod/himalaya/issues/199 +[#204]: https://github.com/soywod/himalaya/issues/204 [#205]: https://github.com/soywod/himalaya/issues/205 [#215]: https://github.com/soywod/himalaya/issues/215 [#220]: https://github.com/soywod/himalaya/issues/220 +[#227]: https://github.com/soywod/himalaya/issues/227 [#228]: https://github.com/soywod/himalaya/issues/228 [#229]: https://github.com/soywod/himalaya/issues/229 [#249]: https://github.com/soywod/himalaya/issues/249 diff --git a/Cargo.lock b/Cargo.lock index e3a66af..3f41163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,6 +226,15 @@ dependencies = [ "termcolor", ] +[[package]] +name = "erased-serde" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56047058e1ab118075ca22f9ecd737bcc961aa3566a3019cb71388afa280bd8a" +dependencies = [ + "serde", +] + [[package]] name = "fastrand" version = "1.5.0" @@ -322,6 +331,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gethostname" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4addc164932852d066774c405dbbdb7914742d2b39e39e1a7ca949c856d054d1" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -361,7 +380,7 @@ dependencies = [ [[package]] name = "himalaya" -version = "0.5.5" +version = "0.5.6" dependencies = [ "ammonia", "anyhow", @@ -369,11 +388,13 @@ dependencies = [ "chrono", "clap", "env_logger", + "erased-serde", "html-escape", "imap", "imap-proto", "lettre", "log", + "maildir", "mailparse", "native-tls", "regex", @@ -563,6 +584,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "maildir" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c47481eb056f735997fe5248a94fe8d03816388858c990a52eb271c21b33ff3" +dependencies = [ + "gethostname", + "mailparse", +] + [[package]] name = "mailparse" version = "0.13.6" diff --git a/Cargo.toml b/Cargo.toml index 62adba0..13c36e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "himalaya" description = "Command-line interface for email management" -version = "0.5.5" +version = "0.5.6" authors = ["soywod "] -edition = "2018" +edition = "2021" license-file = "LICENSE" readme = "README.md" categories = ["command-line-interface", "command-line-utilities", "email"] @@ -23,11 +23,13 @@ atty = "0.2.14" chrono = "0.4.19" clap = { version = "2.33.3", default-features = false, features = ["suggestions", "color"] } env_logger = "0.8.3" +erased-serde = "0.3.18" html-escape = "0.2.9" imap = "3.0.0-alpha.4" imap-proto = "0.14.3" lettre = { version = "0.10.0-rc.1", features = ["serde"] } log = "0.4.14" +maildir = "0.6.0" mailparse = "0.13.6" native-tls = "0.2.8" regex = "1.5.4" diff --git a/README.md b/README.md index 403efd0..e9e961d 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,23 @@ Command-line interface for email management -*The project is under active development. Do not use in production before the `v1.0.0`.* +*The project is under active development. Do not use in production +before the `v1.0.0`.* ![image](https://user-images.githubusercontent.com/10437171/138774902-7b9de5a3-93eb-44b0-8cfb-6d2e11e3b1aa.png) ## Motivation -Bringing emails to the terminal is a *pain*. First, because they are sensitive data. Secondly, the existing TUIs ([Mutt](http://www.mutt.org/), [NeoMutt](https://neomutt.org/), [Alpine](https://alpine.x10host.com/), [aerc](https://aerc-mail.org/)…) are really hard to configure. They require time and patience. +Bringing emails to the terminal is a *pain*. First, because they are +sensitive data. Secondly, the existing TUIs +([Mutt](http://www.mutt.org/), [NeoMutt](https://neomutt.org/), +[Alpine](https://alpine.x10host.com/), +[aerc](https://aerc-mail.org/)…) are really hard to configure. They +require time and patience. -The aim of Himalaya is to extract the email logic into a simple (yet solid) CLI API that can be used directly from the terminal, from scripts, from UIs… Possibilities are endless! +The aim of Himalaya is to extract the email logic into a simple (yet +solid) CLI API that can be used directly from the terminal, from +scripts, from UIs… Possibilities are endless! ## Installation @@ -21,7 +29,9 @@ The aim of Himalaya is to extract the email logic into a simple (yet solid) CLI curl -sSL https://raw.githubusercontent.com/soywod/himalaya/master/install.sh | PREFIX=~/.local sh ``` -*See the [wiki](https://github.com/soywod/himalaya/wiki/Installation:from-binary) for other installation methods.* +*See the +[wiki](https://github.com/soywod/himalaya/wiki/Installation:from-binary) +for other installation methods.* ## Configuration @@ -50,7 +60,9 @@ smtp-login = "your.email@gmail.com" smtp-passwd-cmd = "security find-internet-password -gs gmail -w" ``` -*See the [wiki](https://github.com/soywod/himalaya/wiki/Configuration:config-file) for all the options.* +*See the +[wiki](https://github.com/soywod/himalaya/wiki/Configuration:config-file) +for all the options.* ## Features @@ -59,13 +71,17 @@ smtp-passwd-cmd = "security find-internet-password -gs gmail -w" - Email composition based on `$EDITOR` - Email manipulation (copy/move/delete) - Multi-accounting +- IMAP and Maildir support (POP and Notmuch are coming soon) +- PGP end-to-end encryption - IDLE mode for real-time notifications - Vim plugin - Completions for bash/zsh/fish - JSON output - … -*See the [wiki](https://github.com/soywod/himalaya/wiki/Usage:msg:list) for all the features.* +*See the +[wiki](https://github.com/soywod/himalaya/wiki/Usage:msg:list) for all +the features.* ## Sponsoring @@ -79,8 +95,11 @@ smtp-passwd-cmd = "security find-internet-password -gs gmail -w" - [IMAP RFC3501](https://tools.ietf.org/html/rfc3501) - [Iris](https://github.com/soywod/iris.vim), the himalaya predecessor -- [isync](https://isync.sourceforge.io/), an email synchronizer for offline usage +- [isync](https://isync.sourceforge.io/), an email synchronizer for + offline usage - [NeoMutt](https://neomutt.org/), an email terminal user interface -- [Alpine](http://alpine.x10host.com/alpine/alpine-info/), an other email terminal user interface -- [mutt-wizard](https://github.com/LukeSmithxyz/mutt-wizard), a tool over NeoMutt and isync +- [Alpine](http://alpine.x10host.com/alpine/alpine-info/), an other + email terminal user interface +- [mutt-wizard](https://github.com/LukeSmithxyz/mutt-wizard), a tool + over NeoMutt and isync - [rust-imap](https://github.com/jonhoo/rust-imap), a rust IMAP lib diff --git a/src/backends/backend.rs b/src/backends/backend.rs new file mode 100644 index 0000000..8162822 --- /dev/null +++ b/src/backends/backend.rs @@ -0,0 +1,41 @@ +//! Backend module. +//! +//! This module exposes the backend trait, which can be used to create +//! custom backend implementations. + +use anyhow::Result; + +use crate::{ + mbox::Mboxes, + msg::{Envelopes, Msg}, +}; + +pub trait Backend<'a> { + fn connect(&mut self) -> Result<()> { + Ok(()) + } + + fn add_mbox(&mut self, mbox: &str) -> Result<()>; + fn get_mboxes(&mut self) -> Result>; + fn del_mbox(&mut self, mbox: &str) -> Result<()>; + fn get_envelopes( + &mut self, + mbox: &str, + sort: &str, + filter: &str, + page_size: usize, + page: usize, + ) -> Result>; + fn add_msg(&mut self, mbox: &str, msg: &[u8], flags: &str) -> Result>; + fn get_msg(&mut self, mbox: &str, id: &str) -> Result; + fn copy_msg(&mut self, mbox_src: &str, mbox_dst: &str, ids: &str) -> Result<()>; + fn move_msg(&mut self, mbox_src: &str, mbox_dst: &str, ids: &str) -> Result<()>; + fn del_msg(&mut self, mbox: &str, ids: &str) -> Result<()>; + fn add_flags(&mut self, mbox: &str, ids: &str, flags: &str) -> Result<()>; + fn set_flags(&mut self, mbox: &str, ids: &str, flags: &str) -> Result<()>; + fn del_flags(&mut self, mbox: &str, ids: &str, flags: &str) -> Result<()>; + + fn disconnect(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/src/domain/imap/imap_arg.rs b/src/backends/imap/imap_arg.rs similarity index 100% rename from src/domain/imap/imap_arg.rs rename to src/backends/imap/imap_arg.rs diff --git a/src/backends/imap/imap_backend.rs b/src/backends/imap/imap_backend.rs new file mode 100644 index 0000000..8ed0b3b --- /dev/null +++ b/src/backends/imap/imap_backend.rs @@ -0,0 +1,369 @@ +//! IMAP backend module. +//! +//! This module contains the definition of the IMAP backend. + +use anyhow::{anyhow, Context, Result}; +use log::{debug, log_enabled, trace, Level}; +use native_tls::{TlsConnector, TlsStream}; +use std::{ + collections::HashSet, + convert::{TryFrom, TryInto}, + net::TcpStream, + thread, +}; + +use crate::{ + backends::{ + imap::msg_sort_criterion::SortCriteria, Backend, ImapEnvelope, ImapEnvelopes, ImapMboxes, + }, + config::{AccountConfig, ImapBackendConfig}, + mbox::Mboxes, + msg::{Envelopes, Msg}, + output::run_cmd, +}; + +use super::ImapFlags; + +type ImapSess = imap::Session>; + +pub struct ImapBackend<'a> { + account_config: &'a AccountConfig, + imap_config: &'a ImapBackendConfig, + sess: Option, +} + +impl<'a> ImapBackend<'a> { + pub fn new(account_config: &'a AccountConfig, imap_config: &'a ImapBackendConfig) -> Self { + Self { + account_config, + imap_config, + sess: None, + } + } + + fn sess(&mut self) -> Result<&mut ImapSess> { + if self.sess.is_none() { + debug!("create TLS builder"); + debug!("insecure: {}", self.imap_config.imap_insecure); + let builder = TlsConnector::builder() + .danger_accept_invalid_certs(self.imap_config.imap_insecure) + .danger_accept_invalid_hostnames(self.imap_config.imap_insecure) + .build() + .context("cannot create TLS connector")?; + + debug!("create client"); + debug!("host: {}", self.imap_config.imap_host); + debug!("port: {}", self.imap_config.imap_port); + debug!("starttls: {}", self.imap_config.imap_starttls); + let mut client_builder = + imap::ClientBuilder::new(&self.imap_config.imap_host, self.imap_config.imap_port); + if self.imap_config.imap_starttls { + client_builder.starttls(); + } + let client = client_builder + .connect(|domain, tcp| Ok(TlsConnector::connect(&builder, domain, tcp)?)) + .context("cannot connect to IMAP server")?; + + debug!("create session"); + debug!("login: {}", self.imap_config.imap_login); + debug!("passwd cmd: {}", self.imap_config.imap_passwd_cmd); + let mut sess = client + .login( + &self.imap_config.imap_login, + &self.imap_config.imap_passwd()?, + ) + .map_err(|res| res.0) + .context("cannot login to IMAP server")?; + sess.debug = log_enabled!(Level::Trace); + self.sess = Some(sess); + } + + match self.sess { + Some(ref mut sess) => Ok(sess), + None => Err(anyhow!("cannot get IMAP session")), + } + } + + fn search_new_msgs(&mut self, query: &str) -> Result> { + let uids: Vec = self + .sess()? + .uid_search(query) + .context("cannot search new messages")? + .into_iter() + .collect(); + debug!("found {} new messages", uids.len()); + trace!("uids: {:?}", uids); + + Ok(uids) + } + + pub fn notify(&mut self, keepalive: u64, mbox: &str) -> Result<()> { + debug!("notify"); + + debug!("examine mailbox {:?}", mbox); + self.sess()? + .examine(mbox) + .context(format!("cannot examine mailbox {}", mbox))?; + + debug!("init messages hashset"); + let mut msgs_set: HashSet = self + .search_new_msgs(&self.account_config.notify_query)? + .iter() + .cloned() + .collect::>(); + trace!("messages hashset: {:?}", msgs_set); + + loop { + debug!("begin loop"); + self.sess()? + .idle() + .and_then(|mut idle| { + idle.set_keepalive(std::time::Duration::new(keepalive, 0)); + idle.wait_keepalive_while(|res| { + // TODO: handle response + trace!("idle response: {:?}", res); + false + }) + }) + .context("cannot start the idle mode")?; + + let uids: Vec = self + .search_new_msgs(&self.account_config.notify_query)? + .into_iter() + .filter(|uid| -> bool { msgs_set.get(uid).is_none() }) + .collect(); + debug!("found {} new messages not in hashset", uids.len()); + trace!("messages hashet: {:?}", msgs_set); + + if !uids.is_empty() { + let uids = uids + .iter() + .map(|uid| uid.to_string()) + .collect::>() + .join(","); + let fetches = self + .sess()? + .uid_fetch(uids, "(UID ENVELOPE)") + .context("cannot fetch new messages enveloppe")?; + + for fetch in fetches.iter() { + let msg = ImapEnvelope::try_from(fetch)?; + let uid = fetch.uid.ok_or_else(|| { + anyhow!("cannot retrieve message {}'s UID", fetch.message) + })?; + + let from = msg.sender.to_owned().into(); + self.account_config.run_notify_cmd(&msg.subject, &from)?; + + debug!("notify message: {}", uid); + trace!("message: {:?}", msg); + + debug!("insert message {} in hashset", uid); + msgs_set.insert(uid); + trace!("messages hashset: {:?}", msgs_set); + } + } + + debug!("end loop"); + } + } + + pub fn watch(&mut self, keepalive: u64, mbox: &str) -> Result<()> { + debug!("examine mailbox: {}", mbox); + + self.sess()? + .examine(mbox) + .context(format!("cannot examine mailbox `{}`", mbox))?; + + loop { + debug!("begin loop"); + self.sess()? + .idle() + .and_then(|mut idle| { + idle.set_keepalive(std::time::Duration::new(keepalive, 0)); + idle.wait_keepalive_while(|res| { + // TODO: handle response + trace!("idle response: {:?}", res); + false + }) + }) + .context("cannot start the idle mode")?; + + let cmds = self.account_config.watch_cmds.clone(); + 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); + }) + }); + + debug!("end loop"); + } + } +} + +impl<'a> Backend<'a> for ImapBackend<'a> { + fn add_mbox(&mut self, mbox: &str) -> Result<()> { + self.sess()? + .create(mbox) + .context(format!("cannot create imap mailbox {:?}", mbox)) + } + + fn get_mboxes(&mut self) -> Result> { + let mboxes: ImapMboxes = self + .sess()? + .list(Some(""), Some("*")) + .context("cannot list mailboxes")? + .into(); + Ok(Box::new(mboxes)) + } + + fn del_mbox(&mut self, mbox: &str) -> Result<()> { + self.sess()? + .delete(mbox) + .context(format!("cannot delete imap mailbox {:?}", mbox)) + } + + fn get_envelopes( + &mut self, + mbox: &str, + sort: &str, + filter: &str, + page_size: usize, + page: usize, + ) -> Result> { + let last_seq = self + .sess()? + .select(mbox) + .context(format!("cannot select mailbox {:?}", mbox))? + .exists; + if last_seq == 0 { + return Ok(Box::new(ImapEnvelopes::default())); + } + + let sort: SortCriteria = sort.try_into()?; + let charset = imap::extensions::sort::SortCharset::Utf8; + let begin = page * page_size; + let end = begin + (page_size - 1); + let seqs: Vec = self + .sess()? + .sort(&sort, charset, filter) + .context(format!( + "cannot search in {:?} with query {:?}", + mbox, filter + ))? + .iter() + .map(|seq| seq.to_string()) + .collect(); + if seqs.is_empty() { + return Ok(Box::new(ImapEnvelopes::default())); + } + + let range = seqs[begin..end.min(seqs.len())].join(","); + let fetches = self + .sess()? + .fetch(&range, "(ENVELOPE FLAGS INTERNALDATE)") + .context(format!("cannot fetch messages within range {:?}", range))?; + let envelopes: ImapEnvelopes = fetches.try_into()?; + Ok(Box::new(envelopes)) + } + + fn add_msg(&mut self, mbox: &str, msg: &[u8], flags: &str) -> Result> { + let flags: ImapFlags = flags.into(); + self.sess()? + .append(mbox, msg) + .flags(>>>::into(flags)) + .finish() + .context(format!("cannot append message to {:?}", mbox))?; + let last_seq = self + .sess()? + .select(mbox) + .context(format!("cannot select mailbox {:?}", mbox))? + .exists; + Ok(Box::new(last_seq)) + } + + fn get_msg(&mut self, mbox: &str, seq: &str) -> Result { + self.sess()? + .select(mbox) + .context(format!("cannot select mailbox {:?}", mbox))?; + let fetches = self + .sess()? + .fetch(seq, "(FLAGS INTERNALDATE BODY[])") + .context(format!("cannot fetch messages {:?}", seq))?; + let fetch = fetches + .first() + .ok_or_else(|| anyhow!("cannot find message {:?}", seq))?; + let msg_raw = fetch.body().unwrap_or_default().to_owned(); + let mut msg = Msg::from_parsed_mail( + mailparse::parse_mail(&msg_raw).context("cannot parse message")?, + self.account_config, + )?; + msg.raw = msg_raw; + Ok(msg) + } + + fn copy_msg(&mut self, mbox_src: &str, mbox_dst: &str, seq: &str) -> Result<()> { + let msg = self.get_msg(&mbox_src, seq)?.raw; + println!("raw: {:?}", String::from_utf8(msg.to_vec()).unwrap()); + self.add_msg(&mbox_dst, &msg, "seen")?; + Ok(()) + } + + fn move_msg(&mut self, mbox_src: &str, mbox_dst: &str, seq: &str) -> Result<()> { + let msg = self.get_msg(mbox_src, seq)?.raw; + self.add_flags(mbox_src, seq, "seen deleted")?; + self.add_msg(&mbox_dst, &msg, "seen")?; + Ok(()) + } + + fn del_msg(&mut self, mbox: &str, seq: &str) -> Result<()> { + self.add_flags(mbox, seq, "deleted") + } + + fn add_flags(&mut self, mbox: &str, seq_range: &str, flags: &str) -> Result<()> { + let flags: ImapFlags = flags.into(); + self.sess()? + .select(mbox) + .context(format!("cannot select mailbox {:?}", mbox))?; + self.sess()? + .store(seq_range, format!("+FLAGS ({})", flags)) + .context(format!("cannot add flags {:?}", &flags))?; + self.sess()? + .expunge() + .context(format!("cannot expunge mailbox {:?}", mbox))?; + Ok(()) + } + + fn set_flags(&mut self, mbox: &str, seq_range: &str, flags: &str) -> Result<()> { + let flags: ImapFlags = flags.into(); + self.sess()? + .select(mbox) + .context(format!("cannot select mailbox {:?}", mbox))?; + self.sess()? + .store(seq_range, format!("FLAGS ({})", flags)) + .context(format!("cannot set flags {:?}", &flags))?; + Ok(()) + } + + fn del_flags(&mut self, mbox: &str, seq_range: &str, flags: &str) -> Result<()> { + let flags: ImapFlags = flags.into(); + self.sess()? + .select(mbox) + .context(format!("cannot select mailbox {:?}", mbox))?; + self.sess()? + .store(seq_range, format!("-FLAGS ({})", flags)) + .context(format!("cannot remove flags {:?}", &flags))?; + Ok(()) + } + + fn disconnect(&mut self) -> Result<()> { + if let Some(ref mut sess) = self.sess { + debug!("logout from IMAP server"); + sess.logout().context("cannot logout from IMAP server")?; + } + Ok(()) + } +} diff --git a/src/domain/msg/envelope_entity.rs b/src/backends/imap/imap_envelope.rs similarity index 61% rename from src/domain/msg/envelope_entity.rs rename to src/backends/imap/imap_envelope.rs index c3384f5..68b8d74 100644 --- a/src/domain/msg/envelope_entity.rs +++ b/src/backends/imap/imap_envelope.rs @@ -1,42 +1,115 @@ +//! IMAP envelope module. +//! +//! This module provides IMAP types and conversion utilities related +//! to the envelope. + use anyhow::{anyhow, Context, Error, Result}; -use serde::Serialize; -use std::{borrow::Cow, convert::TryFrom}; +use std::{convert::TryFrom, ops::Deref}; use crate::{ - domain::msg::{Flag, Flags}, + output::{PrintTable, PrintTableOpts, WriteColor}, ui::{Cell, Row, Table}, }; -pub type RawEnvelope = imap::types::Fetch; +use super::{ImapFlag, ImapFlags}; -/// 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<'a> { - /// The sequence number of the message. +/// Represents a list of IMAP envelopes. +#[derive(Debug, Default, serde::Serialize)] +pub struct ImapEnvelopes(pub Vec); + +impl Deref for ImapEnvelopes { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PrintTable for ImapEnvelopes { + fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writter)?; + Table::print(writter, self, opts)?; + writeln!(writter)?; + Ok(()) + } +} + +// impl Envelopes for ImapEnvelopes { +// // +// } + +/// Represents the IMAP envelope. The envelope is just a message +/// subset, and is mostly used for listings. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct ImapEnvelope { + /// Represents 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, + /// Represents the flags attached to the message. + pub flags: ImapFlags, - /// The subject of the message. - pub subject: Cow<'a, str>, + /// Represents the subject of the message. + pub subject: String, - /// The sender of the message. + /// Represents the first sender of the message. pub sender: String, - /// The internal date of the message. + /// Represents 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 RawEnvelope> for Envelope<'a> { +impl Table for ImapEnvelope { + 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(&ImapFlag::Seen); + let subject = &self.subject; + let sender = &self.sender; + let date = self.date.as_deref().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()) + } +} + +/// Represents a list of raw envelopes returned by the `imap` crate. +pub type RawImapEnvelopes = imap::types::ZeroCopy>; + +impl TryFrom for ImapEnvelopes { type Error = Error; - fn try_from(fetch: &'a RawEnvelope) -> Result { + fn try_from(raw_envelopes: RawImapEnvelopes) -> Result { + let mut envelopes = vec![]; + for raw_envelope in raw_envelopes.iter().rev() { + envelopes.push(ImapEnvelope::try_from(raw_envelope).context("cannot parse envelope")?); + } + Ok(Self(envelopes)) + } +} + +/// Represents the raw envelope returned by the `imap` crate. +pub type RawImapEnvelope = imap::types::Fetch; + +impl TryFrom<&RawImapEnvelope> for ImapEnvelope { + type Error = Error; + + fn try_from(fetch: &RawImapEnvelope) -> Result { let envelope = fetch .envelope() .ok_or_else(|| anyhow!("cannot get envelope of message {}", fetch.message))?; @@ -45,10 +118,10 @@ impl<'a> TryFrom<&'a RawEnvelope> for Envelope<'a> { let id = fetch.message; // Get the flags - let flags = Flags::try_from(fetch.flags())?; + let flags = ImapFlags::try_from(fetch.flags())?; // Get the subject - let subject: Cow = envelope + let subject = envelope .subject .as_ref() .map(|subj| { @@ -57,8 +130,7 @@ impl<'a> TryFrom<&'a RawEnvelope> for Envelope<'a> { fetch.message )) }) - .unwrap_or_else(|| Ok(String::default()))? - .into(); + .unwrap_or_else(|| Ok(String::default()))?; // Get the sender let sender = envelope @@ -110,29 +182,3 @@ impl<'a> TryFrom<&'a RawEnvelope> for Envelope<'a> { }) } } - -impl<'a> Table for Envelope<'a> { - 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_deref().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/backends/imap/imap_flag.rs b/src/backends/imap/imap_flag.rs new file mode 100644 index 0000000..a946fee --- /dev/null +++ b/src/backends/imap/imap_flag.rs @@ -0,0 +1,147 @@ +use anyhow::{anyhow, Error, Result}; +use std::{convert::TryFrom, fmt, ops::Deref}; + +/// Represents the imap flag variants. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub enum ImapFlag { + Seen, + Answered, + Flagged, + Deleted, + Draft, + Recent, + MayCreate, + Custom(String), +} + +impl From<&str> for ImapFlag { + fn from(flag_str: &str) -> Self { + match flag_str { + "seen" => ImapFlag::Seen, + "answered" => ImapFlag::Answered, + "flagged" => ImapFlag::Flagged, + "deleted" => ImapFlag::Deleted, + "draft" => ImapFlag::Draft, + "recent" => ImapFlag::Recent, + "maycreate" | "may-create" => ImapFlag::MayCreate, + flag_str => ImapFlag::Custom(flag_str.into()), + } + } +} + +impl TryFrom<&imap::types::Flag<'_>> for ImapFlag { + type Error = Error; + + fn try_from(flag: &imap::types::Flag<'_>) -> Result { + Ok(match flag { + imap::types::Flag::Seen => ImapFlag::Seen, + imap::types::Flag::Answered => ImapFlag::Answered, + imap::types::Flag::Flagged => ImapFlag::Flagged, + imap::types::Flag::Deleted => ImapFlag::Deleted, + imap::types::Flag::Draft => ImapFlag::Draft, + imap::types::Flag::Recent => ImapFlag::Recent, + imap::types::Flag::MayCreate => ImapFlag::MayCreate, + imap::types::Flag::Custom(custom) => ImapFlag::Custom(custom.to_string()), + _ => return Err(anyhow!("cannot parse imap flag")), + }) + } +} + +/// Represents the imap flags. +#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ImapFlags(pub Vec); + +impl ImapFlags { + /// Builds a symbols string + pub fn to_symbols_string(&self) -> String { + let mut flags = String::new(); + flags.push_str(if self.contains(&ImapFlag::Seen) { + " " + } else { + "✷" + }); + flags.push_str(if self.contains(&ImapFlag::Answered) { + "↵" + } else { + " " + }); + flags.push_str(if self.contains(&ImapFlag::Flagged) { + "⚑" + } else { + " " + }); + flags + } +} + +impl Deref for ImapFlags { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for ImapFlags { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut glue = ""; + + for flag in &self.0 { + write!(f, "{}", glue)?; + match flag { + ImapFlag::Seen => write!(f, "\\Seen")?, + ImapFlag::Answered => write!(f, "\\Answered")?, + ImapFlag::Flagged => write!(f, "\\Flagged")?, + ImapFlag::Deleted => write!(f, "\\Deleted")?, + ImapFlag::Draft => write!(f, "\\Draft")?, + ImapFlag::Recent => write!(f, "\\Recent")?, + ImapFlag::MayCreate => write!(f, "\\MayCreate")?, + ImapFlag::Custom(custom) => write!(f, "{}", custom)?, + } + glue = " "; + } + + Ok(()) + } +} + +impl<'a> Into>> for ImapFlags { + fn into(self) -> Vec> { + self.0 + .into_iter() + .map(|flag| match flag { + ImapFlag::Seen => imap::types::Flag::Seen, + ImapFlag::Answered => imap::types::Flag::Answered, + ImapFlag::Flagged => imap::types::Flag::Flagged, + ImapFlag::Deleted => imap::types::Flag::Deleted, + ImapFlag::Draft => imap::types::Flag::Draft, + ImapFlag::Recent => imap::types::Flag::Recent, + ImapFlag::MayCreate => imap::types::Flag::MayCreate, + ImapFlag::Custom(custom) => imap::types::Flag::Custom(custom.into()), + }) + .collect() + } +} + +impl From<&str> for ImapFlags { + fn from(flags_str: &str) -> Self { + ImapFlags( + flags_str + .split_whitespace() + .map(|flag_str| flag_str.trim().into()) + .collect(), + ) + } +} + +impl TryFrom<&[imap::types::Flag<'_>]> for ImapFlags { + type Error = Error; + + fn try_from(flags: &[imap::types::Flag<'_>]) -> Result { + let mut f = vec![]; + for flag in flags { + f.push(flag.try_into()?); + } + Ok(Self(f)) + } +} diff --git a/src/backends/imap/imap_handler.rs b/src/backends/imap/imap_handler.rs new file mode 100644 index 0000000..3805909 --- /dev/null +++ b/src/backends/imap/imap_handler.rs @@ -0,0 +1,15 @@ +//! Module related to IMAP handling. +//! +//! This module gathers all IMAP handlers triggered by the CLI. + +use anyhow::Result; + +use crate::backends::ImapBackend; + +pub fn notify(keepalive: u64, mbox: &str, imap: &mut ImapBackend) -> Result<()> { + imap.notify(keepalive, mbox) +} + +pub fn watch(keepalive: u64, mbox: &str, imap: &mut ImapBackend) -> Result<()> { + imap.watch(keepalive, mbox) +} diff --git a/src/backends/imap/imap_mbox.rs b/src/backends/imap/imap_mbox.rs new file mode 100644 index 0000000..2fdc098 --- /dev/null +++ b/src/backends/imap/imap_mbox.rs @@ -0,0 +1,148 @@ +//! IMAP mailbox module. +//! +//! This module provides IMAP types and conversion utilities related +//! to the mailbox. + +use anyhow::Result; +use std::fmt::{self, Display}; +use std::ops::Deref; + +use crate::mbox::Mboxes; +use crate::{ + output::{PrintTable, PrintTableOpts, WriteColor}, + ui::{Cell, Row, Table}, +}; + +use super::ImapMboxAttrs; + +/// Represents a list of IMAP mailboxes. +#[derive(Debug, Default, serde::Serialize)] +pub struct ImapMboxes(pub Vec); + +impl Deref for ImapMboxes { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PrintTable for ImapMboxes { + fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writter)?; + Table::print(writter, self, opts)?; + writeln!(writter)?; + Ok(()) + } +} + +impl Mboxes for ImapMboxes { + // +} + +/// Represents the IMAP mailbox. +#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct ImapMbox { + /// Represents the mailbox hierarchie delimiter. + pub delim: String, + + /// Represents the mailbox name. + pub name: String, + + /// Represents the mailbox attributes. + pub attrs: ImapMboxAttrs, +} + +impl ImapMbox { + pub fn new(name: &str) -> Self { + Self { + name: name.into(), + ..Self::default() + } + } +} + +impl Display for ImapMbox { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Table for ImapMbox { + fn head() -> Row { + Row::new() + .cell(Cell::new("DELIM").bold().underline().white()) + .cell(Cell::new("NAME").bold().underline().white()) + .cell( + Cell::new("ATTRIBUTES") + .shrinkable() + .bold() + .underline() + .white(), + ) + } + + fn row(&self) -> Row { + Row::new() + .cell(Cell::new(&self.delim).white()) + .cell(Cell::new(&self.name).green()) + .cell(Cell::new(&self.attrs.to_string()).shrinkable().blue()) + } +} + +#[cfg(test)] +mod tests { + use crate::backends::ImapMboxAttr; + + use super::*; + + #[test] + fn it_should_create_new_mbox() { + assert_eq!(ImapMbox::default(), ImapMbox::new("")); + assert_eq!( + ImapMbox { + name: "INBOX".into(), + ..ImapMbox::default() + }, + ImapMbox::new("INBOX") + ); + } + + #[test] + fn it_should_display_mbox() { + let default_mbox = ImapMbox::default(); + assert_eq!("", default_mbox.to_string()); + + let new_mbox = ImapMbox::new("INBOX"); + assert_eq!("INBOX", new_mbox.to_string()); + + let full_mbox = ImapMbox { + delim: ".".into(), + name: "Sent".into(), + attrs: ImapMboxAttrs(vec![ImapMboxAttr::NoSelect]), + }; + assert_eq!("Sent", full_mbox.to_string()); + } +} + +/// Represents a list of raw mailboxes returned by the `imap` crate. +pub type RawImapMboxes = imap::types::ZeroCopy>; + +impl<'a> From for ImapMboxes { + fn from(raw_mboxes: RawImapMboxes) -> Self { + Self(raw_mboxes.iter().map(ImapMbox::from).collect()) + } +} + +/// Represents the raw mailbox returned by the `imap` crate. +pub type RawImapMbox = imap::types::Name; + +impl<'a> From<&'a RawImapMbox> for ImapMbox { + fn from(raw_mbox: &'a RawImapMbox) -> Self { + Self { + delim: raw_mbox.delimiter().unwrap_or_default().into(), + name: raw_mbox.name().into(), + attrs: raw_mbox.attributes().into(), + } + } +} diff --git a/src/backends/imap/imap_mbox_attr.rs b/src/backends/imap/imap_mbox_attr.rs new file mode 100644 index 0000000..208d067 --- /dev/null +++ b/src/backends/imap/imap_mbox_attr.rs @@ -0,0 +1,119 @@ +//! IMAP mailbox attribute module. +//! +//! This module provides IMAP types and conversion utilities related +//! to the mailbox attribute. + +/// Represents the raw mailbox attribute returned by the `imap` crate. +pub use imap::types::NameAttribute as RawImapMboxAttr; +use std::{ + fmt::{self, Display}, + ops::Deref, +}; + +/// Represents the attributes of the mailbox. +#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct ImapMboxAttrs(pub Vec); + +impl Deref for ImapMboxAttrs { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Display for ImapMboxAttrs { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut glue = ""; + for attr in self.iter() { + write!(f, "{}{}", glue, attr)?; + glue = ", "; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize)] +pub enum ImapMboxAttr { + NoInferiors, + NoSelect, + Marked, + Unmarked, + Custom(String), +} + +/// Makes the attribute displayable. +impl Display for ImapMboxAttr { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ImapMboxAttr::NoInferiors => write!(f, "NoInferiors"), + ImapMboxAttr::NoSelect => write!(f, "NoSelect"), + ImapMboxAttr::Marked => write!(f, "Marked"), + ImapMboxAttr::Unmarked => write!(f, "Unmarked"), + ImapMboxAttr::Custom(custom) => write!(f, "{}", custom), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_display_attrs() { + macro_rules! attrs_from { + ($($attr:expr),*) => { + ImapMboxAttrs(vec![$($attr,)*]).to_string() + }; + } + + let empty_attr = attrs_from![]; + let single_attr = attrs_from![ImapMboxAttr::NoInferiors]; + let multiple_attrs = attrs_from![ + ImapMboxAttr::Custom("AttrCustom".into()), + ImapMboxAttr::NoInferiors + ]; + + assert_eq!("", empty_attr); + assert_eq!("NoInferiors", single_attr); + assert!(multiple_attrs.contains("NoInferiors")); + assert!(multiple_attrs.contains("AttrCustom")); + assert!(multiple_attrs.contains(",")); + } + + #[test] + fn it_should_display_attr() { + macro_rules! attr_from { + ($attr:ident) => { + ImapMboxAttr::$attr.to_string() + }; + ($custom:literal) => { + ImapMboxAttr::Custom($custom.into()).to_string() + }; + } + + assert_eq!("NoInferiors", attr_from![NoInferiors]); + assert_eq!("NoSelect", attr_from![NoSelect]); + assert_eq!("Marked", attr_from![Marked]); + assert_eq!("Unmarked", attr_from![Unmarked]); + assert_eq!("CustomAttr", attr_from!["CustomAttr"]); + } +} + +impl<'a> From<&'a [RawImapMboxAttr<'a>]> for ImapMboxAttrs { + fn from(raw_attrs: &'a [RawImapMboxAttr<'a>]) -> Self { + Self(raw_attrs.iter().map(ImapMboxAttr::from).collect()) + } +} + +impl<'a> From<&'a RawImapMboxAttr<'a>> for ImapMboxAttr { + fn from(attr: &'a RawImapMboxAttr<'a>) -> Self { + match attr { + RawImapMboxAttr::NoInferiors => Self::NoInferiors, + RawImapMboxAttr::NoSelect => Self::NoSelect, + RawImapMboxAttr::Marked => Self::Marked, + RawImapMboxAttr::Unmarked => Self::Unmarked, + RawImapMboxAttr::Custom(cow) => Self::Custom(cow.to_string()), + } + } +} diff --git a/src/backends/imap/msg_sort_criterion.rs b/src/backends/imap/msg_sort_criterion.rs new file mode 100644 index 0000000..d20e9bd --- /dev/null +++ b/src/backends/imap/msg_sort_criterion.rs @@ -0,0 +1,61 @@ +//! Message sort criteria module. +//! +//! This module regroups everything related to deserialization of +//! message sort criteria. + +use anyhow::{anyhow, Error, Result}; +use std::{convert::TryFrom, ops::Deref}; + +/// Represents the message sort criteria. It is just a wrapper around +/// the `imap::extensions::sort::SortCriterion`. +pub struct SortCriteria<'a>(Vec>); + +impl<'a> Deref for SortCriteria<'a> { + type Target = Vec>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'a> TryFrom<&'a str> for SortCriteria<'a> { + type Error = Error; + + fn try_from(criteria_str: &'a str) -> Result { + let mut criteria = vec![]; + for criterion_str in criteria_str.split(" ") { + criteria.push(match criterion_str.trim() { + "arrival:asc" | "arrival" => Ok(imap::extensions::sort::SortCriterion::Arrival), + "arrival:desc" => Ok(imap::extensions::sort::SortCriterion::Reverse( + &imap::extensions::sort::SortCriterion::Arrival, + )), + "cc:asc" | "cc" => Ok(imap::extensions::sort::SortCriterion::Cc), + "cc:desc" => Ok(imap::extensions::sort::SortCriterion::Reverse( + &imap::extensions::sort::SortCriterion::Cc, + )), + "date:asc" | "date" => Ok(imap::extensions::sort::SortCriterion::Date), + "date:desc" => Ok(imap::extensions::sort::SortCriterion::Reverse( + &imap::extensions::sort::SortCriterion::Date, + )), + "from:asc" | "from" => Ok(imap::extensions::sort::SortCriterion::From), + "from:desc" => Ok(imap::extensions::sort::SortCriterion::Reverse( + &imap::extensions::sort::SortCriterion::From, + )), + "size:asc" | "size" => Ok(imap::extensions::sort::SortCriterion::Size), + "size:desc" => Ok(imap::extensions::sort::SortCriterion::Reverse( + &imap::extensions::sort::SortCriterion::Size, + )), + "subject:asc" | "subject" => Ok(imap::extensions::sort::SortCriterion::Subject), + "subject:desc" => Ok(imap::extensions::sort::SortCriterion::Reverse( + &imap::extensions::sort::SortCriterion::Subject, + )), + "to:asc" | "to" => Ok(imap::extensions::sort::SortCriterion::To), + "to:desc" => Ok(imap::extensions::sort::SortCriterion::Reverse( + &imap::extensions::sort::SortCriterion::To, + )), + _ => Err(anyhow!("cannot parse sort criterion {:?}", criterion_str)), + }?); + } + Ok(Self(criteria)) + } +} diff --git a/src/backends/maildir/maildir_backend.rs b/src/backends/maildir/maildir_backend.rs new file mode 100644 index 0000000..0fd184c --- /dev/null +++ b/src/backends/maildir/maildir_backend.rs @@ -0,0 +1,185 @@ +use anyhow::{anyhow, Context, Result}; +use std::{convert::TryInto, fs, path::PathBuf}; + +use crate::{ + backends::{Backend, MaildirEnvelopes, MaildirFlags, MaildirMboxes}, + config::{AccountConfig, MaildirBackendConfig}, + mbox::Mboxes, + msg::{Envelopes, Msg}, +}; + +pub struct MaildirBackend<'a> { + mdir: maildir::Maildir, + account_config: &'a AccountConfig, +} + +impl<'a> MaildirBackend<'a> { + pub fn new( + account_config: &'a AccountConfig, + maildir_config: &'a MaildirBackendConfig, + ) -> Self { + Self { + account_config, + mdir: maildir_config.maildir_dir.clone().into(), + } + } + + fn validate_mdir_path(&self, mdir_path: PathBuf) -> Result { + if mdir_path.is_dir() { + Ok(mdir_path) + } else { + Err(anyhow!( + "cannot read maildir from directory {:?}", + mdir_path + )) + } + } + + fn get_mdir_from_name(&self, mdir: &str) -> Result { + if mdir == self.account_config.inbox_folder { + self.validate_mdir_path(self.mdir.path().to_owned()) + .map(maildir::Maildir::from) + } else { + self.validate_mdir_path(mdir.into()) + .or_else(|_| { + let path = self.mdir.path().join(format!(".{}", mdir)); + self.validate_mdir_path(path) + }) + .map(maildir::Maildir::from) + } + } +} + +impl<'a> Backend<'a> for MaildirBackend<'a> { + fn add_mbox(&mut self, mdir: &str) -> Result<()> { + fs::create_dir(self.mdir.path().join(format!(".{}", mdir))) + .context(format!("cannot create maildir subfolder {:?}", mdir)) + } + + fn get_mboxes(&mut self) -> Result> { + let mboxes: MaildirMboxes = self.mdir.list_subdirs().try_into()?; + Ok(Box::new(mboxes)) + } + + fn del_mbox(&mut self, mdir: &str) -> Result<()> { + fs::remove_dir_all(self.mdir.path().join(format!(".{}", mdir))) + .context(format!("cannot delete maildir subfolder {:?}", mdir)) + } + + fn get_envelopes( + &mut self, + mdir: &str, + _sort: &str, + filter: &str, + page_size: usize, + page: usize, + ) -> Result> { + let mdir = self.get_mdir_from_name(mdir)?; + let mail_entries = match filter { + "new" => mdir.list_new(), + _ => mdir.list_cur(), + }; + let mut envelopes: MaildirEnvelopes = mail_entries + .try_into() + .context("cannot parse maildir envelopes from {:?}")?; + envelopes.sort_by(|a, b| b.date.partial_cmp(&a.date).unwrap()); + + let page_begin = page * page_size; + if page_begin > envelopes.len() { + return Err(anyhow!(format!( + "cannot list maildir envelopes at page {:?} (out of bounds)", + page_begin + 1, + ))); + } + let page_end = envelopes.len().min(page_begin + page_size); + envelopes.0 = envelopes[page_begin..page_end].to_owned(); + Ok(Box::new(envelopes)) + } + + fn add_msg(&mut self, mdir: &str, msg: &[u8], flags: &str) -> Result> { + let mdir = self.get_mdir_from_name(mdir)?; + let flags: MaildirFlags = flags.try_into()?; + let id = mdir + .store_cur_with_flags(msg, &flags.to_string()) + .context(format!( + "cannot add message to the \"cur\" folder of maildir {:?}", + mdir.path() + ))?; + Ok(Box::new(id)) + } + + fn get_msg(&mut self, mdir: &str, id: &str) -> Result { + let mdir = self.get_mdir_from_name(mdir)?; + let mut mail_entry = mdir + .find(id) + .ok_or_else(|| anyhow!("cannot find maildir message {:?} in {:?}", id, mdir.path()))?; + let parsed_mail = mail_entry.parsed().context(format!( + "cannot parse maildir message {:?} in {:?}", + id, + mdir.path() + ))?; + Msg::from_parsed_mail(parsed_mail, self.account_config).context(format!( + "cannot parse maildir message {:?} from {:?}", + id, + mdir.path() + )) + } + + fn copy_msg(&mut self, mdir_src: &str, mdir_dst: &str, id: &str) -> Result<()> { + let mdir_src = self.get_mdir_from_name(mdir_src)?; + let mdir_dst = self.get_mdir_from_name(mdir_dst)?; + mdir_src.copy_to(id, &mdir_dst).context(format!( + "cannot copy message {:?} from maildir {:?} to maildir {:?}", + id, + mdir_src.path(), + mdir_dst.path() + )) + } + + fn move_msg(&mut self, mdir_src: &str, mdir_dst: &str, id: &str) -> Result<()> { + let mdir_src = self.get_mdir_from_name(mdir_src)?; + let mdir_dst = self.get_mdir_from_name(mdir_dst)?; + mdir_src.move_to(id, &mdir_dst).context(format!( + "cannot move message {:?} from maildir {:?} to maildir {:?}", + id, + mdir_src.path(), + mdir_dst.path() + )) + } + + fn del_msg(&mut self, mdir: &str, id: &str) -> Result<()> { + let mdir = self.get_mdir_from_name(mdir)?; + mdir.delete(id).context(format!( + "cannot delete message {:?} from maildir {:?}", + id, + mdir.path() + )) + } + + fn add_flags(&mut self, mdir: &str, id: &str, flags_str: &str) -> Result<()> { + let mdir = self.get_mdir_from_name(mdir)?; + let flags: MaildirFlags = flags_str.try_into()?; + mdir.add_flags(id, &flags.to_string()).context(format!( + "cannot add flags {:?} to maildir message {:?}", + flags_str, id + )) + } + + fn set_flags(&mut self, mdir: &str, id: &str, flags_str: &str) -> Result<()> { + let mdir = self.get_mdir_from_name(mdir)?; + let flags: MaildirFlags = flags_str.try_into()?; + mdir.set_flags(id, &flags.to_string()).context(format!( + "cannot set flags {:?} to maildir message {:?}", + flags_str, id + )) + } + + fn del_flags(&mut self, mdir: &str, id: &str, flags_str: &str) -> Result<()> { + let mdir = self.get_mdir_from_name(mdir)?; + let flags: MaildirFlags = flags_str.try_into()?; + mdir.remove_flags(id, &flags.to_string()).context(format!( + "cannot remove flags {:?} from maildir message {:?}", + flags_str, id + )) + } +} diff --git a/src/backends/maildir/maildir_envelope.rs b/src/backends/maildir/maildir_envelope.rs new file mode 100644 index 0000000..4d35f3f --- /dev/null +++ b/src/backends/maildir/maildir_envelope.rs @@ -0,0 +1,187 @@ +//! Maildir mailbox module. +//! +//! This module provides Maildir types and conversion utilities +//! related to the envelope + +use anyhow::{anyhow, Context, Error, Result}; +use chrono::DateTime; +use log::{debug, info, trace}; +use std::{ + convert::{TryFrom, TryInto}, + ops::{Deref, DerefMut}, +}; + +use crate::{ + backends::{MaildirFlag, MaildirFlags}, + msg::{from_slice_to_addrs, Addr}, + output::{PrintTable, PrintTableOpts, WriteColor}, + ui::{Cell, Row, Table}, +}; + +/// Represents a list of envelopes. +#[derive(Debug, Default, serde::Serialize)] +pub struct MaildirEnvelopes(pub Vec); + +impl Deref for MaildirEnvelopes { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for MaildirEnvelopes { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl PrintTable for MaildirEnvelopes { + fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writter)?; + Table::print(writter, self, opts)?; + writeln!(writter)?; + Ok(()) + } +} + +// impl Envelopes for MaildirEnvelopes { +// // +// } + +/// Represents the envelope. The envelope is just a message subset, +/// and is mostly used for listings. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct MaildirEnvelope { + /// Represents the id of the message. + pub id: String, + + /// Represents the flags of the message. + pub flags: MaildirFlags, + + /// Represents the subject of the message. + pub subject: String, + + /// Represents the first sender of the message. + pub sender: String, + + /// Represents the date of the message. + pub date: String, +} + +impl Table for MaildirEnvelope { + fn head() -> Row { + Row::new() + .cell(Cell::new("IDENTIFIER").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 unseen = !self.flags.contains(&MaildirFlag::Seen); + let flags = self.flags.to_symbols_string(); + let subject = &self.subject; + let sender = &self.sender; + let date = &self.date; + 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()) + } +} + +/// Represents a list of raw envelopees returned by the `maildir` crate. +pub type RawMaildirEnvelopes = maildir::MailEntries; + +impl<'a> TryFrom for MaildirEnvelopes { + type Error = Error; + + fn try_from(mail_entries: RawMaildirEnvelopes) -> Result { + let mut envelopes = vec![]; + for entry in mail_entries { + let envelope: MaildirEnvelope = entry + .context("cannot decode maildir mail entry")? + .try_into() + .context("cannot parse maildir mail entry")?; + envelopes.push(envelope); + } + Ok(MaildirEnvelopes(envelopes)) + } +} + +/// Represents the raw envelope returned by the `maildir` crate. +pub type RawMaildirEnvelope = maildir::MailEntry; + +impl<'a> TryFrom for MaildirEnvelope { + type Error = Error; + + fn try_from(mut mail_entry: RawMaildirEnvelope) -> Result { + info!("begin: try building envelope from maildir parsed mail"); + + let mut envelope = Self { + id: mail_entry.id().into(), + flags: (&mail_entry) + .try_into() + .context("cannot parse maildir flags")?, + ..Self::default() + }; + + let parsed_mail = mail_entry + .parsed() + .context("cannot parse maildir mail entry")?; + + debug!("begin: parse headers"); + for h in parsed_mail.get_headers() { + let k = h.get_key(); + debug!("header key: {:?}", k); + + let v = rfc2047_decoder::decode(h.get_value_raw()) + .context(format!("cannot decode value from header {:?}", k))?; + debug!("header value: {:?}", v); + + match k.to_lowercase().as_str() { + "date" => { + envelope.date = + DateTime::parse_from_rfc2822(v.split_at(v.find(" (").unwrap_or(v.len())).0) + .context(format!("cannot parse maildir message date {:?}", v))? + .naive_local() + .to_string(); + } + "subject" => { + envelope.subject = v.into(); + } + "from" => { + envelope.sender = from_slice_to_addrs(v) + .context(format!("cannot parse header {:?}", k))? + .and_then(|senders| { + if senders.is_empty() { + None + } else { + Some(senders) + } + }) + .map(|senders| match &senders[0] { + Addr::Single(mailparse::SingleInfo { display_name, addr }) => { + display_name.as_ref().unwrap_or_else(|| addr).to_owned() + } + Addr::Group(mailparse::GroupInfo { group_name, .. }) => { + group_name.to_owned() + } + }) + .ok_or_else(|| anyhow!("cannot find sender"))?; + } + _ => (), + } + } + debug!("end: parse headers"); + + trace!("envelope: {:?}", envelope); + info!("end: try building envelope from maildir parsed mail"); + Ok(envelope) + } +} diff --git a/src/backends/maildir/maildir_flag.rs b/src/backends/maildir/maildir_flag.rs new file mode 100644 index 0000000..1c97cce --- /dev/null +++ b/src/backends/maildir/maildir_flag.rs @@ -0,0 +1,129 @@ +use anyhow::{anyhow, Error, Result}; +use std::{ + convert::{TryFrom, TryInto}, + ops::Deref, +}; + +/// Represents the maildir flag variants. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub enum MaildirFlag { + Passed, + Replied, + Seen, + Trashed, + Draft, + Flagged, + Custom(char), +} + +/// Represents the maildir flags. +#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)] +pub struct MaildirFlags(pub Vec); + +impl MaildirFlags { + /// Builds a symbols string + pub fn to_symbols_string(&self) -> String { + let mut flags = String::new(); + flags.push_str(if self.contains(&MaildirFlag::Seen) { + " " + } else { + "✷" + }); + flags.push_str(if self.contains(&MaildirFlag::Replied) { + "↵" + } else { + " " + }); + flags.push_str(if self.contains(&MaildirFlag::Passed) { + "↗" + } else { + " " + }); + flags.push_str(if self.contains(&MaildirFlag::Flagged) { + "⚑" + } else { + " " + }); + flags + } +} + +impl Deref for MaildirFlags { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ToString for MaildirFlags { + fn to_string(&self) -> String { + self.0 + .iter() + .map(|flag| { + let flag_char: char = flag.into(); + flag_char + }) + .collect() + } +} + +impl TryFrom<&str> for MaildirFlags { + type Error = Error; + + fn try_from(flags_str: &str) -> Result { + let mut flags = vec![]; + for flag_str in flags_str.split_whitespace() { + flags.push(flag_str.trim().try_into()?); + } + Ok(MaildirFlags(flags)) + } +} + +impl From<&maildir::MailEntry> for MaildirFlags { + fn from(mail_entry: &maildir::MailEntry) -> Self { + let mut flags = vec![]; + for c in mail_entry.flags().chars() { + flags.push(match c { + 'P' => MaildirFlag::Passed, + 'R' => MaildirFlag::Replied, + 'S' => MaildirFlag::Seen, + 'T' => MaildirFlag::Trashed, + 'D' => MaildirFlag::Draft, + 'F' => MaildirFlag::Flagged, + custom => MaildirFlag::Custom(custom), + }) + } + Self(flags) + } +} + +impl Into for &MaildirFlag { + fn into(self) -> char { + match self { + MaildirFlag::Passed => 'P', + MaildirFlag::Replied => 'R', + MaildirFlag::Seen => 'S', + MaildirFlag::Trashed => 'T', + MaildirFlag::Draft => 'D', + MaildirFlag::Flagged => 'F', + MaildirFlag::Custom(custom) => *custom, + } + } +} + +impl TryFrom<&str> for MaildirFlag { + type Error = Error; + + fn try_from(flag_str: &str) -> Result { + match flag_str { + "passed" => Ok(MaildirFlag::Passed), + "replied" => Ok(MaildirFlag::Replied), + "seen" => Ok(MaildirFlag::Seen), + "trashed" => Ok(MaildirFlag::Trashed), + "draft" => Ok(MaildirFlag::Draft), + "flagged" => Ok(MaildirFlag::Flagged), + flag_str => Err(anyhow!("cannot parse maildir flag {:?}", flag_str)), + } + } +} diff --git a/src/backends/maildir/maildir_mbox.rs b/src/backends/maildir/maildir_mbox.rs new file mode 100644 index 0000000..fad90b0 --- /dev/null +++ b/src/backends/maildir/maildir_mbox.rs @@ -0,0 +1,141 @@ +//! Maildir mailbox module. +//! +//! This module provides Maildir types and conversion utilities +//! related to the mailbox + +use anyhow::{anyhow, Error, Result}; +use std::{ + convert::{TryFrom, TryInto}, + ffi::OsStr, + fmt::{self, Display}, + ops::Deref, +}; + +use crate::{ + mbox::Mboxes, + output::{PrintTable, PrintTableOpts, WriteColor}, + ui::{Cell, Row, Table}, +}; + +/// Represents a list of Maildir mailboxes. +#[derive(Debug, Default, serde::Serialize)] +pub struct MaildirMboxes(pub Vec); + +impl Deref for MaildirMboxes { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PrintTable for MaildirMboxes { + fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writter)?; + Table::print(writter, self, opts)?; + writeln!(writter)?; + Ok(()) + } +} + +impl Mboxes for MaildirMboxes { + // +} + +/// Represents the mailbox. +#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct MaildirMbox { + /// Represents the mailbox name. + pub name: String, +} + +impl MaildirMbox { + pub fn new(name: &str) -> Self { + Self { name: name.into() } + } +} + +impl Display for MaildirMbox { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Table for MaildirMbox { + fn head() -> Row { + Row::new().cell(Cell::new("SUBDIR").bold().underline().white()) + } + + fn row(&self) -> Row { + Row::new().cell(Cell::new(&self.name).green()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_new_mbox() { + assert_eq!(MaildirMbox::default(), MaildirMbox::new("")); + assert_eq!( + MaildirMbox { + name: "INBOX".into(), + ..MaildirMbox::default() + }, + MaildirMbox::new("INBOX") + ); + } + + #[test] + fn it_should_display_mbox() { + let default_mbox = MaildirMbox::default(); + assert_eq!("", default_mbox.to_string()); + + let new_mbox = MaildirMbox::new("INBOX"); + assert_eq!("INBOX", new_mbox.to_string()); + + let full_mbox = MaildirMbox { + name: "Sent".into(), + }; + assert_eq!("Sent", full_mbox.to_string()); + } +} + +/// Represents a list of raw mailboxes returned by the `maildir` crate. +pub type RawMaildirMboxes = maildir::MaildirEntries; + +impl TryFrom for MaildirMboxes { + type Error = Error; + + fn try_from(mail_entries: RawMaildirMboxes) -> Result { + let mut mboxes = vec![]; + for entry in mail_entries { + mboxes.push(entry?.try_into()?); + } + Ok(MaildirMboxes(mboxes)) + } +} + +/// Represents the raw mailbox returned by the `maildir` crate. +pub type RawMaildirMbox = maildir::Maildir; + +impl TryFrom for MaildirMbox { + type Error = Error; + + fn try_from(mail_entry: RawMaildirMbox) -> Result { + let subdir_name = mail_entry.path().file_name(); + Ok(Self { + name: subdir_name + .and_then(OsStr::to_str) + .and_then(|s| if s.len() < 2 { None } else { Some(&s[1..]) }) + .ok_or_else(|| { + anyhow!( + "cannot parse maildir subdirectory name from path {:?}", + subdir_name, + ) + })? + .into(), + }) + } +} diff --git a/src/config/account_args.rs b/src/config/account_args.rs new file mode 100644 index 0000000..28d2b5d --- /dev/null +++ b/src/config/account_args.rs @@ -0,0 +1,13 @@ +//! This module provides arguments related to the user account config. + +use clap::Arg; + +/// Represents the user account name argument. +/// This argument allows the user to select a different account than the default one. +pub fn name_arg<'a>() -> Arg<'a, 'a> { + Arg::with_name("account") + .long("account") + .short("a") + .help("Selects a specific account") + .value_name("NAME") +} diff --git a/src/config/account_config.rs b/src/config/account_config.rs new file mode 100644 index 0000000..44c02c0 --- /dev/null +++ b/src/config/account_config.rs @@ -0,0 +1,402 @@ +use anyhow::{anyhow, Context, Result}; +use lettre::transport::smtp::authentication::Credentials as SmtpCredentials; +use log::{debug, info, trace}; +use mailparse::MailAddr; +use std::{env, ffi::OsStr, fs, path::PathBuf}; + +use crate::{config::*, output::run_cmd}; + +/// Represents the user account. +#[derive(Debug, Default, Clone)] +pub struct AccountConfig { + /// Represents the name of the user account. + pub name: String, + /// Makes this account the default one. + pub default: bool, + /// Represents the display name of the user account. + pub display_name: String, + /// Represents the email address of the user account. + pub email: String, + /// Represents the downloads directory (mostly for attachments). + pub downloads_dir: PathBuf, + /// Represents the signature of the user. + pub sig: Option, + /// Represents the default page size for listings. + pub default_page_size: usize, + /// Represents the inbox folder name for this account. + pub inbox_folder: String, + /// Represents the sent folder name for this account. + pub sent_folder: String, + /// Represents the draft folder name for this account. + pub draft_folder: String, + /// Represents the notify command. + pub notify_cmd: Option, + /// Overrides the default IMAP query "NEW" used to fetch new messages + pub notify_query: String, + /// Represents the watch commands. + pub watch_cmds: Vec, + + /// Represents the SMTP host. + pub smtp_host: String, + /// Represents the SMTP port. + pub smtp_port: u16, + /// Enables StartTLS. + pub smtp_starttls: bool, + /// Trusts any certificate. + pub smtp_insecure: bool, + /// Represents the SMTP login. + pub smtp_login: String, + /// Represents the SMTP password command. + pub smtp_passwd_cmd: String, + + /// Represents the command used to encrypt a message. + pub pgp_encrypt_cmd: Option, + /// Represents the command used to decrypt a message. + pub pgp_decrypt_cmd: Option, +} + +impl<'a> AccountConfig { + /// tries to create an account from a config and an optional account name. + pub fn from_config_and_opt_account_name( + config: &'a DeserializedConfig, + account_name: Option<&str>, + ) -> Result<(AccountConfig, BackendConfig)> { + info!("begin: parsing account and backend configs from config and account name"); + + debug!("account name: {:?}", account_name.unwrap_or("default")); + let (name, account) = match account_name.map(|name| name.trim()) { + Some("default") | Some("") | None => config + .accounts + .iter() + .find(|(_, account)| match account { + DeserializedAccountConfig::Imap(account) => account.default.unwrap_or_default(), + DeserializedAccountConfig::Maildir(account) => { + account.default.unwrap_or_default() + } + }) + .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 base_account = account.to_base(); + let downloads_dir = base_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 = base_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 = base_account + .signature_delimiter + .as_ref() + .or_else(|| config.signature_delimiter.as_ref()) + .unwrap_or(&default_sig_delim); + let sig = base_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_config = AccountConfig { + name, + display_name: base_account + .name + .as_ref() + .unwrap_or(&config.name) + .to_owned(), + downloads_dir, + sig, + default_page_size, + inbox_folder: base_account + .inbox_folder + .as_deref() + .or_else(|| config.inbox_folder.as_deref()) + .unwrap_or(DEFAULT_INBOX_FOLDER) + .to_string(), + sent_folder: base_account + .sent_folder + .as_deref() + .or_else(|| config.sent_folder.as_deref()) + .unwrap_or(DEFAULT_SENT_FOLDER) + .to_string(), + draft_folder: base_account + .draft_folder + .as_deref() + .or_else(|| config.draft_folder.as_deref()) + .unwrap_or(DEFAULT_DRAFT_FOLDER) + .to_string(), + notify_cmd: base_account.notify_cmd.clone(), + notify_query: base_account + .notify_query + .as_ref() + .or_else(|| config.notify_query.as_ref()) + .unwrap_or(&String::from("NEW")) + .to_owned(), + watch_cmds: base_account + .watch_cmds + .as_ref() + .or_else(|| config.watch_cmds.as_ref()) + .unwrap_or(&vec![]) + .to_owned(), + default: base_account.default.unwrap_or_default(), + email: base_account.email.to_owned(), + + smtp_host: base_account.smtp_host.to_owned(), + smtp_port: base_account.smtp_port, + smtp_starttls: base_account.smtp_starttls.unwrap_or_default(), + smtp_insecure: base_account.smtp_insecure.unwrap_or_default(), + smtp_login: base_account.smtp_login.to_owned(), + smtp_passwd_cmd: base_account.smtp_passwd_cmd.to_owned(), + + pgp_encrypt_cmd: base_account.pgp_encrypt_cmd.to_owned(), + pgp_decrypt_cmd: base_account.pgp_decrypt_cmd.to_owned(), + }; + trace!("account config: {:?}", account_config); + + let backend_config = match account { + DeserializedAccountConfig::Imap(config) => BackendConfig::Imap(ImapBackendConfig { + imap_host: config.imap_host.clone(), + imap_port: config.imap_port.clone(), + imap_starttls: config.imap_starttls.unwrap_or_default(), + imap_insecure: config.imap_insecure.unwrap_or_default(), + imap_login: config.imap_login.clone(), + imap_passwd_cmd: config.imap_passwd_cmd.clone(), + }), + DeserializedAccountConfig::Maildir(config) => { + BackendConfig::Maildir(MaildirBackendConfig { + maildir_dir: config.maildir_dir.clone(), + }) + } + }; + trace!("backend config: {:?}", backend_config); + + info!("end: parsing account and backend configs from config and account name"); + Ok((account_config, backend_config)) + } + + /// Builds the full RFC822 compliant address of the user account. + pub fn address(&self) -> Result { + let has_special_chars = + "()<>[]:;@.,".contains(|special_char| self.display_name.contains(special_char)); + let addr = if self.display_name.is_empty() { + self.email.clone() + } else if has_special_chars { + // Wraps the name with double quotes if it contains any special character. + format!("\"{}\" <{}>", self.display_name, self.email) + } else { + format!("{} <{}>", self.display_name, self.email) + }; + + Ok(mailparse::addrparse(&addr) + .context(format!( + "cannot parse account address {:?}", + self.display_name + ))? + .first() + .ok_or_else(|| anyhow!("cannot parse account address {:?}", self.display_name))? + .clone()) + } + + /// Builds the user account SMTP credentials. + 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)) + } + + /// Encrypts a file. + pub fn pgp_encrypt_file(&self, addr: &str, path: PathBuf) -> Result> { + if let Some(cmd) = self.pgp_encrypt_cmd.as_ref() { + let encrypt_file_cmd = format!("{} {} {:?}", cmd, addr, path); + run_cmd(&encrypt_file_cmd).map(Some).context(format!( + "cannot run pgp encrypt command {:?}", + encrypt_file_cmd + )) + } else { + Ok(None) + } + } + + /// Decrypts a file. + pub fn pgp_decrypt_file(&self, path: PathBuf) -> Result> { + if let Some(cmd) = self.pgp_decrypt_cmd.as_ref() { + let decrypt_file_cmd = format!("{} {:?}", cmd, path); + run_cmd(&decrypt_file_cmd).map(Some).context(format!( + "cannot run pgp decrypt command {:?}", + decrypt_file_cmd + )) + } else { + Ok(None) + } + } + + /// Gets the download path from a file name. + pub fn get_download_file_path>(&self, file_name: S) -> Result { + let file_path = self.downloads_dir.join(file_name.as_ref()); + self.get_unique_download_file_path(&file_path, |path, _count| path.is_file()) + .context(format!( + "cannot get download file path of {:?}", + file_name.as_ref() + )) + } + + /// Gets the unique download path from a file name by adding suffixes in case of name conflicts. + pub fn get_unique_download_file_path( + &self, + original_file_path: &PathBuf, + is_file: impl Fn(&PathBuf, u8) -> bool, + ) -> Result { + let mut count = 0; + let file_ext = original_file_path + .extension() + .and_then(OsStr::to_str) + .map(|fext| String::from(".") + fext) + .unwrap_or_default(); + let mut file_path = original_file_path.clone(); + + while is_file(&file_path, count) { + count += 1; + file_path.set_file_name(OsStr::new( + &original_file_path + .file_stem() + .and_then(OsStr::to_str) + .map(|fstem| format!("{}_{}{}", fstem, count, file_ext)) + .ok_or_else(|| anyhow!("cannot get stem from file {:?}", original_file_path))?, + )); + } + + Ok(file_path) + } + + /// Runs the notify command. + 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 "New message from {}" "{}""#, sender, subject); + let cmd = self + .notify_cmd + .as_ref() + .map(|cmd| format!(r#"{} {:?} {:?}"#, cmd, subject, sender)) + .unwrap_or(default_cmd); + + debug!("run command: {}", cmd); + run_cmd(&cmd).context("cannot run notify cmd")?; + Ok(()) + } +} + +/// Represents all existing kind of account (backend). +#[derive(Debug, Clone)] +pub enum BackendConfig { + Imap(ImapBackendConfig), + Maildir(MaildirBackendConfig), +} + +/// Represents the IMAP backend. +#[derive(Debug, Default, Clone)] +pub struct ImapBackendConfig { + /// Represents the IMAP host. + pub imap_host: String, + /// Represents the IMAP port. + pub imap_port: u16, + /// Enables StartTLS. + pub imap_starttls: bool, + /// Trusts any certificate. + pub imap_insecure: bool, + /// Represents the IMAP login. + pub imap_login: String, + /// Represents the IMAP password command. + pub imap_passwd_cmd: String, +} + +impl ImapBackendConfig { + /// Gets the IMAP password of the user account. + 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) + } +} + +/// Represents the Maildir backend. +#[derive(Debug, Default, Clone)] +pub struct MaildirBackendConfig { + /// Represents the Maildir directory path. + pub maildir_dir: PathBuf, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_get_unique_download_file_path() { + let account = AccountConfig::default(); + let path = PathBuf::from("downloads/file.ext"); + + // When file path is unique + assert!(matches!( + account.get_unique_download_file_path(&path, |_, _| false), + Ok(path) if path == PathBuf::from("downloads/file.ext") + )); + + // When 1 file path already exist + assert!(matches!( + account.get_unique_download_file_path(&path, |_, count| count < 1), + Ok(path) if path == PathBuf::from("downloads/file_1.ext") + )); + + // When 5 file paths already exist + assert!(matches!( + account.get_unique_download_file_path(&path, |_, count| count < 5), + Ok(path) if path == PathBuf::from("downloads/file_5.ext") + )); + + // When file path has no extension + let path = PathBuf::from("downloads/file"); + assert!(matches!( + account.get_unique_download_file_path(&path, |_, count| count < 5), + Ok(path) if path == PathBuf::from("downloads/file_5") + )); + + // When file path has 2 extensions + let path = PathBuf::from("downloads/file.ext.ext2"); + assert!(matches!( + account.get_unique_download_file_path(&path, |_, count| count < 5), + Ok(path) if path == PathBuf::from("downloads/file.ext_5.ext2") + )); + } +} diff --git a/src/config/account_entity.rs b/src/config/account_entity.rs deleted file mode 100644 index 15667ed..0000000 --- a/src/config/account_entity.rs +++ /dev/null @@ -1,230 +0,0 @@ -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, -}; - -pub const DEFAULT_INBOX_FOLDER: &str = "INBOX"; -pub const DEFAULT_SENT_FOLDER: &str = "Sent"; -pub const DEFAULT_DRAFT_FOLDER: &str = "Drafts"; - -/// 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, - /// Defines the inbox folder name for this account - pub inbox_folder: String, - /// Defines the sent folder name for this account - pub sent_folder: String, - /// Defines the draft folder name for this account - pub draft_folder: String, - /// Defines the IMAP query used to fetch new messages. - pub notify_query: String, - 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, - - pub pgp_encrypt_cmd: Option, - pub pgp_decrypt_cmd: Option, -} - -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() { - self.email.clone() - } 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)) - } - - pub fn pgp_encrypt_file(&self, addr: &str, path: PathBuf) -> Result> { - if let Some(cmd) = self.pgp_encrypt_cmd.as_ref() { - let encrypt_file_cmd = format!("{} {} {:?}", cmd, addr, path); - run_cmd(&encrypt_file_cmd).map(Some).context(format!( - "cannot run pgp encrypt command {:?}", - encrypt_file_cmd - )) - } else { - Ok(None) - } - } - - pub fn pgp_decrypt_file(&self, path: PathBuf) -> Result> { - if let Some(cmd) = self.pgp_decrypt_cmd.as_ref() { - let decrypt_file_cmd = format!("{} {:?}", cmd, path); - run_cmd(&decrypt_file_cmd).map(Some).context(format!( - "cannot run pgp decrypt command {:?}", - decrypt_file_cmd - )) - } else { - Ok(None) - } - } -} - -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, - inbox_folder: account - .inbox_folder - .as_deref() - .or_else(|| config.inbox_folder.as_deref()) - .unwrap_or(DEFAULT_INBOX_FOLDER) - .to_string(), - sent_folder: account - .sent_folder - .as_deref() - .or_else(|| config.sent_folder.as_deref()) - .unwrap_or(DEFAULT_SENT_FOLDER) - .to_string(), - draft_folder: account - .draft_folder - .as_deref() - .or_else(|| config.draft_folder.as_deref()) - .unwrap_or(DEFAULT_DRAFT_FOLDER) - .to_string(), - notify_query: account - .notify_query - .as_ref() - .or_else(|| config.notify_query.as_ref()) - .unwrap_or(&String::from("NEW")) - .to_owned(), - 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(), - - pgp_encrypt_cmd: account.pgp_encrypt_cmd.to_owned(), - pgp_decrypt_cmd: account.pgp_decrypt_cmd.to_owned(), - }; - - trace!("account: {:?}", account); - Ok(account) - } -} diff --git a/src/config/config_arg.rs b/src/config/config_arg.rs deleted file mode 100644 index b0f34d8..0000000 --- a/src/config/config_arg.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Module related to config CLI. -//! -//! This module provides arguments related to config. - -use clap::Arg; - -/// Config arguments. -pub fn args<'a>() -> Vec> { - vec![ - Arg::with_name("config") - .long("config") - .short("c") - .help("Forces a specific config path") - .value_name("PATH"), - Arg::with_name("account") - .long("account") - .short("a") - .help("Selects a specific account") - .value_name("NAME"), - ] -} diff --git a/src/config/config_args.rs b/src/config/config_args.rs new file mode 100644 index 0000000..f42924d --- /dev/null +++ b/src/config/config_args.rs @@ -0,0 +1,13 @@ +//! This module provides arguments related to the user config. + +use clap::Arg; + +/// Represents the config path argument. +/// This argument allows the user to customize the config file path. +pub fn path_arg<'a>() -> Arg<'a, 'a> { + Arg::with_name("config") + .long("config") + .short("c") + .help("Forces a specific config path") + .value_name("PATH") +} diff --git a/src/config/config_entity.rs b/src/config/config_entity.rs deleted file mode 100644 index c96f354..0000000 --- a/src/config/config_entity.rs +++ /dev/null @@ -1,162 +0,0 @@ -use anyhow::{Context, Error, Result}; -use log::{debug, trace}; -use serde::Deserialize; -use std::{collections::HashMap, convert::TryFrom, env, fs, path::PathBuf}; -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 { - /// Defines the full display name of the user. - pub name: String, - /// Defines the downloads directory (eg. for attachments). - pub downloads_dir: Option, - /// Overrides the default signature delimiter "`--\n `". - pub signature_delimiter: Option, - /// Defines the signature. - pub signature: Option, - /// Defines the default page size for listings. - pub default_page_size: Option, - /// Defines the inbox folder name. - pub inbox_folder: Option, - /// Defines the sent folder name. - pub sent_folder: Option, - /// Defines the draft folder name. - pub draft_folder: Option, - /// Defines the notify command. - pub notify_cmd: Option, - /// Customizes the IMAP query used to fetch new messages. - pub notify_query: Option, - /// Defines the watch commands. - 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, - /// Defines a specific inbox folder name for this account. - pub inbox_folder: Option, - /// Defines a specific sent folder name for this account. - pub sent_folder: Option, - /// Defines a specific draft folder name for this account. - pub draft_folder: Option, - /// Customizes the IMAP query used to fetch new messages. - pub notify_query: 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, - - pub pgp_encrypt_cmd: Option, - pub pgp_decrypt_cmd: Option, -} - -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 "New message from {}" "{}""#, sender, subject); - let cmd = self - .notify_cmd - .as_ref() - .map(|cmd| format!(r#"{} {:?} {:?}"#, cmd, subject, sender)) - .unwrap_or(default_cmd); - - debug!("run command: {}", cmd); - run_cmd(&cmd).context("cannot run notify cmd")?; - 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/deserialized_account_config.rs b/src/config/deserialized_account_config.rs new file mode 100644 index 0000000..595e50b --- /dev/null +++ b/src/config/deserialized_account_config.rs @@ -0,0 +1,124 @@ +use serde::Deserialize; +use std::path::PathBuf; + +pub trait ToDeserializedBaseAccountConfig { + fn to_base(&self) -> DeserializedBaseAccountConfig; +} + +/// Represents all existing kind of account config. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum DeserializedAccountConfig { + Imap(DeserializedImapAccountConfig), + Maildir(DeserializedMaildirAccountConfig), +} + +impl ToDeserializedBaseAccountConfig for DeserializedAccountConfig { + fn to_base(&self) -> DeserializedBaseAccountConfig { + match self { + Self::Imap(config) => config.to_base(), + Self::Maildir(config) => config.to_base(), + } + } +} + +macro_rules! make_account_config { + ($AccountConfig:ident, $($element: ident: $ty: ty),*) => { + #[derive(Debug, Default, Clone, PartialEq, Deserialize)] + #[serde(rename_all = "kebab-case")] + pub struct $AccountConfig { + /// Overrides the display name of the user for this account. + pub name: Option, + /// Overrides the downloads directory (mostly for attachments). + pub downloads_dir: Option, + /// Overrides the signature for this account. + pub signature: Option, + /// Overrides the signature delimiter for this account. + pub signature_delimiter: Option, + /// Overrides the default page size for this account. + pub default_page_size: Option, + /// Overrides the inbox folder name for this account. + pub inbox_folder: Option, + /// Overrides the sent folder name for this account. + pub sent_folder: Option, + /// Overrides the draft folder name for this account. + pub draft_folder: Option, + /// Overrides the notify command for this account. + pub notify_cmd: Option, + /// Overrides the IMAP query used to fetch new messages for this account. + pub notify_query: Option, + /// Overrides the watch commands for this account. + pub watch_cmds: Option>, + + /// Makes this account the default one. + pub default: Option, + /// Represents the account email address. + pub email: String, + + /// Represents the SMTP host. + pub smtp_host: String, + /// Represents the SMTP port. + pub smtp_port: u16, + /// Enables StartTLS. + pub smtp_starttls: Option, + /// Trusts any certificate. + pub smtp_insecure: Option, + /// Represents the SMTP login. + pub smtp_login: String, + /// Represents the SMTP password command. + pub smtp_passwd_cmd: String, + + /// Represents the command used to encrypt a message. + pub pgp_encrypt_cmd: Option, + /// Represents the command used to decrypt a message. + pub pgp_decrypt_cmd: Option, + + $(pub $element: $ty),* + } + + impl ToDeserializedBaseAccountConfig for $AccountConfig { + fn to_base(&self) -> DeserializedBaseAccountConfig { + DeserializedBaseAccountConfig { + name: self.name.clone(), + downloads_dir: self.downloads_dir.clone(), + signature: self.signature.clone(), + signature_delimiter: self.signature_delimiter.clone(), + default_page_size: self.default_page_size.clone(), + inbox_folder: self.inbox_folder.clone(), + sent_folder: self.sent_folder.clone(), + draft_folder: self.draft_folder.clone(), + notify_cmd: self.notify_cmd.clone(), + notify_query: self.notify_query.clone(), + watch_cmds: self.watch_cmds.clone(), + + default: self.default.clone(), + email: self.email.clone(), + + smtp_host: self.smtp_host.clone(), + smtp_port: self.smtp_port.clone(), + smtp_starttls: self.smtp_starttls.clone(), + smtp_insecure: self.smtp_insecure.clone(), + smtp_login: self.smtp_login.clone(), + smtp_passwd_cmd: self.smtp_passwd_cmd.clone(), + + pgp_encrypt_cmd: self.pgp_encrypt_cmd.clone(), + pgp_decrypt_cmd: self.pgp_decrypt_cmd.clone(), + } + } + } + } +} + +make_account_config!(DeserializedBaseAccountConfig,); + +make_account_config!( + DeserializedImapAccountConfig, + imap_host: String, + imap_port: u16, + imap_starttls: Option, + imap_insecure: Option, + imap_login: String, + imap_passwd_cmd: String +); + +make_account_config!(DeserializedMaildirAccountConfig, maildir_dir: PathBuf); diff --git a/src/config/deserialized_config.rs b/src/config/deserialized_config.rs new file mode 100644 index 0000000..280b6fb --- /dev/null +++ b/src/config/deserialized_config.rs @@ -0,0 +1,103 @@ +use anyhow::{Context, Result}; +use log::{debug, info, trace}; +use serde::Deserialize; +use std::{collections::HashMap, env, fs, path::PathBuf}; +use toml; + +use crate::config::DeserializedAccountConfig; + +pub const DEFAULT_PAGE_SIZE: usize = 10; +pub const DEFAULT_SIG_DELIM: &str = "-- \n"; + +pub const DEFAULT_INBOX_FOLDER: &str = "INBOX"; +pub const DEFAULT_SENT_FOLDER: &str = "Sent"; +pub const DEFAULT_DRAFT_FOLDER: &str = "Drafts"; + +/// Represents the user config file. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct DeserializedConfig { + /// Represents the display name of the user. + pub name: String, + /// Represents the downloads directory (mostly for attachments). + pub downloads_dir: Option, + /// Represents the signature of the user. + pub signature: Option, + /// Overrides the default signature delimiter "`--\n `". + pub signature_delimiter: Option, + /// Represents the default page size for listings. + pub default_page_size: Option, + /// Overrides the default inbox folder name "INBOX". + pub inbox_folder: Option, + /// Overrides the default sent folder name "Sent". + pub sent_folder: Option, + /// Overrides the default draft folder name "Drafts". + pub draft_folder: Option, + /// Represents the notify command. + pub notify_cmd: Option, + /// Overrides the default IMAP query "NEW" used to fetch new messages + pub notify_query: Option, + /// Represents the watch commands. + pub watch_cmds: Option>, + + /// Represents all the user accounts. + #[serde(flatten)] + pub accounts: HashMap, +} + +impl DeserializedConfig { + /// Tries to create a config from an optional path. + pub fn from_opt_path(path: Option<&str>) -> Result { + info!("begin: trying to parse config from path"); + debug!("path: {:?}", path); + let path = path.map(|s| s.into()).unwrap_or(Self::path()?); + let content = fs::read_to_string(path).context("cannot read config file")?; + let config = toml::from_str(&content).context("cannot parse config file")?; + info!("end: trying to parse config from path"); + trace!("config: {:?}", config); + Ok(config) + } + + /// Tries to get the XDG config file path from XDG_CONFIG_HOME environment variable. + fn path_from_xdg() -> Result { + let path = + env::var("XDG_CONFIG_HOME").context("cannot find \"XDG_CONFIG_HOME\" env var")?; + let path = PathBuf::from(path).join("himalaya").join("config.toml"); + Ok(path) + } + + /// Tries to get the XDG config file path from HOME environment variable. + fn path_from_xdg_alt() -> Result { + let home_var = if cfg!(target_family = "windows") { + "USERPROFILE" + } else { + "HOME" + }; + let path = env::var(home_var).context(format!("cannot find {:?} env var", home_var))?; + let path = PathBuf::from(path) + .join(".config") + .join("himalaya") + .join("config.toml"); + Ok(path) + } + + /// Tries to get the .himalayarc config file path from HOME environment variable. + fn path_from_home() -> Result { + let home_var = if cfg!(target_family = "windows") { + "USERPROFILE" + } else { + "HOME" + }; + let path = env::var(home_var).context(format!("cannot find {:?} env var", home_var))?; + let path = PathBuf::from(path).join(".himalayarc"); + Ok(path) + } + + /// Tries to get the config file path. + pub fn path() -> Result { + Self::path_from_xdg() + .or_else(|_| Self::path_from_xdg_alt()) + .or_else(|_| Self::path_from_home()) + .context("cannot find config path") + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index a033da2..f4adfaa 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,9 +1,11 @@ -//! Module related to the user's configuration. +//! This barrel module provides everything related to the user configuration. -pub mod config_arg; +pub mod config_args; +pub mod deserialized_config; +pub use deserialized_config::*; -pub mod account_entity; -pub use account_entity::*; - -pub mod config_entity; -pub use config_entity::*; +pub mod account_args; +pub mod account_config; +pub use account_config::*; +pub mod deserialized_account_config; +pub use deserialized_account_config::*; diff --git a/src/domain/imap/imap_handler.rs b/src/domain/imap/imap_handler.rs deleted file mode 100644 index 3bb7efc..0000000 --- a/src/domain/imap/imap_handler.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Module related to IMAP handling. -//! -//! This module gathers all IMAP handlers triggered by the CLI. - -use anyhow::Result; - -use crate::{ - config::{Account, Config}, - domain::imap::ImapServiceInterface, -}; - -pub fn notify<'a, ImapService: ImapServiceInterface<'a>>( - keepalive: u64, - config: &Config, - account: &Account, - imap: &mut ImapService, -) -> Result<()> { - imap.notify(config, account, keepalive) -} - -pub fn watch<'a, ImapService: ImapServiceInterface<'a>>( - keepalive: u64, - account: &Account, - imap: &mut ImapService, -) -> Result<()> { - imap.watch(account, keepalive) -} diff --git a/src/domain/imap/imap_service.rs b/src/domain/imap/imap_service.rs deleted file mode 100644 index f479bf5..0000000 --- a/src/domain/imap/imap_service.rs +++ /dev/null @@ -1,416 +0,0 @@ -//! Module related to IMAP servicing. -//! -//! This module exposes a service that can interact with IMAP servers. - -use anyhow::{anyhow, Context, Result}; -use log::{debug, log_enabled, trace, Level}; -use native_tls::{TlsConnector, TlsStream}; -use std::{collections::HashSet, convert::TryFrom, net::TcpStream, thread}; - -use crate::{ - config::{Account, Config}, - domain::{Envelope, Envelopes, Flags, Mbox, Mboxes, Msg, RawEnvelopes, RawMboxes}, - output::run_cmd, -}; - -type ImapSession = imap::Session>; - -pub trait ImapServiceInterface<'a> { - fn notify(&mut self, config: &Config, account: &Account, keepalive: u64) -> Result<()>; - fn watch(&mut self, account: &Account, keepalive: u64) -> Result<()>; - fn fetch_mboxes(&'a mut self) -> Result; - fn fetch_envelopes(&mut self, page_size: &usize, page: &usize) -> Result; - fn fetch_envelopes_with( - &'a mut self, - query: &str, - page_size: &usize, - page: &usize, - ) -> Result; - fn find_msg(&mut self, account: &Account, seq: &str) -> Result; - fn find_raw_msg(&mut self, seq: &str) -> Result>; - fn append_msg(&mut self, mbox: &Mbox, account: &Account, 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> { - account: &'a Account, - mbox: &'a Mbox<'a>, - sess: Option, - /// Holds raw mailboxes fetched by the `imap` crate in order to extend mailboxes lifetime - /// outside of handlers. Without that, it would be impossible for handlers to return a `Mbox` - /// struct or a `Mboxes` struct due to the `ZeroCopy` constraint. - _raw_mboxes_cache: Option, - _raw_msgs_cache: Option, -} - -impl<'a> ImapService<'a> { - fn sess(&mut self) -> Result<&mut ImapSession> { - if self.sess.is_none() { - debug!("create TLS builder"); - debug!("insecure: {}", self.account.imap_insecure); - let builder = TlsConnector::builder() - .danger_accept_invalid_certs(self.account.imap_insecure) - .danger_accept_invalid_hostnames(self.account.imap_insecure) - .build() - .context("cannot create TLS connector")?; - - debug!("create client"); - debug!("host: {}", self.account.imap_host); - debug!("port: {}", self.account.imap_port); - debug!("starttls: {}", self.account.imap_starttls); - let mut client_builder = - imap::ClientBuilder::new(&self.account.imap_host, self.account.imap_port); - if self.account.imap_starttls { - client_builder.starttls(); - } - let client = client_builder - .connect(|domain, tcp| Ok(TlsConnector::connect(&builder, domain, tcp)?)) - .context("cannot connect to IMAP server")?; - - debug!("create session"); - debug!("login: {}", self.account.imap_login); - debug!("passwd cmd: {}", self.account.imap_passwd_cmd); - let mut sess = client - .login(&self.account.imap_login, &self.account.imap_passwd()?) - .map_err(|res| res.0) - .context("cannot login to IMAP server")?; - sess.debug = log_enabled!(Level::Trace); - self.sess = Some(sess); - } - - match self.sess { - Some(ref mut sess) => Ok(sess), - None => Err(anyhow!("cannot get IMAP session")), - } - } - - fn search_new_msgs(&mut self, account: &Account) -> Result> { - let uids: Vec = self - .sess()? - .uid_search(&account.notify_query) - .context("cannot search new messages")? - .into_iter() - .collect(); - debug!("found {} new messages", uids.len()); - trace!("uids: {:?}", uids); - - Ok(uids) - } -} - -impl<'a> ImapServiceInterface<'a> for ImapService<'a> { - fn fetch_mboxes(&'a mut self) -> Result { - let raw_mboxes = self - .sess()? - .list(Some(""), Some("*")) - .context("cannot list mailboxes")?; - self._raw_mboxes_cache = Some(raw_mboxes); - Ok(Mboxes::from(self._raw_mboxes_cache.as_ref().unwrap())) - } - - fn fetch_envelopes(&mut self, page_size: &usize, page: &usize) -> Result { - debug!("fetch envelopes"); - debug!("page size: {:?}", page_size); - debug!("page: {:?}", page); - - let mbox = self.mbox.to_owned(); - let last_seq = self - .sess()? - .select(&mbox.name) - .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))? - .exists as i64; - debug!("last sequence number: {:?}", last_seq); - - if last_seq == 0 { - return Ok(Envelopes::default()); - } - - // TODO: add tests, improve error management when empty page - 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; - format!("{}:{}", end, begin) - } else { - String::from("1:*") - }; - debug!("range: {}", range); - - let fetches = self - .sess()? - .fetch(&range, "(ENVELOPE FLAGS INTERNALDATE)") - .context(format!(r#"cannot fetch messages within range "{}""#, range))?; - self._raw_msgs_cache = Some(fetches); - Envelopes::try_from(self._raw_msgs_cache.as_ref().unwrap()) - } - - fn fetch_envelopes_with( - &'a mut self, - query: &str, - page_size: &usize, - page: &usize, - ) -> Result { - let mbox = self.mbox.to_owned(); - self.sess()? - .select(&mbox.name) - .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))?; - - let begin = page * page_size; - let end = begin + (page_size - 1); - let seqs: Vec = self - .sess()? - .search(query) - .context(format!( - r#"cannot search in "{}" with query: "{}""#, - self.mbox.name, query - ))? - .iter() - .map(|seq| seq.to_string()) - .collect(); - - if seqs.is_empty() { - return Ok(Envelopes::default()); - } - - // FIXME: panic if begin > end - let range = seqs[begin..end.min(seqs.len())].join(","); - let fetches = self - .sess()? - .fetch(&range, "(ENVELOPE FLAGS INTERNALDATE)") - .context(r#"cannot fetch messages within range "{}""#)?; - self._raw_msgs_cache = Some(fetches); - Envelopes::try_from(self._raw_msgs_cache.as_ref().unwrap()) - } - - /// Find a message by sequence number. - fn find_msg(&mut self, account: &Account, seq: &str) -> Result { - let mbox = self.mbox.to_owned(); - self.sess()? - .select(&mbox.name) - .context(format!("cannot select mailbox {}", self.mbox.name))?; - let fetches = self - .sess()? - .fetch(seq, "(ENVELOPE FLAGS INTERNALDATE BODY[])") - .context(r#"cannot fetch messages "{}""#)?; - let fetch = fetches - .first() - .ok_or_else(|| anyhow!(r#"cannot find message "{}"#, seq))?; - - Msg::try_from((account, fetch)) - } - - fn find_raw_msg(&mut self, seq: &str) -> Result> { - let mbox = self.mbox.to_owned(); - self.sess()? - .select(&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_else(|| 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()? - .append(&mbox.name, msg) - .flags(flags.0) - .finish() - .context(format!(r#"cannot append message to "{}""#, mbox.name))?; - Ok(()) - } - - fn append_msg(&mut self, mbox: &Mbox, account: &Account, msg: Msg) -> Result<()> { - let msg_raw = msg.into_sendable_msg(account)?.formatted(); - self.sess()? - .append(&mbox.name, &msg_raw) - .flags(msg.flags.0) - .finish() - .context(format!(r#"cannot append message to "{}""#, mbox.name))?; - Ok(()) - } - - fn notify(&mut self, config: &Config, account: &Account, keepalive: u64) -> Result<()> { - debug!("notify"); - - let mbox = self.mbox.to_owned(); - - debug!("examine mailbox {:?}", mbox); - self.sess()? - .examine(&mbox.name) - .context(format!("cannot examine mailbox {}", self.mbox.name))?; - - debug!("init messages hashset"); - let mut msgs_set: HashSet = self - .search_new_msgs(account)? - .iter() - .cloned() - .collect::>(); - trace!("messages hashset: {:?}", msgs_set); - - loop { - debug!("begin loop"); - self.sess()? - .idle() - .and_then(|mut idle| { - idle.set_keepalive(std::time::Duration::new(keepalive, 0)); - idle.wait_keepalive_while(|res| { - // TODO: handle response - trace!("idle response: {:?}", res); - false - }) - }) - .context("cannot start the idle mode")?; - - let uids: Vec = self - .search_new_msgs(account)? - .into_iter() - .filter(|uid| -> bool { msgs_set.get(uid).is_none() }) - .collect(); - debug!("found {} new messages not in hashset", uids.len()); - trace!("messages hashet: {:?}", msgs_set); - - if !uids.is_empty() { - let uids = uids - .iter() - .map(|uid| uid.to_string()) - .collect::>() - .join(","); - let fetches = self - .sess()? - .uid_fetch(uids, "(UID ENVELOPE)") - .context("cannot fetch new messages enveloppe")?; - - for fetch in fetches.iter() { - let msg = Envelope::try_from(fetch)?; - let uid = fetch.uid.ok_or_else(|| { - anyhow!("cannot retrieve message {}'s UID", fetch.message) - })?; - - let from = msg.sender.to_owned().into(); - config.run_notify_cmd(&msg.subject, &from)?; - - debug!("notify message: {}", uid); - trace!("message: {:?}", msg); - - debug!("insert message {} in hashset", uid); - msgs_set.insert(uid); - trace!("messages hashset: {:?}", msgs_set); - } - } - - debug!("end loop"); - } - } - - fn watch(&mut self, account: &Account, keepalive: u64) -> Result<()> { - debug!("examine mailbox: {}", &self.mbox.name); - let mbox = self.mbox.to_owned(); - - self.sess()? - .examine(&mbox.name) - .context(format!("cannot examine mailbox `{}`", &self.mbox.name))?; - - loop { - debug!("begin loop"); - self.sess()? - .idle() - .and_then(|mut idle| { - idle.set_keepalive(std::time::Duration::new(keepalive, 0)); - idle.wait_keepalive_while(|res| { - // TODO: handle response - trace!("idle response: {:?}", res); - false - }) - }) - .context("cannot start the idle mode")?; - - let cmds = account.watch_cmds.clone(); - 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); - }) - }); - - debug!("end loop"); - } - } - - fn logout(&mut self) -> Result<()> { - if let Some(ref mut sess) = self.sess { - debug!("logout from IMAP server"); - sess.logout().context("cannot logout from IMAP server")?; - } - Ok(()) - } - - fn add_flags(&mut self, seq_range: &str, flags: &Flags) -> Result<()> { - let mbox = self.mbox; - 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, seq_range: &str, flags: &Flags) -> Result<()> { - let mbox = self.mbox; - 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 set flags "{}""#, &flags))?; - Ok(()) - } - - fn remove_flags(&mut self, seq_range: &str, flags: &Flags) -> Result<()> { - let mbox = self.mbox; - let flags = 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 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<'a>)> for ImapService<'a> { - fn from((account, mbox): (&'a Account, &'a Mbox)) -> Self { - Self { - account, - mbox, - sess: None, - _raw_mboxes_cache: None, - _raw_msgs_cache: None, - } - } -} diff --git a/src/domain/imap/mod.rs b/src/domain/imap/mod.rs deleted file mode 100644 index 55a198c..0000000 --- a/src/domain/imap/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Module related to IMAP. - -pub mod imap_arg; -pub mod imap_handler; - -pub mod imap_service; -pub use imap_service::*; diff --git a/src/domain/mbox/attr_entity.rs b/src/domain/mbox/attr_entity.rs deleted file mode 100644 index eae8d1b..0000000 --- a/src/domain/mbox/attr_entity.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Mailbox attribute entity module. -//! -//! This module contains the definition of the mailbox attribute and its traits implementations. - -pub use imap::types::NameAttribute as AttrRemote; -use serde::Serialize; -use std::{ - borrow::Cow, - fmt::{self, Display}, -}; - -/// Wraps an `imap::types::NameAttribute`. -/// See https://serde.rs/remote-derive.html. -#[derive(Debug, PartialEq, Eq, Hash, Serialize)] -#[serde(remote = "AttrRemote")] -pub enum AttrWrap<'a> { - NoInferiors, - NoSelect, - Marked, - Unmarked, - Custom(Cow<'a, str>), -} - -/// Represents the mailbox attribute. -/// See https://serde.rs/remote-derive.html. -#[derive(Debug, PartialEq, Eq, Hash, Serialize)] -pub struct Attr<'a>(#[serde(with = "AttrWrap")] pub AttrRemote<'a>); - -/// Makes the attribute displayable. -impl<'a> Display for Attr<'a> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match &self.0 { - AttrRemote::NoInferiors => write!(f, "NoInferiors"), - AttrRemote::NoSelect => write!(f, "NoSelect"), - AttrRemote::Marked => write!(f, "Marked"), - AttrRemote::Unmarked => write!(f, "Unmarked"), - AttrRemote::Custom(cow) => write!(f, "{}", cow), - } - } -} - -/// Converts an `imap::types::NameAttribute` into an attribute. -impl<'a> From> for Attr<'a> { - fn from(attr: AttrRemote<'a>) -> Self { - Self(attr) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_should_display_attr() { - macro_rules! attr_from { - ($attr:ident) => { - Attr(AttrRemote::$attr).to_string() - }; - ($custom:literal) => { - Attr(AttrRemote::Custom($custom.into())).to_string() - }; - } - - assert_eq!("NoInferiors", attr_from![NoInferiors]); - assert_eq!("NoSelect", attr_from![NoSelect]); - assert_eq!("Marked", attr_from![Marked]); - assert_eq!("Unmarked", attr_from![Unmarked]); - assert_eq!("CustomAttr", attr_from!["CustomAttr"]); - } -} diff --git a/src/domain/mbox/attrs_entity.rs b/src/domain/mbox/attrs_entity.rs deleted file mode 100644 index 2d9469e..0000000 --- a/src/domain/mbox/attrs_entity.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Mailbox attributes entity module. -//! -//! This module contains the definition of the mailbox attributes and its traits implementations. - -use serde::Serialize; -use std::{ - fmt::{self, Display}, - ops::Deref, -}; - -use crate::domain::{Attr, AttrRemote}; - -/// Represents the attributes of the mailbox. -#[derive(Debug, Default, PartialEq, Eq, Serialize)] -pub struct Attrs<'a>(Vec>); - -/// Converts a vector of `imap::types::NameAttribute` into attributes. -impl<'a> From>> for Attrs<'a> { - fn from(attrs: Vec>) -> Self { - Self(attrs.into_iter().map(Attr::from).collect()) - } -} - -/// Derefs the attributes to its inner hashset. -impl<'a> Deref for Attrs<'a> { - type Target = Vec>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -/// Makes the attributes displayable. -impl<'a> Display for Attrs<'a> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let mut glue = ""; - for attr in self.iter() { - write!(f, "{}{}", glue, attr)?; - glue = ", "; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_should_display_attrs() { - macro_rules! attrs_from { - ($($attr:expr),*) => { - Attrs::from(vec![$($attr,)*]).to_string() - }; - } - - let empty_attr = attrs_from![]; - let single_attr = attrs_from![AttrRemote::NoInferiors]; - let multiple_attrs = attrs_from![ - AttrRemote::Custom("AttrCustom".into()), - AttrRemote::NoInferiors - ]; - - assert_eq!("", empty_attr); - assert_eq!("NoInferiors", single_attr); - assert!(multiple_attrs.contains("NoInferiors")); - assert!(multiple_attrs.contains("AttrCustom")); - assert!(multiple_attrs.contains(",")); - } -} diff --git a/src/domain/mbox/mbox_entity.rs b/src/domain/mbox/mbox_entity.rs deleted file mode 100644 index f809807..0000000 --- a/src/domain/mbox/mbox_entity.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Mailbox entity module. -//! -//! This module contains the definition of the mailbox and its traits implementations. - -use serde::Serialize; -use std::{ - borrow::Cow, - fmt::{self, Display}, -}; - -use crate::{ - domain::Attrs, - ui::{Cell, Row, Table}, -}; - -/// Represents a raw mailbox returned by the `imap` crate. -pub type RawMbox = imap::types::Name; - -/// Represents a mailbox. -#[derive(Debug, Default, PartialEq, Eq, Serialize)] -pub struct Mbox<'a> { - /// Represents the mailbox hierarchie delimiter. - pub delim: Cow<'a, str>, - - /// Represents the mailbox name. - pub name: Cow<'a, str>, - - /// Represents the mailbox attributes. - pub attrs: Attrs<'a>, -} - -impl<'a> Mbox<'a> { - /// Creates a new mailbox with just a name. - pub fn new(name: &'a str) -> Self { - Self { - name: name.into(), - ..Self::default() - } - } -} - -/// Makes the mailbox displayable. -impl<'a> Display for Mbox<'a> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.name) - } -} - -/// Makes the mailbox tableable. -impl<'a> Table for Mbox<'a> { - fn head() -> Row { - Row::new() - .cell(Cell::new("DELIM").bold().underline().white()) - .cell(Cell::new("NAME").bold().underline().white()) - .cell( - Cell::new("ATTRIBUTES") - .shrinkable() - .bold() - .underline() - .white(), - ) - } - - fn row(&self) -> Row { - Row::new() - .cell(Cell::new(&self.delim).white()) - .cell(Cell::new(&self.name).green()) - .cell(Cell::new(&self.attrs.to_string()).shrinkable().blue()) - } -} - -/// Converts an `imap::types::Name` into a mailbox. -impl<'a> From<&'a imap::types::Name> for Mbox<'a> { - fn from(raw_mbox: &'a imap::types::Name) -> Self { - Self { - delim: raw_mbox.delimiter().unwrap_or_default().into(), - name: raw_mbox.name().into(), - attrs: Attrs::from(raw_mbox.attributes().to_vec()), - } - } -} - -#[cfg(test)] -mod tests { - use super::super::AttrRemote; - use super::*; - - #[test] - fn it_should_create_new_mbox() { - assert_eq!(Mbox::default(), Mbox::new("")); - assert_eq!( - Mbox { - delim: Cow::default(), - name: "INBOX".into(), - attrs: Attrs::default() - }, - Mbox::new("INBOX") - ); - } - - #[test] - fn it_should_display_mbox() { - let default_mbox = Mbox::default(); - assert_eq!("", default_mbox.to_string()); - - let new_mbox = Mbox::new("INBOX"); - assert_eq!("INBOX", new_mbox.to_string()); - - let full_mbox = Mbox { - delim: ".".into(), - name: "Sent".into(), - attrs: Attrs::from(vec![AttrRemote::NoSelect]), - }; - assert_eq!("Sent", full_mbox.to_string()); - } -} diff --git a/src/domain/mbox/mboxes_entity.rs b/src/domain/mbox/mboxes_entity.rs deleted file mode 100644 index e5a0130..0000000 --- a/src/domain/mbox/mboxes_entity.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Mailboxes entity module. -//! -//! This module contains the definition of the mailboxes and its traits implementations. - -use anyhow::Result; -use serde::Serialize; -use std::ops::Deref; - -use crate::{ - domain::{Mbox, RawMbox}, - output::{PrintTable, PrintTableOpts, WriteColor}, - ui::Table, -}; - -/// Represents a list of raw mailboxes returned by the `imap` crate. -pub(crate) type RawMboxes = imap::types::ZeroCopy>; - -/// Represents a list of mailboxes. -#[derive(Debug, Default, Serialize)] -pub struct Mboxes<'a>(pub Vec>); - -/// Derefs the mailboxes to its inner vector. -impl<'a> Deref for Mboxes<'a> { - type Target = Vec>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -/// Makes the mailboxes printable. -impl<'a> PrintTable for Mboxes<'a> { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; - Ok(()) - } -} - -/// Converts a list of `imap::types::Name` into mailboxes. -impl<'a> From<&'a RawMboxes> for Mboxes<'a> { - fn from(raw_mboxes: &'a RawMboxes) -> Mboxes<'a> { - Self(raw_mboxes.iter().map(Mbox::from).collect()) - } -} diff --git a/src/domain/mbox/mod.rs b/src/domain/mbox/mod.rs deleted file mode 100644 index 7404381..0000000 --- a/src/domain/mbox/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Mailbox module. -//! -//! This module contains everything related to mailbox. - -pub mod mbox_arg; -pub mod mbox_handler; - -pub mod attr_entity; -pub use attr_entity::*; - -pub mod attrs_entity; -pub use attrs_entity::*; - -pub mod mbox_entity; -pub use mbox_entity::*; - -pub mod mboxes_entity; -pub use mboxes_entity::*; diff --git a/src/domain/mod.rs b/src/domain/mod.rs deleted file mode 100644 index e90834f..0000000 --- a/src/domain/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! 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/envelopes_entity.rs b/src/domain/msg/envelopes_entity.rs deleted file mode 100644 index 802153f..0000000 --- a/src/domain/msg/envelopes_entity.rs +++ /dev/null @@ -1,46 +0,0 @@ -use anyhow::{Error, Result}; -use serde::Serialize; -use std::{convert::TryFrom, ops::Deref}; - -use crate::{ - domain::{msg::Envelope, RawEnvelope}, - output::{PrintTable, PrintTableOpts, WriteColor}, - ui::Table, -}; - -pub type RawEnvelopes = imap::types::ZeroCopy>; - -/// Representation of a list of envelopes. -#[derive(Debug, Default, Serialize)] -pub struct Envelopes<'a>(pub Vec>); - -impl<'a> Deref for Envelopes<'a> { - type Target = Vec>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl<'a> TryFrom<&'a RawEnvelopes> for Envelopes<'a> { - type Error = Error; - - fn try_from(fetches: &'a RawEnvelopes) -> Result { - let mut envelopes = vec![]; - - for fetch in fetches.iter().rev() { - envelopes.push(Envelope::try_from(fetch)?); - } - - Ok(Self(envelopes)) - } -} - -impl<'a> PrintTable for Envelopes<'a> { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; - Ok(()) - } -} diff --git a/src/domain/msg/flag_entity.rs b/src/domain/msg/flag_entity.rs deleted file mode 100644 index 127fdb4..0000000 --- a/src/domain/msg/flag_entity.rs +++ /dev/null @@ -1,31 +0,0 @@ -pub use imap::types::Flag; -use serde::ser::{Serialize, Serializer}; - -/// Represents a serializable `imap::types::Flag`. -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct SerializableFlag<'a>(pub &'a Flag<'a>); - -/// Implements the serialize trait for `imap::types::Flag`. -/// Remote serialization cannot be used because of the [#[non_exhaustive]] directive of -/// `imap::types::Flag`. -/// -/// [#[non_exhaustive]]: https://github.com/serde-rs/serde/issues/1991 -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 deleted file mode 100644 index 4d7ad39..0000000 --- a/src/domain/msg/flag_handler.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Message flag handling module. -//! -//! This module gathers all flag actions triggered by the CLI. - -use anyhow::Result; - -use crate::{ - domain::{Flags, ImapServiceInterface}, - output::PrinterService, -}; - -/// Adds flags to all messages matching the given sequence range. -/// Flags are case-insensitive, and they do not need to be prefixed with `\`. -pub fn add<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq_range: &'a str, - flags: Vec<&'a str>, - printer: &'a mut Printer, - imap: &'a mut ImapService, -) -> Result<()> { - let flags = Flags::from(flags); - imap.add_flags(seq_range, &flags)?; - printer.print(format!( - r#"Flag(s) "{}" successfully added to message(s) "{}""#, - flags, seq_range - )) -} - -/// Removes flags from all messages matching the given sequence range. -/// Flags are case-insensitive, and they do not need to be prefixed with `\`. -pub fn remove<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq_range: &'a str, - flags: Vec<&'a str>, - printer: &'a mut Printer, - imap: &'a mut ImapService, -) -> Result<()> { - let flags = Flags::from(flags); - imap.remove_flags(seq_range, &flags)?; - printer.print(format!( - r#"Flag(s) "{}" successfully removed from message(s) "{}""#, - flags, seq_range - )) -} - -/// Replaces flags of all messages matching the given sequence range. -/// Flags are case-insensitive, and they do not need to be prefixed with `\`. -pub fn set<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq_range: &'a str, - flags: Vec<&'a str>, - printer: &'a mut Printer, - imap: &'a mut ImapService, -) -> Result<()> { - let flags = Flags::from(flags); - imap.set_flags(seq_range, &flags)?; - printer.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 deleted file mode 100644 index bc8590b..0000000 --- a/src/domain/msg/flags_entity.rs +++ /dev/null @@ -1,197 +0,0 @@ -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}; - -/// Represents the flags of the message. -/// A hashset is used to avoid duplicates. -#[derive(Debug, Clone, Default)] -pub struct Flags(pub HashSet>); - -impl Flags { - /// Builds 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() - } -} - -impl<'a> From> for Flags { - fn from(flags: Vec<&'a str>) -> Self { - let mut map: HashSet> = HashSet::new(); - - for f in flags { - match f.to_lowercase().as_str() { - "answered" => map.insert(Flag::Answered), - "deleted" => map.insert(Flag::Deleted), - "draft" => map.insert(Flag::Draft), - "flagged" => map.insert(Flag::Flagged), - "maycreate" => map.insert(Flag::MayCreate), - "recent" => map.insert(Flag::Recent), - "seen" => map.insert(Flag::Seen), - custom => map.insert(Flag::Custom(Cow::Owned(custom.into()))), - }; - } - - Self(map) - } -} - -// FIXME -//#[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/mod.rs b/src/domain/msg/mod.rs deleted file mode 100644 index ad12d4b..0000000 --- a/src/domain/msg/mod.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! This module holds everything which is related to a **Msg**/**Mail**. Here are -//! structs which **represent the data** in Msgs/Mails. - -/// Includes the following subcommands: -/// - `list` -/// - `search` -/// - `write` -/// - `send` -/// - `save` -/// - `read` -/// - `attachments` -/// - `reply` -/// - `forward` -/// - `copy` -/// - `move` -/// - `delete` -/// - `template` -/// -/// Execute `himalaya help ` where `` is one entry of this list above -/// to get more information about them. -pub mod msg_arg; - -pub mod msg_handler; -pub mod msg_utils; - -pub mod flag_arg; -pub mod flag_handler; - -pub mod flag_entity; -pub use flag_entity::*; - -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 msg_entity; -pub use msg_entity::*; - -pub mod parts_entity; -pub use parts_entity::*; diff --git a/src/domain/msg/msg_handler.rs b/src/domain/msg/msg_handler.rs deleted file mode 100644 index 12a0572..0000000 --- a/src/domain/msg/msg_handler.rs +++ /dev/null @@ -1,365 +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 log::{debug, info, 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}, - smtp::SmtpServiceInterface, - Parts, - }, - output::{PrintTableOpts, PrinterService}, -}; - -/// Download all message attachments to the user account downloads directory. -pub fn attachments<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq: &str, - account: &Account, - printer: &mut Printer, - imap: &mut ImapService, -) -> Result<()> { - let attachments = imap.find_msg(account, 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))?; - } - - printer.print(format!( - "{} attachment(s) successfully downloaded to {:?}", - attachments_len, account.downloads_dir - )) -} - -/// Copy a message from a mailbox to another. -pub fn copy<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq: &str, - mbox: &str, - printer: &mut Printer, - imap: &mut ImapService, -) -> Result<()> { - let mbox = Mbox::new(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)?; - printer.print(format!( - r#"Message {} successfully copied to folder "{}""#, - seq, mbox - )) -} - -/// Delete messages matching the given sequence range. -pub fn delete<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq: &str, - printer: &mut Printer, - imap: &mut ImapService, -) -> Result<()> { - let flags = Flags::try_from(vec![Flag::Seen, Flag::Deleted])?; - imap.add_flags(seq, &flags)?; - imap.expunge()?; - printer.print(format!(r#"Message(s) {} successfully deleted"#, seq)) -} - -/// Forward the given message UID from the selected mailbox. -pub fn forward< - 'a, - Printer: PrinterService, - ImapService: ImapServiceInterface<'a>, - SmtpService: SmtpServiceInterface, ->( - seq: &str, - attachments_paths: Vec<&str>, - encrypt: bool, - account: &Account, - printer: &mut Printer, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - imap.find_msg(account, seq)? - .into_forward(account)? - .add_attachments(attachments_paths)? - .encrypt(encrypt) - .edit_with_editor(account, printer, imap, smtp) -} - -/// List paginated messages from the selected mailbox. -pub fn list<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - max_width: Option, - page_size: Option, - page: usize, - account: &Account, - printer: &mut Printer, - imap: &'a mut ImapService, -) -> Result<()> { - let page_size = page_size.unwrap_or(account.default_page_size); - trace!("page size: {}", page_size); - - let msgs = imap.fetch_envelopes(&page_size, &page)?; - trace!("messages: {:#?}", msgs); - printer.print_table(msgs, PrintTableOpts { max_width }) -} - -/// Parses and edits a message from a [mailto] URL string. -/// -/// [mailto]: https://en.wikipedia.org/wiki/Mailto -pub fn mailto< - 'a, - Printer: PrinterService, - ImapService: ImapServiceInterface<'a>, - SmtpService: SmtpServiceInterface, ->( - url: &Url, - account: &Account, - printer: &mut Printer, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - info!("entering mailto command handler"); - - 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 msg = Msg { - from: Some(vec![account.address().parse()?]), - to: if to.is_empty() { None } else { Some(to) }, - cc: if cc.is_empty() { None } else { Some(cc) }, - bcc: if bcc.is_empty() { None } else { Some(bcc) }, - subject: subject.into(), - parts: Parts(vec![Part::TextPlain(TextPlainPart { - content: body.into(), - })]), - ..Msg::default() - }; - trace!("message: {:?}", msg); - - msg.edit_with_editor(account, printer, imap, smtp) -} - -/// Move a message from a mailbox to another. -pub fn move_<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - // The sequence number of the message to move - seq: &str, - // The mailbox to move the message in - mbox: &str, - printer: &mut Printer, - imap: &mut ImapService, -) -> Result<()> { - // Copy the message to targetted mailbox - let mbox = Mbox::new(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()?; - - printer.print(format!( - r#"Message {} successfully moved to folder "{}""#, - seq, mbox - )) -} - -/// Read a message by its sequence number. -pub fn read<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq: &str, - text_mime: &str, - raw: bool, - account: &Account, - printer: &mut Printer, - imap: &mut ImapService, -) -> Result<()> { - let msg = if raw { - // Emails don't always have valid utf8. Using "lossy" to display what we can. - String::from_utf8_lossy(&imap.find_raw_msg(seq)?).into_owned() - } else { - imap.find_msg(account, seq)?.fold_text_parts(text_mime) - }; - - printer.print(msg) -} - -/// Reply to the given message UID. -pub fn reply< - 'a, - Printer: PrinterService, - ImapService: ImapServiceInterface<'a>, - SmtpService: SmtpServiceInterface, ->( - seq: &str, - all: bool, - attachments_paths: Vec<&str>, - encrypt: bool, - account: &Account, - printer: &mut Printer, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - imap.find_msg(account, seq)? - .into_reply(all, account)? - .add_attachments(attachments_paths)? - .encrypt(encrypt) - .edit_with_editor(account, printer, imap, smtp)?; - let flags = Flags::try_from(vec![Flag::Answered])?; - imap.add_flags(seq, &flags) -} - -/// Saves a raw message to the targetted mailbox. -pub fn save<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - mbox: &Mbox, - raw_msg: &str, - printer: &mut Printer, - imap: &mut ImapService, -) -> Result<()> { - info!("entering save message handler"); - - debug!("mailbox: {}", mbox); - let flags = Flags::try_from(vec![Flag::Seen])?; - debug!("flags: {}", flags); - - let is_tty = atty::is(Stream::Stdin); - debug!("is tty: {}", is_tty); - let is_json = printer.is_json(); - debug!("is json: {}", is_json); - - let raw_msg = if is_tty || is_json { - raw_msg.replace("\r", "").replace("\n", "\r\n") - } else { - io::stdin() - .lock() - .lines() - .filter_map(Result::ok) - .collect::>() - .join("\r\n") - }; - imap.append_raw_msg_with_flags(mbox, raw_msg.as_bytes(), flags) -} - -/// Paginate messages from the selected mailbox matching the specified query. -pub fn search<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - query: String, - max_width: Option, - page_size: Option, - page: usize, - account: &Account, - printer: &mut Printer, - imap: &'a mut ImapService, -) -> Result<()> { - let page_size = page_size.unwrap_or(account.default_page_size); - trace!("page size: {}", page_size); - - let msgs = imap.fetch_envelopes_with(&query, &page_size, &page)?; - trace!("messages: {:#?}", msgs); - printer.print_table(msgs, PrintTableOpts { max_width }) -} - -/// Send a raw message. -pub fn send< - 'a, - Printer: PrinterService, - ImapService: ImapServiceInterface<'a>, - SmtpService: SmtpServiceInterface, ->( - raw_msg: &str, - account: &Account, - printer: &mut Printer, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - info!("entering send message handler"); - - let mbox = Mbox::new(&account.sent_folder); - debug!("mailbox: {}", mbox); - let flags = Flags::try_from(vec![Flag::Seen])?; - debug!("flags: {}", flags); - - let is_tty = atty::is(Stream::Stdin); - debug!("is tty: {}", is_tty); - let is_json = printer.is_json(); - debug!("is json: {}", is_json); - - let raw_msg = if is_tty || is_json { - raw_msg.replace("\r", "").replace("\n", "\r\n") - } else { - io::stdin() - .lock() - .lines() - .filter_map(Result::ok) - .collect::>() - .join("\r\n") - }; - trace!("raw message: {:?}", raw_msg); - let envelope: lettre::address::Envelope = Msg::from_tpl(&raw_msg)?.try_into()?; - trace!("envelope: {:?}", envelope); - - smtp.send_raw_msg(&envelope, raw_msg.as_bytes())?; - imap.append_raw_msg_with_flags(&mbox, raw_msg.as_bytes(), flags) -} - -/// Compose a new message. -pub fn write< - 'a, - Printer: PrinterService, - ImapService: ImapServiceInterface<'a>, - SmtpService: SmtpServiceInterface, ->( - attachments_paths: Vec<&str>, - encrypt: bool, - account: &Account, - printer: &mut Printer, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - Msg::default() - .add_attachments(attachments_paths)? - .encrypt(encrypt) - .edit_with_editor(account, printer, imap, smtp) -} diff --git a/src/domain/msg/tpl_handler.rs b/src/domain/msg/tpl_handler.rs deleted file mode 100644 index c809e4d..0000000 --- a/src/domain/msg/tpl_handler.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Module related to message template handling. -//! -//! This module gathers all message template commands. - -use anyhow::Result; -use atty::Stream; -use imap::types::Flag; -use std::{ - convert::TryFrom, - io::{self, BufRead}, -}; - -use crate::{ - config::Account, - domain::{ - imap::ImapServiceInterface, - msg::{Msg, TplOverride}, - Flags, Mbox, SmtpServiceInterface, - }, - output::PrinterService, -}; - -/// Generate a new message template. -pub fn new<'a, Printer: PrinterService>( - opts: TplOverride<'a>, - account: &'a Account, - printer: &'a mut Printer, -) -> Result<()> { - let tpl = Msg::default().to_tpl(opts, account); - printer.print(tpl) -} - -/// Generate a reply message template. -pub fn reply<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq: &str, - all: bool, - opts: TplOverride<'a>, - account: &'a Account, - printer: &'a mut Printer, - imap: &'a mut ImapService, -) -> Result<()> { - let tpl = imap - .find_msg(account, seq)? - .into_reply(all, account)? - .to_tpl(opts, account); - printer.print(tpl) -} - -/// Generate a forward message template. -pub fn forward<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - seq: &str, - opts: TplOverride<'a>, - account: &'a Account, - printer: &'a mut Printer, - imap: &'a mut ImapService, -) -> Result<()> { - let tpl = imap - .find_msg(account, seq)? - .into_forward(account)? - .to_tpl(opts, account); - printer.print(tpl) -} - -/// Saves a message based on a template. -pub fn save<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( - mbox: &Mbox, - account: &Account, - attachments_paths: Vec<&str>, - tpl: &str, - printer: &mut Printer, - imap: &mut ImapService, -) -> Result<()> { - let tpl = if atty::is(Stream::Stdin) || printer.is_json() { - tpl.replace("\r", "") - } else { - io::stdin() - .lock() - .lines() - .filter_map(Result::ok) - .collect::>() - .join("\n") - }; - let msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?; - let raw_msg = msg.into_sendable_msg(account)?.formatted(); - let flags = Flags::try_from(vec![Flag::Seen])?; - imap.append_raw_msg_with_flags(mbox, &raw_msg, flags)?; - printer.print("Template successfully saved") -} - -/// Sends a message based on a template. -pub fn send< - 'a, - Printer: PrinterService, - ImapService: ImapServiceInterface<'a>, - SmtpService: SmtpServiceInterface, ->( - mbox: &Mbox, - account: &Account, - attachments_paths: Vec<&str>, - tpl: &str, - printer: &mut Printer, - imap: &mut ImapService, - smtp: &mut SmtpService, -) -> Result<()> { - let tpl = if atty::is(Stream::Stdin) || printer.is_json() { - tpl.replace("\r", "") - } else { - io::stdin() - .lock() - .lines() - .filter_map(Result::ok) - .collect::>() - .join("\n") - }; - let msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?; - let sent_msg = smtp.send_msg(account, &msg)?; - let flags = Flags::try_from(vec![Flag::Seen])?; - imap.append_raw_msg_with_flags(mbox, &sent_msg.formatted(), flags)?; - printer.print("Template successfully sent") -} diff --git a/src/domain/smtp/mod.rs b/src/domain/smtp/mod.rs deleted file mode 100644 index b593f23..0000000 --- a/src/domain/smtp/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Module related to SMTP. - -pub mod smtp_service; -pub use smtp_service::*; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..625792f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,88 @@ +pub mod mbox { + pub mod mbox; + pub use mbox::*; + + pub mod mbox_arg; + pub mod mbox_handler; +} + +pub mod msg { + pub mod envelope; + pub use envelope::*; + + pub mod msg_arg; + + pub mod msg_handler; + pub mod msg_utils; + + pub mod flag_arg; + pub mod flag_handler; + + pub mod tpl_arg; + pub use tpl_arg::TplOverride; + + pub mod tpl_handler; + + pub mod msg_entity; + pub use msg_entity::*; + + pub mod parts_entity; + pub use parts_entity::*; + + pub mod addr_entity; + pub use addr_entity::*; +} + +pub mod backends { + pub use backend::*; + pub mod backend; + + pub use self::imap::*; + pub mod imap { + pub mod imap_arg; + + pub use imap_backend::*; + pub mod imap_backend; + + pub mod imap_handler; + + pub use imap_mbox::*; + pub mod imap_mbox; + + pub use imap_mbox_attr::*; + pub mod imap_mbox_attr; + + pub use imap_envelope::*; + pub mod imap_envelope; + + pub use imap_flag::*; + pub mod imap_flag; + + pub mod msg_sort_criterion; + } + + pub use self::maildir::*; + pub mod maildir { + pub mod maildir_backend; + pub use maildir_backend::*; + + pub mod maildir_mbox; + pub use maildir_mbox::*; + + pub mod maildir_envelope; + pub use maildir_envelope::*; + + pub mod maildir_flag; + pub use maildir_flag::*; + } +} + +pub mod smtp { + pub mod smtp_service; + pub use smtp_service::*; +} + +pub mod compl; +pub mod config; +pub mod output; +pub mod ui; diff --git a/src/main.rs b/src/main.rs index 8ea73d2..c11fee0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,16 @@ use anyhow::Result; -use output::StdoutPrinter; use std::{convert::TryFrom, env}; use url::Url; -mod compl; -mod config; -mod domain; -mod output; -mod ui; - -use compl::{compl_arg, compl_handler}; -use config::{config_arg, Account, Config}; -use domain::{ - imap::{imap_arg, imap_handler, ImapService, ImapServiceInterface}, - mbox::{mbox_arg, mbox_handler, Mbox}, +use himalaya::{ + backends::{imap_arg, imap_handler, Backend, ImapBackend, MaildirBackend}, + compl::{compl_arg, compl_handler}, + config::{account_args, config_args, AccountConfig, BackendConfig, DeserializedConfig}, + mbox::{mbox_arg, mbox_handler}, msg::{flag_arg, flag_handler, msg_arg, msg_handler, tpl_arg, tpl_handler}, - smtp::SmtpService, + output::{output_arg, OutputFmt, StdoutPrinter}, + smtp::LettreService, }; -use output::{output_arg, OutputFmt}; fn create_app<'a>() -> clap::App<'a, 'a> { clap::App::new(env!("CARGO_PKG_NAME")) @@ -25,7 +18,8 @@ fn create_app<'a>() -> clap::App<'a, 'a> { .about(env!("CARGO_PKG_DESCRIPTION")) .author(env!("CARGO_PKG_AUTHORS")) .global_setting(clap::AppSettings::GlobalVersion) - .args(&config_arg::args()) + .arg(&config_args::path_arg()) + .arg(&account_args::name_arg()) .args(&output_arg::args()) .arg(mbox_arg::source_arg()) .subcommands(compl_arg::subcmds()) @@ -42,14 +36,27 @@ fn main() -> Result<()> { // Check mailto command BEFORE app initialization. let raw_args: Vec = env::args().collect(); if raw_args.len() > 1 && raw_args[1].starts_with("mailto:") { - let config = Config::try_from(None)?; - let account = Account::try_from((&config, None))?; - let mbox = Mbox::new(&account.inbox_folder); + let config = DeserializedConfig::from_opt_path(None)?; + let (account_config, backend_config) = + AccountConfig::from_config_and_opt_account_name(&config, None)?; let mut printer = StdoutPrinter::from(OutputFmt::Plain); 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, &mut printer, &mut imap, &mut smtp); + let mut smtp = LettreService::from(&account_config); + + let mut imap; + let mut maildir; + let backend: Box<&mut dyn Backend> = match backend_config { + BackendConfig::Imap(ref imap_config) => { + imap = ImapBackend::new(&account_config, imap_config); + Box::new(&mut imap) + } + BackendConfig::Maildir(ref maildir_config) => { + maildir = MaildirBackend::new(&account_config, maildir_config); + Box::new(&mut maildir) + } + }; + + return msg_handler::mailto(&url, &account_config, &mut printer, backend, &mut smtp); } let app = create_app(); @@ -65,135 +72,192 @@ fn main() -> Result<()> { } // Init entities and services. - let config = Config::try_from(m.value_of("config"))?; - let account = Account::try_from((&config, m.value_of("account")))?; - let mbox = Mbox::new(m.value_of("mbox-source").unwrap_or(&account.inbox_folder)); + let config = DeserializedConfig::from_opt_path(m.value_of("config"))?; + let (account_config, backend_config) = + AccountConfig::from_config_and_opt_account_name(&config, m.value_of("account"))?; + let mbox = m + .value_of("mbox-source") + .unwrap_or(&account_config.inbox_folder); let mut printer = StdoutPrinter::try_from(m.value_of("output"))?; - let mut imap = ImapService::from((&account, &mbox)); - let mut smtp = SmtpService::from(&account); + let mut imap; + let mut maildir; + let backend: Box<&mut dyn Backend> = match backend_config { + BackendConfig::Imap(ref imap_config) => { + imap = ImapBackend::new(&account_config, imap_config); + Box::new(&mut imap) + } + BackendConfig::Maildir(ref maildir_config) => { + maildir = MaildirBackend::new(&account_config, maildir_config); + Box::new(&mut maildir) + } + }; + + let mut smtp = LettreService::from(&account_config); // Check IMAP commands. - match imap_arg::matches(&m)? { - Some(imap_arg::Command::Notify(keepalive)) => { - return imap_handler::notify(keepalive, &config, &account, &mut imap); + if let BackendConfig::Imap(ref imap_config) = backend_config { + let mut imap = ImapBackend::new(&account_config, imap_config); + match imap_arg::matches(&m)? { + Some(imap_arg::Command::Notify(keepalive)) => { + return imap_handler::notify(keepalive, mbox, &mut imap); + } + Some(imap_arg::Command::Watch(keepalive)) => { + return imap_handler::watch(keepalive, mbox, &mut imap); + } + _ => (), } - Some(imap_arg::Command::Watch(keepalive)) => { - return imap_handler::watch(keepalive, &account, &mut imap); - } - _ => (), } // Check mailbox commands. match mbox_arg::matches(&m)? { Some(mbox_arg::Cmd::List(max_width)) => { - return mbox_handler::list(max_width, &mut printer, &mut imap); + return mbox_handler::list(max_width, &mut printer, backend); } _ => (), } // Check message commands. match msg_arg::matches(&m)? { - Some(msg_arg::Command::Attachments(seq)) => { - return msg_handler::attachments(seq, &account, &mut printer, &mut imap); + Some(msg_arg::Cmd::Attachments(seq)) => { + return msg_handler::attachments(seq, mbox, &account_config, &mut printer, backend); } - Some(msg_arg::Command::Copy(seq, mbox)) => { - return msg_handler::copy(seq, mbox, &mut printer, &mut imap); + Some(msg_arg::Cmd::Copy(seq, mbox_dst)) => { + return msg_handler::copy(seq, mbox, mbox_dst, &mut printer, backend); } - Some(msg_arg::Command::Delete(seq)) => { - return msg_handler::delete(seq, &mut printer, &mut imap); + Some(msg_arg::Cmd::Delete(seq)) => { + return msg_handler::delete(seq, mbox, &mut printer, backend); } - Some(msg_arg::Command::Forward(seq, attachment_paths, encrypt)) => { + Some(msg_arg::Cmd::Forward(seq, attachment_paths, encrypt)) => { return msg_handler::forward( seq, attachment_paths, encrypt, - &account, + mbox, + &account_config, &mut printer, - &mut imap, + backend, &mut smtp, ); } - Some(msg_arg::Command::List(max_width, page_size, page)) => { + Some(msg_arg::Cmd::List(max_width, page_size, page)) => { return msg_handler::list( max_width, page_size, page, - &account, + mbox, + &account_config, &mut printer, - &mut imap, + backend, ); } - Some(msg_arg::Command::Move(seq, mbox)) => { - return msg_handler::move_(seq, mbox, &mut printer, &mut imap); + Some(msg_arg::Cmd::Move(seq, mbox_dst)) => { + return msg_handler::move_(seq, mbox, mbox_dst, &mut printer, backend); } - Some(msg_arg::Command::Read(seq, text_mime, raw)) => { - return msg_handler::read(seq, text_mime, raw, &account, &mut printer, &mut imap); + Some(msg_arg::Cmd::Read(seq, text_mime, raw)) => { + return msg_handler::read(seq, text_mime, raw, mbox, &mut printer, backend); } - Some(msg_arg::Command::Reply(seq, all, attachment_paths, encrypt)) => { + Some(msg_arg::Cmd::Reply(seq, all, attachment_paths, encrypt)) => { return msg_handler::reply( seq, all, attachment_paths, encrypt, - &account, + mbox, + &account_config, &mut printer, - &mut imap, + backend, &mut smtp, ); } - Some(msg_arg::Command::Save(raw_msg)) => { - return msg_handler::save(&mbox, raw_msg, &mut printer, &mut imap); + Some(msg_arg::Cmd::Save(raw_msg)) => { + return msg_handler::save(mbox, raw_msg, &mut printer, backend); } - Some(msg_arg::Command::Search(query, max_width, page_size, page)) => { + Some(msg_arg::Cmd::Search(query, max_width, page_size, page)) => { return msg_handler::search( query, max_width, page_size, page, - &account, + mbox, + &account_config, &mut printer, - &mut imap, + backend, ); } - Some(msg_arg::Command::Send(raw_msg)) => { - return msg_handler::send(raw_msg, &account, &mut printer, &mut imap, &mut smtp); + Some(msg_arg::Cmd::Sort(criteria, query, max_width, page_size, page)) => { + return msg_handler::sort( + criteria, + query, + max_width, + page_size, + page, + mbox, + &account_config, + &mut printer, + backend, + ); } - Some(msg_arg::Command::Write(atts, encrypt)) => { - return msg_handler::write(atts, encrypt, &account, &mut printer, &mut imap, &mut smtp); + Some(msg_arg::Cmd::Send(raw_msg)) => { + return msg_handler::send(raw_msg, &account_config, &mut printer, backend, &mut smtp); } - Some(msg_arg::Command::Flag(m)) => match m { - Some(flag_arg::Command::Set(seq_range, flags)) => { - return flag_handler::set(seq_range, flags, &mut printer, &mut imap); + Some(msg_arg::Cmd::Write(atts, encrypt)) => { + return msg_handler::write( + atts, + encrypt, + &account_config, + &mut printer, + backend, + &mut smtp, + ); + } + Some(msg_arg::Cmd::Flag(m)) => match m { + Some(flag_arg::Cmd::Set(seq_range, flags)) => { + return flag_handler::set(seq_range, mbox, &flags, &mut printer, backend); } - Some(flag_arg::Command::Add(seq_range, flags)) => { - return flag_handler::add(seq_range, flags, &mut printer, &mut imap); + Some(flag_arg::Cmd::Add(seq_range, flags)) => { + return flag_handler::add(seq_range, mbox, &flags, &mut printer, backend); } - Some(flag_arg::Command::Remove(seq_range, flags)) => { - return flag_handler::remove(seq_range, flags, &mut printer, &mut imap); + Some(flag_arg::Cmd::Remove(seq_range, flags)) => { + return flag_handler::remove(seq_range, mbox, &flags, &mut printer, backend); } _ => (), }, - Some(msg_arg::Command::Tpl(m)) => match m { - Some(tpl_arg::Command::New(tpl)) => { - return tpl_handler::new(tpl, &account, &mut printer); + Some(msg_arg::Cmd::Tpl(m)) => match m { + Some(tpl_arg::Cmd::New(tpl)) => { + return tpl_handler::new(tpl, &account_config, &mut printer); } - Some(tpl_arg::Command::Reply(seq, all, tpl)) => { - return tpl_handler::reply(seq, all, tpl, &account, &mut printer, &mut imap); + Some(tpl_arg::Cmd::Reply(seq, all, tpl)) => { + return tpl_handler::reply( + seq, + all, + tpl, + mbox, + &account_config, + &mut printer, + backend, + ); } - Some(tpl_arg::Command::Forward(seq, tpl)) => { - return tpl_handler::forward(seq, tpl, &account, &mut printer, &mut imap); + Some(tpl_arg::Cmd::Forward(seq, tpl)) => { + return tpl_handler::forward( + seq, + tpl, + mbox, + &account_config, + &mut printer, + backend, + ); } - Some(tpl_arg::Command::Save(atts, tpl)) => { - return tpl_handler::save(&mbox, &account, atts, tpl, &mut printer, &mut imap); + Some(tpl_arg::Cmd::Save(atts, tpl)) => { + return tpl_handler::save(mbox, &account_config, atts, tpl, &mut printer, backend); } - Some(tpl_arg::Command::Send(atts, tpl)) => { + Some(tpl_arg::Cmd::Send(atts, tpl)) => { return tpl_handler::send( - &mbox, - &account, + mbox, + &account_config, atts, tpl, &mut printer, - &mut imap, + backend, &mut smtp, ); } @@ -202,5 +266,5 @@ fn main() -> Result<()> { _ => (), } - imap.logout() + backend.disconnect() } diff --git a/src/mbox/mbox.rs b/src/mbox/mbox.rs new file mode 100644 index 0000000..45f7713 --- /dev/null +++ b/src/mbox/mbox.rs @@ -0,0 +1,7 @@ +use std::fmt; + +use crate::output::PrintTable; + +pub trait Mboxes: fmt::Debug + erased_serde::Serialize + PrintTable { + // +} diff --git a/src/domain/mbox/mbox_arg.rs b/src/mbox/mbox_arg.rs similarity index 100% rename from src/domain/mbox/mbox_arg.rs rename to src/mbox/mbox_arg.rs diff --git a/src/domain/mbox/mbox_handler.rs b/src/mbox/mbox_handler.rs similarity index 58% rename from src/domain/mbox/mbox_handler.rs rename to src/mbox/mbox_handler.rs index b08f8c3..7bb27e4 100644 --- a/src/domain/mbox/mbox_handler.rs +++ b/src/mbox/mbox_handler.rs @@ -6,31 +6,31 @@ use anyhow::Result; use log::{info, trace}; use crate::{ - domain::ImapServiceInterface, + backends::Backend, output::{PrintTableOpts, PrinterService}, }; /// Lists all mailboxes. -pub fn list<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>( +pub fn list<'a, P: PrinterService, B: Backend<'a> + ?Sized>( max_width: Option, - printer: &mut Printer, - imap: &'a mut ImapService, + printer: &mut P, + backend: Box<&'a mut B>, ) -> Result<()> { info!("entering list mailbox handler"); - let mboxes = imap.fetch_mboxes()?; + let mboxes = backend.get_mboxes()?; trace!("mailboxes: {:?}", mboxes); printer.print_table(mboxes, PrintTableOpts { max_width }) } #[cfg(test)] mod tests { - use serde::Serialize; use std::{fmt::Debug, io}; use termcolor::ColorSpec; use crate::{ - config::{Account, Config}, - domain::{AttrRemote, Attrs, Envelopes, Flags, Mbox, Mboxes, Msg}, + backends::{ImapMbox, ImapMboxAttr, ImapMboxAttrs, ImapMboxes}, + mbox::Mboxes, + msg::{Envelopes, Msg}, output::{Print, PrintTable, WriteColor}, }; @@ -78,15 +78,15 @@ mod tests { } impl PrinterService for PrinterServiceTest { - fn print_table( + fn print_table( &mut self, - data: T, + data: Box, opts: PrintTableOpts, ) -> Result<()> { data.print_table(&mut self.writter, opts)?; Ok(()) } - fn print(&mut self, _data: T) -> Result<()> { + fn print(&mut self, _data: T) -> Result<()> { unimplemented!() } fn is_json(&self) -> bool { @@ -94,72 +94,73 @@ mod tests { } } - struct ImapServiceTest; + struct TestBackend; - impl<'a> ImapServiceInterface<'a> for ImapServiceTest { - fn fetch_mboxes(&'a mut self) -> Result { - Ok(Mboxes(vec![ - Mbox { + impl<'a> Backend<'a> for TestBackend { + fn add_mbox(&mut self, _: &str) -> Result<()> { + unimplemented!(); + } + fn get_mboxes(&mut self) -> Result> { + Ok(Box::new(ImapMboxes(vec![ + ImapMbox { delim: "/".into(), name: "INBOX".into(), - attrs: Attrs::from(vec![AttrRemote::NoSelect]), + attrs: ImapMboxAttrs(vec![ImapMboxAttr::NoSelect]), }, - Mbox { + ImapMbox { delim: "/".into(), name: "Sent".into(), - attrs: Attrs::from(vec![ - AttrRemote::NoInferiors, - AttrRemote::Custom("HasNoChildren".into()), + attrs: ImapMboxAttrs(vec![ + ImapMboxAttr::NoInferiors, + ImapMboxAttr::Custom("HasNoChildren".into()), ]), }, - ])) + ]))) } - - fn notify(&mut self, _: &Config, _: &Account, _: u64) -> Result<()> { + fn del_mbox(&mut self, _: &str) -> Result<()> { + unimplemented!(); + } + fn get_envelopes( + &mut self, + _: &str, + _: &str, + _: &str, + _: usize, + _: usize, + ) -> Result> { unimplemented!() } - fn watch(&mut self, _: &Account, _: u64) -> Result<()> { + fn add_msg(&mut self, _: &str, _: &[u8], _: &str) -> Result> { unimplemented!() } - fn fetch_envelopes(&mut self, _: &usize, _: &usize) -> Result { + fn get_msg(&mut self, _: &str, _: &str) -> Result { unimplemented!() } - fn fetch_envelopes_with(&mut self, _: &str, _: &usize, _: &usize) -> Result { + fn copy_msg(&mut self, _: &str, _: &str, _: &str) -> Result<()> { unimplemented!() } - fn find_msg(&mut self, _: &Account, _: &str) -> Result { + fn move_msg(&mut self, _: &str, _: &str, _: &str) -> Result<()> { unimplemented!() } - fn find_raw_msg(&mut self, _: &str) -> Result> { + fn del_msg(&mut self, _: &str, _: &str) -> Result<()> { unimplemented!() } - fn append_msg(&mut self, _: &Mbox, _: &Account, _: Msg) -> Result<()> { + fn add_flags(&mut self, _: &str, _: &str, _: &str) -> Result<()> { unimplemented!() } - fn append_raw_msg_with_flags(&mut self, _: &Mbox, _: &[u8], _: Flags) -> Result<()> { + fn set_flags(&mut self, _: &str, _: &str, _: &str) -> Result<()> { unimplemented!() } - fn expunge(&mut self) -> Result<()> { - unimplemented!() - } - fn logout(&mut self) -> Result<()> { - unimplemented!() - } - fn add_flags(&mut self, _: &str, _: &Flags) -> Result<()> { - unimplemented!() - } - fn set_flags(&mut self, _: &str, _: &Flags) -> Result<()> { - unimplemented!() - } - fn remove_flags(&mut self, _: &str, _: &Flags) -> Result<()> { + fn del_flags(&mut self, _: &str, _: &str, _: &str) -> Result<()> { unimplemented!() } } let mut printer = PrinterServiceTest::default(); - let mut imap = ImapServiceTest {}; + let mut backend = TestBackend {}; + let backend = Box::new(&mut backend); - assert!(list(None, &mut printer, &mut imap).is_ok()); + assert!(list(None, &mut printer, backend).is_ok()); assert_eq!( concat![ "\n", diff --git a/src/msg/addr_entity.rs b/src/msg/addr_entity.rs new file mode 100644 index 0000000..efa7504 --- /dev/null +++ b/src/msg/addr_entity.rs @@ -0,0 +1,133 @@ +//! Module related to email addresses. +//! +//! This module regroups email address entities and converters. + +use anyhow::{Context, Result}; +use log::trace; +use mailparse; +use std::fmt::Debug; + +/// Defines a single email address. +pub type Addr = mailparse::MailAddr; + +/// Defines a list of email addresses. +pub type Addrs = mailparse::MailAddrList; + +/// Converts a slice into an optional list of addresses. +pub fn from_slice_to_addrs + Debug>(addrs: S) -> Result> { + let addrs = mailparse::addrparse(addrs.as_ref())?; + Ok(if addrs.is_empty() { None } else { Some(addrs) }) +} + +/// Converts a list of addresses into a list of [`lettre::message::Mailbox`]. +pub fn from_addrs_to_sendable_mbox(addrs: &Addrs) -> Result> { + let mut sendable_addrs: Vec = vec![]; + for addr in addrs.iter() { + match addr { + Addr::Single(mailparse::SingleInfo { display_name, addr }) => sendable_addrs.push( + lettre::message::Mailbox::new(display_name.clone(), addr.parse()?), + ), + Addr::Group(mailparse::GroupInfo { group_name, addrs }) => { + for addr in addrs { + sendable_addrs.push(lettre::message::Mailbox::new( + addr.display_name.clone().or(Some(group_name.clone())), + addr.to_string().parse()?, + )) + } + } + } + } + Ok(sendable_addrs) +} + +/// Converts a list of addresses into a list of [`lettre::Address`]. +pub fn from_addrs_to_sendable_addrs(addrs: &Addrs) -> Result> { + let mut sendable_addrs = vec![]; + for addr in addrs.iter() { + match addr { + mailparse::MailAddr::Single(mailparse::SingleInfo { + display_name: _, + addr, + }) => { + sendable_addrs.push(addr.parse()?); + } + mailparse::MailAddr::Group(mailparse::GroupInfo { + group_name: _, + addrs, + }) => { + for addr in addrs { + sendable_addrs.push(addr.addr.parse()?); + } + } + }; + } + Ok(sendable_addrs) +} + +/// Converts a [`imap_proto::Address`] into an address. +pub fn from_imap_addr_to_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(Some) + }) + .unwrap_or(Ok(None))?; + let mbox = addr + .mailbox + .as_ref() + .map(|mbox| { + rfc2047_decoder::decode(&mbox.to_vec()) + .context("cannot decode address mailbox") + .map(Some) + }) + .unwrap_or(Ok(None))?; + let host = addr + .host + .as_ref() + .map(|host| { + rfc2047_decoder::decode(&host.to_vec()) + .context("cannot decode address host") + .map(Some) + }) + .unwrap_or(Ok(None))?; + + trace!("parsing address from imap address"); + trace!("name: {:?}", name); + trace!("mbox: {:?}", mbox); + trace!("host: {:?}", host); + + Ok(Addr::Single(mailparse::SingleInfo { + display_name: name, + addr: match host { + Some(host) => format!("{}@{}", mbox.unwrap_or_default(), host), + None => mbox.unwrap_or_default(), + }, + })) +} + +/// Converts a list of [`imap_proto::Address`] into a list of addresses. +pub fn from_imap_addrs_to_addrs(proto_addrs: &[imap_proto::Address]) -> Result { + let mut addrs = vec![]; + for addr in proto_addrs { + addrs.push( + from_imap_addr_to_addr(addr).context(format!("cannot parse address {:?}", addr))?, + ); + } + Ok(addrs.into()) +} + +/// Converts an optional list of [`imap_proto::Address`] into an optional list of addresses. +pub fn from_imap_addrs_to_some_addrs( + addrs: &Option>, +) -> Result> { + Ok( + if let Some(addrs) = addrs.as_deref().map(from_imap_addrs_to_addrs) { + Some(addrs?) + } else { + None + }, + ) +} diff --git a/src/msg/envelope.rs b/src/msg/envelope.rs new file mode 100644 index 0000000..91c5c04 --- /dev/null +++ b/src/msg/envelope.rs @@ -0,0 +1,13 @@ +use std::{any, fmt}; + +use crate::output::PrintTable; + +pub trait Envelopes: fmt::Debug + erased_serde::Serialize + PrintTable + any::Any { + fn as_any(&self) -> &dyn any::Any; +} + +impl Envelopes for T { + fn as_any(&self) -> &dyn any::Any { + self + } +} diff --git a/src/domain/msg/flag_arg.rs b/src/msg/flag_arg.rs similarity index 74% rename from src/domain/msg/flag_arg.rs rename to src/msg/flag_arg.rs index a920c26..100167d 100644 --- a/src/domain/msg/flag_arg.rs +++ b/src/msg/flag_arg.rs @@ -7,50 +7,63 @@ use anyhow::Result; use clap::{self, App, AppSettings, Arg, ArgMatches, SubCommand}; use log::{debug, info}; -use crate::domain::msg::msg_arg; +use crate::msg::msg_arg; type SeqRange<'a> = &'a str; -type Flags<'a> = Vec<&'a str>; +type Flags = String; /// Represents the flag commands. -pub enum Command<'a> { +#[derive(Debug, PartialEq, Eq)] +pub enum Cmd<'a> { /// Represents the add flags command. - Add(SeqRange<'a>, Flags<'a>), + Add(SeqRange<'a>, Flags), /// Represents the set flags command. - Set(SeqRange<'a>, Flags<'a>), + Set(SeqRange<'a>, Flags), /// Represents the remove flags command. - Remove(SeqRange<'a>, Flags<'a>), + Remove(SeqRange<'a>, Flags), } /// Defines the flag command matcher. -pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { +pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { info!("entering message flag command matcher"); if let Some(m) = m.subcommand_matches("add") { info!("add subcommand matched"); let seq_range = m.value_of("seq-range").unwrap(); debug!("seq range: {}", seq_range); - let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect(); + let flags: String = m + .values_of("flags") + .unwrap_or_default() + .collect::>() + .join(" "); debug!("flags: {:?}", flags); - return Ok(Some(Command::Add(seq_range, flags))); + return Ok(Some(Cmd::Add(seq_range, flags))); } if let Some(m) = m.subcommand_matches("set") { info!("set subcommand matched"); let seq_range = m.value_of("seq-range").unwrap(); debug!("seq range: {}", seq_range); - let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect(); + let flags: String = m + .values_of("flags") + .unwrap_or_default() + .collect::>() + .join(" "); debug!("flags: {:?}", flags); - return Ok(Some(Command::Set(seq_range, flags))); + return Ok(Some(Cmd::Set(seq_range, flags))); } if let Some(m) = m.subcommand_matches("remove") { info!("remove subcommand matched"); let seq_range = m.value_of("seq-range").unwrap(); debug!("seq range: {}", seq_range); - let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect(); + let flags: String = m + .values_of("flags") + .unwrap_or_default() + .collect::>() + .join(" "); debug!("flags: {:?}", flags); - return Ok(Some(Command::Remove(seq_range, flags))); + return Ok(Some(Cmd::Remove(seq_range, flags))); } Ok(None) diff --git a/src/msg/flag_handler.rs b/src/msg/flag_handler.rs new file mode 100644 index 0000000..18af167 --- /dev/null +++ b/src/msg/flag_handler.rs @@ -0,0 +1,55 @@ +//! Message flag handling module. +//! +//! This module gathers all flag actions triggered by the CLI. + +use anyhow::Result; + +use crate::{backends::Backend, output::PrinterService}; + +/// Adds flags to all messages matching the given sequence range. +/// Flags are case-insensitive, and they do not need to be prefixed with `\`. +pub fn add<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq_range: &'a str, + flags: &'a str, + mbox: &'a str, + printer: &'a mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + backend.add_flags(mbox, seq_range, flags)?; + printer.print(format!( + "Flag(s) {:?} successfully added to message(s) {:?}", + flags, seq_range + )) +} + +/// Removes flags from all messages matching the given sequence range. +/// Flags are case-insensitive, and they do not need to be prefixed with `\`. +pub fn remove<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq_range: &'a str, + flags: &'a str, + mbox: &'a str, + printer: &'a mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + backend.del_flags(mbox, seq_range, flags)?; + printer.print(format!( + "Flag(s) {:?} successfully removed from message(s) {:?}", + flags, seq_range + )) +} + +/// Replaces flags of all messages matching the given sequence range. +/// Flags are case-insensitive, and they do not need to be prefixed with `\`. +pub fn set<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq_range: &'a str, + flags: &'a str, + mbox: &'a str, + printer: &'a mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + backend.set_flags(mbox, seq_range, flags)?; + printer.print(format!( + "Flag(s) {:?} successfully set for message(s) {:?}", + flags, seq_range + )) +} diff --git a/src/domain/msg/msg_arg.rs b/src/msg/msg_arg.rs similarity index 73% rename from src/domain/msg/msg_arg.rs rename to src/msg/msg_arg.rs index f6b3677..4c34ff7 100644 --- a/src/domain/msg/msg_arg.rs +++ b/src/msg/msg_arg.rs @@ -7,10 +7,8 @@ use clap::{self, App, Arg, ArgMatches, SubCommand}; use log::{debug, info, trace}; use crate::{ - domain::{ - mbox::mbox_arg, - msg::{flag_arg, msg_arg, tpl_arg}, - }, + mbox::mbox_arg, + msg::{flag_arg, msg_arg, tpl_arg}, ui::table_arg, }; @@ -26,9 +24,11 @@ type Query = String; type AttachmentPaths<'a> = Vec<&'a str>; type MaxTableWidth = Option; type Encrypt = bool; +type Criteria = String; /// Message commands. -pub enum Command<'a> { +#[derive(Debug, PartialEq, Eq)] +pub enum Cmd<'a> { Attachments(Seq<'a>), Copy(Seq<'a>, Mbox<'a>), Delete(Seq<'a>), @@ -39,22 +39,23 @@ pub enum Command<'a> { Reply(Seq<'a>, All, AttachmentPaths<'a>, Encrypt), Save(RawMsg<'a>), Search(Query, MaxTableWidth, Option, Page), + Sort(Criteria, Query, MaxTableWidth, Option, Page), Send(RawMsg<'a>), Write(AttachmentPaths<'a>, Encrypt), - Flag(Option>), - Tpl(Option>), + Flag(Option>), + Tpl(Option>), } /// Message command matcher. -pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { +pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { info!("entering message command matcher"); if let Some(m) = m.subcommand_matches("attachments") { info!("attachments command matched"); let seq = m.value_of("seq").unwrap(); debug!("seq: {}", seq); - return Ok(Some(Command::Attachments(seq))); + return Ok(Some(Cmd::Attachments(seq))); } if let Some(m) = m.subcommand_matches("copy") { @@ -63,14 +64,14 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { debug!("seq: {}", seq); let mbox = m.value_of("mbox-target").unwrap(); debug!(r#"target mailbox: "{:?}""#, mbox); - return Ok(Some(Command::Copy(seq, mbox))); + return Ok(Some(Cmd::Copy(seq, mbox))); } if let Some(m) = m.subcommand_matches("delete") { info!("copy command matched"); let seq = m.value_of("seq").unwrap(); debug!("seq: {}", seq); - return Ok(Some(Command::Delete(seq))); + return Ok(Some(Cmd::Delete(seq))); } if let Some(m) = m.subcommand_matches("forward") { @@ -81,7 +82,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { debug!("attachments paths: {:?}", paths); let encrypt = m.is_present("encrypt"); debug!("encrypt: {}", encrypt); - return Ok(Some(Command::Forward(seq, paths, encrypt))); + return Ok(Some(Cmd::Forward(seq, paths, encrypt))); } if let Some(m) = m.subcommand_matches("list") { @@ -100,7 +101,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { .map(|page| 1.max(page) - 1) .unwrap_or_default(); debug!("page: {}", page); - return Ok(Some(Command::List(max_table_width, page_size, page))); + return Ok(Some(Cmd::List(max_table_width, page_size, page))); } if let Some(m) = m.subcommand_matches("move") { @@ -109,7 +110,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { debug!("seq: {}", seq); let mbox = m.value_of("mbox-target").unwrap(); debug!("target mailbox: {:?}", mbox); - return Ok(Some(Command::Move(seq, mbox))); + return Ok(Some(Cmd::Move(seq, mbox))); } if let Some(m) = m.subcommand_matches("read") { @@ -120,7 +121,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { debug!("text mime: {}", mime); let raw = m.is_present("raw"); debug!("raw: {}", raw); - return Ok(Some(Command::Read(seq, mime, raw))); + return Ok(Some(Cmd::Read(seq, mime, raw))); } if let Some(m) = m.subcommand_matches("reply") { @@ -134,14 +135,14 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { let encrypt = m.is_present("encrypt"); debug!("encrypt: {}", encrypt); - return Ok(Some(Command::Reply(seq, all, paths, encrypt))); + return Ok(Some(Cmd::Reply(seq, all, paths, encrypt))); } if let Some(m) = m.subcommand_matches("save") { info!("save command matched"); let msg = m.value_of("message").unwrap_or_default(); trace!("message: {}", msg); - return Ok(Some(Command::Save(msg))); + return Ok(Some(Cmd::Save(msg))); } if let Some(m) = m.subcommand_matches("search") { @@ -185,7 +186,58 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { .1 .join(" "); debug!("query: {}", query); - return Ok(Some(Command::Search( + return Ok(Some(Cmd::Search(query, max_table_width, page_size, page))); + } + + if let Some(m) = m.subcommand_matches("sort") { + info!("sort command matched"); + let max_table_width = m + .value_of("max-table-width") + .and_then(|width| width.parse::().ok()); + debug!("max table width: {:?}", max_table_width); + let page_size = m.value_of("page-size").and_then(|s| s.parse().ok()); + debug!("page size: {:?}", page_size); + let page = m + .value_of("page") + .unwrap() + .parse() + .ok() + .map(|page| 1.max(page) - 1) + .unwrap_or_default(); + debug!("page: {:?}", page); + let criteria = m + .values_of("criterion") + .unwrap_or_default() + .collect::>() + .join(" "); + debug!("criteria: {:?}", criteria); + let query = m + .values_of("query") + .unwrap_or_default() + .fold((false, vec![]), |(escape, mut cmds), cmd| { + match (cmd, escape) { + // Next command is an arg and needs to be escaped + ("subject", _) | ("body", _) | ("text", _) => { + cmds.push(cmd.to_string()); + (true, cmds) + } + // Escaped arg commands + (_, true) => { + cmds.push(format!("\"{}\"", cmd)); + (false, cmds) + } + // Regular commands + (_, false) => { + cmds.push(cmd.to_string()); + (false, cmds) + } + } + }) + .1 + .join(" "); + debug!("query: {:?}", query); + return Ok(Some(Cmd::Sort( + criteria, query, max_table_width, page_size, @@ -197,7 +249,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { info!("send command matched"); let msg = m.value_of("message").unwrap_or_default(); trace!("message: {}", msg); - return Ok(Some(Command::Send(msg))); + return Ok(Some(Cmd::Send(msg))); } if let Some(m) = m.subcommand_matches("write") { @@ -206,19 +258,19 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { debug!("attachments paths: {:?}", attachment_paths); let encrypt = m.is_present("encrypt"); debug!("encrypt: {}", encrypt); - return Ok(Some(Command::Write(attachment_paths, encrypt))); + return Ok(Some(Cmd::Write(attachment_paths, encrypt))); } if let Some(m) = m.subcommand_matches("template") { - return Ok(Some(Command::Tpl(tpl_arg::matches(m)?))); + return Ok(Some(Cmd::Tpl(tpl_arg::matches(m)?))); } if let Some(m) = m.subcommand_matches("flag") { - return Ok(Some(Command::Flag(flag_arg::matches(m)?))); + return Ok(Some(Cmd::Flag(flag_arg::matches(m)?))); } info!("default list command matched"); - Ok(Some(Command::List(None, None, 0))) + Ok(Some(Cmd::List(None, None, 0))) } /// Message sequence number argument. @@ -313,13 +365,45 @@ pub fn subcmds<'a>() -> Vec> { .multiple(true) .required(true), ), + SubCommand::with_name("sort") + .about("Sorts messages by the given criteria and matching the given IMAP query") + .arg(page_size_arg()) + .arg(page_arg()) + .arg(table_arg::max_width()) + .arg( + Arg::with_name("criterion") + .long("criterion") + .short("c") + .help("Defines the message sorting preferences") + .value_name("CRITERION:ORDER") + .takes_value(true) + .multiple(true) + .required(true) + .possible_values(&[ + "arrival", "arrival:asc", "arrival:desc", + "cc", "cc:asc", "cc:desc", + "date", "date:asc", "date:desc", + "from", "from:asc", "from:desc", + "size", "size:asc", "size:desc", + "subject", "subject:asc", "subject:desc", + "to", "to:asc", "to:desc", + ]), + ) + .arg( + Arg::with_name("query") + .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") + .default_value("ALL") + .raw(true), + ), SubCommand::with_name("write") .about("Writes a new message") .arg(attachment_arg()) .arg(encrypt_arg()), SubCommand::with_name("send") .about("Sends a raw message") - .arg(Arg::with_name("message").raw(true).last(true)), + .arg(Arg::with_name("message").raw(true)), SubCommand::with_name("save") .about("Saves a raw message") .arg(Arg::with_name("message").raw(true)), diff --git a/src/domain/msg/msg_entity.rs b/src/msg/msg_entity.rs similarity index 61% rename from src/domain/msg/msg_entity.rs rename to src/msg/msg_entity.rs index c5eb515..e4f3b5c 100644 --- a/src/domain/msg/msg_entity.rs +++ b/src/msg/msg_entity.rs @@ -2,38 +2,27 @@ use ammonia; use anyhow::{anyhow, Context, Error, Result}; use chrono::{DateTime, FixedOffset}; use html_escape; -use imap::types::Flag; use lettre::message::{header::ContentType, Attachment, MultiPart, SinglePart}; use log::{debug, info, trace}; use regex::Regex; -use rfc2047_decoder; -use std::{ - collections::HashSet, - convert::{TryFrom, TryInto}, - env::temp_dir, - fmt::Debug, - fs, - path::PathBuf, -}; +use std::{collections::HashSet, env::temp_dir, fmt::Debug, fs, path::PathBuf}; use uuid::Uuid; use crate::{ - config::{Account, DEFAULT_SIG_DELIM}, - domain::{ - imap::ImapServiceInterface, - mbox::Mbox, - msg::{msg_utils, BinaryPart, Flags, Part, Parts, TextPlainPart, TplOverride}, - smtp::SmtpServiceInterface, + backends::Backend, + config::{AccountConfig, DEFAULT_SIG_DELIM}, + msg::{ + from_addrs_to_sendable_addrs, from_addrs_to_sendable_mbox, from_slice_to_addrs, msg_utils, + Addrs, BinaryPart, Part, Parts, TextPlainPart, TplOverride, }, output::PrinterService, + smtp::SmtpService, ui::{ choice::{self, PostEditChoice, PreEditChoice}, editor, }, }; -type Addr = lettre::message::Mailbox; - /// Representation of a message. #[derive(Debug, Default)] pub struct Msg { @@ -42,17 +31,14 @@ pub struct Msg { /// [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 from: Option, + pub reply_to: Option, + pub to: Option, + pub cc: Option, + pub bcc: Option, pub in_reply_to: Option, pub message_id: Option, @@ -63,6 +49,8 @@ pub struct Msg { pub parts: Parts, pub encrypt: bool, + + pub raw: Vec, } impl Msg { @@ -173,8 +161,8 @@ impl Msg { } } - pub fn into_reply(mut self, all: bool, account: &Account) -> Result { - let account_addr: Addr = account.address().parse()?; + pub fn into_reply(mut self, all: bool, account: &AccountConfig) -> Result { + let account_addr = account.address()?; // Message-Id self.message_id = None; @@ -183,13 +171,13 @@ impl Msg { self.in_reply_to = self.message_id.to_owned(); // From - self.from = Some(vec![account_addr.to_owned()]); + self.from = Some(vec![account_addr.clone()].into()); // To let addrs = self .reply_to - .as_ref() - .or_else(|| self.from.as_ref()) + .as_deref() + .or_else(|| self.from.as_deref()) .map(|addrs| { addrs .clone() @@ -197,11 +185,11 @@ impl Msg { .filter(|addr| addr != &account_addr) }); if all { - self.to = addrs.map(|addrs| addrs.collect()); + self.to = addrs.map(|addrs| addrs.collect::>().into()); } else { self.to = addrs .and_then(|mut addrs| addrs.next()) - .map(|addr| vec![addr]); + .map(|addr| vec![addr].into()); } // Cc & Bcc @@ -226,12 +214,8 @@ impl Msg { .reply_to .as_ref() .or_else(|| self.from.as_ref()) - .and_then(|addrs| addrs.first()) - .map(|addr| { - addr.name - .to_owned() - .unwrap_or_else(|| addr.email.to_string()) - }) + .and_then(|addrs| addrs.clone().extract_single_info()) + .map(|addr| addr.display_name.clone().unwrap_or_else(|| addr.addr)) .unwrap_or_else(|| "unknown sender".into()); let mut content = format!("\n\nOn {}, {} wrote:\n", date, sender); @@ -255,8 +239,8 @@ impl Msg { Ok(self) } - pub fn into_forward(mut self, account: &Account) -> Result { - let account_addr: Addr = account.address().parse()?; + pub fn into_forward(mut self, account: &AccountConfig) -> Result { + let account_addr = account.address()?; let prev_subject = self.subject.to_owned(); let prev_date = self.date.to_owned(); @@ -270,10 +254,10 @@ impl Msg { self.in_reply_to = None; // From - self.from = Some(vec![account_addr]); + self.from = Some(vec![account_addr].into()); // To - self.to = Some(vec![]); + self.to = Some(vec![].into()); // Cc self.cc = None; @@ -295,22 +279,12 @@ impl Msg { } 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(&addrs.to_string()); content.push('\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(&addrs.to_string()); content.push('\n'); } content.push('\n'); @@ -321,24 +295,19 @@ impl Msg { Ok(self) } - fn _edit_with_editor(&self, account: &Account) -> Result { - let tpl = self.to_tpl(TplOverride::default(), account); + fn _edit_with_editor(&self, account: &AccountConfig) -> Result { + let tpl = self.to_tpl(TplOverride::default(), account)?; let tpl = editor::open_with_tpl(tpl)?; Self::from_tpl(&tpl) } - pub fn edit_with_editor< - 'a, - Printer: PrinterService, - ImapService: ImapServiceInterface<'a>, - SmtpService: SmtpServiceInterface, - >( + pub fn edit_with_editor<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( mut self, - account: &Account, - printer: &mut Printer, - imap: &mut ImapService, - smtp: &mut SmtpService, - ) -> Result<()> { + account: &AccountConfig, + printer: &mut P, + backend: Box<&'a mut B>, + smtp: &mut S, + ) -> Result> { info!("start editing with editor"); let draft = msg_utils::local_draft_path(); @@ -355,7 +324,7 @@ impl Msg { self.merge_with(self._edit_with_editor(account)?); break; } - PreEditChoice::Quit => return Ok(()), + PreEditChoice::Quit => return Ok(backend), }, Err(err) => { println!("{}", err); @@ -370,10 +339,8 @@ impl Msg { loop { match choice::post_edit() { Ok(PostEditChoice::Send) => { - let mbox = Mbox::new(&account.sent_folder); let sent_msg = smtp.send_msg(account, &self)?; - let flags = Flags::try_from(vec![Flag::Seen])?; - imap.append_raw_msg_with_flags(&mbox, &sent_msg.formatted(), flags)?; + backend.add_msg(&account.sent_folder, &sent_msg.formatted(), "seen")?; msg_utils::remove_local_draft()?; printer.print("Message successfully sent")?; break; @@ -387,10 +354,8 @@ impl Msg { break; } Ok(PostEditChoice::RemoteDraft) => { - let mbox = Mbox::new(&account.draft_folder); - let flags = Flags::try_from(vec![Flag::Seen, Flag::Draft])?; - let tpl = self.to_tpl(TplOverride::default(), account); - imap.append_raw_msg_with_flags(&mbox, tpl.as_bytes(), flags)?; + let tpl = self.to_tpl(TplOverride::default(), account)?; + backend.add_msg(&account.draft_folder, tpl.as_bytes(), "seen draft")?; msg_utils::remove_local_draft()?; printer.print(format!( "Message successfully saved to {}", @@ -409,7 +374,7 @@ impl Msg { } } - Ok(()) + Ok(backend) } pub fn encrypt(mut self, encrypt: bool) -> Self { @@ -476,7 +441,8 @@ impl Msg { } } - pub fn to_tpl(&self, opts: TplOverride, account: &Account) -> String { + pub fn to_tpl(&self, opts: TplOverride, account: &AccountConfig) -> Result { + let account_addr: Addrs = vec![account.address()?].into(); let mut tpl = String::default(); tpl.push_str("Content-Type: text/plain; charset=utf-8\n"); @@ -490,7 +456,7 @@ impl Msg { "From: {}\n", opts.from .map(|addrs| addrs.join(", ")) - .unwrap_or_else(|| account.address()) + .unwrap_or_else(|| account_addr.to_string()) )); // To @@ -498,37 +464,25 @@ impl Msg { "To: {}\n", opts.to .map(|addrs| addrs.join(", ")) - .or_else(|| self.to.clone().map(|addrs| addrs - .iter() - .map(|addr| addr.to_string()) - .collect::>() - .join(", "))) + .or_else(|| self.to.clone().map(|addrs| addrs.to_string())) .unwrap_or_default() )); // Cc - if let Some(addrs) = opts.cc.map(|addrs| addrs.join(", ")).or_else(|| { - self.cc.clone().map(|addrs| { - addrs - .iter() - .map(|addr| addr.to_string()) - .collect::>() - .join(", ") - }) - }) { + if let Some(addrs) = opts + .cc + .map(|addrs| addrs.join(", ")) + .or_else(|| self.cc.clone().map(|addrs| addrs.to_string())) + { tpl.push_str(&format!("Cc: {}\n", addrs)); } // Bcc - if let Some(addrs) = opts.bcc.map(|addrs| addrs.join(", ")).or_else(|| { - self.bcc.clone().map(|addrs| { - addrs - .iter() - .map(|addr| addr.to_string()) - .collect::>() - .join(", ") - }) - }) { + if let Some(addrs) = opts + .bcc + .map(|addrs| addrs.join(", ")) + .or_else(|| self.bcc.clone().map(|addrs| addrs.to_string())) + { tpl.push_str(&format!("Bcc: {}\n", addrs)); } @@ -560,72 +514,20 @@ impl Msg { tpl.push('\n'); trace!("template: {:?}", tpl); - tpl + Ok(tpl) } pub fn from_tpl(tpl: &str) -> Result { info!("begin: building message from template"); trace!("template: {:?}", tpl); - let mut msg = Msg::default(); - let parsed_msg = mailparse::parse_mail(tpl.as_bytes()).context("cannot parse template")?; - - debug!("parsing headers"); - for header in parsed_msg.get_headers() { - let key = header.get_key(); - debug!("header key: {:?}", key); - - let val = header.get_value(); - let val = String::from_utf8(header.get_value_raw().to_vec()) - .map(|val| val.trim().to_string()) - .context(format!( - "cannot decode value {:?} from header {:?}", - key, val - ))?; - debug!("header value: {:?}", val); - - match key.to_lowercase().as_str() { - "message-id" => msg.message_id = Some(val), - "in-reply-to" => msg.in_reply_to = Some(val), - "subject" => { - msg.subject = val; - } - "from" => { - msg.from = parse_addrs(val).context(format!("cannot parse header {:?}", key))? - } - "to" => { - msg.to = parse_addrs(val).context(format!("cannot parse header {:?}", key))? - } - "reply-to" => { - msg.reply_to = - parse_addrs(val).context(format!("cannot parse header {:?}", key))? - } - "cc" => { - msg.cc = parse_addrs(val).context(format!("cannot parse header {:?}", key))? - } - "bcc" => { - msg.bcc = parse_addrs(val).context(format!("cannot parse header {:?}", key))? - } - _ => (), - } - } - - debug!("parsing body"); - let body = parsed_msg - .get_body_raw() - .context("cannot get raw body from message") - .and_then(|body| String::from_utf8(body).context("cannot decode body from utf8"))?; - trace!("body: {:?}", body); - - msg.parts - .push(Part::TextPlain(TextPlainPart { content: body })); + let parsed_mail = mailparse::parse_mail(tpl.as_bytes()).context("cannot parse template")?; info!("end: building message from template"); - trace!("message: {:?}", msg); - Ok(msg) + Self::from_parsed_mail(parsed_mail, &AccountConfig::default()) } - pub fn into_sendable_msg(&self, account: &Account) -> Result { + pub fn into_sendable_msg(&self, account: &AccountConfig) -> Result { let mut msg_builder = lettre::Message::builder() .message_id(self.message_id.to_owned()) .subject(self.subject.to_owned()); @@ -635,33 +537,33 @@ impl Msg { }; if let Some(addrs) = self.from.as_ref() { - msg_builder = addrs - .iter() - .fold(msg_builder, |builder, addr| builder.from(addr.to_owned())) + for addr in from_addrs_to_sendable_mbox(addrs)? { + msg_builder = msg_builder.from(addr) + } }; if let Some(addrs) = self.to.as_ref() { - msg_builder = addrs - .iter() - .fold(msg_builder, |builder, addr| builder.to(addr.to_owned())) + for addr in from_addrs_to_sendable_mbox(addrs)? { + msg_builder = msg_builder.to(addr) + } }; if let Some(addrs) = self.reply_to.as_ref() { - msg_builder = addrs.iter().fold(msg_builder, |builder, addr| { - builder.reply_to(addr.to_owned()) - }) + for addr in from_addrs_to_sendable_mbox(addrs)? { + msg_builder = msg_builder.reply_to(addr) + } }; if let Some(addrs) = self.cc.as_ref() { - msg_builder = addrs - .iter() - .fold(msg_builder, |builder, addr| builder.cc(addr.to_owned())) + for addr in from_addrs_to_sendable_mbox(addrs)? { + msg_builder = msg_builder.cc(addr) + } }; if let Some(addrs) = self.bcc.as_ref() { - msg_builder = addrs - .iter() - .fold(msg_builder, |builder, addr| builder.bcc(addr.to_owned())) + for addr in from_addrs_to_sendable_mbox(addrs)? { + msg_builder = msg_builder.bcc(addr) + } }; let mut multipart = { @@ -682,11 +584,14 @@ impl Msg { if self.encrypt { let multipart_buffer = temp_dir().join(Uuid::new_v4().to_string()); fs::write(multipart_buffer.clone(), multipart.formatted())?; + let addr = self + .to + .as_ref() + .and_then(|addrs| addrs.clone().extract_single_info()) + .map(|addr| addr.addr) + .ok_or_else(|| anyhow!("cannot find recipient"))?; let encrypted_multipart = account - .pgp_encrypt_file( - &self.to.as_ref().unwrap().first().unwrap().email.to_string(), - multipart_buffer.clone(), - )? + .pgp_encrypt_file(&addr, multipart_buffer.clone())? .ok_or_else(|| anyhow!("cannot find pgp encrypt command in config"))?; trace!("encrypted multipart: {:#?}", encrypted_multipart); multipart = MultiPart::encrypted(String::from("application/pgp-encrypted")) @@ -706,187 +611,82 @@ impl Msg { .multipart(multipart) .context("cannot build sendable message") } + + pub fn from_parsed_mail( + parsed_mail: mailparse::ParsedMail<'_>, + config: &AccountConfig, + ) -> Result { + info!("begin: building message from parsed mail"); + trace!("parsed mail: {:?}", parsed_mail); + + let mut msg = Msg::default(); + + debug!("parsing headers"); + for header in parsed_mail.get_headers() { + let key = header.get_key(); + debug!("header key: {:?}", key); + + let val = header.get_value(); + let val = String::from_utf8(header.get_value_raw().to_vec()) + .map(|val| val.trim().to_string()) + .context(format!( + "cannot decode value {:?} from header {:?}", + key, val + ))?; + debug!("header value: {:?}", val); + + match key.to_lowercase().as_str() { + "message-id" => msg.message_id = Some(val), + "in-reply-to" => msg.in_reply_to = Some(val), + "subject" => { + msg.subject = val; + } + "from" => { + msg.from = from_slice_to_addrs(val) + .context(format!("cannot parse header {:?}", key))? + } + "to" => { + msg.to = from_slice_to_addrs(val) + .context(format!("cannot parse header {:?}", key))? + } + "reply-to" => { + msg.reply_to = from_slice_to_addrs(val) + .context(format!("cannot parse header {:?}", key))? + } + "cc" => { + msg.cc = from_slice_to_addrs(val) + .context(format!("cannot parse header {:?}", key))? + } + "bcc" => { + msg.bcc = from_slice_to_addrs(val) + .context(format!("cannot parse header {:?}", key))? + } + _ => (), + } + } + + msg.parts = Parts::from_parsed_mail(config, &parsed_mail) + .context("cannot parsed message mime parts")?; + trace!("message: {:?}", msg); + + info!("end: building message from parsed mail"); + 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 from = match self.from.and_then(|addrs| addrs.extract_single_info()) { + Some(addr) => addr.addr.parse().map(Some), + None => Ok(None), + }?; 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<'a> TryFrom<(&'a Account, &'a imap::types::Fetch)> for Msg { - type Error = Error; - - fn try_from((account, fetch): (&'a Account, &'a imap::types::Fetch)) -> Result { - let envelope = fetch - .envelope() - .ok_or_else(|| 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() - .map(|subj| { - rfc2047_decoder::decode(subj).context(format!( - "cannot decode subject of message {}", - fetch.message - )) - }) - .unwrap_or_else(|| Ok(String::default()))?; - - // Get the sender(s) address(es) - let from = match envelope - .sender - .as_deref() - .or_else(|| envelope.from.as_deref()) - .map(to_addrs) - { - Some(addrs) => Some(addrs?), - None => None, - }; - - // Get the "Reply-To" address(es) - let reply_to = to_some_addrs(&envelope.reply_to).context(format!( - r#"cannot parse "reply to" address of message {}"#, - id - ))?; - - // Get the recipient(s) address(es) - let to = to_some_addrs(&envelope.to) - .context(format!(r#"cannot parse "to" address of message {}"#, id))?; - - // Get the "Cc" recipient(s) address(es) - let cc = to_some_addrs(&envelope.cc) - .context(format!(r#"cannot parse "cc" address of message {}"#, id))?; - - // Get the "Bcc" recipient(s) address(es) - let bcc = to_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 body = fetch - .body() - .ok_or_else(|| anyhow!("cannot get body of message {}", id))?; - let parsed_mail = - mailparse::parse_mail(body).context(format!("cannot parse body of message {}", id))?; - let parts = Parts::from_parsed_mail(account, &parsed_mail)?; - - Ok(Self { - id, - flags, - subject, - from, - reply_to, - to, - cc, - bcc, - in_reply_to, - message_id, - date, - parts, - encrypt: false, - }) + .map(from_addrs_to_sendable_addrs) + .unwrap_or(Ok(vec![]))?; + Ok(lettre::address::Envelope::new(from, to).context("cannot create envelope")?) } } - -pub fn parse_addr + Debug>(raw_addr: S) -> Result { - raw_addr - .as_ref() - .trim() - .parse() - .context(format!("cannot parse address {:?}", raw_addr)) -} - -pub fn parse_addrs + Debug>(raw_addrs: S) -> Result>> { - let mut addrs: Vec = vec![]; - for raw_addr in raw_addrs.as_ref().split(',') { - addrs - .push(parse_addr(raw_addr).context(format!("cannot parse addresses {:?}", raw_addrs))?); - } - Ok(if addrs.is_empty() { None } else { Some(addrs) }) -} - -pub fn to_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(Some) - }) - .unwrap_or(Ok(None))?; - let mbox = addr - .mailbox - .as_ref() - .ok_or_else(|| 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_else(|| 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 to_addrs(addrs: &[imap_proto::Address]) -> Result> { - let mut parsed_addrs = vec![]; - for addr in addrs { - parsed_addrs.push(to_addr(addr).context(format!(r#"cannot parse address "{:?}""#, addr))?); - } - Ok(parsed_addrs) -} - -pub fn to_some_addrs(addrs: &Option>) -> Result>> { - Ok(match addrs.as_deref().map(to_addrs) { - Some(addrs) => Some(addrs?), - None => None, - }) -} diff --git a/src/msg/msg_handler.rs b/src/msg/msg_handler.rs new file mode 100644 index 0000000..3bd5dd3 --- /dev/null +++ b/src/msg/msg_handler.rs @@ -0,0 +1,348 @@ +//! Module related to message handling. +//! +//! This module gathers all message commands. + +use anyhow::{Context, Result}; +use atty::Stream; +use log::{debug, info, trace}; +use mailparse::addrparse; +use std::{ + borrow::Cow, + convert::TryInto, + fs, + io::{self, BufRead}, +}; +use url::Url; + +use crate::{ + backends::Backend, + config::AccountConfig, + msg::{Msg, Part, Parts, TextPlainPart}, + output::{PrintTableOpts, PrinterService}, + smtp::SmtpService, +}; + +/// Downloads all message attachments to the user account downloads directory. +pub fn attachments<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq: &str, + mbox: &str, + config: &AccountConfig, + printer: &mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + let attachments = backend.get_msg(mbox, seq)?.attachments(); + let attachments_len = attachments.len(); + debug!( + r#"{} attachment(s) found for message "{}""#, + attachments_len, seq + ); + + for attachment in attachments { + let file_path = config.get_download_file_path(&attachment.filename)?; + debug!("downloading {}…", attachment.filename); + fs::write(&file_path, &attachment.content) + .context(format!("cannot download attachment {:?}", file_path))?; + } + + printer.print(format!( + "{} attachment(s) successfully downloaded to {:?}", + attachments_len, config.downloads_dir + )) +} + +/// Copy a message from a mailbox to another. +pub fn copy<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq: &str, + mbox_src: &str, + mbox_dst: &str, + printer: &mut P, + backend: Box<&mut B>, +) -> Result<()> { + backend.copy_msg(mbox_src, mbox_dst, seq)?; + printer.print(format!( + r#"Message {} successfully copied to folder "{}""#, + seq, mbox_dst + )) +} + +/// Delete messages matching the given sequence range. +pub fn delete<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq: &str, + mbox: &str, + printer: &mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + backend.del_msg(mbox, seq)?; + printer.print(format!(r#"Message(s) {} successfully deleted"#, seq)) +} + +/// Forward the given message UID from the selected mailbox. +pub fn forward<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( + seq: &str, + attachments_paths: Vec<&str>, + encrypt: bool, + mbox: &str, + config: &AccountConfig, + printer: &mut P, + backend: Box<&'a mut B>, + smtp: &mut S, +) -> Result<()> { + backend + .get_msg(mbox, seq)? + .into_forward(config)? + .add_attachments(attachments_paths)? + .encrypt(encrypt) + .edit_with_editor(config, printer, backend, smtp)?; + Ok(()) +} + +/// List paginated messages from the selected mailbox. +pub fn list<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + max_width: Option, + page_size: Option, + page: usize, + mbox: &str, + config: &AccountConfig, + printer: &mut P, + imap: Box<&'a mut B>, +) -> Result<()> { + let page_size = page_size.unwrap_or(config.default_page_size); + debug!("page size: {}", page_size); + let msgs = imap.get_envelopes(mbox, "arrival:desc", "all", page_size, page)?; + trace!("envelopes: {:?}", msgs); + printer.print_table(msgs, PrintTableOpts { max_width }) +} + +/// Parses and edits a message from a [mailto] URL string. +/// +/// [mailto]: https://en.wikipedia.org/wiki/Mailto +pub fn mailto<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( + url: &Url, + config: &AccountConfig, + printer: &mut P, + backend: Box<&'a mut B>, + smtp: &mut S, +) -> Result<()> { + info!("entering mailto command handler"); + + let to = addrparse(url.path())?; + 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.to_string()); + } + b"bcc" => { + bcc.push(val.to_string()); + } + b"subject" => { + subject = val; + } + b"body" => { + body = val; + } + _ => (), + } + } + + let msg = Msg { + from: Some(vec![config.address()?].into()), + to: if to.is_empty() { None } else { Some(to) }, + cc: if cc.is_empty() { + None + } else { + Some(addrparse(&cc.join(","))?) + }, + bcc: if bcc.is_empty() { + None + } else { + Some(addrparse(&bcc.join(","))?) + }, + subject: subject.into(), + parts: Parts(vec![Part::TextPlain(TextPlainPart { + content: body.into(), + })]), + ..Msg::default() + }; + trace!("message: {:?}", msg); + + msg.edit_with_editor(config, printer, backend, smtp)?; + Ok(()) +} + +/// Move a message from a mailbox to another. +pub fn move_<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq: &str, + mbox_src: &str, + mbox_dst: &str, + printer: &mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + backend.move_msg(mbox_src, mbox_dst, seq)?; + printer.print(format!( + r#"Message {} successfully moved to folder "{}""#, + seq, mbox_dst + )) +} + +/// Read a message by its sequence number. +pub fn read<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq: &str, + text_mime: &str, + raw: bool, + mbox: &str, + printer: &mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + let msg = backend.get_msg(mbox, seq)?; + let msg = if raw { + // Emails don't always have valid utf8. Using "lossy" to display what we can. + String::from_utf8_lossy(&msg.raw).into_owned() + } else { + msg.fold_text_parts(text_mime) + }; + + printer.print(msg) +} + +/// Reply to the given message UID. +pub fn reply<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( + seq: &str, + all: bool, + attachments_paths: Vec<&str>, + encrypt: bool, + mbox: &str, + config: &AccountConfig, + printer: &mut P, + backend: Box<&'a mut B>, + smtp: &mut S, +) -> Result<()> { + backend + .get_msg(mbox, seq)? + .into_reply(all, config)? + .add_attachments(attachments_paths)? + .encrypt(encrypt) + .edit_with_editor(config, printer, backend, smtp)? + .add_flags(mbox, seq, "replied") +} + +/// Saves a raw message to the targetted mailbox. +pub fn save<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + mbox: &str, + raw_msg: &str, + printer: &mut P, + backend: Box<&mut B>, +) -> Result<()> { + info!("entering save message handler"); + + debug!("mailbox: {}", mbox); + + let is_tty = atty::is(Stream::Stdin); + debug!("is tty: {}", is_tty); + let is_json = printer.is_json(); + debug!("is json: {}", is_json); + + let raw_msg = if is_tty || is_json { + raw_msg.replace("\r", "").replace("\n", "\r\n") + } else { + io::stdin() + .lock() + .lines() + .filter_map(Result::ok) + .collect::>() + .join("\r\n") + }; + backend.add_msg(mbox, raw_msg.as_bytes(), "seen")?; + Ok(()) +} + +/// Paginate messages from the selected mailbox matching the specified query. +pub fn search<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + query: String, + max_width: Option, + page_size: Option, + page: usize, + mbox: &str, + config: &AccountConfig, + printer: &mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + let page_size = page_size.unwrap_or(config.default_page_size); + debug!("page size: {}", page_size); + let msgs = backend.get_envelopes(mbox, "arrival:desc", &query, page_size, page)?; + trace!("messages: {:#?}", msgs); + printer.print_table(msgs, PrintTableOpts { max_width }) +} + +/// Paginates messages from the selected mailbox matching the specified query, sorted by the given criteria. +pub fn sort<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + sort: String, + query: String, + max_width: Option, + page_size: Option, + page: usize, + mbox: &str, + config: &AccountConfig, + printer: &mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + let page_size = page_size.unwrap_or(config.default_page_size); + debug!("page size: {}", page_size); + let msgs = backend.get_envelopes(mbox, &sort, &query, page_size, page)?; + trace!("envelopes: {:#?}", msgs); + printer.print_table(msgs, PrintTableOpts { max_width }) +} + +/// Send a raw message. +pub fn send<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( + raw_msg: &str, + config: &AccountConfig, + printer: &mut P, + backend: Box<&mut B>, + smtp: &mut S, +) -> Result<()> { + info!("entering send message handler"); + + let is_tty = atty::is(Stream::Stdin); + debug!("is tty: {}", is_tty); + let is_json = printer.is_json(); + debug!("is json: {}", is_json); + + let raw_msg = if is_tty || is_json { + raw_msg.replace("\r", "").replace("\n", "\r\n") + } else { + io::stdin() + .lock() + .lines() + .filter_map(Result::ok) + .collect::>() + .join("\r\n") + }; + trace!("raw message: {:?}", raw_msg); + let envelope: lettre::address::Envelope = Msg::from_tpl(&raw_msg)?.try_into()?; + trace!("envelope: {:?}", envelope); + + smtp.send_raw_msg(&envelope, raw_msg.as_bytes())?; + backend.add_msg(&config.sent_folder, raw_msg.as_bytes(), "seen")?; + Ok(()) +} + +/// Compose a new message. +pub fn write<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( + attachments_paths: Vec<&str>, + encrypt: bool, + config: &AccountConfig, + printer: &mut P, + backend: Box<&'a mut B>, + smtp: &mut S, +) -> Result<()> { + Msg::default() + .add_attachments(attachments_paths)? + .encrypt(encrypt) + .edit_with_editor(config, printer, backend, smtp)?; + Ok(()) +} diff --git a/src/domain/msg/msg_utils.rs b/src/msg/msg_utils.rs similarity index 100% rename from src/domain/msg/msg_utils.rs rename to src/msg/msg_utils.rs diff --git a/src/domain/msg/parts_entity.rs b/src/msg/parts_entity.rs similarity index 95% rename from src/domain/msg/parts_entity.rs rename to src/msg/parts_entity.rs index 07875ef..d4d0640 100644 --- a/src/domain/msg/parts_entity.rs +++ b/src/msg/parts_entity.rs @@ -7,7 +7,7 @@ use std::{ }; use uuid::Uuid; -use crate::config::Account; +use crate::config::AccountConfig; #[derive(Debug, Clone, Default, Serialize)] pub struct TextPlainPart { @@ -51,7 +51,7 @@ impl Parts { } pub fn from_parsed_mail<'a>( - account: &'a Account, + account: &'a AccountConfig, part: &'a mailparse::ParsedMail<'a>, ) -> Result { let mut parts = vec![]; @@ -75,7 +75,7 @@ impl DerefMut for Parts { } fn build_parts_map_rec( - account: &Account, + account: &AccountConfig, parsed_mail: &mailparse::ParsedMail, parts: &mut Vec, ) -> Result<()> { @@ -133,7 +133,7 @@ fn build_parts_map_rec( Ok(()) } -fn decrypt_part(account: &Account, msg: &mailparse::ParsedMail) -> Result { +fn decrypt_part(account: &AccountConfig, msg: &mailparse::ParsedMail) -> Result { let msg_path = env::temp_dir().join(Uuid::new_v4().to_string()); let msg_body = msg .get_body() diff --git a/src/domain/msg/tpl_arg.rs b/src/msg/tpl_arg.rs similarity index 93% rename from src/domain/msg/tpl_arg.rs rename to src/msg/tpl_arg.rs index 8816d09..6632ea5 100644 --- a/src/domain/msg/tpl_arg.rs +++ b/src/msg/tpl_arg.rs @@ -6,14 +6,14 @@ use anyhow::Result; use clap::{self, App, AppSettings, Arg, ArgMatches, SubCommand}; use log::{debug, info, trace}; -use crate::domain::msg::msg_arg; +use crate::msg::msg_arg; type Seq<'a> = &'a str; type ReplyAll = bool; type AttachmentPaths<'a> = Vec<&'a str>; type Tpl<'a> = &'a str; -#[derive(Debug, Default)] +#[derive(Debug, Default, PartialEq, Eq)] pub struct TplOverride<'a> { pub subject: Option<&'a str>, pub from: Option>, @@ -41,7 +41,8 @@ impl<'a> From<&'a ArgMatches<'a>> for TplOverride<'a> { } /// Message template commands. -pub enum Command<'a> { +#[derive(Debug, PartialEq, Eq)] +pub enum Cmd<'a> { New(TplOverride<'a>), Reply(Seq<'a>, ReplyAll, TplOverride<'a>), Forward(Seq<'a>, TplOverride<'a>), @@ -50,14 +51,14 @@ pub enum Command<'a> { } /// Message template command matcher. -pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { +pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { info!("entering message template command matcher"); if let Some(m) = m.subcommand_matches("new") { info!("new subcommand matched"); let tpl = TplOverride::from(m); trace!("template override: {:?}", tpl); - return Ok(Some(Command::New(tpl))); + return Ok(Some(Cmd::New(tpl))); } if let Some(m) = m.subcommand_matches("reply") { @@ -68,7 +69,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { debug!("reply all: {}", all); let tpl = TplOverride::from(m); trace!("template override: {:?}", tpl); - return Ok(Some(Command::Reply(seq, all, tpl))); + return Ok(Some(Cmd::Reply(seq, all, tpl))); } if let Some(m) = m.subcommand_matches("forward") { @@ -77,7 +78,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { debug!("sequence: {}", seq); let tpl = TplOverride::from(m); trace!("template args: {:?}", tpl); - return Ok(Some(Command::Forward(seq, tpl))); + return Ok(Some(Cmd::Forward(seq, tpl))); } if let Some(m) = m.subcommand_matches("save") { @@ -86,7 +87,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { trace!("attachments paths: {:?}", attachment_paths); let tpl = m.value_of("template").unwrap_or_default(); trace!("template: {}", tpl); - return Ok(Some(Command::Save(attachment_paths, tpl))); + return Ok(Some(Cmd::Save(attachment_paths, tpl))); } if let Some(m) = m.subcommand_matches("send") { @@ -95,7 +96,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { trace!("attachments paths: {:?}", attachment_paths); let tpl = m.value_of("template").unwrap_or_default(); trace!("template: {}", tpl); - return Ok(Some(Command::Send(attachment_paths, tpl))); + return Ok(Some(Cmd::Send(attachment_paths, tpl))); } Ok(None) diff --git a/src/msg/tpl_handler.rs b/src/msg/tpl_handler.rs new file mode 100644 index 0000000..45be083 --- /dev/null +++ b/src/msg/tpl_handler.rs @@ -0,0 +1,109 @@ +//! Module related to message template handling. +//! +//! This module gathers all message template commands. + +use anyhow::Result; +use atty::Stream; +use std::io::{self, BufRead}; + +use crate::{ + backends::Backend, + config::AccountConfig, + msg::{Msg, TplOverride}, + output::PrinterService, + smtp::SmtpService, +}; + +/// Generate a new message template. +pub fn new<'a, P: PrinterService>( + opts: TplOverride<'a>, + account: &'a AccountConfig, + printer: &'a mut P, +) -> Result<()> { + let tpl = Msg::default().to_tpl(opts, account)?; + printer.print(tpl) +} + +/// Generate a reply message template. +pub fn reply<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq: &str, + all: bool, + opts: TplOverride<'a>, + mbox: &str, + config: &'a AccountConfig, + printer: &'a mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + let tpl = backend + .get_msg(mbox, seq)? + .into_reply(all, config)? + .to_tpl(opts, config)?; + printer.print(tpl) +} + +/// Generate a forward message template. +pub fn forward<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + seq: &str, + opts: TplOverride<'a>, + mbox: &str, + config: &'a AccountConfig, + printer: &'a mut P, + backend: Box<&'a mut B>, +) -> Result<()> { + let tpl = backend + .get_msg(mbox, seq)? + .into_forward(config)? + .to_tpl(opts, config)?; + printer.print(tpl) +} + +/// Saves a message based on a template. +pub fn save<'a, P: PrinterService, B: Backend<'a> + ?Sized>( + mbox: &str, + config: &AccountConfig, + attachments_paths: Vec<&str>, + tpl: &str, + printer: &mut P, + backend: Box<&mut B>, +) -> Result<()> { + let tpl = if atty::is(Stream::Stdin) || printer.is_json() { + tpl.replace("\r", "") + } else { + io::stdin() + .lock() + .lines() + .filter_map(Result::ok) + .collect::>() + .join("\n") + }; + let msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?; + let raw_msg = msg.into_sendable_msg(config)?.formatted(); + backend.add_msg(mbox, &raw_msg, "seen")?; + printer.print("Template successfully saved") +} + +/// Sends a message based on a template. +pub fn send<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( + mbox: &str, + account: &AccountConfig, + attachments_paths: Vec<&str>, + tpl: &str, + printer: &mut P, + backend: Box<&mut B>, + smtp: &mut S, +) -> Result<()> { + let tpl = if atty::is(Stream::Stdin) || printer.is_json() { + tpl.replace("\r", "") + } else { + io::stdin() + .lock() + .lines() + .filter_map(Result::ok) + .collect::>() + .join("\n") + }; + let msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?; + let sent_msg = smtp.send_msg(account, &msg)?; + backend.add_msg(mbox, &sent_msg.formatted(), "seen")?; + printer.print("Template successfully sent") +} diff --git a/src/output/output_entity.rs b/src/output/output_entity.rs index 0accfe0..860f643 100644 --- a/src/output/output_entity.rs +++ b/src/output/output_entity.rs @@ -1,9 +1,5 @@ use anyhow::{anyhow, Error, Result}; -use serde::Serialize; -use std::{ - convert::TryFrom, - fmt::{self, Display}, -}; +use std::{convert::TryFrom, fmt}; /// Represents the available output formats. #[derive(Debug, PartialEq)] @@ -34,7 +30,7 @@ impl TryFrom> for OutputFmt { } } -impl Display for OutputFmt { +impl fmt::Display for OutputFmt { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let fmt = match *self { OutputFmt::Json => "JSON", @@ -45,12 +41,12 @@ impl Display for OutputFmt { } /// Defines a struct-wrapper to provide a JSON output. -#[derive(Debug, Serialize, Clone)] -pub struct OutputJson { +#[derive(Debug, Clone, serde::Serialize)] +pub struct OutputJson { response: T, } -impl OutputJson { +impl OutputJson { pub fn new(response: T) -> Self { Self { response } } diff --git a/src/output/printer_service.rs b/src/output/printer_service.rs index c875c94..65ee91b 100644 --- a/src/output/printer_service.rs +++ b/src/output/printer_service.rs @@ -1,16 +1,18 @@ use anyhow::{Context, Error, Result}; use atty::Stream; -use serde::Serialize; -use std::{convert::TryFrom, fmt::Debug}; +use std::{ + convert::TryFrom, + fmt::{self, Debug}, +}; use termcolor::{ColorChoice, StandardStream}; use crate::output::{OutputFmt, OutputJson, Print, PrintTable, PrintTableOpts, WriteColor}; pub trait PrinterService { - fn print(&mut self, data: T) -> Result<()>; - fn print_table( + fn print(&mut self, data: T) -> Result<()>; + fn print_table( &mut self, - data: T, + data: Box, opts: PrintTableOpts, ) -> Result<()>; fn is_json(&self) -> bool; @@ -22,7 +24,7 @@ pub struct StdoutPrinter { } impl PrinterService for StdoutPrinter { - fn print(&mut self, data: T) -> Result<()> { + fn print(&mut self, data: T) -> Result<()> { match self.fmt { OutputFmt::Plain => data.print(self.writter.as_mut()), OutputFmt::Json => serde_json::to_writer(self.writter.as_mut(), &OutputJson::new(data)) @@ -30,15 +32,19 @@ impl PrinterService for StdoutPrinter { } } - fn print_table( + fn print_table( &mut self, - data: T, + data: Box, opts: PrintTableOpts, ) -> Result<()> { match self.fmt { OutputFmt::Plain => data.print_table(self.writter.as_mut(), opts), - OutputFmt::Json => serde_json::to_writer(self.writter.as_mut(), &OutputJson::new(data)) - .context("cannot write JSON to writter"), + OutputFmt::Json => { + let json = &mut serde_json::Serializer::new(self.writter.as_mut()); + let ser = &mut ::erase(json); + data.erased_serialize(ser).unwrap(); + Ok(()) + } } } diff --git a/src/smtp/mod.rs b/src/smtp/mod.rs new file mode 100644 index 0000000..dc0a9f6 --- /dev/null +++ b/src/smtp/mod.rs @@ -0,0 +1 @@ +//! Module related to SMTP. diff --git a/src/domain/smtp/smtp_service.rs b/src/smtp/smtp_service.rs similarity index 79% rename from src/domain/smtp/smtp_service.rs rename to src/smtp/smtp_service.rs index 0817503..9eeccc1 100644 --- a/src/domain/smtp/smtp_service.rs +++ b/src/smtp/smtp_service.rs @@ -9,19 +9,19 @@ use lettre::{ }; use log::debug; -use crate::{config::Account, domain::msg::Msg}; +use crate::{config::AccountConfig, msg::Msg}; -pub trait SmtpServiceInterface { - fn send_msg(&mut self, account: &Account, msg: &Msg) -> Result; +pub trait SmtpService { + fn send_msg(&mut self, account: &AccountConfig, msg: &Msg) -> Result; fn send_raw_msg(&mut self, envelope: &lettre::address::Envelope, msg: &[u8]) -> Result<()>; } -pub struct SmtpService<'a> { - account: &'a Account, +pub struct LettreService<'a> { + account: &'a AccountConfig, transport: Option, } -impl<'a> SmtpService<'a> { +impl<'a> LettreService<'a> { fn transport(&mut self) -> Result<&SmtpTransport> { if let Some(ref transport) = self.transport { Ok(transport) @@ -55,8 +55,8 @@ impl<'a> SmtpService<'a> { } } -impl<'a> SmtpServiceInterface for SmtpService<'a> { - fn send_msg(&mut self, account: &Account, msg: &Msg) -> Result { +impl<'a> SmtpService for LettreService<'a> { + fn send_msg(&mut self, account: &AccountConfig, msg: &Msg) -> Result { debug!("sending message…"); let sendable_msg = msg.into_sendable_msg(account)?; self.transport()?.send(&sendable_msg)?; @@ -70,8 +70,8 @@ impl<'a> SmtpServiceInterface for SmtpService<'a> { } } -impl<'a> From<&'a Account> for SmtpService<'a> { - fn from(account: &'a Account) -> Self { +impl<'a> From<&'a AccountConfig> for LettreService<'a> { + fn from(account: &'a AccountConfig) -> Self { debug!("init SMTP service"); Self { account, diff --git a/src/ui/editor.rs b/src/ui/editor.rs index 703d221..9e7a5c2 100644 --- a/src/ui/editor.rs +++ b/src/ui/editor.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use log::debug; use std::{env, fs, process::Command}; -use crate::domain::msg::msg_utils; +use crate::msg::msg_utils; pub fn open_with_tpl(tpl: String) -> Result { let path = msg_utils::local_draft_path(); diff --git a/tests/emails/alice-to-patrick.eml b/tests/emails/alice-to-patrick.eml new file mode 100644 index 0000000..1fd4651 --- /dev/null +++ b/tests/emails/alice-to-patrick.eml @@ -0,0 +1,6 @@ +From: alice@localhost +To: patrick@localhost +Subject: Plain message +Content-Type: text/plain; charset=utf-8 + +Ceci est un message. \ No newline at end of file diff --git a/tests/test_imap_backend.rs b/tests/test_imap_backend.rs new file mode 100644 index 0000000..37c03e8 --- /dev/null +++ b/tests/test_imap_backend.rs @@ -0,0 +1,90 @@ +use himalaya::{ + backends::{Backend, ImapBackend, ImapEnvelopes}, + config::{AccountConfig, ImapBackendConfig}, +}; + +#[test] +fn test_imap_backend() { + // configure accounts + let account_config = AccountConfig { + smtp_host: "localhost".into(), + smtp_port: 3465, + smtp_starttls: false, + smtp_insecure: true, + smtp_login: "inbox@localhost".into(), + smtp_passwd_cmd: "echo 'password'".into(), + ..AccountConfig::default() + }; + let imap_config = ImapBackendConfig { + imap_host: "localhost".into(), + imap_port: 3993, + imap_starttls: false, + imap_insecure: true, + imap_login: "inbox@localhost".into(), + imap_passwd_cmd: "echo 'password'".into(), + }; + let mut imap = ImapBackend::new(&account_config, &imap_config); + imap.connect().unwrap(); + + // set up mailboxes + if let Err(_) = imap.add_mbox("Mailbox1") {}; + if let Err(_) = imap.add_mbox("Mailbox2") {}; + imap.del_msg("Mailbox1", "1:*").unwrap(); + imap.del_msg("Mailbox2", "1:*").unwrap(); + + // check that a message can be added + let msg = include_bytes!("./emails/alice-to-patrick.eml"); + let id = imap.add_msg("Mailbox1", msg, "seen").unwrap().to_string(); + + // check that the added message exists + let msg = imap.get_msg("Mailbox1", &id).unwrap(); + assert_eq!("alice@localhost", msg.from.clone().unwrap().to_string()); + assert_eq!("patrick@localhost", msg.to.clone().unwrap().to_string()); + assert_eq!("Ceci est un message.", msg.fold_text_plain_parts()); + + // check that the envelope of the added message exists + let envelopes = imap + .get_envelopes("Mailbox1", "arrival:desc", "ALL", 10, 0) + .unwrap(); + let envelopes: &ImapEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + assert_eq!(1, envelopes.len()); + let envelope = envelopes.first().unwrap(); + assert_eq!("alice@localhost", envelope.sender); + assert_eq!("Plain message", envelope.subject); + + // check that the message can be copied + imap.copy_msg("Mailbox1", "Mailbox2", &envelope.id.to_string()) + .unwrap(); + let envelopes = imap + .get_envelopes("Mailbox1", "arrival:desc", "ALL", 10, 0) + .unwrap(); + let envelopes: &ImapEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + assert_eq!(1, envelopes.len()); + let envelopes = imap + .get_envelopes("Mailbox2", "arrival:desc", "ALL", 10, 0) + .unwrap(); + let envelopes: &ImapEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + assert_eq!(1, envelopes.len()); + + // check that the message can be moved + imap.move_msg("Mailbox1", "Mailbox2", &envelope.id.to_string()) + .unwrap(); + let envelopes = imap + .get_envelopes("Mailbox1", "arrival:desc", "ALL", 10, 0) + .unwrap(); + let envelopes: &ImapEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + assert_eq!(0, envelopes.len()); + let envelopes = imap + .get_envelopes("Mailbox2", "arrival:desc", "ALL", 10, 0) + .unwrap(); + let envelopes: &ImapEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + assert_eq!(2, envelopes.len()); + let id = envelopes.first().unwrap().id.to_string(); + + // check that the message can be deleted + imap.del_msg("Mailbox2", &id).unwrap(); + assert!(imap.get_msg("Mailbox2", &id).is_err()); + + // check that disconnection works + imap.disconnect().unwrap(); +} diff --git a/tests/test_maildir_backend.rs b/tests/test_maildir_backend.rs new file mode 100644 index 0000000..cfc37a5 --- /dev/null +++ b/tests/test_maildir_backend.rs @@ -0,0 +1,68 @@ +use maildir::Maildir; +use std::{env, fs}; + +use himalaya::{ + backends::{Backend, MaildirBackend, MaildirEnvelopes}, + config::{AccountConfig, MaildirBackendConfig}, +}; + +#[test] +fn test_maildir_backend() { + // set up maildir folders + let mdir: Maildir = env::temp_dir().join("himalaya-test-mdir").into(); + if let Err(_) = fs::remove_dir_all(mdir.path()) {} + mdir.create_dirs().unwrap(); + + let mdir_sub: Maildir = mdir.path().join(".Subdir").into(); + if let Err(_) = fs::remove_dir_all(mdir_sub.path()) {} + mdir_sub.create_dirs().unwrap(); + + // configure accounts + let account_config = AccountConfig { + inbox_folder: "INBOX".into(), + ..AccountConfig::default() + }; + let mdir_config = MaildirBackendConfig { + maildir_dir: mdir.path().to_owned(), + }; + let mut mdir = MaildirBackend::new(&account_config, &mdir_config); + let mdir_sub_config = MaildirBackendConfig { + maildir_dir: mdir_sub.path().to_owned(), + }; + let mut mdir_subdir = MaildirBackend::new(&account_config, &mdir_sub_config); + + // check that a message can be added + let msg = include_bytes!("./emails/alice-to-patrick.eml"); + let id = mdir.add_msg("INBOX", msg, "seen").unwrap().to_string(); + + // check that the added message exists + let msg = mdir.get_msg("INBOX", &id).unwrap(); + assert_eq!("alice@localhost", msg.from.clone().unwrap().to_string()); + assert_eq!("patrick@localhost", msg.to.clone().unwrap().to_string()); + assert_eq!("Ceci est un message.", msg.fold_text_plain_parts()); + + // check that the envelope of the added message exists + let envelopes = mdir.get_envelopes("INBOX", "", "cur", 10, 0).unwrap(); + let envelopes: &MaildirEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + let envelope = envelopes.first().unwrap(); + assert_eq!(1, envelopes.len()); + assert_eq!("alice@localhost", envelope.sender); + assert_eq!("Plain message", envelope.subject); + + // check that the message can be copied + mdir.copy_msg("INBOX", "Subdir", &envelope.id).unwrap(); + assert!(mdir.get_msg("INBOX", &id).is_ok()); + assert!(mdir.get_msg("Subdir", &id).is_ok()); + assert!(mdir_subdir.get_msg("INBOX", &id).is_ok()); + + // check that the message can be moved + mdir.move_msg("INBOX", "Subdir", &envelope.id).unwrap(); + assert!(mdir.get_msg("INBOX", &id).is_err()); + assert!(mdir.get_msg("Subdir", &id).is_ok()); + assert!(mdir_subdir.get_msg("INBOX", &id).is_ok()); + + // check that the message can be deleted + mdir.del_msg("Subdir", &id).unwrap(); + assert!(mdir.get_msg("Subdir", &id).is_err()); + assert!(mdir_subdir.get_msg("INBOX", &id).is_err()); +}