diff --git a/CHANGELOG.md b/CHANGELOG.md index 904b816..29fe069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.9] - 2022-03-12 + +### Added + +- SMTP pre-send hook [#178] +- Customize headers to show at the top of a read message [#338] + +### Changed + +- Improve `attachments` command [#281] + +### Fixed + +- `In-Reply-To` not set properly when replying to a message [#323] +- `Cc` missing or invalid when replying to a message [#324] +- Notmuch backend hangs [#329] +- Maildir e2e tests [#335] +- JSON API for listings [#331] + ## [0.5.8] - 2022-03-04 ### Added @@ -338,7 +357,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.8...HEAD +[unreleased]: https://github.com/soywod/himalaya/compare/v0.5.9...HEAD +[0.5.9]: https://github.com/soywod/himalaya/compare/v0.5.8...v0.5.9 [0.5.8]: https://github.com/soywod/himalaya/compare/v0.5.7...v0.5.8 [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 @@ -444,6 +464,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#162]: https://github.com/soywod/himalaya/issues/162 [#176]: https://github.com/soywod/himalaya/issues/176 [#172]: https://github.com/soywod/himalaya/issues/172 +[#178]: https://github.com/soywod/himalaya/issues/178 [#181]: https://github.com/soywod/himalaya/issues/181 [#185]: https://github.com/soywod/himalaya/issues/185 [#186]: https://github.com/soywod/himalaya/issues/186 @@ -470,6 +491,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#271]: https://github.com/soywod/himalaya/issues/271 [#276]: https://github.com/soywod/himalaya/issues/276 [#280]: https://github.com/soywod/himalaya/issues/280 +[#281]: https://github.com/soywod/himalaya/issues/281 [#288]: https://github.com/soywod/himalaya/issues/288 [#289]: https://github.com/soywod/himalaya/issues/289 [#298]: https://github.com/soywod/himalaya/issues/298 @@ -480,3 +502,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#309]: https://github.com/soywod/himalaya/issues/309 [#318]: https://github.com/soywod/himalaya/issues/318 [#321]: https://github.com/soywod/himalaya/issues/321 +[#323]: https://github.com/soywod/himalaya/issues/323 +[#324]: https://github.com/soywod/himalaya/issues/324 +[#329]: https://github.com/soywod/himalaya/issues/329 +[#331]: https://github.com/soywod/himalaya/issues/331 +[#335]: https://github.com/soywod/himalaya/issues/335 +[#338]: https://github.com/soywod/himalaya/issues/338 diff --git a/Cargo.lock b/Cargo.lock index 027fa1f..171ec19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -167,6 +167,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "convert_case" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8" + [[package]] name = "core-foundation" version = "0.9.2" @@ -436,13 +442,14 @@ dependencies = [ [[package]] name = "himalaya" -version = "0.5.8" +version = "0.5.9" dependencies = [ "ammonia", "anyhow", "atty", "chrono", "clap", + "convert_case", "env_logger", "erased-serde", "html-escape", diff --git a/Cargo.toml b/Cargo.toml index 642dab2..c4010d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "himalaya" description = "Command-line interface for email management" -version = "0.5.8" +version = "0.5.9" authors = ["soywod "] edition = "2018" license-file = "LICENSE" @@ -28,6 +28,7 @@ anyhow = "1.0.44" atty = "0.2.14" chrono = "0.4.19" clap = { version = "2.33.3", default-features = false, features = ["suggestions", "color"] } +convert_case = "0.5.0" env_logger = "0.8.3" erased-serde = "0.3.18" html-escape = "0.2.9" diff --git a/src/backends/id_mapper.rs b/src/backends/id_mapper.rs index 953a729..09a5422 100644 --- a/src/backends/id_mapper.rs +++ b/src/backends/id_mapper.rs @@ -26,7 +26,6 @@ impl IdMapper { .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")?; @@ -83,13 +82,13 @@ impl IdMapper { for (hash, id) in self.iter() { loop { - let short_hash = &hash[0..self.short_hash_len]; + let short_hash = &hash[0..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 { + if short_hash_len > 32 || !conflict_found { break; } short_hash_len += 1; diff --git a/src/backends/imap/imap_envelope.rs b/src/backends/imap/imap_envelope.rs index 68b8d74..d087eb1 100644 --- a/src/backends/imap/imap_envelope.rs +++ b/src/backends/imap/imap_envelope.rs @@ -15,21 +15,24 @@ use super::{ImapFlag, ImapFlags}; /// Represents a list of IMAP envelopes. #[derive(Debug, Default, serde::Serialize)] -pub struct ImapEnvelopes(pub Vec); +pub struct ImapEnvelopes { + #[serde(rename = "response")] + pub envelopes: Vec, +} impl Deref for ImapEnvelopes { type Target = Vec; fn deref(&self) -> &Self::Target { - &self.0 + &self.envelopes } } impl PrintTable for ImapEnvelopes { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; + fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writer)?; + Table::print(writer, self, opts)?; + writeln!(writer)?; Ok(()) } } @@ -99,7 +102,7 @@ impl TryFrom for ImapEnvelopes { for raw_envelope in raw_envelopes.iter().rev() { envelopes.push(ImapEnvelope::try_from(raw_envelope).context("cannot parse envelope")?); } - Ok(Self(envelopes)) + Ok(Self { envelopes }) } } diff --git a/src/backends/imap/imap_mbox.rs b/src/backends/imap/imap_mbox.rs index 2fdc098..223ab6c 100644 --- a/src/backends/imap/imap_mbox.rs +++ b/src/backends/imap/imap_mbox.rs @@ -4,6 +4,7 @@ //! to the mailbox. use anyhow::Result; +use serde::Serialize; use std::fmt::{self, Display}; use std::ops::Deref; @@ -16,22 +17,25 @@ use crate::{ use super::ImapMboxAttrs; /// Represents a list of IMAP mailboxes. -#[derive(Debug, Default, serde::Serialize)] -pub struct ImapMboxes(pub Vec); +#[derive(Debug, Default, Serialize)] +pub struct ImapMboxes { + #[serde(rename = "response")] + pub mboxes: Vec, +} impl Deref for ImapMboxes { type Target = Vec; fn deref(&self) -> &Self::Target { - &self.0 + &self.mboxes } } impl PrintTable for ImapMboxes { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; + fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writer)?; + Table::print(writer, self, opts)?; + writeln!(writer)?; Ok(()) } } @@ -130,7 +134,9 @@ 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()) + Self { + mboxes: raw_mboxes.iter().map(ImapMbox::from).collect(), + } } } diff --git a/src/backends/maildir/maildir_backend.rs b/src/backends/maildir/maildir_backend.rs index 2bf3a9c..a2e85a5 100644 --- a/src/backends/maildir/maildir_backend.rs +++ b/src/backends/maildir/maildir_backend.rs @@ -5,7 +5,7 @@ use anyhow::{anyhow, Context, Result}; use log::{debug, info, trace}; -use std::{convert::TryInto, fs, path::PathBuf}; +use std::{convert::TryInto, env, fs, path::PathBuf}; use crate::{ backends::{Backend, IdMapper, MaildirEnvelopes, MaildirFlags, MaildirMboxes}, @@ -41,30 +41,32 @@ impl<'a> MaildirBackend<'a> { /// Creates a maildir instance from a string slice. pub fn get_mdir_from_dir(&self, dir: &str) -> Result { + let dir = self.account_config.get_mbox_alias(dir)?; + // 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 { - // If the dir is a valid maildir path, creates a maildir instance from it. - self.validate_mdir_path(dir.into()) - .or_else(|_| { - // 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) + if &dir == "inbox" { + return self + .validate_mdir_path(self.mdir.path().to_owned()) + .map(maildir::Maildir::from); } + + // If the dir is a valid maildir path, creates a maildir + // instance from it. First checks for absolute path, + self.validate_mdir_path((&dir).into()) + // then for relative path to `maildir-dir`, + .or_else(|_| self.validate_mdir_path(self.mdir.path().join(&dir))) + // and finally for relative path to the current directory. + .or_else(|_| self.validate_mdir_path(env::current_dir()?.join(&dir))) + .or_else(|_| { + // Otherwise creates a maildir instance from a maildir + // subdirectory by adding a "." in front of the name + // as described in the [spec]. + // + // [spec]: http://www.courier-mta.org/imap/README.maildirquota.html + self.validate_mdir_path(self.mdir.path().join(format!(".{}", dir))) + }) + .map(maildir::Maildir::from) } } @@ -149,7 +151,7 @@ impl<'a> Backend<'a> for MaildirBackend<'a> { envelopes.sort_by(|a, b| b.date.partial_cmp(&a.date).unwrap()); // Applies pagination boundaries. - envelopes.0 = envelopes[page_begin..page_end].to_owned(); + envelopes.envelopes = 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 diff --git a/src/backends/maildir/maildir_envelope.rs b/src/backends/maildir/maildir_envelope.rs index 137be42..10cb3be 100644 --- a/src/backends/maildir/maildir_envelope.rs +++ b/src/backends/maildir/maildir_envelope.rs @@ -5,7 +5,7 @@ use anyhow::{anyhow, Context, Error, Result}; use chrono::DateTime; -use log::{debug, info, trace}; +use log::trace; use std::{ convert::{TryFrom, TryInto}, ops::{Deref, DerefMut}, @@ -20,27 +20,30 @@ use crate::{ /// Represents a list of envelopes. #[derive(Debug, Default, serde::Serialize)] -pub struct MaildirEnvelopes(pub Vec); +pub struct MaildirEnvelopes { + #[serde(rename = "response")] + pub envelopes: Vec, +} impl Deref for MaildirEnvelopes { type Target = Vec; fn deref(&self) -> &Self::Target { - &self.0 + &self.envelopes } } impl DerefMut for MaildirEnvelopes { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + &mut self.envelopes } } impl PrintTable for MaildirEnvelopes { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; + fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writer)?; + Table::print(writer, self, opts)?; + writeln!(writer)?; Ok(()) } } @@ -114,7 +117,7 @@ impl<'a> TryFrom for MaildirEnvelopes { envelopes.push(envelope); } - Ok(MaildirEnvelopes(envelopes)) + Ok(MaildirEnvelopes { envelopes }) } } @@ -125,7 +128,7 @@ 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"); + trace!(">> build envelope from maildir parsed mail"); let mut envelope = Self::default(); @@ -139,14 +142,14 @@ impl<'a> TryFrom for MaildirEnvelope { .parsed() .context("cannot parse maildir mail entry")?; - debug!("begin: parse headers"); + trace!(">> parse headers"); for h in parsed_mail.get_headers() { let k = h.get_key(); - debug!("header key: {:?}", k); + trace!("header key: {:?}", k); let v = rfc2047_decoder::decode(h.get_value_raw()) .context(format!("cannot decode value from header {:?}", k))?; - debug!("header value: {:?}", v); + trace!("header value: {:?}", v); match k.to_lowercase().as_str() { "date" => { @@ -182,10 +185,10 @@ impl<'a> TryFrom for MaildirEnvelope { _ => (), } } - debug!("end: parse headers"); + trace!("<< parse headers"); trace!("envelope: {:?}", envelope); - info!("end: try building envelope from maildir parsed mail"); + trace!("<< build envelope from maildir parsed mail"); Ok(envelope) } } diff --git a/src/backends/maildir/maildir_mbox.rs b/src/backends/maildir/maildir_mbox.rs index fad90b0..3f2ec2f 100644 --- a/src/backends/maildir/maildir_mbox.rs +++ b/src/backends/maildir/maildir_mbox.rs @@ -19,21 +19,24 @@ use crate::{ /// Represents a list of Maildir mailboxes. #[derive(Debug, Default, serde::Serialize)] -pub struct MaildirMboxes(pub Vec); +pub struct MaildirMboxes { + #[serde(rename = "response")] + pub mboxes: Vec, +} impl Deref for MaildirMboxes { type Target = Vec; fn deref(&self) -> &Self::Target { - &self.0 + &self.mboxes } } impl PrintTable for MaildirMboxes { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; + fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writer)?; + Table::print(writer, self, opts)?; + writeln!(writer)?; Ok(()) } } @@ -113,7 +116,7 @@ impl TryFrom for MaildirMboxes { for entry in mail_entries { mboxes.push(entry?.try_into()?); } - Ok(MaildirMboxes(mboxes)) + Ok(MaildirMboxes { mboxes }) } } diff --git a/src/backends/notmuch/notmuch_backend.rs b/src/backends/notmuch/notmuch_backend.rs index 416e1c0..37e559a 100644 --- a/src/backends/notmuch/notmuch_backend.rs +++ b/src/backends/notmuch/notmuch_backend.rs @@ -81,7 +81,7 @@ impl<'a> NotmuchBackend<'a> { envelopes.sort_by(|a, b| b.date.partial_cmp(&a.date).unwrap()); // Applies pagination boundaries. - envelopes.0 = envelopes[page_begin..page_end].to_owned(); + envelopes.envelopes = 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 @@ -118,17 +118,17 @@ impl<'a> Backend<'a> for NotmuchBackend<'a> { fn get_mboxes(&mut self) -> Result> { info!(">> get notmuch virtual mailboxes"); - let mut virt_mboxes: Vec<_> = self + let mut 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()); + trace!("virtual mailboxes: {:?}", mboxes); + mboxes.sort_by(|a, b| b.name.partial_cmp(&a.name).unwrap()); info!("<< get notmuch virtual mailboxes"); - Ok(Box::new(NotmuchMboxes(virt_mboxes))) + Ok(Box::new(NotmuchMboxes { mboxes })) } fn del_mbox(&mut self, _mbox: &str) -> Result<()> { @@ -202,7 +202,7 @@ impl<'a> Backend<'a> for NotmuchBackend<'a> { // Adds the message to the maildir folder and gets its hash. let hash = self .mdir - .add_msg("inbox", msg, "seen") + .add_msg("", msg, "seen") .with_context(|| { format!( "cannot add notmuch message to maildir {:?}", diff --git a/src/backends/notmuch/notmuch_envelope.rs b/src/backends/notmuch/notmuch_envelope.rs index 297535f..626d949 100644 --- a/src/backends/notmuch/notmuch_envelope.rs +++ b/src/backends/notmuch/notmuch_envelope.rs @@ -19,27 +19,30 @@ use crate::{ /// Represents a list of envelopes. #[derive(Debug, Default, serde::Serialize)] -pub struct NotmuchEnvelopes(pub Vec); +pub struct NotmuchEnvelopes { + #[serde(rename = "response")] + pub envelopes: Vec, +} impl Deref for NotmuchEnvelopes { type Target = Vec; fn deref(&self) -> &Self::Target { - &self.0 + &self.envelopes } } impl DerefMut for NotmuchEnvelopes { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + &mut self.envelopes } } impl PrintTable for NotmuchEnvelopes { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; + fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writer)?; + Table::print(writer, self, opts)?; + writeln!(writer)?; Ok(()) } } @@ -107,7 +110,7 @@ impl<'a> TryFrom for NotmuchEnvelopes { .context("cannot parse notmuch mail entry")?; envelopes.push(envelope); } - Ok(NotmuchEnvelopes(envelopes)) + Ok(NotmuchEnvelopes { envelopes }) } } diff --git a/src/backends/notmuch/notmuch_mbox.rs b/src/backends/notmuch/notmuch_mbox.rs index 6cde8b5..2fe1262 100644 --- a/src/backends/notmuch/notmuch_mbox.rs +++ b/src/backends/notmuch/notmuch_mbox.rs @@ -17,21 +17,24 @@ use crate::{ /// Represents a list of Notmuch mailboxes. #[derive(Debug, Default, serde::Serialize)] -pub struct NotmuchMboxes(pub Vec); +pub struct NotmuchMboxes { + #[serde(rename = "response")] + pub mboxes: Vec, +} impl Deref for NotmuchMboxes { type Target = Vec; fn deref(&self) -> &Self::Target { - &self.0 + &self.mboxes } } impl PrintTable for NotmuchMboxes { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; + fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writer)?; + Table::print(writer, self, opts)?; + writeln!(writer)?; Ok(()) } } diff --git a/src/config/account.rs b/src/config/account.rs index 7ecd4cc..d593b8f 100644 --- a/src/config/account.rs +++ b/src/config/account.rs @@ -31,10 +31,10 @@ impl Deref for Accounts { } impl PrintTable for Accounts { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { - writeln!(writter)?; - Table::print(writter, self, opts)?; - writeln!(writter)?; + fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { + writeln!(writer)?; + Table::print(writer, self, opts)?; + writeln!(writer)?; Ok(()) } } diff --git a/src/config/account_config.rs b/src/config/account_config.rs index ed01a30..0d32a19 100644 --- a/src/config/account_config.rs +++ b/src/config/account_config.rs @@ -32,10 +32,16 @@ pub struct AccountConfig { /// Represents the text/plain format as defined in the /// [RFC2646](https://www.ietf.org/rfc/rfc2646.txt) pub format: Format, + /// Overrides the default headers displayed at the top of + /// the read message. + pub read_headers: Vec, /// Represents mailbox aliases. pub mailboxes: HashMap, + /// Represents hooks. + pub hooks: Hooks, + /// Represents the SMTP host. pub smtp_host: String, /// Represents the SMTP port. @@ -154,7 +160,9 @@ impl<'a> AccountConfig { .unwrap_or(&vec![]) .to_owned(), format: base_account.format.unwrap_or_default(), + read_headers: base_account.read_headers, mailboxes: base_account.mailboxes.clone(), + hooks: base_account.hooks.unwrap_or_default(), default: base_account.default.unwrap_or_default(), email: base_account.email.to_owned(), @@ -203,8 +211,7 @@ impl<'a> AccountConfig { /// 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 has_special_chars = "()<>[]:;@.,".contains(|c| self.display_name.contains(c)); let addr = if self.display_name.is_empty() { self.email.clone() } else if has_special_chars { @@ -314,6 +321,19 @@ impl<'a> AccountConfig { run_cmd(&cmd).context("cannot run notify cmd")?; Ok(()) } + + /// Gets the mailbox alias if exists, otherwise returns the + /// mailbox. Also tries to expand shell variables. + pub fn get_mbox_alias(&self, mbox: &str) -> Result { + let mbox = self + .mailboxes + .get(&mbox.trim().to_lowercase()) + .map(|s| s.as_str()) + .unwrap_or(mbox); + shellexpand::full(mbox) + .map(String::from) + .with_context(|| format!("cannot expand mailbox path {:?}", mbox)) + } } /// Represents all existing kind of account (backend). diff --git a/src/config/account_handlers.rs b/src/config/account_handlers.rs index a4f42cd..4ee2c57 100644 --- a/src/config/account_handlers.rs +++ b/src/config/account_handlers.rs @@ -49,11 +49,11 @@ mod tests { #[test] fn it_should_match_cmds_accounts() { #[derive(Debug, Default, Clone)] - struct StringWritter { + struct StringWriter { content: String, } - impl io::Write for StringWritter { + impl io::Write for StringWriter { fn write(&mut self, buf: &[u8]) -> io::Result { self.content .push_str(&String::from_utf8(buf.to_vec()).unwrap()); @@ -66,7 +66,7 @@ mod tests { } } - impl termcolor::WriteColor for StringWritter { + impl termcolor::WriteColor for StringWriter { fn supports_color(&self) -> bool { false } @@ -80,11 +80,11 @@ mod tests { } } - impl WriteColor for StringWritter {} + impl WriteColor for StringWriter {} #[derive(Debug, Default)] struct PrinterServiceTest { - pub writter: StringWritter, + pub writer: StringWriter, } impl PrinterService for PrinterServiceTest { @@ -93,10 +93,16 @@ mod tests { data: Box, opts: PrintTableOpts, ) -> Result<()> { - data.print_table(&mut self.writter, opts)?; + data.print_table(&mut self.writer, opts)?; Ok(()) } - fn print(&mut self, _data: T) -> Result<()> { + fn print_str(&mut self, _data: T) -> Result<()> { + unimplemented!() + } + fn print_struct( + &mut self, + _data: T, + ) -> Result<()> { unimplemented!() } fn is_json(&self) -> bool { @@ -126,7 +132,7 @@ mod tests { "account-1 │imap │yes \n", "\n" ], - printer.writter.content + printer.writer.content ); } } diff --git a/src/config/deserialized_account_config.rs b/src/config/deserialized_account_config.rs index 3164ab1..8ada7f9 100644 --- a/src/config/deserialized_account_config.rs +++ b/src/config/deserialized_account_config.rs @@ -1,7 +1,7 @@ use serde::Deserialize; use std::{collections::HashMap, path::PathBuf}; -use crate::config::Format; +use crate::config::{Format, Hooks}; pub trait ToDeserializedBaseAccountConfig { fn to_base(&self) -> DeserializedBaseAccountConfig; @@ -45,7 +45,7 @@ macro_rules! make_account_config { pub signature: Option, /// Overrides the signature delimiter for this account. pub signature_delimiter: Option, - /// Overrides the default page size for this account. + /// Overrides the default page size for this account. pub default_page_size: Option, /// Overrides the notify command for this account. pub notify_cmd: Option, @@ -56,6 +56,10 @@ macro_rules! make_account_config { /// Represents the text/plain format as defined in the /// [RFC2646](https://www.ietf.org/rfc/rfc2646.txt) pub format: Option, + /// Represents the default headers displayed at the top of + /// the read message. + #[serde(default)] + pub read_headers: Vec, /// Makes this account the default one. pub default: Option, @@ -84,6 +88,9 @@ macro_rules! make_account_config { #[serde(default)] pub mailboxes: HashMap, + /// Represents hooks. + pub hooks: Option, + $(pub $element: $ty),* } @@ -99,6 +106,7 @@ macro_rules! make_account_config { notify_query: self.notify_query.clone(), watch_cmds: self.watch_cmds.clone(), format: self.format.clone(), + read_headers: self.read_headers.clone(), default: self.default.clone(), email: self.email.clone(), @@ -114,6 +122,7 @@ macro_rules! make_account_config { pgp_decrypt_cmd: self.pgp_decrypt_cmd.clone(), mailboxes: self.mailboxes.clone(), + hooks: self.hooks.clone(), } } } diff --git a/src/config/deserialized_config.rs b/src/config/deserialized_config.rs index 7067162..e26b5a0 100644 --- a/src/config/deserialized_config.rs +++ b/src/config/deserialized_config.rs @@ -23,7 +23,7 @@ pub struct DeserializedConfig { pub downloads_dir: Option, /// Represents the signature of the user. pub signature: Option, - /// Overrides the default signature delimiter "`--\n `". + /// Overrides the default signature delimiter "`-- \n`". pub signature_delimiter: Option, /// Represents the default page size for listings. pub default_page_size: Option, diff --git a/src/config/hooks.rs b/src/config/hooks.rs new file mode 100644 index 0000000..4bd44f0 --- /dev/null +++ b/src/config/hooks.rs @@ -0,0 +1,7 @@ +use serde::Deserialize; + +#[derive(Debug, Default, Clone, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Hooks { + pub pre_send: Option, +} diff --git a/src/lib.rs b/src/lib.rs index 3994874..32384d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -126,6 +126,9 @@ pub mod config { pub mod format; pub use format::*; + + pub mod hooks; + pub use hooks::*; } pub mod compl; diff --git a/src/main.rs b/src/main.rs index 334005a..61c647d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -221,8 +221,17 @@ fn main() -> Result<()> { Some(msg_args::Cmd::Move(seq, mbox_dst)) => { return msg_handlers::move_(seq, mbox, mbox_dst, &mut printer, backend); } - Some(msg_args::Cmd::Read(seq, text_mime, raw)) => { - return msg_handlers::read(seq, text_mime, raw, mbox, &mut printer, backend); + Some(msg_args::Cmd::Read(seq, text_mime, raw, headers)) => { + return msg_handlers::read( + seq, + text_mime, + raw, + headers, + mbox, + &account_config, + &mut printer, + backend, + ); } Some(msg_args::Cmd::Reply(seq, all, attachment_paths, encrypt)) => { return msg_handlers::reply( diff --git a/src/mbox/mbox_handlers.rs b/src/mbox/mbox_handlers.rs index 52c36f8..4a110e9 100644 --- a/src/mbox/mbox_handlers.rs +++ b/src/mbox/mbox_handlers.rs @@ -47,11 +47,11 @@ mod tests { #[test] fn it_should_list_mboxes() { #[derive(Debug, Default, Clone)] - struct StringWritter { + struct StringWriter { content: String, } - impl io::Write for StringWritter { + impl io::Write for StringWriter { fn write(&mut self, buf: &[u8]) -> io::Result { self.content .push_str(&String::from_utf8(buf.to_vec()).unwrap()); @@ -64,7 +64,7 @@ mod tests { } } - impl termcolor::WriteColor for StringWritter { + impl termcolor::WriteColor for StringWriter { fn supports_color(&self) -> bool { false } @@ -78,11 +78,11 @@ mod tests { } } - impl WriteColor for StringWritter {} + impl WriteColor for StringWriter {} #[derive(Debug, Default)] struct PrinterServiceTest { - pub writter: StringWritter, + pub writer: StringWriter, } impl PrinterService for PrinterServiceTest { @@ -91,10 +91,16 @@ mod tests { data: Box, opts: PrintTableOpts, ) -> Result<()> { - data.print_table(&mut self.writter, opts)?; + data.print_table(&mut self.writer, opts)?; Ok(()) } - fn print(&mut self, _data: T) -> Result<()> { + fn print_str(&mut self, _data: T) -> Result<()> { + unimplemented!() + } + fn print_struct( + &mut self, + _data: T, + ) -> Result<()> { unimplemented!() } fn is_json(&self) -> bool { @@ -109,21 +115,23 @@ mod tests { unimplemented!(); } fn get_mboxes(&mut self) -> Result> { - Ok(Box::new(ImapMboxes(vec![ - ImapMbox { - delim: "/".into(), - name: "INBOX".into(), - attrs: ImapMboxAttrs(vec![ImapMboxAttr::NoSelect]), - }, - ImapMbox { - delim: "/".into(), - name: "Sent".into(), - attrs: ImapMboxAttrs(vec![ - ImapMboxAttr::NoInferiors, - ImapMboxAttr::Custom("HasNoChildren".into()), - ]), - }, - ]))) + Ok(Box::new(ImapMboxes { + mboxes: vec![ + ImapMbox { + delim: "/".into(), + name: "INBOX".into(), + attrs: ImapMboxAttrs(vec![ImapMboxAttr::NoSelect]), + }, + ImapMbox { + delim: "/".into(), + name: "Sent".into(), + attrs: ImapMboxAttrs(vec![ + ImapMboxAttr::NoInferiors, + ImapMboxAttr::Custom("HasNoChildren".into()), + ]), + }, + ], + })) } fn del_mbox(&mut self, _: &str) -> Result<()> { unimplemented!(); @@ -181,7 +189,7 @@ mod tests { "/ │Sent │NoInferiors, HasNoChildren \n", "\n" ], - printer.writter.content + printer.writer.content ); } } diff --git a/src/msg/flag_handlers.rs b/src/msg/flag_handlers.rs index 18af167..33ed696 100644 --- a/src/msg/flag_handlers.rs +++ b/src/msg/flag_handlers.rs @@ -16,7 +16,7 @@ pub fn add<'a, P: PrinterService, B: Backend<'a> + ?Sized>( backend: Box<&'a mut B>, ) -> Result<()> { backend.add_flags(mbox, seq_range, flags)?; - printer.print(format!( + printer.print_struct(format!( "Flag(s) {:?} successfully added to message(s) {:?}", flags, seq_range )) @@ -32,7 +32,7 @@ pub fn remove<'a, P: PrinterService, B: Backend<'a> + ?Sized>( backend: Box<&'a mut B>, ) -> Result<()> { backend.del_flags(mbox, seq_range, flags)?; - printer.print(format!( + printer.print_struct(format!( "Flag(s) {:?} successfully removed from message(s) {:?}", flags, seq_range )) @@ -48,7 +48,7 @@ pub fn set<'a, P: PrinterService, B: Backend<'a> + ?Sized>( backend: Box<&'a mut B>, ) -> Result<()> { backend.set_flags(mbox, seq_range, flags)?; - printer.print(format!( + printer.print_struct(format!( "Flag(s) {:?} successfully set for message(s) {:?}", flags, seq_range )) diff --git a/src/msg/msg_args.rs b/src/msg/msg_args.rs index 12ccbde..32e02b6 100644 --- a/src/msg/msg_args.rs +++ b/src/msg/msg_args.rs @@ -25,6 +25,7 @@ type AttachmentPaths<'a> = Vec<&'a str>; type MaxTableWidth = Option; type Encrypt = bool; type Criteria = String; +type Headers<'a> = Vec<&'a str>; /// Message commands. #[derive(Debug, PartialEq, Eq)] @@ -35,7 +36,7 @@ pub enum Cmd<'a> { Forward(Seq<'a>, AttachmentPaths<'a>, Encrypt), List(MaxTableWidth, Option, Page), Move(Seq<'a>, Mbox<'a>), - Read(Seq<'a>, TextMime<'a>, Raw), + Read(Seq<'a>, TextMime<'a>, Raw, Headers<'a>), Reply(Seq<'a>, All, AttachmentPaths<'a>, Encrypt), Save(RawMsg<'a>), Search(Query, MaxTableWidth, Option, Page), @@ -121,7 +122,9 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> { debug!("text mime: {}", mime); let raw = m.is_present("raw"); debug!("raw: {}", raw); - return Ok(Some(Cmd::Read(seq, mime, raw))); + let headers: Vec<&str> = m.values_of("headers").unwrap_or_default().collect(); + debug!("headers: {:?}", headers); + return Ok(Some(Cmd::Read(seq, mime, raw, headers))); } if let Some(m) = m.subcommand_matches("reply") { @@ -318,7 +321,7 @@ fn page_arg<'a>() -> Arg<'a, 'a> { } /// Message attachment argument. -pub fn attachment_arg<'a>() -> Arg<'a, 'a> { +pub fn attachments_arg<'a>() -> Arg<'a, 'a> { Arg::with_name("attachments") .help("Adds attachment to the message") .short("a") @@ -327,6 +330,16 @@ pub fn attachment_arg<'a>() -> Arg<'a, 'a> { .multiple(true) } +/// Represents the message headers argument. +pub fn headers_arg<'a>() -> Arg<'a, 'a> { + Arg::with_name("headers") + .help("Shows additional headers with the message") + .short("h") + .long("header") + .value_name("STR") + .multiple(true) +} + /// Message encrypt argument. pub fn encrypt_arg<'a>() -> Arg<'a, 'a> { Arg::with_name("encrypt") @@ -399,7 +412,7 @@ pub fn subcmds<'a>() -> Vec> { ), SubCommand::with_name("write") .about("Writes a new message") - .arg(attachment_arg()) + .arg(attachments_arg()) .arg(encrypt_arg()), SubCommand::with_name("send") .about("Sends a raw message") @@ -424,19 +437,20 @@ pub fn subcmds<'a>() -> Vec> { .help("Reads raw message") .long("raw") .short("r"), - ), + ) + .arg(headers_arg()), SubCommand::with_name("reply") .aliases(&["rep", "r"]) .about("Answers to a message") .arg(seq_arg()) .arg(reply_all_arg()) - .arg(attachment_arg()) + .arg(attachments_arg()) .arg(encrypt_arg()), SubCommand::with_name("forward") .aliases(&["fwd", "f"]) .about("Forwards a message") .arg(seq_arg()) - .arg(attachment_arg()) + .arg(attachments_arg()) .arg(encrypt_arg()), SubCommand::with_name("copy") .aliases(&["cp", "c"]) diff --git a/src/msg/msg_entity.rs b/src/msg/msg_entity.rs index 6c2be35..ffe87b6 100644 --- a/src/msg/msg_entity.rs +++ b/src/msg/msg_entity.rs @@ -1,11 +1,19 @@ use ammonia; use anyhow::{anyhow, Context, Error, Result}; -use chrono::{DateTime, FixedOffset}; +use chrono::{DateTime, Local, TimeZone, Utc}; +use convert_case::{Case, Casing}; use html_escape; use lettre::message::{header::ContentType, Attachment, MultiPart, SinglePart}; -use log::{debug, info, trace, warn}; +use log::{info, trace, warn}; use regex::Regex; -use std::{collections::HashSet, convert::TryInto, env::temp_dir, fmt::Debug, fs, path::PathBuf}; +use std::{ + collections::{HashMap, HashSet}, + convert::TryInto, + env::temp_dir, + fmt::Debug, + fs, + path::PathBuf, +}; use uuid::Uuid; use crate::{ @@ -13,7 +21,7 @@ use crate::{ 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, + Addr, Addrs, BinaryPart, Part, Parts, TextPlainPart, TplOverride, }, output::PrinterService, smtp::SmtpService, @@ -24,7 +32,7 @@ use crate::{ }; /// Representation of a message. -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub struct Msg { /// The sequence number of the message. /// @@ -41,11 +49,12 @@ pub struct Msg { pub bcc: Option, pub in_reply_to: Option, pub message_id: Option, + pub headers: HashMap, /// The internal date of the message. /// /// [RFC3501]: https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.3 - pub date: Option>, + pub date: Option>, pub parts: Parts, pub encrypt: bool, @@ -64,8 +73,9 @@ impl Msg { .collect() } - /// Folds string body from all plain text parts into a single string body. If no plain text - /// parts are found, HTML parts are used instead. The result is sanitized (all HTML markup is + /// Folds string body from all plain text parts into a single + /// string body. If no plain text parts are found, HTML parts are + /// used instead. The result is sanitized (all HTML markup is /// removed). pub fn fold_text_plain_parts(&self) -> String { let (plain, html) = self.parts.iter().fold( @@ -133,7 +143,8 @@ impl Msg { } } - /// Fold string body from all HTML parts into a single string body. + /// Fold string body from all HTML parts into a single string + /// body. fn fold_text_html_parts(&self) -> String { let text_parts = self .parts @@ -151,8 +162,9 @@ impl Msg { text_parts } - /// Fold string body from all text parts into a single string body. The mime allows users to - /// choose between plain text parts and html text parts. + /// Fold string body from all text parts into a single string + /// body. The mime allows users to choose between plain text parts + /// and html text parts. pub fn fold_text_parts(&self, text_mime: &str) -> String { if text_mime == "html" { self.fold_text_html_parts() @@ -164,22 +176,25 @@ impl Msg { pub fn into_reply(mut self, all: bool, account: &AccountConfig) -> Result { let account_addr = account.address()?; - // Message-Id - self.message_id = None; - // In-Reply-To self.in_reply_to = self.message_id.to_owned(); + // Message-Id + self.message_id = None; + // To let addrs = self .reply_to .as_deref() .or_else(|| self.from.as_deref()) .map(|addrs| { - addrs - .clone() - .into_iter() - .filter(|addr| addr != &account_addr) + addrs.iter().cloned().filter(|addr| match addr { + Addr::Group(_) => false, + Addr::Single(a) => match &account_addr { + Addr::Group(_) => false, + Addr::Single(b) => a.addr != b.addr, + }, + }) }); if all { self.to = addrs.map(|addrs| addrs.collect::>().into()); @@ -189,18 +204,35 @@ impl Msg { .map(|addr| vec![addr].into()); } - // Cc & Bcc - if !all { - self.cc = None; - self.bcc = None; - } + // Cc + self.cc = if all { + self.cc.as_deref().map(|addrs| { + addrs + .iter() + .cloned() + .filter(|addr| match addr { + Addr::Group(_) => false, + Addr::Single(a) => match &account_addr { + Addr::Group(_) => false, + Addr::Single(b) => a.addr != b.addr, + }, + }) + .collect::>() + .into() + }) + } else { + None + }; + + // Bcc + self.bcc = None; // Body let plain_content = { let date = self .date .as_ref() - .map(|date| date.format("%d %b %Y, at %H:%M").to_string()) + .map(|date| date.format("%d %b %Y, at %H:%M (%z)").to_string()) .unwrap_or_else(|| "unknown date".into()); let sender = self .reply_to @@ -339,15 +371,18 @@ impl Msg { loop { match choice::post_edit() { Ok(PostEditChoice::Send) => { - let sent_msg = smtp.send_msg(account, &self)?; + printer.print_str("Sending message…")?; + let sent_msg = smtp.send(account, &self)?; 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")?; + printer + .print_str(format!("Adding message to the {:?} folder…", sent_folder))?; + backend.add_msg(&sent_folder, &sent_msg, "seen")?; msg_utils::remove_local_draft()?; - printer.print("Message successfully sent")?; + printer.print_struct("Done!")?; break; } Ok(PostEditChoice::Edit) => { @@ -355,7 +390,7 @@ impl Msg { continue; } Ok(PostEditChoice::LocalDraft) => { - printer.print("Message successfully saved locally")?; + printer.print_struct("Message successfully saved locally")?; break; } Ok(PostEditChoice::RemoteDraft) => { @@ -367,7 +402,8 @@ impl Msg { .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 {}", draft_folder))?; + printer + .print_struct(format!("Message successfully saved to {}", draft_folder))?; break; } Ok(PostEditChoice::Discard) => { @@ -413,24 +449,19 @@ impl Msg { } pub fn merge_with(&mut self, msg: Msg) { - if msg.from.is_some() { - self.from = msg.from; + self.from = msg.from; + self.reply_to = msg.reply_to; + self.to = msg.to; + self.cc = msg.cc; + self.bcc = msg.bcc; + self.subject = msg.subject; + + if msg.message_id.is_some() { + self.message_id = msg.message_id; } - if msg.to.is_some() { - self.to = msg.to; - } - - if msg.cc.is_some() { - self.cc = msg.cc; - } - - if msg.bcc.is_some() { - self.bcc = msg.bcc; - } - - if !msg.subject.is_empty() { - self.subject = msg.subject; + if msg.in_reply_to.is_some() { + self.in_reply_to = msg.in_reply_to; } for part in msg.parts.0.into_iter() { @@ -623,24 +654,18 @@ impl Msg { parsed_mail: mailparse::ParsedMail<'_>, config: &AccountConfig, ) -> Result { - info!("begin: building message from parsed mail"); + trace!(">> build message from parsed mail"); trace!("parsed mail: {:?}", parsed_mail); let mut msg = Msg::default(); - - debug!("parsing headers"); for header in parsed_mail.get_headers() { + trace!(">> parse header {:?}", header); + let key = header.get_key(); - debug!("header key: {:?}", key); + trace!("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); + trace!("header value: {:?}", val); match key.to_lowercase().as_str() { "message-id" => msg.message_id = Some(val), @@ -648,16 +673,15 @@ impl Msg { "subject" => { msg.subject = val; } - "date" => { - msg.date = DateTime::parse_from_rfc2822( - val.split_at(val.find(" (").unwrap_or_else(|| val.len())).0, - ) - .map_err(|err| { + "date" => match mailparse::dateparse(&val) { + Ok(timestamp) => { + msg.date = Some(Utc.timestamp(timestamp, 0).with_timezone(&Local)) + } + Err(err) => { warn!("cannot parse message date {:?}, skipping it", val); - err - }) - .ok(); - } + warn!("{}", err); + } + }, "from" => { msg.from = from_slice_to_addrs(val) .context(format!("cannot parse header {:?}", key))? @@ -678,24 +702,133 @@ impl Msg { msg.bcc = from_slice_to_addrs(val) .context(format!("cannot parse header {:?}", key))? } - _ => (), + key => { + msg.headers.insert(key.to_lowercase(), val); + } } + trace!("<< parse header"); } 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"); + info!("<< build message from parsed mail"); Ok(msg) } + + /// Transforms a message into a readable string. A readable + /// message is like a template, except that: + /// - headers part is customizable (can be omitted if empty filter given in argument) + /// - body type is customizable (plain or html) + pub fn to_readable_string( + &self, + text_mime: &str, + headers: Vec<&str>, + config: &AccountConfig, + ) -> Result { + let mut all_headers = vec![]; + for h in config.read_headers.iter() { + let h = h.to_lowercase(); + if !all_headers.contains(&h) { + all_headers.push(h) + } + } + for h in headers.iter() { + let h = h.to_lowercase(); + if !all_headers.contains(&h) { + all_headers.push(h) + } + } + + let mut readable_msg = String::new(); + for h in all_headers { + match h.as_str() { + "message-id" => match self.message_id { + Some(ref message_id) if !message_id.is_empty() => { + readable_msg.push_str(&format!("Message-Id: {}\n", message_id)); + } + _ => (), + }, + "in-reply-to" => match self.in_reply_to { + Some(ref in_reply_to) if !in_reply_to.is_empty() => { + readable_msg.push_str(&format!("In-Reply-To: {}\n", in_reply_to)); + } + _ => (), + }, + "subject" => { + readable_msg.push_str(&format!("Subject: {}\n", self.subject)); + } + "date" => { + if let Some(ref date) = self.date { + readable_msg.push_str(&format!("Date: {}\n", date.to_rfc2822())); + } + } + "from" => match self.from { + Some(ref addrs) if !addrs.is_empty() => { + readable_msg.push_str(&format!("From: {}\n", addrs)); + } + _ => (), + }, + "to" => match self.to { + Some(ref addrs) if !addrs.is_empty() => { + readable_msg.push_str(&format!("To: {}\n", addrs)); + } + _ => (), + }, + "reply-to" => match self.reply_to { + Some(ref addrs) if !addrs.is_empty() => { + readable_msg.push_str(&format!("Reply-To: {}\n", addrs)); + } + _ => (), + }, + "cc" => match self.cc { + Some(ref addrs) if !addrs.is_empty() => { + readable_msg.push_str(&format!("Cc: {}\n", addrs)); + } + _ => (), + }, + "bcc" => match self.bcc { + Some(ref addrs) if !addrs.is_empty() => { + readable_msg.push_str(&format!("Bcc: {}\n", addrs)); + } + _ => (), + }, + key => match self.headers.get(key) { + Some(ref val) if !val.is_empty() => { + readable_msg.push_str(&format!("{}: {}\n", key.to_case(Case::Train), val)); + } + _ => (), + }, + }; + } + + if !readable_msg.is_empty() { + readable_msg.push_str("\n"); + } + + readable_msg.push_str(&self.fold_text_parts(text_mime)); + Ok(readable_msg) + } } impl TryInto for Msg { type Error = Error; fn try_into(self) -> Result { - let from = match self.from.and_then(|addrs| addrs.extract_single_info()) { + (&self).try_into() + } +} + +impl TryInto for &Msg { + type Error = Error; + + fn try_into(self) -> Result { + let from = match self + .from + .as_ref() + .and_then(|addrs| addrs.clone().extract_single_info()) + { Some(addr) => addr.addr.parse().map(Some), None => Ok(None), }?; @@ -707,3 +840,234 @@ impl TryInto for Msg { Ok(lettre::address::Envelope::new(from, to).context("cannot create envelope")?) } } + +#[cfg(test)] +mod tests { + use mailparse::SingleInfo; + use std::iter::FromIterator; + + use crate::msg::Addr; + + use super::*; + + #[test] + fn test_into_reply() { + let config = AccountConfig { + display_name: "Test".into(), + email: "test-account@local".into(), + ..AccountConfig::default() + }; + + // Checks that: + // - "message_id" moves to "in_reply_to" + // - "subject" starts by "Re: " + // - "to" is replaced by "from" + // - "from" is replaced by the address from the account config + + let msg = Msg { + message_id: Some("msg-id".into()), + subject: "subject".into(), + from: Some( + vec![Addr::Single(SingleInfo { + addr: "test-sender@local".into(), + display_name: None, + })] + .into(), + ), + ..Msg::default() + } + .into_reply(false, &config) + .unwrap(); + + assert_eq!(msg.message_id, None); + assert_eq!(msg.in_reply_to.unwrap(), "msg-id"); + assert_eq!(msg.subject, "Re: subject"); + assert_eq!( + msg.from.unwrap().to_string(), + "\"Test\" " + ); + assert_eq!(msg.to.unwrap().to_string(), "test-sender@local"); + + // Checks that: + // - "subject" does not contains additional "Re: " + // - "to" is replaced by reply_to + // - "to" contains one address when "all" is false + // - "cc" are empty when "all" is false + + let msg = Msg { + subject: "Re: subject".into(), + from: Some( + vec![Addr::Single(SingleInfo { + addr: "test-sender@local".into(), + display_name: None, + })] + .into(), + ), + reply_to: Some( + vec![ + Addr::Single(SingleInfo { + addr: "test-sender-to-reply@local".into(), + display_name: Some("Sender".into()), + }), + Addr::Single(SingleInfo { + addr: "test-sender-to-reply-2@local".into(), + display_name: Some("Sender 2".into()), + }), + ] + .into(), + ), + cc: Some( + vec![Addr::Single(SingleInfo { + addr: "test-cc@local".into(), + display_name: None, + })] + .into(), + ), + ..Msg::default() + } + .into_reply(false, &config) + .unwrap(); + + assert_eq!(msg.subject, "Re: subject"); + assert_eq!( + msg.to.unwrap().to_string(), + "\"Sender\" " + ); + assert_eq!(msg.cc, None); + + // Checks that: + // - "to" contains all addresses except for the sender when "all" is true + // - "cc" contains all addresses except for the sender when "all" is true + + let msg = Msg { + from: Some( + vec![ + Addr::Single(SingleInfo { + addr: "test-sender-1@local".into(), + display_name: Some("Sender 1".into()), + }), + Addr::Single(SingleInfo { + addr: "test-sender-2@local".into(), + display_name: Some("Sender 2".into()), + }), + Addr::Single(SingleInfo { + addr: "test-account@local".into(), + display_name: Some("Test".into()), + }), + ] + .into(), + ), + cc: Some( + vec![ + Addr::Single(SingleInfo { + addr: "test-sender-1@local".into(), + display_name: Some("Sender 1".into()), + }), + Addr::Single(SingleInfo { + addr: "test-sender-2@local".into(), + display_name: Some("Sender 2".into()), + }), + Addr::Single(SingleInfo { + addr: "test-account@local".into(), + display_name: None, + }), + ] + .into(), + ), + ..Msg::default() + } + .into_reply(true, &config) + .unwrap(); + + assert_eq!( + msg.to.unwrap().to_string(), + "\"Sender 1\" , \"Sender 2\" " + ); + assert_eq!( + msg.cc.unwrap().to_string(), + "\"Sender 1\" , \"Sender 2\" " + ); + } + + #[test] + fn test_to_readable() { + let config = AccountConfig::default(); + let msg = Msg { + parts: Parts(vec![Part::TextPlain(TextPlainPart { + content: String::from("hello, world!"), + })]), + ..Msg::default() + }; + + // empty msg headers, empty headers, empty config + assert_eq!( + "hello, world!", + msg.to_readable_string("plain", vec![], &config).unwrap() + ); + // empty msg headers, basic headers + assert_eq!( + "hello, world!", + msg.to_readable_string("plain", vec!["From", "DATE", "custom-hEader"], &config) + .unwrap() + ); + // empty msg headers, multiple subject headers + assert_eq!( + "Subject: \n\nhello, world!", + msg.to_readable_string("plain", vec!["subject", "Subject", "SUBJECT"], &config) + .unwrap() + ); + + let msg = Msg { + headers: HashMap::from_iter([("custom-header".into(), "custom value".into())]), + message_id: Some("".into()), + from: Some( + vec![Addr::Single(SingleInfo { + addr: "test@local".into(), + display_name: Some("Test".into()), + })] + .into(), + ), + cc: Some(vec![].into()), + parts: Parts(vec![Part::TextPlain(TextPlainPart { + content: String::from("hello, world!"), + })]), + ..Msg::default() + }; + + // header present in msg headers, empty config + assert_eq!( + "From: \"Test\" \n\nhello, world!", + msg.to_readable_string("plain", vec!["from"], &config) + .unwrap() + ); + // header present but empty in msg headers, empty config + assert_eq!( + "hello, world!", + msg.to_readable_string("plain", vec!["cc"], &config) + .unwrap() + ); + // multiple same custom headers present in msg headers, empty + // config + assert_eq!( + "Custom-Header: custom value\n\nhello, world!", + msg.to_readable_string("plain", vec!["custom-header", "cuSTom-HeaDer"], &config) + .unwrap() + ); + + let config = AccountConfig { + read_headers: vec![ + "CusTOM-heaDER".into(), + "Subject".into(), + "from".into(), + "cc".into(), + ], + ..AccountConfig::default() + }; + // header present but empty in msg headers, empty config + assert_eq!( + "Custom-Header: custom value\nSubject: \nFrom: \"Test\" \nMessage-Id: \n\nhello, world!", + msg.to_readable_string("plain", vec!["cc", "message-ID"], &config) + .unwrap() + ); + } +} diff --git a/src/msg/msg_handlers.rs b/src/msg/msg_handlers.rs index 69e9154..80520c5 100644 --- a/src/msg/msg_handlers.rs +++ b/src/msg/msg_handlers.rs @@ -8,7 +8,6 @@ use log::{debug, info, trace}; use mailparse::addrparse; use std::{ borrow::Cow, - convert::TryInto, fs, io::{self, BufRead}, }; @@ -32,21 +31,29 @@ pub fn attachments<'a, P: PrinterService, B: Backend<'a> + ?Sized>( ) -> Result<()> { let attachments = backend.get_msg(mbox, seq)?.attachments(); let attachments_len = attachments.len(); - debug!( - r#"{} attachment(s) found for message "{}""#, - attachments_len, seq - ); + + if attachments_len == 0 { + return printer.print_struct(format!("No attachment found for message {:?}", seq)); + } + + printer.print_str(format!( + "Found {:?} attachment{} for message {:?}", + attachments_len, + if attachments_len > 1 { "s" } else { "" }, + seq + ))?; for attachment in attachments { let file_path = config.get_download_file_path(&attachment.filename)?; - debug!("downloading {}…", attachment.filename); + printer.print_str(format!("Downloading {:?}…", file_path))?; 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 + printer.print_struct(format!( + "Attachment{} successfully downloaded to {:?}", + if attachments_len > 1 { "s" } else { "" }, + config.downloads_dir )) } @@ -59,7 +66,7 @@ pub fn copy<'a, P: PrinterService, B: Backend<'a> + ?Sized>( backend: Box<&mut B>, ) -> Result<()> { backend.copy_msg(mbox_src, mbox_dst, seq)?; - printer.print(format!( + printer.print_struct(format!( r#"Message {} successfully copied to folder "{}""#, seq, mbox_dst )) @@ -73,7 +80,7 @@ pub fn delete<'a, P: PrinterService, B: Backend<'a> + ?Sized>( backend: Box<&'a mut B>, ) -> Result<()> { backend.del_msg(mbox, seq)?; - printer.print(format!(r#"Message(s) {} successfully deleted"#, seq)) + printer.print_struct(format!(r#"Message(s) {} successfully deleted"#, seq)) } /// Forward the given message UID from the selected mailbox. @@ -189,7 +196,7 @@ pub fn move_<'a, P: PrinterService, B: Backend<'a> + ?Sized>( backend: Box<&'a mut B>, ) -> Result<()> { backend.move_msg(mbox_src, mbox_dst, seq)?; - printer.print(format!( + printer.print_struct(format!( r#"Message {} successfully moved to folder "{}""#, seq, mbox_dst )) @@ -200,19 +207,20 @@ pub fn read<'a, P: PrinterService, B: Backend<'a> + ?Sized>( seq: &str, text_mime: &str, raw: bool, + headers: Vec<&str>, mbox: &str, + config: &AccountConfig, printer: &mut P, backend: Box<&'a mut B>, ) -> Result<()> { let msg = backend.get_msg(mbox, seq)?; - let msg = if raw { + + printer.print_struct(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) + msg.to_readable_string(text_mime, headers, config)? + }) } /// Reply to the given message UID. @@ -348,9 +356,8 @@ pub fn send<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( .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())?; + let msg = Msg::from_tpl(&raw_msg)?; + smtp.send(&config, &msg)?; backend.add_msg(&sent_folder, raw_msg.as_bytes(), "seen")?; Ok(()) } diff --git a/src/msg/msg_utils.rs b/src/msg/msg_utils.rs index 27d4b37..b5ccacf 100644 --- a/src/msg/msg_utils.rs +++ b/src/msg/msg_utils.rs @@ -3,7 +3,7 @@ use log::{debug, trace}; use std::{env, fs, path::PathBuf}; pub fn local_draft_path() -> PathBuf { - let path = env::temp_dir().join("himalaya-draft.mail"); + let path = env::temp_dir().join("himalaya-draft.eml"); trace!("local draft path: {:?}", path); path } diff --git a/src/msg/tpl_args.rs b/src/msg/tpl_args.rs index 5379acc..e3254ff 100644 --- a/src/msg/tpl_args.rs +++ b/src/msg/tpl_args.rs @@ -183,13 +183,13 @@ pub fn subcmds<'a>() -> Vec> { .subcommand( SubCommand::with_name("save") .about("Saves a message based on the given template") - .arg(&msg_args::attachment_arg()) + .arg(&msg_args::attachments_arg()) .arg(Arg::with_name("template").raw(true)), ) .subcommand( SubCommand::with_name("send") .about("Sends a message based on the given template") - .arg(&msg_args::attachment_arg()) + .arg(&msg_args::attachments_arg()) .arg(Arg::with_name("template").raw(true)), )] } diff --git a/src/msg/tpl_handlers.rs b/src/msg/tpl_handlers.rs index 45be083..b2db225 100644 --- a/src/msg/tpl_handlers.rs +++ b/src/msg/tpl_handlers.rs @@ -21,7 +21,7 @@ pub fn new<'a, P: PrinterService>( printer: &'a mut P, ) -> Result<()> { let tpl = Msg::default().to_tpl(opts, account)?; - printer.print(tpl) + printer.print_struct(tpl) } /// Generate a reply message template. @@ -38,7 +38,7 @@ pub fn reply<'a, P: PrinterService, B: Backend<'a> + ?Sized>( .get_msg(mbox, seq)? .into_reply(all, config)? .to_tpl(opts, config)?; - printer.print(tpl) + printer.print_struct(tpl) } /// Generate a forward message template. @@ -54,7 +54,7 @@ pub fn forward<'a, P: PrinterService, B: Backend<'a> + ?Sized>( .get_msg(mbox, seq)? .into_forward(config)? .to_tpl(opts, config)?; - printer.print(tpl) + printer.print_struct(tpl) } /// Saves a message based on a template. @@ -79,7 +79,7 @@ pub fn save<'a, P: PrinterService, B: Backend<'a> + ?Sized>( 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") + printer.print_struct("Template successfully saved") } /// Sends a message based on a template. @@ -103,7 +103,7 @@ pub fn send<'a, P: PrinterService, B: Backend<'a> + ?Sized, S: SmtpService>( .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") + let sent_msg = smtp.send(account, &msg)?; + backend.add_msg(mbox, &sent_msg, "seen")?; + printer.print_struct("Template successfully sent") } diff --git a/src/output/output_utils.rs b/src/output/output_utils.rs index 779089b..2053479 100644 --- a/src/output/output_utils.rs +++ b/src/output/output_utils.rs @@ -1,6 +1,9 @@ -use anyhow::Result; +use anyhow::{anyhow, Context, Result}; use log::debug; -use std::process::Command; +use std::{ + io::prelude::*, + process::{Command, Stdio}, +}; /// TODO: move this in a more approriate place. pub fn run_cmd(cmd: &str) -> Result { @@ -14,3 +17,25 @@ pub fn run_cmd(cmd: &str) -> Result { Ok(String::from_utf8(output.stdout)?) } + +pub fn pipe_cmd(cmd: &str, data: &[u8]) -> Result> { + let mut res = Vec::new(); + + let process = Command::new(cmd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .with_context(|| format!("cannot spawn process from command {:?}", cmd))?; + process + .stdin + .ok_or_else(|| anyhow!("cannot get stdin"))? + .write_all(data) + .with_context(|| "cannot write data to stdin")?; + process + .stdout + .ok_or_else(|| anyhow!("cannot get stdout"))? + .read_to_end(&mut res) + .with_context(|| "cannot read data from stdout")?; + + Ok(res) +} diff --git a/src/output/print.rs b/src/output/print.rs index 62f65f6..a843501 100644 --- a/src/output/print.rs +++ b/src/output/print.rs @@ -1,23 +1,19 @@ use anyhow::{Context, Result}; -use log::error; use crate::output::WriteColor; pub trait Print { - fn print(&self, writter: &mut dyn WriteColor) -> Result<()>; + fn print(&self, writer: &mut dyn WriteColor) -> Result<()>; } impl Print for &str { - fn print(&self, writter: &mut dyn WriteColor) -> Result<()> { - writeln!(writter, "{}", self).with_context(|| { - error!(r#"cannot write string to writter: "{}""#, self); - "cannot write string to writter" - }) + fn print(&self, writer: &mut dyn WriteColor) -> Result<()> { + writeln!(writer, "{}", self).context("cannot write string to writer") } } impl Print for String { - fn print(&self, writter: &mut dyn WriteColor) -> Result<()> { - self.as_str().print(writter) + fn print(&self, writer: &mut dyn WriteColor) -> Result<()> { + self.as_str().print(writer) } } diff --git a/src/output/print_table.rs b/src/output/print_table.rs index 489ecfa..45557b9 100644 --- a/src/output/print_table.rs +++ b/src/output/print_table.rs @@ -9,7 +9,7 @@ pub trait WriteColor: io::Write + termcolor::WriteColor {} impl WriteColor for StandardStream {} pub trait PrintTable { - fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()>; + fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()>; } pub struct PrintTableOpts<'a> { diff --git a/src/output/printer_service.rs b/src/output/printer_service.rs index 65ee91b..cc4685a 100644 --- a/src/output/printer_service.rs +++ b/src/output/printer_service.rs @@ -9,8 +9,9 @@ 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_str(&mut self, data: T) -> Result<()>; + fn print_struct(&mut self, data: T) -> Result<()>; + fn print_table( &mut self, data: Box, opts: PrintTableOpts, @@ -19,16 +20,23 @@ pub trait PrinterService { } pub struct StdoutPrinter { - pub writter: Box, + pub writer: Box, pub fmt: OutputFmt, } impl PrinterService for StdoutPrinter { - fn print(&mut self, data: T) -> Result<()> { + fn print_str(&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)) - .context("cannot write JSON to writter"), + OutputFmt::Plain => data.print(self.writer.as_mut()), + OutputFmt::Json => Ok(()), + } + } + + fn print_struct(&mut self, data: T) -> Result<()> { + match self.fmt { + OutputFmt::Plain => data.print(self.writer.as_mut()), + OutputFmt::Json => serde_json::to_writer(self.writer.as_mut(), &OutputJson::new(data)) + .context("cannot write JSON to writer"), } } @@ -38,9 +46,9 @@ impl PrinterService for StdoutPrinter { opts: PrintTableOpts, ) -> Result<()> { match self.fmt { - OutputFmt::Plain => data.print_table(self.writter.as_mut(), opts), + OutputFmt::Plain => data.print_table(self.writer.as_mut(), opts), OutputFmt::Json => { - let json = &mut serde_json::Serializer::new(self.writter.as_mut()); + let json = &mut serde_json::Serializer::new(self.writer.as_mut()); let ser = &mut ::erase(json); data.erased_serialize(ser).unwrap(); Ok(()) @@ -55,7 +63,7 @@ impl PrinterService for StdoutPrinter { impl From for StdoutPrinter { fn from(fmt: OutputFmt) -> Self { - let writter = StandardStream::stdout(if atty::isnt(Stream::Stdin) { + let writer = StandardStream::stdout(if atty::isnt(Stream::Stdin) { // Colors should be deactivated if the terminal is not a tty. ColorChoice::Never } else { @@ -67,8 +75,8 @@ impl From for StdoutPrinter { // [doc]: https://github.com/BurntSushi/termcolor#automatic-color-selection ColorChoice::Auto }); - let writter = Box::new(writter); - Self { writter, fmt } + let writer = Box::new(writer); + Self { writer, fmt } } } diff --git a/src/smtp/smtp_service.rs b/src/smtp/smtp_service.rs index 9eeccc1..13ea6ce 100644 --- a/src/smtp/smtp_service.rs +++ b/src/smtp/smtp_service.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use lettre::{ self, transport::smtp::{ @@ -7,13 +7,12 @@ use lettre::{ }, Transport, }; -use log::debug; +use std::convert::TryInto; -use crate::{config::AccountConfig, msg::Msg}; +use crate::{config::AccountConfig, msg::Msg, output::pipe_cmd}; 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<()>; + fn send(&mut self, account: &AccountConfig, msg: &Msg) -> Result>; } pub struct LettreService<'a> { @@ -21,7 +20,7 @@ pub struct LettreService<'a> { transport: Option, } -impl<'a> LettreService<'a> { +impl LettreService<'_> { fn transport(&mut self) -> Result<&SmtpTransport> { if let Some(ref transport) = self.transport { Ok(transport) @@ -55,24 +54,29 @@ impl<'a> LettreService<'a> { } } -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)?; - Ok(sendable_msg) - } +impl SmtpService for LettreService<'_> { + fn send(&mut self, account: &AccountConfig, msg: &Msg) -> Result> { + let mut raw_msg = msg.into_sendable_msg(account)?.formatted(); - fn send_raw_msg(&mut self, envelope: &lettre::address::Envelope, msg: &[u8]) -> Result<()> { - debug!("sending raw message…"); - self.transport()?.send_raw(envelope, msg)?; - Ok(()) + let envelope: lettre::address::Envelope = + if let Some(cmd) = account.hooks.pre_send.as_deref() { + for cmd in cmd.split('|') { + raw_msg = pipe_cmd(cmd.trim(), &raw_msg) + .with_context(|| format!("cannot execute pre-send hook {:?}", cmd))? + } + let parsed_mail = mailparse::parse_mail(&raw_msg)?; + Msg::from_parsed_mail(parsed_mail, account)?.try_into() + } else { + msg.try_into() + }?; + + self.transport()?.send_raw(&envelope, &raw_msg)?; + Ok(raw_msg) } } impl<'a> From<&'a AccountConfig> for LettreService<'a> { fn from(account: &'a AccountConfig) -> Self { - debug!("init SMTP service"); Self { account, transport: None, diff --git a/src/ui/table.rs b/src/ui/table.rs index 137c80e..0c97a13 100644 --- a/src/ui/table.rs +++ b/src/ui/table.rs @@ -127,14 +127,14 @@ impl Cell { /// Makes the cell printable. impl Print for Cell { - fn print(&self, writter: &mut dyn WriteColor) -> Result<()> { + fn print(&self, writer: &mut dyn WriteColor) -> Result<()> { // Applies colors to the cell - writter + writer .set_color(&self.style) .context(format!(r#"cannot apply colors to cell "{}""#, self.value))?; // Writes the colorized cell to stdout - write!(writter, "{}", self.value).context(format!(r#"cannot print cell "{}""#, self.value)) + write!(writer, "{}", self.value).context(format!(r#"cannot print cell "{}""#, self.value)) } } @@ -167,8 +167,8 @@ where /// Defines the row template. fn row(&self) -> Row; - /// Writes the table to the writter. - fn print(writter: &mut dyn WriteColor, items: &[Self], opts: PrintTableOpts) -> Result<()> { + /// Writes the table to the writer. + fn print(writer: &mut dyn WriteColor, items: &[Self], opts: PrintTableOpts) -> Result<()> { let is_format_flowed = matches!(opts.format, Format::Flowed); let max_width = match opts.format { Format::Fixed(width) => opts.max_width.unwrap_or(*width), @@ -202,7 +202,7 @@ where for row in table.iter_mut() { let mut glue = Cell::default(); for (i, cell) in row.0.iter_mut().enumerate() { - glue.print(writter)?; + glue.print(writer)?; let table_is_overflowing = table_width > max_width; if table_is_overflowing && !is_format_flowed && cell.is_shrinkable() { @@ -256,10 +256,10 @@ where trace!("number of spaces added to value: {}", spaces_count); cell.value.push_str(&" ".repeat(spaces_count)); } - cell.print(writter)?; + cell.print(writer)?; glue = Cell::new("│").ansi_256(8); } - writeln!(writter)?; + writeln!(writer)?; } Ok(()) } @@ -272,11 +272,11 @@ mod tests { use super::*; #[derive(Debug, Default)] - struct StringWritter { + struct StringWriter { content: String, } - impl io::Write for StringWritter { + impl io::Write for StringWriter { fn write(&mut self, buf: &[u8]) -> io::Result { self.content .push_str(&String::from_utf8(buf.to_vec()).unwrap()); @@ -289,7 +289,7 @@ mod tests { } } - impl termcolor::WriteColor for StringWritter { + impl termcolor::WriteColor for StringWriter { fn supports_color(&self) -> bool { false } @@ -303,7 +303,7 @@ mod tests { } } - impl WriteColor for StringWritter {} + impl WriteColor for StringWriter {} struct Item { id: u16, @@ -338,16 +338,16 @@ mod tests { } macro_rules! write_items { - ($writter:expr, $($item:expr),*) => { - Table::print($writter, &[$($item,)*], PrintTableOpts { format: &Format::Auto, max_width: Some(20) }).unwrap(); + ($writer:expr, $($item:expr),*) => { + Table::print($writer, &[$($item,)*], PrintTableOpts { format: &Format::Auto, max_width: Some(20) }).unwrap(); }; } #[test] fn row_smaller_than_head() { - let mut writter = StringWritter::default(); + let mut writer = StringWriter::default(); write_items![ - &mut writter, + &mut writer, Item::new(1, "a", "aa"), Item::new(2, "b", "bb"), Item::new(3, "c", "cc") @@ -359,14 +359,14 @@ mod tests { "2 │b │bb \n", "3 │c │cc \n", ]; - assert_eq!(expected, writter.content); + assert_eq!(expected, writer.content); } #[test] fn row_bigger_than_head() { - let mut writter = StringWritter::default(); + let mut writer = StringWriter::default(); write_items![ - &mut writter, + &mut writer, Item::new(1, "a", "aa"), Item::new(2222, "bbbbb", "bbbbb"), Item::new(3, "c", "cc") @@ -378,11 +378,11 @@ mod tests { "2222 │bbbbb │bbbbb \n", "3 │c │cc \n", ]; - assert_eq!(expected, writter.content); + assert_eq!(expected, writer.content); - let mut writter = StringWritter::default(); + let mut writer = StringWriter::default(); write_items![ - &mut writter, + &mut writer, Item::new(1, "a", "aa"), Item::new(2222, "bbbbb", "bbbbb"), Item::new(3, "cccccc", "cc") @@ -394,14 +394,14 @@ mod tests { "2222 │bbbbb │bbbbb \n", "3 │cccccc │cc \n", ]; - assert_eq!(expected, writter.content); + assert_eq!(expected, writer.content); } #[test] fn basic_shrink() { - let mut writter = StringWritter::default(); + let mut writer = StringWriter::default(); write_items![ - &mut writter, + &mut writer, Item::new(1, "", "desc"), Item::new(2, "short", "desc"), Item::new(3, "loooooong", "desc"), @@ -423,14 +423,14 @@ mod tests { "7 │😍😍😍😍… │desc \n", "8 │!😍😍😍… │desc \n", ]; - assert_eq!(expected, writter.content); + assert_eq!(expected, writer.content); } #[test] fn max_shrink_width() { - let mut writter = StringWritter::default(); + let mut writer = StringWriter::default(); write_items![ - &mut writter, + &mut writer, Item::new(1111, "shriiiiiiiink", "desc very looong"), Item::new(2222, "shriiiiiiiink", "desc very loooooooooong") ]; @@ -440,6 +440,6 @@ mod tests { "1111 │shri… │desc very looong \n", "2222 │shri… │desc very loooooooooong \n", ]; - assert_eq!(expected, writter.content); + assert_eq!(expected, writer.content); } } diff --git a/tests/test_maildir_backend.rs b/tests/test_maildir_backend.rs index d998789..11eaa52 100644 --- a/tests/test_maildir_backend.rs +++ b/tests/test_maildir_backend.rs @@ -19,10 +19,7 @@ fn test_maildir_backend() { // configure accounts let account_config = AccountConfig { - mailboxes: HashMap::from_iter([ - ("inbox".into(), "INBOX".into()), - ("subdir".into(), "Subdir".into()), - ]), + mailboxes: HashMap::from_iter([("subdir".into(), "Subdir".into())]), ..AccountConfig::default() }; let mdir_config = MaildirBackendConfig {