diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc87fde..1aef8cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.7] - 2022-03-01 + +### Added + +- Notmuch support [#57] + +### Fixed + +- Build failure due to `imap` version [#303] +- No tilde expansion in `maildir-dir` [#305] +- Unknown command SORT [#308] + +### Changed + +- [**BREAKING**] Replace `inbox-folder`, `sent-folder` and `draft-folder` by a generic hashmap `mailboxes` +- Display short envelopes id for `maildir` and `notmuch` backends [#309] + ## [0.5.6] - 2022-02-22 ### Added @@ -304,7 +321,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.6...HEAD +[unreleased]: https://github.com/soywod/himalaya/compare/v0.5.7...HEAD +[0.5.7]: https://github.com/soywod/himalaya/compare/v0.5.6...v0.5.7 [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 @@ -364,6 +382,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#48]: https://github.com/soywod/himalaya/issues/48 [#50]: https://github.com/soywod/himalaya/issues/50 [#54]: https://github.com/soywod/himalaya/issues/54 +[#57]: https://github.com/soywod/himalaya/issues/57 [#58]: https://github.com/soywod/himalaya/issues/58 [#59]: https://github.com/soywod/himalaya/issues/59 [#61]: https://github.com/soywod/himalaya/issues/61 @@ -433,3 +452,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#280]: https://github.com/soywod/himalaya/issues/280 [#288]: https://github.com/soywod/himalaya/issues/288 [#289]: https://github.com/soywod/himalaya/issues/289 +[#303]: https://github.com/soywod/himalaya/issues/303 +[#305]: https://github.com/soywod/himalaya/issues/305 +[#308]: https://github.com/soywod/himalaya/issues/308 +[#309]: https://github.com/soywod/himalaya/issues/309 diff --git a/Cargo.lock b/Cargo.lock index 3f411638..c51aa6ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,7 @@ dependencies = [ "ansi_term", "atty", "bitflags", - "strsim", + "strsim 0.8.0", "textwrap", "unicode-width", ] @@ -183,6 +183,41 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "darling" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.9.3", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -281,6 +316,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "from_variants" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221a1eb1a3c98980bc1b740f462b3dcf73f4e371cda294986bac72497995a4e3" +dependencies = [ + "from_variants_impl", +] + +[[package]] +name = "from_variants_impl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e08079fa3c89edec9160ceaa9e7172785468c26c053d12924cce0d5a55c241a" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "funty" version = "1.1.0" @@ -380,7 +436,7 @@ dependencies = [ [[package]] name = "himalaya" -version = "0.5.6" +version = "0.5.7" dependencies = [ "ammonia", "anyhow", @@ -396,7 +452,9 @@ dependencies = [ "log", "maildir", "mailparse", + "md5", "native-tls", + "notmuch", "regex", "rfc2047-decoder", "serde", @@ -457,6 +515,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.2.3" @@ -649,6 +713,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "1.0.2" @@ -732,6 +802,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "notmuch" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0941fd9af5b8529e3d42494f56efafb909b76190a7a454cde9d6e397390cf9" +dependencies = [ + "from_variants", + "libc", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -1241,6 +1321,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" +[[package]] +name = "strsim" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" + [[package]] name = "syn" version = "1.0.81" diff --git a/Cargo.toml b/Cargo.toml index 13c36e04..ee3f0a3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "himalaya" description = "Command-line interface for email management" -version = "0.5.6" +version = "0.5.7" authors = ["soywod "] -edition = "2021" +edition = "2018" license-file = "LICENSE" readme = "README.md" categories = ["command-line-interface", "command-line-utilities", "email"] @@ -25,13 +25,15 @@ clap = { version = "2.33.3", default-features = false, features = ["suggestions" env_logger = "0.8.3" erased-serde = "0.3.18" html-escape = "0.2.9" -imap = "3.0.0-alpha.4" +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" +md5 = "0.7.0" native-tls = "0.2.8" +notmuch = { version = "0.7.1", optional = true } regex = "1.5.4" rfc2047-decoder = "0.1.2" serde = { version = "1.0.118", features = ["derive"] } diff --git a/README.md b/README.md index e9e961d4..d52595df 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ curl -sSL https://raw.githubusercontent.com/soywod/himalaya/master/install.sh | ``` *See the -[wiki](https://github.com/soywod/himalaya/wiki/Installation:from-binary) +[wiki](https://github.com/soywod/himalaya/wiki/Installation:binary) for other installation methods.* ## Configuration diff --git a/rustfmt.toml b/rustfmt.toml index c39d2eb1..a04de65a 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -61,7 +61,6 @@ use_field_init_shorthand = false force_explicit_abi = true condense_wildcard_suffixes = false color = "Auto" -required_version = "1.4.37" unstable_features = false disable_all_formatting = false skip_children = false diff --git a/src/backends/backend.rs b/src/backends/backend.rs index 81628220..d00ad1f4 100644 --- a/src/backends/backend.rs +++ b/src/backends/backend.rs @@ -21,8 +21,14 @@ pub trait Backend<'a> { fn get_envelopes( &mut self, mbox: &str, + page_size: usize, + page: usize, + ) -> Result>; + fn search_envelopes( + &mut self, + mbox: &str, + query: &str, sort: &str, - filter: &str, page_size: usize, page: usize, ) -> Result>; diff --git a/src/backends/id_mapper.rs b/src/backends/id_mapper.rs new file mode 100644 index 00000000..953a729a --- /dev/null +++ b/src/backends/id_mapper.rs @@ -0,0 +1,127 @@ +use anyhow::{anyhow, Context, Result}; +use std::{ + collections::HashMap, + fs::OpenOptions, + io::{BufRead, BufReader, Write}, + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Default)] +pub struct IdMapper { + path: PathBuf, + map: HashMap, + short_hash_len: usize, +} + +impl IdMapper { + pub fn new(dir: &Path) -> Result { + let mut mapper = Self::default(); + mapper.path = dir.join(".himalaya-id-map"); + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&mapper.path) + .context("cannot open id hash map file")?; + let reader = BufReader::new(file); + + for line in reader.lines() { + let line = + line.context("cannot read line from maildir envelopes id mapper cache file")?; + if mapper.short_hash_len == 0 { + mapper.short_hash_len = 2.max(line.parse().unwrap_or(2)); + } else { + let (hash, id) = line.split_once(' ').ok_or_else(|| { + anyhow!( + "cannot parse line {:?} from maildir envelopes id mapper cache file", + line + ) + })?; + mapper.insert(hash.to_owned(), id.to_owned()); + } + } + + Ok(mapper) + } + + pub fn find(&self, short_hash: &str) -> Result { + let matching_hashes: Vec<_> = self + .keys() + .filter(|hash| hash.starts_with(short_hash)) + .collect(); + if matching_hashes.len() == 0 { + Err(anyhow!( + "cannot find maildir message id from short hash {:?}", + short_hash, + )) + } else if matching_hashes.len() > 1 { + Err(anyhow!( + "the short hash {:?} matches more than one hash: {}", + short_hash, + matching_hashes + .iter() + .map(|s| s.to_string()) + .collect::>() + .join(", ") + ) + .context(format!( + "cannot find maildir message id from short hash {:?}", + short_hash + ))) + } else { + Ok(self.get(matching_hashes[0]).unwrap().to_owned()) + } + } + + pub fn append(&mut self, lines: Vec<(String, String)>) -> Result { + self.extend(lines); + + let mut entries = String::new(); + let mut short_hash_len = self.short_hash_len; + + for (hash, id) in self.iter() { + loop { + let short_hash = &hash[0..self.short_hash_len]; + let conflict_found = self + .map + .keys() + .find(|cached_hash| cached_hash.starts_with(short_hash) && cached_hash != &hash) + .is_some(); + if self.short_hash_len > 32 || !conflict_found { + break; + } + short_hash_len += 1; + } + entries.push_str(&format!("{} {}\n", hash, id)); + } + + self.short_hash_len = short_hash_len; + + OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&self.path) + .context("cannot open maildir id hash map cache")? + .write(format!("{}\n{}", short_hash_len, entries).as_bytes()) + .context("cannot write maildir id hash map cache")?; + + Ok(short_hash_len) + } +} + +impl Deref for IdMapper { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.map + } +} + +impl DerefMut for IdMapper { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.map + } +} diff --git a/src/backends/imap/imap_backend.rs b/src/backends/imap/imap_backend.rs index 8ed0b3b8..f6319e52 100644 --- a/src/backends/imap/imap_backend.rs +++ b/src/backends/imap/imap_backend.rs @@ -229,8 +229,42 @@ impl<'a> Backend<'a> for ImapBackend<'a> { fn get_envelopes( &mut self, mbox: &str, + page_size: usize, + page: usize, + ) -> Result> { + let last_seq = self + .sess()? + .select(mbox) + .context(format!("cannot select mailbox {:?}", mbox))? + .exists as usize; + debug!("last sequence number: {:?}", last_seq); + if last_seq == 0 { + return Ok(Box::new(ImapEnvelopes::default())); + } + + let range = if page_size > 0 { + let cursor = page * page_size; + let begin = 1.max(last_seq - cursor); + let end = begin - begin.min(page_size) + 1; + format!("{}:{}", end, begin) + } else { + String::from("1:*") + }; + debug!("range: {:?}", range); + + 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 search_envelopes( + &mut self, + mbox: &str, + query: &str, sort: &str, - filter: &str, page_size: usize, page: usize, ) -> Result> { @@ -239,24 +273,36 @@ impl<'a> Backend<'a> for ImapBackend<'a> { .select(mbox) .context(format!("cannot select mailbox {:?}", mbox))? .exists; + debug!("last sequence number: {:?}", last_seq); 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(); + let seqs: Vec = if sort.is_empty() { + self.sess()? + .search(query) + .context(format!( + "cannot find envelopes in {:?} with query {:?}", + mbox, query + ))? + .iter() + .map(|seq| seq.to_string()) + .collect() + } else { + let sort: SortCriteria = sort.try_into()?; + let charset = imap::extensions::sort::SortCharset::Utf8; + self.sess()? + .sort(&sort, charset, query) + .context(format!( + "cannot find envelopes in {:?} with query {:?}", + mbox, query + ))? + .iter() + .map(|seq| seq.to_string()) + .collect() + }; if seqs.is_empty() { return Ok(Box::new(ImapEnvelopes::default())); } diff --git a/src/backends/imap/imap_flag.rs b/src/backends/imap/imap_flag.rs index a946fee1..87bb8b9d 100644 --- a/src/backends/imap/imap_flag.rs +++ b/src/backends/imap/imap_flag.rs @@ -1,5 +1,9 @@ use anyhow::{anyhow, Error, Result}; -use std::{convert::TryFrom, fmt, ops::Deref}; +use std::{ + convert::{TryFrom, TryInto}, + fmt, + ops::Deref, +}; /// Represents the imap flag variants. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] diff --git a/src/backends/maildir/maildir_backend.rs b/src/backends/maildir/maildir_backend.rs index 0fd184ce..2bf3a9cd 100644 --- a/src/backends/maildir/maildir_backend.rs +++ b/src/backends/maildir/maildir_backend.rs @@ -1,16 +1,23 @@ +//! Maildir backend module. +//! +//! This module contains the definition of the maildir backend and its +//! traits implementation. + use anyhow::{anyhow, Context, Result}; +use log::{debug, info, trace}; use std::{convert::TryInto, fs, path::PathBuf}; use crate::{ - backends::{Backend, MaildirEnvelopes, MaildirFlags, MaildirMboxes}, + backends::{Backend, IdMapper, MaildirEnvelopes, MaildirFlags, MaildirMboxes}, config::{AccountConfig, MaildirBackendConfig}, mbox::Mboxes, msg::{Envelopes, Msg}, }; +/// Represents the maildir backend. pub struct MaildirBackend<'a> { - mdir: maildir::Maildir, account_config: &'a AccountConfig, + mdir: maildir::Maildir, } impl<'a> MaildirBackend<'a> { @@ -28,21 +35,32 @@ impl<'a> MaildirBackend<'a> { if mdir_path.is_dir() { Ok(mdir_path) } else { - Err(anyhow!( - "cannot read maildir from directory {:?}", - mdir_path - )) + Err(anyhow!("cannot read maildir directory {:?}", mdir_path)) } } - fn get_mdir_from_name(&self, mdir: &str) -> Result { - if mdir == self.account_config.inbox_folder { + /// Creates a maildir instance from a string slice. + pub fn get_mdir_from_dir(&self, dir: &str) -> Result { + // If the dir points to the inbox folder, creates a maildir + // instance from the root folder. + if dir == "inbox" { self.validate_mdir_path(self.mdir.path().to_owned()) .map(maildir::Maildir::from) } else { - self.validate_mdir_path(mdir.into()) + // If the dir is a valid maildir path, creates a maildir instance from it. + self.validate_mdir_path(dir.into()) .or_else(|_| { - let path = self.mdir.path().join(format!(".{}", mdir)); + // Otherwise creates a maildir instance from a + // maildir subdirectory by adding a "." in front + // of the name as described in the spec: + // https://cr.yp.to/proto/maildir.html + let dir = self + .account_config + .mailboxes + .get(dir) + .map(|s| s.as_str()) + .unwrap_or(dir); + let path = self.mdir.path().join(format!(".{}", dir)); self.validate_mdir_path(path) }) .map(maildir::Maildir::from) @@ -51,135 +69,423 @@ impl<'a> MaildirBackend<'a> { } 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 add_mbox(&mut self, subdir: &str) -> Result<()> { + info!(">> add maildir subdir"); + debug!("subdir: {:?}", subdir); + + let path = self.mdir.path().join(format!(".{}", subdir)); + trace!("subdir path: {:?}", path); + + fs::create_dir(&path) + .with_context(|| format!("cannot create maildir subdir {:?} at {:?}", subdir, path))?; + + info!("<< add maildir subdir"); + Ok(()) } fn get_mboxes(&mut self) -> Result> { - let mboxes: MaildirMboxes = self.mdir.list_subdirs().try_into()?; - Ok(Box::new(mboxes)) + info!(">> get maildir dirs"); + + let dirs: MaildirMboxes = + self.mdir.list_subdirs().try_into().with_context(|| { + format!("cannot parse maildir dirs from {:?}", self.mdir.path()) + })?; + trace!("dirs: {:?}", dirs); + + info!("<< get maildir dirs"); + Ok(Box::new(dirs)) } - 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 del_mbox(&mut self, dir: &str) -> Result<()> { + info!(">> delete maildir dir"); + debug!("dir: {:?}", dir); + + let path = self.mdir.path().join(format!(".{}", dir)); + trace!("dir path: {:?}", path); + + fs::remove_dir_all(&path) + .with_context(|| format!("cannot delete maildir {:?} from {:?}", dir, path))?; + + info!("<< delete maildir dir"); + Ok(()) } fn get_envelopes( &mut self, - mdir: &str, - _sort: &str, - filter: &str, + dir: &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()); + info!(">> get maildir envelopes"); + debug!("dir: {:?}", dir); + debug!("page size: {:?}", page_size); + debug!("page: {:?}", page); + let mdir = self + .get_mdir_from_dir(dir) + .with_context(|| format!("cannot get maildir instance from {:?}", dir))?; + + // Reads envelopes from the "cur" folder of the selected + // maildir. + let mut envelopes: MaildirEnvelopes = mdir.list_cur().try_into().with_context(|| { + format!("cannot parse maildir envelopes from {:?}", self.mdir.path()) + })?; + debug!("envelopes len: {:?}", envelopes.len()); + trace!("envelopes: {:?}", envelopes); + + // Calculates pagination boundaries. let page_begin = page * page_size; + debug!("page begin: {:?}", page_begin); if page_begin > envelopes.len() { - return Err(anyhow!(format!( - "cannot list maildir envelopes at page {:?} (out of bounds)", + return Err(anyhow!( + "cannot get maildir envelopes at page {:?} (out of bounds)", page_begin + 1, - ))); + )); } let page_end = envelopes.len().min(page_begin + page_size); + debug!("page end: {:?}", page_end); + + // Sorts envelopes by most recent date. + envelopes.sort_by(|a, b| b.date.partial_cmp(&a.date).unwrap()); + + // Applies pagination boundaries. envelopes.0 = envelopes[page_begin..page_end].to_owned(); + + // Appends envelopes hash to the id mapper cache file and + // calculates the new short hash length. The short hash length + // represents the minimum hash length possible to avoid + // conflicts. + let short_hash_len = { + let mut mapper = IdMapper::new(mdir.path())?; + let entries = envelopes + .iter() + .map(|env| (env.hash.to_owned(), env.id.to_owned())) + .collect(); + mapper.append(entries)? + }; + debug!("short hash length: {:?}", short_hash_len); + + // Shorten envelopes hash. + envelopes + .iter_mut() + .for_each(|env| env.hash = env.hash[0..short_hash_len].to_owned()); + + info!("<< get maildir envelopes"); 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()?; + fn search_envelopes( + &mut self, + _dir: &str, + _query: &str, + _sort: &str, + _page_size: usize, + _page: usize, + ) -> Result> { + info!(">> search maildir envelopes"); + info!("<< search maildir envelopes"); + Err(anyhow!( + "cannot find maildir envelopes: feature not implemented" + )) + } + + fn add_msg(&mut self, dir: &str, msg: &[u8], flags: &str) -> Result> { + info!(">> add maildir message"); + debug!("dir: {:?}", dir); + debug!("flags: {:?}", flags); + + let mdir = self + .get_mdir_from_dir(dir) + .with_context(|| format!("cannot get maildir instance from {:?}", dir))?; + let flags: MaildirFlags = flags + .try_into() + .with_context(|| format!("cannot parse maildir flags {:?}", flags))?; let id = mdir .store_cur_with_flags(msg, &flags.to_string()) - .context(format!( - "cannot add message to the \"cur\" folder of maildir {:?}", + .with_context(|| format!("cannot add maildir message to {:?}", mdir.path()))?; + debug!("id: {:?}", id); + let hash = format!("{:x}", md5::compute(&id)); + debug!("hash: {:?}", hash); + + // Appends hash entry to the id mapper cache file. + let mut mapper = IdMapper::new(mdir.path()) + .with_context(|| format!("cannot create id mapper instance for {:?}", mdir.path()))?; + mapper + .append(vec![(hash.clone(), id.clone())]) + .with_context(|| { + format!( + "cannot append hash {:?} with id {:?} to id mapper", + hash, id + ) + })?; + + info!("<< add maildir message"); + Ok(Box::new(hash)) + } + + fn get_msg(&mut self, dir: &str, short_hash: &str) -> Result { + info!(">> get maildir message"); + debug!("dir: {:?}", dir); + debug!("short hash: {:?}", short_hash); + + let mdir = self + .get_mdir_from_dir(dir) + .with_context(|| format!("cannot get maildir instance from {:?}", dir))?; + let id = IdMapper::new(mdir.path())? + .find(short_hash) + .with_context(|| { + format!( + "cannot find maildir message by short hash {:?} at {:?}", + short_hash, + mdir.path() + ) + })?; + debug!("id: {:?}", id); + let mut mail_entry = mdir.find(&id).ok_or_else(|| { + anyhow!( + "cannot find maildir message by id {:?} at {:?}", + id, mdir.path() - ))?; - Ok(Box::new(id)) + ) + })?; + let parsed_mail = mail_entry.parsed().with_context(|| { + format!("cannot parse maildir message {:?} at {:?}", id, mdir.path()) + })?; + let msg = Msg::from_parsed_mail(parsed_mail, self.account_config).with_context(|| { + format!("cannot parse maildir message {:?} at {:?}", id, mdir.path()) + })?; + trace!("message: {:?}", msg); + + info!("<< get maildir message"); + Ok(msg) } - 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, dir_src: &str, dir_dst: &str, short_hash: &str) -> Result<()> { + info!(">> copy maildir message"); + debug!("source dir: {:?}", dir_src); + debug!("destination dir: {:?}", dir_dst); + + let mdir_src = self + .get_mdir_from_dir(dir_src) + .with_context(|| format!("cannot get source maildir instance from {:?}", dir_src))?; + let mdir_dst = self.get_mdir_from_dir(dir_dst).with_context(|| { + format!("cannot get destination maildir instance from {:?}", dir_dst) + })?; + let id = IdMapper::new(mdir_src.path()) + .with_context(|| format!("cannot create id mapper instance for {:?}", mdir_src.path()))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find maildir message by short hash {:?} at {:?}", + short_hash, + mdir_src.path() + ) + })?; + debug!("id: {:?}", id); + + mdir_src.copy_to(&id, &mdir_dst).with_context(|| { + format!( + "cannot copy message {:?} from maildir {:?} to maildir {:?}", + id, + mdir_src.path(), + mdir_dst.path() + ) + })?; + + // Appends hash entry to the id mapper cache file. + let mut mapper = IdMapper::new(mdir_dst.path()).with_context(|| { + format!("cannot create id mapper instance for {:?}", mdir_dst.path()) + })?; + let hash = format!("{:x}", md5::compute(&id)); + mapper + .append(vec![(hash.clone(), id.clone())]) + .with_context(|| { + format!( + "cannot append hash {:?} with id {:?} to id mapper", + hash, id + ) + })?; + + info!("<< copy maildir message"); + Ok(()) } - 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, dir_src: &str, dir_dst: &str, short_hash: &str) -> Result<()> { + info!(">> move maildir message"); + debug!("source dir: {:?}", dir_src); + debug!("destination dir: {:?}", dir_dst); + + let mdir_src = self + .get_mdir_from_dir(dir_src) + .with_context(|| format!("cannot get source maildir instance from {:?}", dir_src))?; + let mdir_dst = self.get_mdir_from_dir(dir_dst).with_context(|| { + format!("cannot get destination maildir instance from {:?}", dir_dst) + })?; + let id = IdMapper::new(mdir_src.path()) + .with_context(|| format!("cannot create id mapper instance for {:?}", mdir_src.path()))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find maildir message by short hash {:?} at {:?}", + short_hash, + mdir_src.path() + ) + })?; + debug!("id: {:?}", id); + + mdir_src.move_to(&id, &mdir_dst).with_context(|| { + format!( + "cannot move message {:?} from maildir {:?} to maildir {:?}", + id, + mdir_src.path(), + mdir_dst.path() + ) + })?; + + // Appends hash entry to the id mapper cache file. + let mut mapper = IdMapper::new(mdir_dst.path()).with_context(|| { + format!("cannot create id mapper instance for {:?}", mdir_dst.path()) + })?; + let hash = format!("{:x}", md5::compute(&id)); + mapper + .append(vec![(hash.clone(), id.clone())]) + .with_context(|| { + format!( + "cannot append hash {:?} with id {:?} to id mapper", + hash, id + ) + })?; + + info!("<< move maildir message"); + Ok(()) } - 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, dir: &str, short_hash: &str) -> Result<()> { + info!(">> delete maildir message"); + debug!("dir: {:?}", dir); + debug!("short hash: {:?}", short_hash); + + let mdir = self + .get_mdir_from_dir(dir) + .with_context(|| format!("cannot get maildir instance from {:?}", dir))?; + let id = IdMapper::new(mdir.path()) + .with_context(|| format!("cannot create id mapper instance for {:?}", mdir.path()))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find maildir message by short hash {:?} at {:?}", + short_hash, + mdir.path() + ) + })?; + debug!("id: {:?}", id); + mdir.delete(&id).with_context(|| { + format!( + "cannot delete message {:?} from maildir {:?}", + id, + mdir.path() + ) + })?; + + info!("<< delete maildir message"); + Ok(()) } - 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, dir: &str, short_hash: &str, flags: &str) -> Result<()> { + info!(">> add maildir message flags"); + debug!("dir: {:?}", dir); + debug!("short hash: {:?}", short_hash); + debug!("flags: {:?}", flags); + + let mdir = self + .get_mdir_from_dir(dir) + .with_context(|| format!("cannot get maildir instance from {:?}", dir))?; + let flags: MaildirFlags = flags + .try_into() + .with_context(|| format!("cannot parse maildir flags {:?}", flags))?; + debug!("flags: {:?}", flags); + let id = IdMapper::new(mdir.path()) + .with_context(|| format!("cannot create id mapper instance for {:?}", mdir.path()))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find maildir message by short hash {:?} at {:?}", + short_hash, + mdir.path() + ) + })?; + debug!("id: {:?}", id); + mdir.add_flags(&id, &flags.to_string()) + .with_context(|| format!("cannot add flags {:?} to maildir message {:?}", flags, id))?; + + info!("<< add maildir message flags"); + Ok(()) } - 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, dir: &str, short_hash: &str, flags: &str) -> Result<()> { + info!(">> set maildir message flags"); + debug!("dir: {:?}", dir); + debug!("short hash: {:?}", short_hash); + debug!("flags: {:?}", flags); + + let mdir = self + .get_mdir_from_dir(dir) + .with_context(|| format!("cannot get maildir instance from {:?}", dir))?; + let flags: MaildirFlags = flags + .try_into() + .with_context(|| format!("cannot parse maildir flags {:?}", flags))?; + debug!("flags: {:?}", flags); + let id = IdMapper::new(mdir.path()) + .with_context(|| format!("cannot create id mapper instance for {:?}", mdir.path()))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find maildir message by short hash {:?} at {:?}", + short_hash, + mdir.path() + ) + })?; + debug!("id: {:?}", id); + mdir.set_flags(&id, &flags.to_string()) + .with_context(|| format!("cannot set flags {:?} to maildir message {:?}", flags, id))?; + + info!("<< set maildir message flags"); + Ok(()) } - 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, dir: &str, short_hash: &str, flags: &str) -> Result<()> { + info!(">> delete maildir message flags"); + debug!("dir: {:?}", dir); + debug!("short hash: {:?}", short_hash); + debug!("flags: {:?}", flags); - 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 - )) + let mdir = self + .get_mdir_from_dir(dir) + .with_context(|| format!("cannot get maildir instance from {:?}", dir))?; + let flags: MaildirFlags = flags + .try_into() + .with_context(|| format!("cannot parse maildir flags {:?}", flags))?; + debug!("flags: {:?}", flags); + let id = IdMapper::new(mdir.path()) + .with_context(|| format!("cannot create id mapper instance for {:?}", mdir.path()))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find maildir message by short hash {:?} at {:?}", + short_hash, + mdir.path() + ) + })?; + debug!("id: {:?}", id); + mdir.remove_flags(&id, &flags.to_string()) + .with_context(|| { + format!( + "cannot delete flags {:?} to maildir message {:?}", + flags, id + ) + })?; + + info!("<< delete maildir message flags"); + Ok(()) } } diff --git a/src/backends/maildir/maildir_envelope.rs b/src/backends/maildir/maildir_envelope.rs index 4d35f3f0..137be427 100644 --- a/src/backends/maildir/maildir_envelope.rs +++ b/src/backends/maildir/maildir_envelope.rs @@ -56,6 +56,9 @@ pub struct MaildirEnvelope { /// Represents the id of the message. pub id: String, + /// Represents the MD5 hash of the message id. + pub hash: String, + /// Represents the flags of the message. pub flags: MaildirFlags, @@ -72,7 +75,7 @@ pub struct MaildirEnvelope { impl Table for MaildirEnvelope { fn head() -> Row { Row::new() - .cell(Cell::new("IDENTIFIER").bold().underline().white()) + .cell(Cell::new("HASH").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()) @@ -80,14 +83,14 @@ impl Table for MaildirEnvelope { } fn row(&self) -> Row { - let id = self.id.to_string(); + let hash = self.hash.clone(); 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(hash).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()) @@ -110,6 +113,7 @@ impl<'a> TryFrom for MaildirEnvelopes { .context("cannot parse maildir mail entry")?; envelopes.push(envelope); } + Ok(MaildirEnvelopes(envelopes)) } } @@ -123,13 +127,13 @@ impl<'a> TryFrom for MaildirEnvelope { 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 mut envelope = Self::default(); + + envelope.id = mail_entry.id().into(); + envelope.hash = format!("{:x}", md5::compute(&envelope.id)); + envelope.flags = (&mail_entry) + .try_into() + .context("cannot parse maildir flags")?; let parsed_mail = mail_entry .parsed() diff --git a/src/backends/notmuch/notmuch_backend.rs b/src/backends/notmuch/notmuch_backend.rs new file mode 100644 index 00000000..416e1c0b --- /dev/null +++ b/src/backends/notmuch/notmuch_backend.rs @@ -0,0 +1,453 @@ +use std::{convert::TryInto, fs}; + +use anyhow::{anyhow, Context, Result}; +use log::{debug, info, trace}; + +use crate::{ + backends::{Backend, IdMapper, MaildirBackend, NotmuchEnvelopes, NotmuchMbox, NotmuchMboxes}, + config::{AccountConfig, NotmuchBackendConfig}, + mbox::Mboxes, + msg::{Envelopes, Msg}, +}; + +/// Represents the Notmuch backend. +pub struct NotmuchBackend<'a> { + account_config: &'a AccountConfig, + notmuch_config: &'a NotmuchBackendConfig, + pub mdir: &'a mut MaildirBackend<'a>, + db: notmuch::Database, +} + +impl<'a> NotmuchBackend<'a> { + pub fn new( + account_config: &'a AccountConfig, + notmuch_config: &'a NotmuchBackendConfig, + mdir: &'a mut MaildirBackend<'a>, + ) -> Result> { + info!(">> create new notmuch backend"); + + let backend = Self { + account_config, + notmuch_config, + mdir, + db: notmuch::Database::open( + notmuch_config.notmuch_database_dir.clone(), + notmuch::DatabaseMode::ReadWrite, + ) + .with_context(|| { + format!( + "cannot open notmuch database at {:?}", + notmuch_config.notmuch_database_dir + ) + })?, + }; + + info!("<< create new notmuch backend"); + Ok(backend) + } + + fn _search_envelopes( + &mut self, + query: &str, + page_size: usize, + page: usize, + ) -> Result> { + // Gets envelopes matching the given Notmuch query. + let query_builder = self + .db + .create_query(query) + .with_context(|| format!("cannot create notmuch query from {:?}", query))?; + let mut envelopes: NotmuchEnvelopes = query_builder + .search_messages() + .with_context(|| format!("cannot find notmuch envelopes from query {:?}", query))? + .try_into() + .with_context(|| format!("cannot parse notmuch envelopes from query {:?}", query))?; + debug!("envelopes len: {:?}", envelopes.len()); + trace!("envelopes: {:?}", envelopes); + + // Calculates pagination boundaries. + let page_begin = page * page_size; + debug!("page begin: {:?}", page_begin); + if page_begin > envelopes.len() { + return Err(anyhow!( + "cannot get notmuch envelopes at page {:?} (out of bounds)", + page_begin + 1, + )); + } + let page_end = envelopes.len().min(page_begin + page_size); + debug!("page end: {:?}", page_end); + + // Sorts envelopes by most recent date. + envelopes.sort_by(|a, b| b.date.partial_cmp(&a.date).unwrap()); + + // Applies pagination boundaries. + envelopes.0 = envelopes[page_begin..page_end].to_owned(); + + // Appends envelopes hash to the id mapper cache file and + // calculates the new short hash length. The short hash length + // represents the minimum hash length possible to avoid + // conflicts. + let short_hash_len = { + let mut mapper = IdMapper::new(&self.notmuch_config.notmuch_database_dir)?; + let entries = envelopes + .iter() + .map(|env| (env.hash.to_owned(), env.id.to_owned())) + .collect(); + mapper.append(entries)? + }; + debug!("short hash length: {:?}", short_hash_len); + + // Shorten envelopes hash. + envelopes + .iter_mut() + .for_each(|env| env.hash = env.hash[0..short_hash_len].to_owned()); + + Ok(Box::new(envelopes)) + } +} + +impl<'a> Backend<'a> for NotmuchBackend<'a> { + fn add_mbox(&mut self, _mbox: &str) -> Result<()> { + info!(">> add notmuch mailbox"); + info!("<< add notmuch mailbox"); + Err(anyhow!( + "cannot add notmuch mailbox: feature not implemented" + )) + } + + fn get_mboxes(&mut self) -> Result> { + info!(">> get notmuch virtual mailboxes"); + + let mut virt_mboxes: Vec<_> = self + .account_config + .mailboxes + .iter() + .map(|(k, v)| NotmuchMbox::new(k, v)) + .collect(); + trace!("virtual mailboxes: {:?}", virt_mboxes); + virt_mboxes.sort_by(|a, b| b.name.partial_cmp(&a.name).unwrap()); + + info!("<< get notmuch virtual mailboxes"); + Ok(Box::new(NotmuchMboxes(virt_mboxes))) + } + + fn del_mbox(&mut self, _mbox: &str) -> Result<()> { + info!(">> delete notmuch mailbox"); + info!("<< delete notmuch mailbox"); + Err(anyhow!( + "cannot delete notmuch mailbox: feature not implemented" + )) + } + + fn get_envelopes( + &mut self, + virt_mbox: &str, + page_size: usize, + page: usize, + ) -> Result> { + info!(">> get notmuch envelopes"); + debug!("virtual mailbox: {:?}", virt_mbox); + debug!("page size: {:?}", page_size); + debug!("page: {:?}", page); + + let query = self + .account_config + .mailboxes + .get(virt_mbox) + .map(|s| s.as_str()) + .unwrap_or("all"); + debug!("query: {:?}", query); + let envelopes = self._search_envelopes(query, page_size, page)?; + + info!("<< get notmuch envelopes"); + Ok(envelopes) + } + + fn search_envelopes( + &mut self, + virt_mbox: &str, + query: &str, + _sort: &str, + page_size: usize, + page: usize, + ) -> Result> { + info!(">> search notmuch envelopes"); + debug!("virtual mailbox: {:?}", virt_mbox); + debug!("query: {:?}", query); + debug!("page size: {:?}", page_size); + debug!("page: {:?}", page); + + let query = if query.is_empty() { + self.account_config + .mailboxes + .get(virt_mbox) + .map(|s| s.as_str()) + .unwrap_or("all") + } else { + query + }; + debug!("final query: {:?}", query); + let envelopes = self._search_envelopes(query, page_size, page)?; + + info!("<< search notmuch envelopes"); + Ok(envelopes) + } + + fn add_msg(&mut self, _: &str, msg: &[u8], tags: &str) -> Result> { + info!(">> add notmuch envelopes"); + debug!("tags: {:?}", tags); + + let dir = &self.notmuch_config.notmuch_database_dir; + + // Adds the message to the maildir folder and gets its hash. + let hash = self + .mdir + .add_msg("inbox", msg, "seen") + .with_context(|| { + format!( + "cannot add notmuch message to maildir {:?}", + self.notmuch_config.notmuch_database_dir + ) + })? + .to_string(); + debug!("hash: {:?}", hash); + + // Retrieves the file path of the added message by its maildir + // identifier. + let mut mapper = IdMapper::new(dir) + .with_context(|| format!("cannot create id mapper instance for {:?}", dir))?; + let id = mapper + .find(&hash) + .with_context(|| format!("cannot find notmuch message from short hash {:?}", hash))?; + debug!("id: {:?}", id); + let file_path = dir.join("cur").join(format!("{}:2,S", id)); + debug!("file path: {:?}", file_path); + + // Adds the message to the notmuch database by indexing it. + let id = self + .db + .index_file(&file_path, None) + .with_context(|| format!("cannot index notmuch message from file {:?}", file_path))? + .id() + .to_string(); + let hash = format!("{:x}", md5::compute(&id)); + + // Appends hash entry to the id mapper cache file. + mapper + .append(vec![(hash.clone(), id.clone())]) + .with_context(|| { + format!( + "cannot append hash {:?} with id {:?} to id mapper", + hash, id + ) + })?; + + // Attaches tags to the notmuch message. + self.add_flags("", &hash, tags) + .with_context(|| format!("cannot add flags to notmuch message {:?}", id))?; + + info!("<< add notmuch envelopes"); + Ok(Box::new(hash)) + } + + fn get_msg(&mut self, _: &str, short_hash: &str) -> Result { + info!(">> add notmuch envelopes"); + debug!("short hash: {:?}", short_hash); + + let dir = &self.notmuch_config.notmuch_database_dir; + let id = IdMapper::new(dir) + .with_context(|| format!("cannot create id mapper instance for {:?}", dir))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find notmuch message from short hash {:?}", + short_hash + ) + })?; + debug!("id: {:?}", id); + let msg_file_path = self + .db + .find_message(&id) + .with_context(|| format!("cannot find notmuch message {:?}", id))? + .ok_or_else(|| anyhow!("cannot find notmuch message {:?}", id))? + .filename() + .to_owned(); + debug!("message file path: {:?}", msg_file_path); + let raw_msg = fs::read(&msg_file_path).with_context(|| { + format!("cannot read notmuch message from file {:?}", msg_file_path) + })?; + let msg = mailparse::parse_mail(&raw_msg) + .with_context(|| format!("cannot parse raw notmuch message {:?}", id))?; + let msg = Msg::from_parsed_mail(msg, &self.account_config) + .with_context(|| format!("cannot parse notmuch message {:?}", id))?; + trace!("message: {:?}", msg); + + info!("<< get notmuch message"); + Ok(msg) + } + + fn copy_msg(&mut self, _dir_src: &str, _dir_dst: &str, _short_hash: &str) -> Result<()> { + info!(">> copy notmuch message"); + info!("<< copy notmuch message"); + Err(anyhow!( + "cannot copy notmuch message: feature not implemented" + )) + } + + fn move_msg(&mut self, _dir_src: &str, _dir_dst: &str, _short_hash: &str) -> Result<()> { + info!(">> move notmuch message"); + info!("<< move notmuch message"); + Err(anyhow!( + "cannot move notmuch message: feature not implemented" + )) + } + + fn del_msg(&mut self, _virt_mbox: &str, short_hash: &str) -> Result<()> { + info!(">> delete notmuch message"); + debug!("short hash: {:?}", short_hash); + + let dir = &self.notmuch_config.notmuch_database_dir; + let id = IdMapper::new(dir) + .with_context(|| format!("cannot create id mapper instance for {:?}", dir))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find notmuch message from short hash {:?}", + short_hash + ) + })?; + debug!("id: {:?}", id); + let msg_file_path = self + .db + .find_message(&id) + .with_context(|| format!("cannot find notmuch message {:?}", id))? + .ok_or_else(|| anyhow!("cannot find notmuch message {:?}", id))? + .filename() + .to_owned(); + debug!("message file path: {:?}", msg_file_path); + self.db + .remove_message(msg_file_path) + .with_context(|| format!("cannot delete notmuch message {:?}", id))?; + + info!("<< delete notmuch message"); + Ok(()) + } + + fn add_flags(&mut self, _virt_mbox: &str, short_hash: &str, tags: &str) -> Result<()> { + info!(">> add notmuch message flags"); + debug!("tags: {:?}", tags); + + let dir = &self.notmuch_config.notmuch_database_dir; + let id = IdMapper::new(dir) + .with_context(|| format!("cannot create id mapper instance for {:?}", dir))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find notmuch message from short hash {:?}", + short_hash + ) + })?; + debug!("id: {:?}", id); + let query = format!("id:{}", id); + debug!("query: {:?}", query); + let tags: Vec<_> = tags.split_whitespace().collect(); + let query_builder = self + .db + .create_query(&query) + .with_context(|| format!("cannot create notmuch query from {:?}", query))?; + let msgs = query_builder + .search_messages() + .with_context(|| format!("cannot find notmuch envelopes from query {:?}", query))?; + for msg in msgs { + for tag in tags.iter() { + msg.add_tag(*tag).with_context(|| { + format!("cannot add tag {:?} to notmuch message {:?}", tag, msg.id()) + })? + } + } + + info!("<< add notmuch message flags"); + Ok(()) + } + + fn set_flags(&mut self, _virt_mbox: &str, short_hash: &str, tags: &str) -> Result<()> { + info!(">> set notmuch message flags"); + debug!("tags: {:?}", tags); + + let dir = &self.notmuch_config.notmuch_database_dir; + let id = IdMapper::new(dir) + .with_context(|| format!("cannot create id mapper instance for {:?}", dir))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find notmuch message from short hash {:?}", + short_hash + ) + })?; + debug!("id: {:?}", id); + let query = format!("id:{}", id); + debug!("query: {:?}", query); + let tags: Vec<_> = tags.split_whitespace().collect(); + let query_builder = self + .db + .create_query(&query) + .with_context(|| format!("cannot create notmuch query from {:?}", query))?; + let msgs = query_builder + .search_messages() + .with_context(|| format!("cannot find notmuch envelopes from query {:?}", query))?; + for msg in msgs { + msg.remove_all_tags().with_context(|| { + format!("cannot remove all tags from notmuch message {:?}", msg.id()) + })?; + for tag in tags.iter() { + msg.add_tag(*tag).with_context(|| { + format!("cannot add tag {:?} to notmuch message {:?}", tag, msg.id()) + })? + } + } + + info!("<< set notmuch message flags"); + Ok(()) + } + + fn del_flags(&mut self, _virt_mbox: &str, short_hash: &str, tags: &str) -> Result<()> { + info!(">> delete notmuch message flags"); + debug!("tags: {:?}", tags); + + let dir = &self.notmuch_config.notmuch_database_dir; + let id = IdMapper::new(dir) + .with_context(|| format!("cannot create id mapper instance for {:?}", dir))? + .find(short_hash) + .with_context(|| { + format!( + "cannot find notmuch message from short hash {:?}", + short_hash + ) + })?; + debug!("id: {:?}", id); + let query = format!("id:{}", id); + debug!("query: {:?}", query); + let tags: Vec<_> = tags.split_whitespace().collect(); + let query_builder = self + .db + .create_query(&query) + .with_context(|| format!("cannot create notmuch query from {:?}", query))?; + let msgs = query_builder + .search_messages() + .with_context(|| format!("cannot find notmuch envelopes from query {:?}", query))?; + for msg in msgs { + for tag in tags.iter() { + msg.remove_tag(*tag).with_context(|| { + format!( + "cannot delete tag {:?} from notmuch message {:?}", + tag, + msg.id() + ) + })? + } + } + + info!("<< delete notmuch message flags"); + Ok(()) + } +} diff --git a/src/backends/notmuch/notmuch_envelope.rs b/src/backends/notmuch/notmuch_envelope.rs new file mode 100644 index 00000000..297535f1 --- /dev/null +++ b/src/backends/notmuch/notmuch_envelope.rs @@ -0,0 +1,177 @@ +//! Notmuch mailbox module. +//! +//! This module provides Notmuch types and conversion utilities +//! related to the envelope + +use anyhow::{anyhow, Context, Error, Result}; +use chrono::DateTime; +use log::{info, trace}; +use std::{ + convert::{TryFrom, TryInto}, + ops::{Deref, DerefMut}, +}; + +use crate::{ + 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 NotmuchEnvelopes(pub Vec); + +impl Deref for NotmuchEnvelopes { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for NotmuchEnvelopes { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl PrintTable for NotmuchEnvelopes { + fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writter)?; + Table::print(writter, self, opts)?; + writeln!(writter)?; + Ok(()) + } +} + +/// Represents the envelope. The envelope is just a message subset, +/// and is mostly used for listings. +#[derive(Debug, Default, Clone, serde::Serialize)] +pub struct NotmuchEnvelope { + /// Represents the id of the message. + pub id: String, + + /// Represents the MD5 hash of the message id. + pub hash: String, + + /// Represents the tags of the message. + pub flags: Vec, + + /// 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 NotmuchEnvelope { + fn head() -> Row { + Row::new() + .cell(Cell::new("HASH").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 hash = self.hash.to_string(); + let unseen = !self.flags.contains(&String::from("unread")); + let flags = String::new(); + let subject = &self.subject; + let sender = &self.sender; + let date = &self.date; + Row::new() + .cell(Cell::new(hash).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 `notmuch` crate. +pub type RawNotmuchEnvelopes = notmuch::Messages; + +impl<'a> TryFrom for NotmuchEnvelopes { + type Error = Error; + + fn try_from(raw_envelopes: RawNotmuchEnvelopes) -> Result { + let mut envelopes = vec![]; + for raw_envelope in raw_envelopes { + let envelope: NotmuchEnvelope = raw_envelope + .try_into() + .context("cannot parse notmuch mail entry")?; + envelopes.push(envelope); + } + Ok(NotmuchEnvelopes(envelopes)) + } +} + +/// Represents the raw envelope returned by the `notmuch` crate. +pub type RawNotmuchEnvelope = notmuch::Message; + +impl<'a> TryFrom for NotmuchEnvelope { + type Error = Error; + + fn try_from(raw_envelope: RawNotmuchEnvelope) -> Result { + info!("begin: try building envelope from notmuch parsed mail"); + + let id = raw_envelope.id().to_string(); + let hash = format!("{:x}", md5::compute(&id)); + let subject = raw_envelope + .header("subject") + .context("cannot get header \"Subject\" from notmuch message")? + .unwrap_or_default() + .to_string(); + let sender = raw_envelope + .header("from") + .context("cannot get header \"From\" from notmuch message")? + .ok_or_else(|| anyhow!("cannot parse sender from notmuch message {:?}", id))? + .to_string(); + let sender = from_slice_to_addrs(sender)? + .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"))?; + let date = raw_envelope + .header("date") + .context("cannot get header \"Date\" from notmuch message")? + .ok_or_else(|| anyhow!("cannot parse date of notmuch message {:?}", id))? + .to_string(); + let date = + DateTime::parse_from_rfc2822(date.split_at(date.find(" (").unwrap_or(date.len())).0) + .context(format!( + "cannot parse message date {:?} of notmuch message {:?}", + date, id + ))? + .naive_local() + .to_string(); + + let envelope = Self { + id, + hash, + flags: raw_envelope.tags().collect(), + subject, + sender, + date, + }; + trace!("envelope: {:?}", envelope); + + info!("end: try building envelope from notmuch parsed mail"); + Ok(envelope) + } +} diff --git a/src/backends/notmuch/notmuch_mbox.rs b/src/backends/notmuch/notmuch_mbox.rs new file mode 100644 index 00000000..6cde8b54 --- /dev/null +++ b/src/backends/notmuch/notmuch_mbox.rs @@ -0,0 +1,80 @@ +//! Notmuch mailbox module. +//! +//! This module provides Notmuch types and conversion utilities +//! related to the mailbox + +use anyhow::Result; +use std::{ + fmt::{self, Display}, + ops::Deref, +}; + +use crate::{ + mbox::Mboxes, + output::{PrintTable, PrintTableOpts, WriteColor}, + ui::{Cell, Row, Table}, +}; + +/// Represents a list of Notmuch mailboxes. +#[derive(Debug, Default, serde::Serialize)] +pub struct NotmuchMboxes(pub Vec); + +impl Deref for NotmuchMboxes { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PrintTable for NotmuchMboxes { + fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writter)?; + Table::print(writter, self, opts)?; + writeln!(writter)?; + Ok(()) + } +} + +impl Mboxes for NotmuchMboxes { + // +} + +/// Represents the notmuch virtual mailbox. +#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)] +pub struct NotmuchMbox { + /// Represents the virtual mailbox name. + pub name: String, + + /// Represents the query associated to the virtual mailbox name. + pub query: String, +} + +impl NotmuchMbox { + pub fn new(name: &str, query: &str) -> Self { + Self { + name: name.into(), + query: query.into(), + } + } +} + +impl Display for NotmuchMbox { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name) + } +} + +impl Table for NotmuchMbox { + fn head() -> Row { + Row::new() + .cell(Cell::new("NAME").bold().underline().white()) + .cell(Cell::new("QUERY").bold().underline().white()) + } + + fn row(&self) -> Row { + Row::new() + .cell(Cell::new(&self.name).white()) + .cell(Cell::new(&self.query).green()) + } +} diff --git a/src/config/account_config.rs b/src/config/account_config.rs index 44c02c07..20c51ca9 100644 --- a/src/config/account_config.rs +++ b/src/config/account_config.rs @@ -2,7 +2,7 @@ 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 std::{collections::HashMap, env, ffi::OsStr, fs, path::PathBuf}; use crate::{config::*, output::run_cmd}; @@ -23,12 +23,6 @@ pub struct AccountConfig { 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 @@ -36,6 +30,9 @@ pub struct AccountConfig { /// Represents the watch commands. pub watch_cmds: Vec, + /// Represents mailbox aliases. + pub mailboxes: HashMap, + /// Represents the SMTP host. pub smtp_host: String, /// Represents the SMTP port. @@ -73,6 +70,10 @@ impl<'a> AccountConfig { DeserializedAccountConfig::Maildir(account) => { account.default.unwrap_or_default() } + #[cfg(feature = "notmuch")] + DeserializedAccountConfig::Notmuch(account) => { + account.default.unwrap_or_default() + } }) .map(|(name, account)| (name.to_owned(), account)) .ok_or_else(|| anyhow!("cannot find default account")), @@ -134,24 +135,6 @@ impl<'a> AccountConfig { 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 @@ -165,6 +148,7 @@ impl<'a> AccountConfig { .or_else(|| config.watch_cmds.as_ref()) .unwrap_or(&vec![]) .to_owned(), + mailboxes: base_account.mailboxes.clone(), default: base_account.default.unwrap_or_default(), email: base_account.email.to_owned(), @@ -191,7 +175,15 @@ impl<'a> AccountConfig { }), DeserializedAccountConfig::Maildir(config) => { BackendConfig::Maildir(MaildirBackendConfig { - maildir_dir: config.maildir_dir.clone(), + maildir_dir: shellexpand::full(&config.maildir_dir)?.to_string().into(), + }) + } + #[cfg(feature = "notmuch")] + DeserializedAccountConfig::Notmuch(config) => { + BackendConfig::Notmuch(NotmuchBackendConfig { + notmuch_database_dir: shellexpand::full(&config.notmuch_database_dir)? + .to_string() + .into(), }) } }; @@ -321,6 +313,8 @@ impl<'a> AccountConfig { pub enum BackendConfig { Imap(ImapBackendConfig), Maildir(MaildirBackendConfig), + #[cfg(feature = "notmuch")] + Notmuch(NotmuchBackendConfig), } /// Represents the IMAP backend. @@ -358,6 +352,14 @@ pub struct MaildirBackendConfig { pub maildir_dir: PathBuf, } +/// Represents the Notmuch backend. +#[cfg(feature = "notmuch")] +#[derive(Debug, Default, Clone)] +pub struct NotmuchBackendConfig { + /// Represents the Notmuch database path. + pub notmuch_database_dir: PathBuf, +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/config/deserialized_account_config.rs b/src/config/deserialized_account_config.rs index 595e50bc..80b59fb0 100644 --- a/src/config/deserialized_account_config.rs +++ b/src/config/deserialized_account_config.rs @@ -1,5 +1,5 @@ use serde::Deserialize; -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; pub trait ToDeserializedBaseAccountConfig { fn to_base(&self) -> DeserializedBaseAccountConfig; @@ -11,6 +11,8 @@ pub trait ToDeserializedBaseAccountConfig { pub enum DeserializedAccountConfig { Imap(DeserializedImapAccountConfig), Maildir(DeserializedMaildirAccountConfig), + #[cfg(feature = "notmuch")] + Notmuch(DeserializedNotmuchAccountConfig), } impl ToDeserializedBaseAccountConfig for DeserializedAccountConfig { @@ -18,6 +20,8 @@ impl ToDeserializedBaseAccountConfig for DeserializedAccountConfig { match self { Self::Imap(config) => config.to_base(), Self::Maildir(config) => config.to_base(), + #[cfg(feature = "notmuch")] + Self::Notmuch(config) => config.to_base(), } } } @@ -37,12 +41,6 @@ macro_rules! make_account_config { 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. @@ -73,6 +71,10 @@ macro_rules! make_account_config { /// Represents the command used to decrypt a message. pub pgp_decrypt_cmd: Option, + /// Represents mailbox aliases. + #[serde(default)] + pub mailboxes: HashMap, + $(pub $element: $ty),* } @@ -84,9 +86,6 @@ macro_rules! make_account_config { 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(), @@ -103,6 +102,8 @@ macro_rules! make_account_config { pgp_encrypt_cmd: self.pgp_encrypt_cmd.clone(), pgp_decrypt_cmd: self.pgp_decrypt_cmd.clone(), + + mailboxes: self.mailboxes.clone(), } } } @@ -121,4 +122,10 @@ make_account_config!( imap_passwd_cmd: String ); -make_account_config!(DeserializedMaildirAccountConfig, maildir_dir: PathBuf); +make_account_config!(DeserializedMaildirAccountConfig, maildir_dir: String); + +#[cfg(feature = "notmuch")] +make_account_config!( + DeserializedNotmuchAccountConfig, + notmuch_database_dir: String +); diff --git a/src/config/deserialized_config.rs b/src/config/deserialized_config.rs index 280b6fbd..70671624 100644 --- a/src/config/deserialized_config.rs +++ b/src/config/deserialized_config.rs @@ -27,12 +27,6 @@ pub struct DeserializedConfig { 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 @@ -48,12 +42,12 @@ pub struct DeserializedConfig { 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"); + info!("begin: try 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"); + info!("end: try to parse config from path"); trace!("config: {:?}", config); Ok(config) } diff --git a/src/lib.rs b/src/lib.rs index 625792fb..2665914c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,9 @@ pub mod backends { pub use backend::*; pub mod backend; + pub use id_mapper::*; + pub mod id_mapper; + pub use self::imap::*; pub mod imap { pub mod imap_arg; @@ -75,6 +78,20 @@ pub mod backends { pub mod maildir_flag; pub use maildir_flag::*; } + + #[cfg(feature = "notmuch")] + pub use self::notmuch::*; + #[cfg(feature = "notmuch")] + pub mod notmuch { + pub mod notmuch_backend; + pub use notmuch_backend::*; + + pub mod notmuch_mbox; + pub use notmuch_mbox::*; + + pub mod notmuch_envelope; + pub use notmuch_envelope::*; + } } pub mod smtp { diff --git a/src/main.rs b/src/main.rs index c11fee00..2d543d5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,13 +5,19 @@ use url::Url; use himalaya::{ backends::{imap_arg, imap_handler, Backend, ImapBackend, MaildirBackend}, compl::{compl_arg, compl_handler}, - config::{account_args, config_args, AccountConfig, BackendConfig, DeserializedConfig}, + config::{ + account_args, config_args, AccountConfig, BackendConfig, DeserializedConfig, + DEFAULT_INBOX_FOLDER, + }, mbox::{mbox_arg, mbox_handler}, msg::{flag_arg, flag_handler, msg_arg, msg_handler, tpl_arg, tpl_handler}, output::{output_arg, OutputFmt, StdoutPrinter}, smtp::LettreService, }; +#[cfg(feature = "notmuch")] +use himalaya::{backends::NotmuchBackend, config::MaildirBackendConfig}; + fn create_app<'a>() -> clap::App<'a, 'a> { clap::App::new(env!("CARGO_PKG_NAME")) .version(env!("CARGO_PKG_VERSION")) @@ -45,6 +51,10 @@ fn main() -> Result<()> { let mut imap; let mut maildir; + #[cfg(feature = "notmuch")] + let maildir_config: MaildirBackendConfig; + #[cfg(feature = "notmuch")] + let mut notmuch; let backend: Box<&mut dyn Backend> = match backend_config { BackendConfig::Imap(ref imap_config) => { imap = ImapBackend::new(&account_config, imap_config); @@ -54,6 +64,15 @@ fn main() -> Result<()> { maildir = MaildirBackend::new(&account_config, maildir_config); Box::new(&mut maildir) } + #[cfg(feature = "notmuch")] + BackendConfig::Notmuch(ref notmuch_config) => { + maildir_config = MaildirBackendConfig { + maildir_dir: notmuch_config.notmuch_database_dir.clone(), + }; + maildir = MaildirBackend::new(&account_config, &maildir_config); + notmuch = NotmuchBackend::new(&account_config, notmuch_config, &mut maildir)?; + Box::new(&mut notmuch) + } }; return msg_handler::mailto(&url, &account_config, &mut printer, backend, &mut smtp); @@ -77,10 +96,15 @@ fn main() -> Result<()> { 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); + .or_else(|| account_config.mailboxes.get("inbox").map(|s| s.as_str())) + .unwrap_or(DEFAULT_INBOX_FOLDER); let mut printer = StdoutPrinter::try_from(m.value_of("output"))?; let mut imap; let mut maildir; + #[cfg(feature = "notmuch")] + let maildir_config: MaildirBackendConfig; + #[cfg(feature = "notmuch")] + let mut notmuch; let backend: Box<&mut dyn Backend> = match backend_config { BackendConfig::Imap(ref imap_config) => { imap = ImapBackend::new(&account_config, imap_config); @@ -90,6 +114,15 @@ fn main() -> Result<()> { maildir = MaildirBackend::new(&account_config, maildir_config); Box::new(&mut maildir) } + #[cfg(feature = "notmuch")] + BackendConfig::Notmuch(ref notmuch_config) => { + maildir_config = MaildirBackendConfig { + maildir_dir: notmuch_config.notmuch_database_dir.clone(), + }; + maildir = MaildirBackend::new(&account_config, &maildir_config); + notmuch = NotmuchBackend::new(&account_config, notmuch_config, &mut maildir)?; + Box::new(&mut notmuch) + } }; let mut smtp = LettreService::from(&account_config); diff --git a/src/mbox/mbox_handler.rs b/src/mbox/mbox_handler.rs index 7bb27e4c..4ed3aa0c 100644 --- a/src/mbox/mbox_handler.rs +++ b/src/mbox/mbox_handler.rs @@ -120,7 +120,10 @@ mod tests { fn del_mbox(&mut self, _: &str) -> Result<()> { unimplemented!(); } - fn get_envelopes( + fn get_envelopes(&mut self, _: &str, _: usize, _: usize) -> Result> { + unimplemented!() + } + fn search_envelopes( &mut self, _: &str, _: &str, diff --git a/src/msg/msg_entity.rs b/src/msg/msg_entity.rs index e4f3b5c4..3fa4483a 100644 --- a/src/msg/msg_entity.rs +++ b/src/msg/msg_entity.rs @@ -5,12 +5,12 @@ use html_escape; use lettre::message::{header::ContentType, Attachment, MultiPart, SinglePart}; use log::{debug, info, trace}; use regex::Regex; -use std::{collections::HashSet, env::temp_dir, fmt::Debug, fs, path::PathBuf}; +use std::{collections::HashSet, convert::TryInto, env::temp_dir, fmt::Debug, fs, path::PathBuf}; use uuid::Uuid; use crate::{ backends::Backend, - config::{AccountConfig, DEFAULT_SIG_DELIM}, + config::{AccountConfig, DEFAULT_DRAFT_FOLDER, DEFAULT_SENT_FOLDER, 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, @@ -340,7 +340,12 @@ impl Msg { match choice::post_edit() { Ok(PostEditChoice::Send) => { let sent_msg = smtp.send_msg(account, &self)?; - backend.add_msg(&account.sent_folder, &sent_msg.formatted(), "seen")?; + let sent_folder = account + .mailboxes + .get("sent") + .map(|s| s.as_str()) + .unwrap_or(DEFAULT_SENT_FOLDER); + backend.add_msg(&sent_folder, &sent_msg.formatted(), "seen")?; msg_utils::remove_local_draft()?; printer.print("Message successfully sent")?; break; @@ -355,12 +360,14 @@ impl Msg { } Ok(PostEditChoice::RemoteDraft) => { let tpl = self.to_tpl(TplOverride::default(), account)?; - backend.add_msg(&account.draft_folder, tpl.as_bytes(), "seen draft")?; + let draft_folder = account + .mailboxes + .get("draft") + .map(|s| s.as_str()) + .unwrap_or(DEFAULT_DRAFT_FOLDER); + backend.add_msg(&draft_folder, tpl.as_bytes(), "seen draft")?; msg_utils::remove_local_draft()?; - printer.print(format!( - "Message successfully saved to {}", - account.draft_folder - ))?; + printer.print(format!("Message successfully saved to {}", draft_folder))?; break; } Ok(PostEditChoice::Discard) => { diff --git a/src/msg/msg_handler.rs b/src/msg/msg_handler.rs index 3bd5dd3a..c9e10cee 100644 --- a/src/msg/msg_handler.rs +++ b/src/msg/msg_handler.rs @@ -16,7 +16,7 @@ use url::Url; use crate::{ backends::Backend, - config::AccountConfig, + config::{AccountConfig, DEFAULT_SENT_FOLDER}, msg::{Msg, Part, Parts, TextPlainPart}, output::{PrintTableOpts, PrinterService}, smtp::SmtpService, @@ -108,7 +108,7 @@ pub fn list<'a, P: PrinterService, B: Backend<'a> + ?Sized>( ) -> 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)?; + let msgs = imap.get_envelopes(mbox, page_size, page)?; trace!("envelopes: {:?}", msgs); printer.print_table(msgs, PrintTableOpts { max_width }) } @@ -273,7 +273,7 @@ pub fn search<'a, P: PrinterService, B: Backend<'a> + ?Sized>( ) -> 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)?; + let msgs = backend.search_envelopes(mbox, &query, "", page_size, page)?; trace!("messages: {:#?}", msgs); printer.print_table(msgs, PrintTableOpts { max_width }) } @@ -292,7 +292,7 @@ pub fn sort<'a, P: PrinterService, B: Backend<'a> + ?Sized>( ) -> 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)?; + let msgs = backend.search_envelopes(mbox, &query, &sort, page_size, page)?; trace!("envelopes: {:#?}", msgs); printer.print_table(msgs, PrintTableOpts { max_width }) } @@ -312,6 +312,13 @@ pub fn send<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( let is_json = printer.is_json(); debug!("is json: {}", is_json); + let sent_folder = config + .mailboxes + .get("sent") + .map(|s| s.as_str()) + .unwrap_or(DEFAULT_SENT_FOLDER); + debug!("sent folder: {:?}", sent_folder); + let raw_msg = if is_tty || is_json { raw_msg.replace("\r", "").replace("\n", "\r\n") } else { @@ -325,9 +332,8 @@ pub fn send<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( 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")?; + backend.add_msg(&sent_folder, raw_msg.as_bytes(), "seen")?; Ok(()) } diff --git a/tests/emails/alice-to-patrick.eml b/tests/emails/alice-to-patrick.eml index 1fd46511..2cef116e 100644 --- a/tests/emails/alice-to-patrick.eml +++ b/tests/emails/alice-to-patrick.eml @@ -2,5 +2,6 @@ From: alice@localhost To: patrick@localhost Subject: Plain message Content-Type: text/plain; charset=utf-8 +Date: Tue, 1 Mar 2022 12:00:00 +0000 Ceci est un message. \ No newline at end of file diff --git a/tests/test_imap_backend.rs b/tests/test_imap_backend.rs index 37c03e8a..7356a920 100644 --- a/tests/test_imap_backend.rs +++ b/tests/test_imap_backend.rs @@ -43,9 +43,7 @@ fn test_imap_backend() { 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 = imap.get_envelopes("Mailbox1", 10, 0).unwrap(); let envelopes: &ImapEnvelopes = envelopes.as_any().downcast_ref().unwrap(); assert_eq!(1, envelopes.len()); let envelope = envelopes.first().unwrap(); @@ -55,28 +53,20 @@ fn test_imap_backend() { // 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 = imap.get_envelopes("Mailbox1", 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 = imap.get_envelopes("Mailbox2", 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 = imap.get_envelopes("Mailbox1", 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 = imap.get_envelopes("Mailbox2", 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(); diff --git a/tests/test_maildir_backend.rs b/tests/test_maildir_backend.rs index cfc37a56..d9987896 100644 --- a/tests/test_maildir_backend.rs +++ b/tests/test_maildir_backend.rs @@ -1,8 +1,8 @@ use maildir::Maildir; -use std::{env, fs}; +use std::{collections::HashMap, env, fs, iter::FromIterator}; use himalaya::{ - backends::{Backend, MaildirBackend, MaildirEnvelopes}, + backends::{Backend, MaildirBackend, MaildirEnvelopes, MaildirFlag}, config::{AccountConfig, MaildirBackendConfig}, }; @@ -19,7 +19,10 @@ fn test_maildir_backend() { // configure accounts let account_config = AccountConfig { - inbox_folder: "INBOX".into(), + mailboxes: HashMap::from_iter([ + ("inbox".into(), "INBOX".into()), + ("subdir".into(), "Subdir".into()), + ]), ..AccountConfig::default() }; let mdir_config = MaildirBackendConfig { @@ -33,36 +36,64 @@ fn test_maildir_backend() { // 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(); + let hash = mdir.add_msg("inbox", msg, "seen").unwrap().to_string(); // check that the added message exists - let msg = mdir.get_msg("INBOX", &id).unwrap(); + let msg = mdir.get_msg("inbox", &hash).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 = mdir.get_envelopes("inbox", 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 a flag can be added to the message + mdir.add_flags("inbox", &envelope.hash, "flagged passed") + .unwrap(); + let envelopes = mdir.get_envelopes("inbox", 1, 0).unwrap(); + let envelopes: &MaildirEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + let envelope = envelopes.first().unwrap(); + assert!(envelope.flags.contains(&MaildirFlag::Seen)); + assert!(envelope.flags.contains(&MaildirFlag::Flagged)); + assert!(envelope.flags.contains(&MaildirFlag::Passed)); + + // check that the message flags can be changed + mdir.set_flags("inbox", &envelope.hash, "passed").unwrap(); + let envelopes = mdir.get_envelopes("inbox", 1, 0).unwrap(); + let envelopes: &MaildirEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + let envelope = envelopes.first().unwrap(); + assert!(!envelope.flags.contains(&MaildirFlag::Seen)); + assert!(!envelope.flags.contains(&MaildirFlag::Flagged)); + assert!(envelope.flags.contains(&MaildirFlag::Passed)); + + // check that a flag can be removed from the message + mdir.del_flags("inbox", &envelope.hash, "passed").unwrap(); + let envelopes = mdir.get_envelopes("inbox", 1, 0).unwrap(); + let envelopes: &MaildirEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + let envelope = envelopes.first().unwrap(); + assert!(!envelope.flags.contains(&MaildirFlag::Seen)); + assert!(!envelope.flags.contains(&MaildirFlag::Flagged)); + assert!(!envelope.flags.contains(&MaildirFlag::Passed)); + // 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()); + mdir.copy_msg("inbox", "subdir", &envelope.hash).unwrap(); + assert!(mdir.get_msg("inbox", &hash).is_ok()); + assert!(mdir.get_msg("subdir", &hash).is_ok()); + assert!(mdir_subdir.get_msg("inbox", &hash).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()); + mdir.move_msg("inbox", "subdir", &envelope.hash).unwrap(); + assert!(mdir.get_msg("inbox", &hash).is_err()); + assert!(mdir.get_msg("subdir", &hash).is_ok()); + assert!(mdir_subdir.get_msg("inbox", &hash).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()); + mdir.del_msg("subdir", &hash).unwrap(); + assert!(mdir.get_msg("subdir", &hash).is_err()); + assert!(mdir_subdir.get_msg("inbox", &hash).is_err()); } diff --git a/tests/test_notmuch_backend.rs b/tests/test_notmuch_backend.rs new file mode 100644 index 00000000..183fab9b --- /dev/null +++ b/tests/test_notmuch_backend.rs @@ -0,0 +1,88 @@ +#[cfg(feature = "notmuch")] +use std::{collections::HashMap, env, fs, iter::FromIterator}; + +#[cfg(feature = "notmuch")] +use himalaya::{ + backends::{Backend, MaildirBackend, NotmuchBackend, NotmuchEnvelopes}, + config::{AccountConfig, MaildirBackendConfig, NotmuchBackendConfig}, +}; + +#[cfg(feature = "notmuch")] +#[test] +fn test_notmuch_backend() { + // set up maildir folders and notmuch database + let mdir: maildir::Maildir = env::temp_dir().join("himalaya-test-notmuch").into(); + if let Err(_) = fs::remove_dir_all(mdir.path()) {} + mdir.create_dirs().unwrap(); + notmuch::Database::create(mdir.path()).unwrap(); + + // configure accounts + let account_config = AccountConfig { + mailboxes: HashMap::from_iter([("inbox".into(), "*".into())]), + ..AccountConfig::default() + }; + let mdir_config = MaildirBackendConfig { + maildir_dir: mdir.path().to_owned(), + }; + let notmuch_config = NotmuchBackendConfig { + notmuch_database_dir: mdir.path().to_owned(), + }; + let mut mdir = MaildirBackend::new(&account_config, &mdir_config); + let mut notmuch = NotmuchBackend::new(&account_config, ¬much_config, &mut mdir).unwrap(); + + // check that a message can be added + let msg = include_bytes!("./emails/alice-to-patrick.eml"); + let hash = notmuch.add_msg("", msg, "inbox seen").unwrap().to_string(); + + // check that the added message exists + let msg = notmuch.get_msg("", &hash).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 = notmuch.get_envelopes("inbox", 10, 0).unwrap(); + let envelopes: &NotmuchEnvelopes = 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 a flag can be added to the message + notmuch + .add_flags("", &envelope.hash, "flagged passed") + .unwrap(); + let envelopes = notmuch.get_envelopes("inbox", 10, 0).unwrap(); + let envelopes: &NotmuchEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + let envelope = envelopes.first().unwrap(); + assert!(envelope.flags.contains(&"inbox".into())); + assert!(envelope.flags.contains(&"seen".into())); + assert!(envelope.flags.contains(&"flagged".into())); + assert!(envelope.flags.contains(&"passed".into())); + + // check that the message flags can be changed + notmuch + .set_flags("", &envelope.hash, "inbox passed") + .unwrap(); + let envelopes = notmuch.get_envelopes("inbox", 10, 0).unwrap(); + let envelopes: &NotmuchEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + let envelope = envelopes.first().unwrap(); + assert!(envelope.flags.contains(&"inbox".into())); + assert!(!envelope.flags.contains(&"seen".into())); + assert!(!envelope.flags.contains(&"flagged".into())); + assert!(envelope.flags.contains(&"passed".into())); + + // check that a flag can be removed from the message + notmuch.del_flags("", &envelope.hash, "passed").unwrap(); + let envelopes = notmuch.get_envelopes("inbox", 10, 0).unwrap(); + let envelopes: &NotmuchEnvelopes = envelopes.as_any().downcast_ref().unwrap(); + let envelope = envelopes.first().unwrap(); + assert!(envelope.flags.contains(&"inbox".into())); + assert!(!envelope.flags.contains(&"seen".into())); + assert!(!envelope.flags.contains(&"flagged".into())); + assert!(!envelope.flags.contains(&"passed".into())); + + // check that the message can be deleted + notmuch.del_msg("", &hash).unwrap(); + assert!(notmuch.get_msg("inbox", &hash).is_err()); +}