Merge pull request #339 from soywod/development

Release v0.5.9
This commit is contained in:
Clément DOUIN 2022-03-12 18:06:29 +01:00 committed by GitHub
commit a44e94bd6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 846 additions and 311 deletions

View file

@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.5.8] - 2022-03-04
### Added ### Added
@ -338,7 +357,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Password from command [#22] - Password from command [#22]
- Set up README [#20] - 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.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.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.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 [#162]: https://github.com/soywod/himalaya/issues/162
[#176]: https://github.com/soywod/himalaya/issues/176 [#176]: https://github.com/soywod/himalaya/issues/176
[#172]: https://github.com/soywod/himalaya/issues/172 [#172]: https://github.com/soywod/himalaya/issues/172
[#178]: https://github.com/soywod/himalaya/issues/178
[#181]: https://github.com/soywod/himalaya/issues/181 [#181]: https://github.com/soywod/himalaya/issues/181
[#185]: https://github.com/soywod/himalaya/issues/185 [#185]: https://github.com/soywod/himalaya/issues/185
[#186]: https://github.com/soywod/himalaya/issues/186 [#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 [#271]: https://github.com/soywod/himalaya/issues/271
[#276]: https://github.com/soywod/himalaya/issues/276 [#276]: https://github.com/soywod/himalaya/issues/276
[#280]: https://github.com/soywod/himalaya/issues/280 [#280]: https://github.com/soywod/himalaya/issues/280
[#281]: https://github.com/soywod/himalaya/issues/281
[#288]: https://github.com/soywod/himalaya/issues/288 [#288]: https://github.com/soywod/himalaya/issues/288
[#289]: https://github.com/soywod/himalaya/issues/289 [#289]: https://github.com/soywod/himalaya/issues/289
[#298]: https://github.com/soywod/himalaya/issues/298 [#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 [#309]: https://github.com/soywod/himalaya/issues/309
[#318]: https://github.com/soywod/himalaya/issues/318 [#318]: https://github.com/soywod/himalaya/issues/318
[#321]: https://github.com/soywod/himalaya/issues/321 [#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

9
Cargo.lock generated
View file

@ -167,6 +167,12 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "convert_case"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb4a24b1aaf0fd0ce8b45161144d6f42cd91677fd5940fd431183eb023b3a2b8"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.2" version = "0.9.2"
@ -436,13 +442,14 @@ dependencies = [
[[package]] [[package]]
name = "himalaya" name = "himalaya"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
"atty", "atty",
"chrono", "chrono",
"clap", "clap",
"convert_case",
"env_logger", "env_logger",
"erased-serde", "erased-serde",
"html-escape", "html-escape",

View file

@ -1,7 +1,7 @@
[package] [package]
name = "himalaya" name = "himalaya"
description = "Command-line interface for email management" description = "Command-line interface for email management"
version = "0.5.8" version = "0.5.9"
authors = ["soywod <clement.douin@posteo.net>"] authors = ["soywod <clement.douin@posteo.net>"]
edition = "2018" edition = "2018"
license-file = "LICENSE" license-file = "LICENSE"
@ -28,6 +28,7 @@ anyhow = "1.0.44"
atty = "0.2.14" atty = "0.2.14"
chrono = "0.4.19" chrono = "0.4.19"
clap = { version = "2.33.3", default-features = false, features = ["suggestions", "color"] } clap = { version = "2.33.3", default-features = false, features = ["suggestions", "color"] }
convert_case = "0.5.0"
env_logger = "0.8.3" env_logger = "0.8.3"
erased-serde = "0.3.18" erased-serde = "0.3.18"
html-escape = "0.2.9" html-escape = "0.2.9"

View file

@ -26,7 +26,6 @@ impl IdMapper {
.open(&mapper.path) .open(&mapper.path)
.context("cannot open id hash map file")?; .context("cannot open id hash map file")?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
for line in reader.lines() { for line in reader.lines() {
let line = let line =
line.context("cannot read line from maildir envelopes id mapper cache file")?; line.context("cannot read line from maildir envelopes id mapper cache file")?;
@ -83,13 +82,13 @@ impl IdMapper {
for (hash, id) in self.iter() { for (hash, id) in self.iter() {
loop { loop {
let short_hash = &hash[0..self.short_hash_len]; let short_hash = &hash[0..short_hash_len];
let conflict_found = self let conflict_found = self
.map .map
.keys() .keys()
.find(|cached_hash| cached_hash.starts_with(short_hash) && cached_hash != &hash) .find(|cached_hash| cached_hash.starts_with(short_hash) && cached_hash != &hash)
.is_some(); .is_some();
if self.short_hash_len > 32 || !conflict_found { if short_hash_len > 32 || !conflict_found {
break; break;
} }
short_hash_len += 1; short_hash_len += 1;

View file

@ -15,21 +15,24 @@ use super::{ImapFlag, ImapFlags};
/// Represents a list of IMAP envelopes. /// Represents a list of IMAP envelopes.
#[derive(Debug, Default, serde::Serialize)] #[derive(Debug, Default, serde::Serialize)]
pub struct ImapEnvelopes(pub Vec<ImapEnvelope>); pub struct ImapEnvelopes {
#[serde(rename = "response")]
pub envelopes: Vec<ImapEnvelope>,
}
impl Deref for ImapEnvelopes { impl Deref for ImapEnvelopes {
type Target = Vec<ImapEnvelope>; type Target = Vec<ImapEnvelope>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.envelopes
} }
} }
impl PrintTable for ImapEnvelopes { impl PrintTable for ImapEnvelopes {
fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writter)?; writeln!(writer)?;
Table::print(writter, self, opts)?; Table::print(writer, self, opts)?;
writeln!(writter)?; writeln!(writer)?;
Ok(()) Ok(())
} }
} }
@ -99,7 +102,7 @@ impl TryFrom<RawImapEnvelopes> for ImapEnvelopes {
for raw_envelope in raw_envelopes.iter().rev() { for raw_envelope in raw_envelopes.iter().rev() {
envelopes.push(ImapEnvelope::try_from(raw_envelope).context("cannot parse envelope")?); envelopes.push(ImapEnvelope::try_from(raw_envelope).context("cannot parse envelope")?);
} }
Ok(Self(envelopes)) Ok(Self { envelopes })
} }
} }

View file

@ -4,6 +4,7 @@
//! to the mailbox. //! to the mailbox.
use anyhow::Result; use anyhow::Result;
use serde::Serialize;
use std::fmt::{self, Display}; use std::fmt::{self, Display};
use std::ops::Deref; use std::ops::Deref;
@ -16,22 +17,25 @@ use crate::{
use super::ImapMboxAttrs; use super::ImapMboxAttrs;
/// Represents a list of IMAP mailboxes. /// Represents a list of IMAP mailboxes.
#[derive(Debug, Default, serde::Serialize)] #[derive(Debug, Default, Serialize)]
pub struct ImapMboxes(pub Vec<ImapMbox>); pub struct ImapMboxes {
#[serde(rename = "response")]
pub mboxes: Vec<ImapMbox>,
}
impl Deref for ImapMboxes { impl Deref for ImapMboxes {
type Target = Vec<ImapMbox>; type Target = Vec<ImapMbox>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.mboxes
} }
} }
impl PrintTable for ImapMboxes { impl PrintTable for ImapMboxes {
fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writter)?; writeln!(writer)?;
Table::print(writter, self, opts)?; Table::print(writer, self, opts)?;
writeln!(writter)?; writeln!(writer)?;
Ok(()) Ok(())
} }
} }
@ -130,7 +134,9 @@ pub type RawImapMboxes = imap::types::ZeroCopy<Vec<RawImapMbox>>;
impl<'a> From<RawImapMboxes> for ImapMboxes { impl<'a> From<RawImapMboxes> for ImapMboxes {
fn from(raw_mboxes: RawImapMboxes) -> Self { fn from(raw_mboxes: RawImapMboxes) -> Self {
Self(raw_mboxes.iter().map(ImapMbox::from).collect()) Self {
mboxes: raw_mboxes.iter().map(ImapMbox::from).collect(),
}
} }
} }

View file

@ -5,7 +5,7 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use log::{debug, info, trace}; use log::{debug, info, trace};
use std::{convert::TryInto, fs, path::PathBuf}; use std::{convert::TryInto, env, fs, path::PathBuf};
use crate::{ use crate::{
backends::{Backend, IdMapper, MaildirEnvelopes, MaildirFlags, MaildirMboxes}, backends::{Backend, IdMapper, MaildirEnvelopes, MaildirFlags, MaildirMboxes},
@ -41,30 +41,32 @@ impl<'a> MaildirBackend<'a> {
/// Creates a maildir instance from a string slice. /// Creates a maildir instance from a string slice.
pub fn get_mdir_from_dir(&self, dir: &str) -> Result<maildir::Maildir> { pub fn get_mdir_from_dir(&self, dir: &str) -> Result<maildir::Maildir> {
let dir = self.account_config.get_mbox_alias(dir)?;
// If the dir points to the inbox folder, creates a maildir // If the dir points to the inbox folder, creates a maildir
// instance from the root folder. // instance from the root folder.
if dir == "inbox" { if &dir == "inbox" {
self.validate_mdir_path(self.mdir.path().to_owned()) return self
.map(maildir::Maildir::from) .validate_mdir_path(self.mdir.path().to_owned())
} else { .map(maildir::Maildir::from);
// 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 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()); envelopes.sort_by(|a, b| b.date.partial_cmp(&a.date).unwrap());
// Applies pagination boundaries. // 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 // Appends envelopes hash to the id mapper cache file and
// calculates the new short hash length. The short hash length // calculates the new short hash length. The short hash length

View file

@ -5,7 +5,7 @@
use anyhow::{anyhow, Context, Error, Result}; use anyhow::{anyhow, Context, Error, Result};
use chrono::DateTime; use chrono::DateTime;
use log::{debug, info, trace}; use log::trace;
use std::{ use std::{
convert::{TryFrom, TryInto}, convert::{TryFrom, TryInto},
ops::{Deref, DerefMut}, ops::{Deref, DerefMut},
@ -20,27 +20,30 @@ use crate::{
/// Represents a list of envelopes. /// Represents a list of envelopes.
#[derive(Debug, Default, serde::Serialize)] #[derive(Debug, Default, serde::Serialize)]
pub struct MaildirEnvelopes(pub Vec<MaildirEnvelope>); pub struct MaildirEnvelopes {
#[serde(rename = "response")]
pub envelopes: Vec<MaildirEnvelope>,
}
impl Deref for MaildirEnvelopes { impl Deref for MaildirEnvelopes {
type Target = Vec<MaildirEnvelope>; type Target = Vec<MaildirEnvelope>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.envelopes
} }
} }
impl DerefMut for MaildirEnvelopes { impl DerefMut for MaildirEnvelopes {
fn deref_mut(&mut self) -> &mut Self::Target { fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0 &mut self.envelopes
} }
} }
impl PrintTable for MaildirEnvelopes { impl PrintTable for MaildirEnvelopes {
fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writter)?; writeln!(writer)?;
Table::print(writter, self, opts)?; Table::print(writer, self, opts)?;
writeln!(writter)?; writeln!(writer)?;
Ok(()) Ok(())
} }
} }
@ -114,7 +117,7 @@ impl<'a> TryFrom<RawMaildirEnvelopes> for MaildirEnvelopes {
envelopes.push(envelope); envelopes.push(envelope);
} }
Ok(MaildirEnvelopes(envelopes)) Ok(MaildirEnvelopes { envelopes })
} }
} }
@ -125,7 +128,7 @@ impl<'a> TryFrom<RawMaildirEnvelope> for MaildirEnvelope {
type Error = Error; type Error = Error;
fn try_from(mut mail_entry: RawMaildirEnvelope) -> Result<Self, Self::Error> { fn try_from(mut mail_entry: RawMaildirEnvelope) -> Result<Self, Self::Error> {
info!("begin: try building envelope from maildir parsed mail"); trace!(">> build envelope from maildir parsed mail");
let mut envelope = Self::default(); let mut envelope = Self::default();
@ -139,14 +142,14 @@ impl<'a> TryFrom<RawMaildirEnvelope> for MaildirEnvelope {
.parsed() .parsed()
.context("cannot parse maildir mail entry")?; .context("cannot parse maildir mail entry")?;
debug!("begin: parse headers"); trace!(">> parse headers");
for h in parsed_mail.get_headers() { for h in parsed_mail.get_headers() {
let k = h.get_key(); let k = h.get_key();
debug!("header key: {:?}", k); trace!("header key: {:?}", k);
let v = rfc2047_decoder::decode(h.get_value_raw()) let v = rfc2047_decoder::decode(h.get_value_raw())
.context(format!("cannot decode value from header {:?}", k))?; .context(format!("cannot decode value from header {:?}", k))?;
debug!("header value: {:?}", v); trace!("header value: {:?}", v);
match k.to_lowercase().as_str() { match k.to_lowercase().as_str() {
"date" => { "date" => {
@ -182,10 +185,10 @@ impl<'a> TryFrom<RawMaildirEnvelope> for MaildirEnvelope {
_ => (), _ => (),
} }
} }
debug!("end: parse headers"); trace!("<< parse headers");
trace!("envelope: {:?}", envelope); trace!("envelope: {:?}", envelope);
info!("end: try building envelope from maildir parsed mail"); trace!("<< build envelope from maildir parsed mail");
Ok(envelope) Ok(envelope)
} }
} }

View file

@ -19,21 +19,24 @@ use crate::{
/// Represents a list of Maildir mailboxes. /// Represents a list of Maildir mailboxes.
#[derive(Debug, Default, serde::Serialize)] #[derive(Debug, Default, serde::Serialize)]
pub struct MaildirMboxes(pub Vec<MaildirMbox>); pub struct MaildirMboxes {
#[serde(rename = "response")]
pub mboxes: Vec<MaildirMbox>,
}
impl Deref for MaildirMboxes { impl Deref for MaildirMboxes {
type Target = Vec<MaildirMbox>; type Target = Vec<MaildirMbox>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.mboxes
} }
} }
impl PrintTable for MaildirMboxes { impl PrintTable for MaildirMboxes {
fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writter)?; writeln!(writer)?;
Table::print(writter, self, opts)?; Table::print(writer, self, opts)?;
writeln!(writter)?; writeln!(writer)?;
Ok(()) Ok(())
} }
} }
@ -113,7 +116,7 @@ impl TryFrom<RawMaildirMboxes> for MaildirMboxes {
for entry in mail_entries { for entry in mail_entries {
mboxes.push(entry?.try_into()?); mboxes.push(entry?.try_into()?);
} }
Ok(MaildirMboxes(mboxes)) Ok(MaildirMboxes { mboxes })
} }
} }

View file

@ -81,7 +81,7 @@ impl<'a> NotmuchBackend<'a> {
envelopes.sort_by(|a, b| b.date.partial_cmp(&a.date).unwrap()); envelopes.sort_by(|a, b| b.date.partial_cmp(&a.date).unwrap());
// Applies pagination boundaries. // 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 // Appends envelopes hash to the id mapper cache file and
// calculates the new short hash length. The short hash length // 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<Box<dyn Mboxes>> { fn get_mboxes(&mut self) -> Result<Box<dyn Mboxes>> {
info!(">> get notmuch virtual mailboxes"); info!(">> get notmuch virtual mailboxes");
let mut virt_mboxes: Vec<_> = self let mut mboxes: Vec<_> = self
.account_config .account_config
.mailboxes .mailboxes
.iter() .iter()
.map(|(k, v)| NotmuchMbox::new(k, v)) .map(|(k, v)| NotmuchMbox::new(k, v))
.collect(); .collect();
trace!("virtual mailboxes: {:?}", virt_mboxes); trace!("virtual mailboxes: {:?}", mboxes);
virt_mboxes.sort_by(|a, b| b.name.partial_cmp(&a.name).unwrap()); mboxes.sort_by(|a, b| b.name.partial_cmp(&a.name).unwrap());
info!("<< get notmuch virtual mailboxes"); info!("<< get notmuch virtual mailboxes");
Ok(Box::new(NotmuchMboxes(virt_mboxes))) Ok(Box::new(NotmuchMboxes { mboxes }))
} }
fn del_mbox(&mut self, _mbox: &str) -> Result<()> { 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. // Adds the message to the maildir folder and gets its hash.
let hash = self let hash = self
.mdir .mdir
.add_msg("inbox", msg, "seen") .add_msg("", msg, "seen")
.with_context(|| { .with_context(|| {
format!( format!(
"cannot add notmuch message to maildir {:?}", "cannot add notmuch message to maildir {:?}",

View file

@ -19,27 +19,30 @@ use crate::{
/// Represents a list of envelopes. /// Represents a list of envelopes.
#[derive(Debug, Default, serde::Serialize)] #[derive(Debug, Default, serde::Serialize)]
pub struct NotmuchEnvelopes(pub Vec<NotmuchEnvelope>); pub struct NotmuchEnvelopes {
#[serde(rename = "response")]
pub envelopes: Vec<NotmuchEnvelope>,
}
impl Deref for NotmuchEnvelopes { impl Deref for NotmuchEnvelopes {
type Target = Vec<NotmuchEnvelope>; type Target = Vec<NotmuchEnvelope>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.envelopes
} }
} }
impl DerefMut for NotmuchEnvelopes { impl DerefMut for NotmuchEnvelopes {
fn deref_mut(&mut self) -> &mut Self::Target { fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0 &mut self.envelopes
} }
} }
impl PrintTable for NotmuchEnvelopes { impl PrintTable for NotmuchEnvelopes {
fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writter)?; writeln!(writer)?;
Table::print(writter, self, opts)?; Table::print(writer, self, opts)?;
writeln!(writter)?; writeln!(writer)?;
Ok(()) Ok(())
} }
} }
@ -107,7 +110,7 @@ impl<'a> TryFrom<RawNotmuchEnvelopes> for NotmuchEnvelopes {
.context("cannot parse notmuch mail entry")?; .context("cannot parse notmuch mail entry")?;
envelopes.push(envelope); envelopes.push(envelope);
} }
Ok(NotmuchEnvelopes(envelopes)) Ok(NotmuchEnvelopes { envelopes })
} }
} }

View file

@ -17,21 +17,24 @@ use crate::{
/// Represents a list of Notmuch mailboxes. /// Represents a list of Notmuch mailboxes.
#[derive(Debug, Default, serde::Serialize)] #[derive(Debug, Default, serde::Serialize)]
pub struct NotmuchMboxes(pub Vec<NotmuchMbox>); pub struct NotmuchMboxes {
#[serde(rename = "response")]
pub mboxes: Vec<NotmuchMbox>,
}
impl Deref for NotmuchMboxes { impl Deref for NotmuchMboxes {
type Target = Vec<NotmuchMbox>; type Target = Vec<NotmuchMbox>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.mboxes
} }
} }
impl PrintTable for NotmuchMboxes { impl PrintTable for NotmuchMboxes {
fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writter)?; writeln!(writer)?;
Table::print(writter, self, opts)?; Table::print(writer, self, opts)?;
writeln!(writter)?; writeln!(writer)?;
Ok(()) Ok(())
} }
} }

View file

@ -31,10 +31,10 @@ impl Deref for Accounts {
} }
impl PrintTable for Accounts { impl PrintTable for Accounts {
fn print_table(&self, writter: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> { fn print_table(&self, writer: &mut dyn WriteColor, opts: PrintTableOpts) -> Result<()> {
writeln!(writter)?; writeln!(writer)?;
Table::print(writter, self, opts)?; Table::print(writer, self, opts)?;
writeln!(writter)?; writeln!(writer)?;
Ok(()) Ok(())
} }
} }

View file

@ -32,10 +32,16 @@ pub struct AccountConfig {
/// Represents the text/plain format as defined in the /// Represents the text/plain format as defined in the
/// [RFC2646](https://www.ietf.org/rfc/rfc2646.txt) /// [RFC2646](https://www.ietf.org/rfc/rfc2646.txt)
pub format: Format, pub format: Format,
/// Overrides the default headers displayed at the top of
/// the read message.
pub read_headers: Vec<String>,
/// Represents mailbox aliases. /// Represents mailbox aliases.
pub mailboxes: HashMap<String, String>, pub mailboxes: HashMap<String, String>,
/// Represents hooks.
pub hooks: Hooks,
/// Represents the SMTP host. /// Represents the SMTP host.
pub smtp_host: String, pub smtp_host: String,
/// Represents the SMTP port. /// Represents the SMTP port.
@ -154,7 +160,9 @@ impl<'a> AccountConfig {
.unwrap_or(&vec![]) .unwrap_or(&vec![])
.to_owned(), .to_owned(),
format: base_account.format.unwrap_or_default(), format: base_account.format.unwrap_or_default(),
read_headers: base_account.read_headers,
mailboxes: base_account.mailboxes.clone(), mailboxes: base_account.mailboxes.clone(),
hooks: base_account.hooks.unwrap_or_default(),
default: base_account.default.unwrap_or_default(), default: base_account.default.unwrap_or_default(),
email: base_account.email.to_owned(), email: base_account.email.to_owned(),
@ -203,8 +211,7 @@ impl<'a> AccountConfig {
/// Builds the full RFC822 compliant address of the user account. /// Builds the full RFC822 compliant address of the user account.
pub fn address(&self) -> Result<MailAddr> { pub fn address(&self) -> Result<MailAddr> {
let has_special_chars = let has_special_chars = "()<>[]:;@.,".contains(|c| self.display_name.contains(c));
"()<>[]:;@.,".contains(|special_char| self.display_name.contains(special_char));
let addr = if self.display_name.is_empty() { let addr = if self.display_name.is_empty() {
self.email.clone() self.email.clone()
} else if has_special_chars { } else if has_special_chars {
@ -314,6 +321,19 @@ impl<'a> AccountConfig {
run_cmd(&cmd).context("cannot run notify cmd")?; run_cmd(&cmd).context("cannot run notify cmd")?;
Ok(()) 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<String> {
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). /// Represents all existing kind of account (backend).

View file

@ -49,11 +49,11 @@ mod tests {
#[test] #[test]
fn it_should_match_cmds_accounts() { fn it_should_match_cmds_accounts() {
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
struct StringWritter { struct StringWriter {
content: String, content: String,
} }
impl io::Write for StringWritter { impl io::Write for StringWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> { fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.content self.content
.push_str(&String::from_utf8(buf.to_vec()).unwrap()); .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 { fn supports_color(&self) -> bool {
false false
} }
@ -80,11 +80,11 @@ mod tests {
} }
} }
impl WriteColor for StringWritter {} impl WriteColor for StringWriter {}
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct PrinterServiceTest { struct PrinterServiceTest {
pub writter: StringWritter, pub writer: StringWriter,
} }
impl PrinterService for PrinterServiceTest { impl PrinterService for PrinterServiceTest {
@ -93,10 +93,16 @@ mod tests {
data: Box<T>, data: Box<T>,
opts: PrintTableOpts, opts: PrintTableOpts,
) -> Result<()> { ) -> Result<()> {
data.print_table(&mut self.writter, opts)?; data.print_table(&mut self.writer, opts)?;
Ok(()) Ok(())
} }
fn print<T: serde::Serialize + Print>(&mut self, _data: T) -> Result<()> { fn print_str<T: Debug + Print>(&mut self, _data: T) -> Result<()> {
unimplemented!()
}
fn print_struct<T: Debug + Print + serde::Serialize>(
&mut self,
_data: T,
) -> Result<()> {
unimplemented!() unimplemented!()
} }
fn is_json(&self) -> bool { fn is_json(&self) -> bool {
@ -126,7 +132,7 @@ mod tests {
"account-1 │imap │yes \n", "account-1 │imap │yes \n",
"\n" "\n"
], ],
printer.writter.content printer.writer.content
); );
} }
} }

View file

@ -1,7 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
use crate::config::Format; use crate::config::{Format, Hooks};
pub trait ToDeserializedBaseAccountConfig { pub trait ToDeserializedBaseAccountConfig {
fn to_base(&self) -> DeserializedBaseAccountConfig; fn to_base(&self) -> DeserializedBaseAccountConfig;
@ -45,7 +45,7 @@ macro_rules! make_account_config {
pub signature: Option<String>, pub signature: Option<String>,
/// Overrides the signature delimiter for this account. /// Overrides the signature delimiter for this account.
pub signature_delimiter: Option<String>, pub signature_delimiter: Option<String>,
/// Overrides the default page size for this account. /// Overrides the default page size for this account.
pub default_page_size: Option<usize>, pub default_page_size: Option<usize>,
/// Overrides the notify command for this account. /// Overrides the notify command for this account.
pub notify_cmd: Option<String>, pub notify_cmd: Option<String>,
@ -56,6 +56,10 @@ macro_rules! make_account_config {
/// Represents the text/plain format as defined in the /// Represents the text/plain format as defined in the
/// [RFC2646](https://www.ietf.org/rfc/rfc2646.txt) /// [RFC2646](https://www.ietf.org/rfc/rfc2646.txt)
pub format: Option<Format>, pub format: Option<Format>,
/// Represents the default headers displayed at the top of
/// the read message.
#[serde(default)]
pub read_headers: Vec<String>,
/// Makes this account the default one. /// Makes this account the default one.
pub default: Option<bool>, pub default: Option<bool>,
@ -84,6 +88,9 @@ macro_rules! make_account_config {
#[serde(default)] #[serde(default)]
pub mailboxes: HashMap<String, String>, pub mailboxes: HashMap<String, String>,
/// Represents hooks.
pub hooks: Option<Hooks>,
$(pub $element: $ty),* $(pub $element: $ty),*
} }
@ -99,6 +106,7 @@ macro_rules! make_account_config {
notify_query: self.notify_query.clone(), notify_query: self.notify_query.clone(),
watch_cmds: self.watch_cmds.clone(), watch_cmds: self.watch_cmds.clone(),
format: self.format.clone(), format: self.format.clone(),
read_headers: self.read_headers.clone(),
default: self.default.clone(), default: self.default.clone(),
email: self.email.clone(), email: self.email.clone(),
@ -114,6 +122,7 @@ macro_rules! make_account_config {
pgp_decrypt_cmd: self.pgp_decrypt_cmd.clone(), pgp_decrypt_cmd: self.pgp_decrypt_cmd.clone(),
mailboxes: self.mailboxes.clone(), mailboxes: self.mailboxes.clone(),
hooks: self.hooks.clone(),
} }
} }
} }

View file

@ -23,7 +23,7 @@ pub struct DeserializedConfig {
pub downloads_dir: Option<PathBuf>, pub downloads_dir: Option<PathBuf>,
/// Represents the signature of the user. /// Represents the signature of the user.
pub signature: Option<String>, pub signature: Option<String>,
/// Overrides the default signature delimiter "`--\n `". /// Overrides the default signature delimiter "`-- \n`".
pub signature_delimiter: Option<String>, pub signature_delimiter: Option<String>,
/// Represents the default page size for listings. /// Represents the default page size for listings.
pub default_page_size: Option<usize>, pub default_page_size: Option<usize>,

7
src/config/hooks.rs Normal file
View file

@ -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<String>,
}

View file

@ -126,6 +126,9 @@ pub mod config {
pub mod format; pub mod format;
pub use format::*; pub use format::*;
pub mod hooks;
pub use hooks::*;
} }
pub mod compl; pub mod compl;

View file

@ -221,8 +221,17 @@ fn main() -> Result<()> {
Some(msg_args::Cmd::Move(seq, mbox_dst)) => { Some(msg_args::Cmd::Move(seq, mbox_dst)) => {
return msg_handlers::move_(seq, mbox, mbox_dst, &mut printer, backend); return msg_handlers::move_(seq, mbox, mbox_dst, &mut printer, backend);
} }
Some(msg_args::Cmd::Read(seq, text_mime, raw)) => { Some(msg_args::Cmd::Read(seq, text_mime, raw, headers)) => {
return msg_handlers::read(seq, text_mime, raw, mbox, &mut printer, backend); 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)) => { Some(msg_args::Cmd::Reply(seq, all, attachment_paths, encrypt)) => {
return msg_handlers::reply( return msg_handlers::reply(

View file

@ -47,11 +47,11 @@ mod tests {
#[test] #[test]
fn it_should_list_mboxes() { fn it_should_list_mboxes() {
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
struct StringWritter { struct StringWriter {
content: String, content: String,
} }
impl io::Write for StringWritter { impl io::Write for StringWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> { fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.content self.content
.push_str(&String::from_utf8(buf.to_vec()).unwrap()); .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 { fn supports_color(&self) -> bool {
false false
} }
@ -78,11 +78,11 @@ mod tests {
} }
} }
impl WriteColor for StringWritter {} impl WriteColor for StringWriter {}
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct PrinterServiceTest { struct PrinterServiceTest {
pub writter: StringWritter, pub writer: StringWriter,
} }
impl PrinterService for PrinterServiceTest { impl PrinterService for PrinterServiceTest {
@ -91,10 +91,16 @@ mod tests {
data: Box<T>, data: Box<T>,
opts: PrintTableOpts, opts: PrintTableOpts,
) -> Result<()> { ) -> Result<()> {
data.print_table(&mut self.writter, opts)?; data.print_table(&mut self.writer, opts)?;
Ok(()) Ok(())
} }
fn print<T: serde::Serialize + Print>(&mut self, _data: T) -> Result<()> { fn print_str<T: Debug + Print>(&mut self, _data: T) -> Result<()> {
unimplemented!()
}
fn print_struct<T: Debug + Print + serde::Serialize>(
&mut self,
_data: T,
) -> Result<()> {
unimplemented!() unimplemented!()
} }
fn is_json(&self) -> bool { fn is_json(&self) -> bool {
@ -109,21 +115,23 @@ mod tests {
unimplemented!(); unimplemented!();
} }
fn get_mboxes(&mut self) -> Result<Box<dyn Mboxes>> { fn get_mboxes(&mut self) -> Result<Box<dyn Mboxes>> {
Ok(Box::new(ImapMboxes(vec![ Ok(Box::new(ImapMboxes {
ImapMbox { mboxes: vec![
delim: "/".into(), ImapMbox {
name: "INBOX".into(), delim: "/".into(),
attrs: ImapMboxAttrs(vec![ImapMboxAttr::NoSelect]), name: "INBOX".into(),
}, attrs: ImapMboxAttrs(vec![ImapMboxAttr::NoSelect]),
ImapMbox { },
delim: "/".into(), ImapMbox {
name: "Sent".into(), delim: "/".into(),
attrs: ImapMboxAttrs(vec![ name: "Sent".into(),
ImapMboxAttr::NoInferiors, attrs: ImapMboxAttrs(vec![
ImapMboxAttr::Custom("HasNoChildren".into()), ImapMboxAttr::NoInferiors,
]), ImapMboxAttr::Custom("HasNoChildren".into()),
}, ]),
]))) },
],
}))
} }
fn del_mbox(&mut self, _: &str) -> Result<()> { fn del_mbox(&mut self, _: &str) -> Result<()> {
unimplemented!(); unimplemented!();
@ -181,7 +189,7 @@ mod tests {
"/ │Sent │NoInferiors, HasNoChildren \n", "/ │Sent │NoInferiors, HasNoChildren \n",
"\n" "\n"
], ],
printer.writter.content printer.writer.content
); );
} }
} }

View file

@ -16,7 +16,7 @@ pub fn add<'a, P: PrinterService, B: Backend<'a> + ?Sized>(
backend: Box<&'a mut B>, backend: Box<&'a mut B>,
) -> Result<()> { ) -> Result<()> {
backend.add_flags(mbox, seq_range, flags)?; backend.add_flags(mbox, seq_range, flags)?;
printer.print(format!( printer.print_struct(format!(
"Flag(s) {:?} successfully added to message(s) {:?}", "Flag(s) {:?} successfully added to message(s) {:?}",
flags, seq_range flags, seq_range
)) ))
@ -32,7 +32,7 @@ pub fn remove<'a, P: PrinterService, B: Backend<'a> + ?Sized>(
backend: Box<&'a mut B>, backend: Box<&'a mut B>,
) -> Result<()> { ) -> Result<()> {
backend.del_flags(mbox, seq_range, flags)?; backend.del_flags(mbox, seq_range, flags)?;
printer.print(format!( printer.print_struct(format!(
"Flag(s) {:?} successfully removed from message(s) {:?}", "Flag(s) {:?} successfully removed from message(s) {:?}",
flags, seq_range flags, seq_range
)) ))
@ -48,7 +48,7 @@ pub fn set<'a, P: PrinterService, B: Backend<'a> + ?Sized>(
backend: Box<&'a mut B>, backend: Box<&'a mut B>,
) -> Result<()> { ) -> Result<()> {
backend.set_flags(mbox, seq_range, flags)?; backend.set_flags(mbox, seq_range, flags)?;
printer.print(format!( printer.print_struct(format!(
"Flag(s) {:?} successfully set for message(s) {:?}", "Flag(s) {:?} successfully set for message(s) {:?}",
flags, seq_range flags, seq_range
)) ))

View file

@ -25,6 +25,7 @@ type AttachmentPaths<'a> = Vec<&'a str>;
type MaxTableWidth = Option<usize>; type MaxTableWidth = Option<usize>;
type Encrypt = bool; type Encrypt = bool;
type Criteria = String; type Criteria = String;
type Headers<'a> = Vec<&'a str>;
/// Message commands. /// Message commands.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
@ -35,7 +36,7 @@ pub enum Cmd<'a> {
Forward(Seq<'a>, AttachmentPaths<'a>, Encrypt), Forward(Seq<'a>, AttachmentPaths<'a>, Encrypt),
List(MaxTableWidth, Option<PageSize>, Page), List(MaxTableWidth, Option<PageSize>, Page),
Move(Seq<'a>, Mbox<'a>), 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), Reply(Seq<'a>, All, AttachmentPaths<'a>, Encrypt),
Save(RawMsg<'a>), Save(RawMsg<'a>),
Search(Query, MaxTableWidth, Option<PageSize>, Page), Search(Query, MaxTableWidth, Option<PageSize>, Page),
@ -121,7 +122,9 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result<Option<Cmd<'a>>> {
debug!("text mime: {}", mime); debug!("text mime: {}", mime);
let raw = m.is_present("raw"); let raw = m.is_present("raw");
debug!("raw: {}", 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") { if let Some(m) = m.subcommand_matches("reply") {
@ -318,7 +321,7 @@ fn page_arg<'a>() -> Arg<'a, 'a> {
} }
/// Message attachment argument. /// Message attachment argument.
pub fn attachment_arg<'a>() -> Arg<'a, 'a> { pub fn attachments_arg<'a>() -> Arg<'a, 'a> {
Arg::with_name("attachments") Arg::with_name("attachments")
.help("Adds attachment to the message") .help("Adds attachment to the message")
.short("a") .short("a")
@ -327,6 +330,16 @@ pub fn attachment_arg<'a>() -> Arg<'a, 'a> {
.multiple(true) .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. /// Message encrypt argument.
pub fn encrypt_arg<'a>() -> Arg<'a, 'a> { pub fn encrypt_arg<'a>() -> Arg<'a, 'a> {
Arg::with_name("encrypt") Arg::with_name("encrypt")
@ -399,7 +412,7 @@ pub fn subcmds<'a>() -> Vec<App<'a, 'a>> {
), ),
SubCommand::with_name("write") SubCommand::with_name("write")
.about("Writes a new message") .about("Writes a new message")
.arg(attachment_arg()) .arg(attachments_arg())
.arg(encrypt_arg()), .arg(encrypt_arg()),
SubCommand::with_name("send") SubCommand::with_name("send")
.about("Sends a raw message") .about("Sends a raw message")
@ -424,19 +437,20 @@ pub fn subcmds<'a>() -> Vec<App<'a, 'a>> {
.help("Reads raw message") .help("Reads raw message")
.long("raw") .long("raw")
.short("r"), .short("r"),
), )
.arg(headers_arg()),
SubCommand::with_name("reply") SubCommand::with_name("reply")
.aliases(&["rep", "r"]) .aliases(&["rep", "r"])
.about("Answers to a message") .about("Answers to a message")
.arg(seq_arg()) .arg(seq_arg())
.arg(reply_all_arg()) .arg(reply_all_arg())
.arg(attachment_arg()) .arg(attachments_arg())
.arg(encrypt_arg()), .arg(encrypt_arg()),
SubCommand::with_name("forward") SubCommand::with_name("forward")
.aliases(&["fwd", "f"]) .aliases(&["fwd", "f"])
.about("Forwards a message") .about("Forwards a message")
.arg(seq_arg()) .arg(seq_arg())
.arg(attachment_arg()) .arg(attachments_arg())
.arg(encrypt_arg()), .arg(encrypt_arg()),
SubCommand::with_name("copy") SubCommand::with_name("copy")
.aliases(&["cp", "c"]) .aliases(&["cp", "c"])

View file

@ -1,11 +1,19 @@
use ammonia; use ammonia;
use anyhow::{anyhow, Context, Error, Result}; 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 html_escape;
use lettre::message::{header::ContentType, Attachment, MultiPart, SinglePart}; use lettre::message::{header::ContentType, Attachment, MultiPart, SinglePart};
use log::{debug, info, trace, warn}; use log::{info, trace, warn};
use regex::Regex; 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 uuid::Uuid;
use crate::{ use crate::{
@ -13,7 +21,7 @@ use crate::{
config::{AccountConfig, DEFAULT_DRAFT_FOLDER, DEFAULT_SENT_FOLDER, DEFAULT_SIG_DELIM}, config::{AccountConfig, DEFAULT_DRAFT_FOLDER, DEFAULT_SENT_FOLDER, DEFAULT_SIG_DELIM},
msg::{ msg::{
from_addrs_to_sendable_addrs, from_addrs_to_sendable_mbox, from_slice_to_addrs, msg_utils, 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, output::PrinterService,
smtp::SmtpService, smtp::SmtpService,
@ -24,7 +32,7 @@ use crate::{
}; };
/// Representation of a message. /// Representation of a message.
#[derive(Debug, Default)] #[derive(Debug, Clone, Default)]
pub struct Msg { pub struct Msg {
/// The sequence number of the message. /// The sequence number of the message.
/// ///
@ -41,11 +49,12 @@ pub struct Msg {
pub bcc: Option<Addrs>, pub bcc: Option<Addrs>,
pub in_reply_to: Option<String>, pub in_reply_to: Option<String>,
pub message_id: Option<String>, pub message_id: Option<String>,
pub headers: HashMap<String, String>,
/// The internal date of the message. /// The internal date of the message.
/// ///
/// [RFC3501]: https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.3 /// [RFC3501]: https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.3
pub date: Option<DateTime<FixedOffset>>, pub date: Option<DateTime<Local>>,
pub parts: Parts, pub parts: Parts,
pub encrypt: bool, pub encrypt: bool,
@ -64,8 +73,9 @@ impl Msg {
.collect() .collect()
} }
/// Folds string body from all plain text parts into a single string body. If no plain text /// Folds string body from all plain text parts into a single
/// parts are found, HTML parts are used instead. The result is sanitized (all HTML markup is /// string body. If no plain text parts are found, HTML parts are
/// used instead. The result is sanitized (all HTML markup is
/// removed). /// removed).
pub fn fold_text_plain_parts(&self) -> String { pub fn fold_text_plain_parts(&self) -> String {
let (plain, html) = self.parts.iter().fold( 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 { fn fold_text_html_parts(&self) -> String {
let text_parts = self let text_parts = self
.parts .parts
@ -151,8 +162,9 @@ impl Msg {
text_parts text_parts
} }
/// Fold string body from all text parts into a single string body. The mime allows users to /// Fold string body from all text parts into a single string
/// choose between plain text parts and html text parts. /// 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 { pub fn fold_text_parts(&self, text_mime: &str) -> String {
if text_mime == "html" { if text_mime == "html" {
self.fold_text_html_parts() self.fold_text_html_parts()
@ -164,22 +176,25 @@ impl Msg {
pub fn into_reply(mut self, all: bool, account: &AccountConfig) -> Result<Self> { pub fn into_reply(mut self, all: bool, account: &AccountConfig) -> Result<Self> {
let account_addr = account.address()?; let account_addr = account.address()?;
// Message-Id
self.message_id = None;
// In-Reply-To // In-Reply-To
self.in_reply_to = self.message_id.to_owned(); self.in_reply_to = self.message_id.to_owned();
// Message-Id
self.message_id = None;
// To // To
let addrs = self let addrs = self
.reply_to .reply_to
.as_deref() .as_deref()
.or_else(|| self.from.as_deref()) .or_else(|| self.from.as_deref())
.map(|addrs| { .map(|addrs| {
addrs addrs.iter().cloned().filter(|addr| match addr {
.clone() Addr::Group(_) => false,
.into_iter() Addr::Single(a) => match &account_addr {
.filter(|addr| addr != &account_addr) Addr::Group(_) => false,
Addr::Single(b) => a.addr != b.addr,
},
})
}); });
if all { if all {
self.to = addrs.map(|addrs| addrs.collect::<Vec<_>>().into()); self.to = addrs.map(|addrs| addrs.collect::<Vec<_>>().into());
@ -189,18 +204,35 @@ impl Msg {
.map(|addr| vec![addr].into()); .map(|addr| vec![addr].into());
} }
// Cc & Bcc // Cc
if !all { self.cc = if all {
self.cc = None; self.cc.as_deref().map(|addrs| {
self.bcc = None; 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::<Vec<_>>()
.into()
})
} else {
None
};
// Bcc
self.bcc = None;
// Body // Body
let plain_content = { let plain_content = {
let date = self let date = self
.date .date
.as_ref() .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()); .unwrap_or_else(|| "unknown date".into());
let sender = self let sender = self
.reply_to .reply_to
@ -339,15 +371,18 @@ impl Msg {
loop { loop {
match choice::post_edit() { match choice::post_edit() {
Ok(PostEditChoice::Send) => { 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 let sent_folder = account
.mailboxes .mailboxes
.get("sent") .get("sent")
.map(|s| s.as_str()) .map(|s| s.as_str())
.unwrap_or(DEFAULT_SENT_FOLDER); .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()?; msg_utils::remove_local_draft()?;
printer.print("Message successfully sent")?; printer.print_struct("Done!")?;
break; break;
} }
Ok(PostEditChoice::Edit) => { Ok(PostEditChoice::Edit) => {
@ -355,7 +390,7 @@ impl Msg {
continue; continue;
} }
Ok(PostEditChoice::LocalDraft) => { Ok(PostEditChoice::LocalDraft) => {
printer.print("Message successfully saved locally")?; printer.print_struct("Message successfully saved locally")?;
break; break;
} }
Ok(PostEditChoice::RemoteDraft) => { Ok(PostEditChoice::RemoteDraft) => {
@ -367,7 +402,8 @@ impl Msg {
.unwrap_or(DEFAULT_DRAFT_FOLDER); .unwrap_or(DEFAULT_DRAFT_FOLDER);
backend.add_msg(&draft_folder, tpl.as_bytes(), "seen draft")?; backend.add_msg(&draft_folder, tpl.as_bytes(), "seen draft")?;
msg_utils::remove_local_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; break;
} }
Ok(PostEditChoice::Discard) => { Ok(PostEditChoice::Discard) => {
@ -413,24 +449,19 @@ impl Msg {
} }
pub fn merge_with(&mut self, msg: 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() { if msg.in_reply_to.is_some() {
self.to = msg.to; self.in_reply_to = msg.in_reply_to;
}
if msg.cc.is_some() {
self.cc = msg.cc;
}
if msg.bcc.is_some() {
self.bcc = msg.bcc;
}
if !msg.subject.is_empty() {
self.subject = msg.subject;
} }
for part in msg.parts.0.into_iter() { for part in msg.parts.0.into_iter() {
@ -623,24 +654,18 @@ impl Msg {
parsed_mail: mailparse::ParsedMail<'_>, parsed_mail: mailparse::ParsedMail<'_>,
config: &AccountConfig, config: &AccountConfig,
) -> Result<Self> { ) -> Result<Self> {
info!("begin: building message from parsed mail"); trace!(">> build message from parsed mail");
trace!("parsed mail: {:?}", parsed_mail); trace!("parsed mail: {:?}", parsed_mail);
let mut msg = Msg::default(); let mut msg = Msg::default();
debug!("parsing headers");
for header in parsed_mail.get_headers() { for header in parsed_mail.get_headers() {
trace!(">> parse header {:?}", header);
let key = header.get_key(); let key = header.get_key();
debug!("header key: {:?}", key); trace!("header key: {:?}", key);
let val = header.get_value(); let val = header.get_value();
let val = String::from_utf8(header.get_value_raw().to_vec()) trace!("header value: {:?}", val);
.map(|val| val.trim().to_string())
.context(format!(
"cannot decode value {:?} from header {:?}",
key, val
))?;
debug!("header value: {:?}", val);
match key.to_lowercase().as_str() { match key.to_lowercase().as_str() {
"message-id" => msg.message_id = Some(val), "message-id" => msg.message_id = Some(val),
@ -648,16 +673,15 @@ impl Msg {
"subject" => { "subject" => {
msg.subject = val; msg.subject = val;
} }
"date" => { "date" => match mailparse::dateparse(&val) {
msg.date = DateTime::parse_from_rfc2822( Ok(timestamp) => {
val.split_at(val.find(" (").unwrap_or_else(|| val.len())).0, msg.date = Some(Utc.timestamp(timestamp, 0).with_timezone(&Local))
) }
.map_err(|err| { Err(err) => {
warn!("cannot parse message date {:?}, skipping it", val); warn!("cannot parse message date {:?}, skipping it", val);
err warn!("{}", err);
}) }
.ok(); },
}
"from" => { "from" => {
msg.from = from_slice_to_addrs(val) msg.from = from_slice_to_addrs(val)
.context(format!("cannot parse header {:?}", key))? .context(format!("cannot parse header {:?}", key))?
@ -678,24 +702,133 @@ impl Msg {
msg.bcc = from_slice_to_addrs(val) msg.bcc = from_slice_to_addrs(val)
.context(format!("cannot parse header {:?}", key))? .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) msg.parts = Parts::from_parsed_mail(config, &parsed_mail)
.context("cannot parsed message mime parts")?; .context("cannot parsed message mime parts")?;
trace!("message: {:?}", msg); trace!("message: {:?}", msg);
info!("end: building message from parsed mail"); info!("<< build message from parsed mail");
Ok(msg) 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<String> {
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<lettre::address::Envelope> for Msg { impl TryInto<lettre::address::Envelope> for Msg {
type Error = Error; type Error = Error;
fn try_into(self) -> Result<lettre::address::Envelope> { fn try_into(self) -> Result<lettre::address::Envelope> {
let from = match self.from.and_then(|addrs| addrs.extract_single_info()) { (&self).try_into()
}
}
impl TryInto<lettre::address::Envelope> for &Msg {
type Error = Error;
fn try_into(self) -> Result<lettre::address::Envelope> {
let from = match self
.from
.as_ref()
.and_then(|addrs| addrs.clone().extract_single_info())
{
Some(addr) => addr.addr.parse().map(Some), Some(addr) => addr.addr.parse().map(Some),
None => Ok(None), None => Ok(None),
}?; }?;
@ -707,3 +840,234 @@ impl TryInto<lettre::address::Envelope> for Msg {
Ok(lettre::address::Envelope::new(from, to).context("cannot create envelope")?) 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\" <test-account@local>"
);
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\" <test-sender-to-reply@local>"
);
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\" <test-sender-1@local>, \"Sender 2\" <test-sender-2@local>"
);
assert_eq!(
msg.cc.unwrap().to_string(),
"\"Sender 1\" <test-sender-1@local>, \"Sender 2\" <test-sender-2@local>"
);
}
#[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("<message-id>".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\" <test@local>\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\" <test@local>\nMessage-Id: <message-id>\n\nhello, world!",
msg.to_readable_string("plain", vec!["cc", "message-ID"], &config)
.unwrap()
);
}
}

View file

@ -8,7 +8,6 @@ use log::{debug, info, trace};
use mailparse::addrparse; use mailparse::addrparse;
use std::{ use std::{
borrow::Cow, borrow::Cow,
convert::TryInto,
fs, fs,
io::{self, BufRead}, io::{self, BufRead},
}; };
@ -32,21 +31,29 @@ pub fn attachments<'a, P: PrinterService, B: Backend<'a> + ?Sized>(
) -> Result<()> { ) -> Result<()> {
let attachments = backend.get_msg(mbox, seq)?.attachments(); let attachments = backend.get_msg(mbox, seq)?.attachments();
let attachments_len = attachments.len(); let attachments_len = attachments.len();
debug!(
r#"{} attachment(s) found for message "{}""#, if attachments_len == 0 {
attachments_len, seq 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 { for attachment in attachments {
let file_path = config.get_download_file_path(&attachment.filename)?; 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) fs::write(&file_path, &attachment.content)
.context(format!("cannot download attachment {:?}", file_path))?; .context(format!("cannot download attachment {:?}", file_path))?;
} }
printer.print(format!( printer.print_struct(format!(
"{} attachment(s) successfully downloaded to {:?}", "Attachment{} successfully downloaded to {:?}",
attachments_len, config.downloads_dir 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>, backend: Box<&mut B>,
) -> Result<()> { ) -> Result<()> {
backend.copy_msg(mbox_src, mbox_dst, seq)?; backend.copy_msg(mbox_src, mbox_dst, seq)?;
printer.print(format!( printer.print_struct(format!(
r#"Message {} successfully copied to folder "{}""#, r#"Message {} successfully copied to folder "{}""#,
seq, mbox_dst seq, mbox_dst
)) ))
@ -73,7 +80,7 @@ pub fn delete<'a, P: PrinterService, B: Backend<'a> + ?Sized>(
backend: Box<&'a mut B>, backend: Box<&'a mut B>,
) -> Result<()> { ) -> Result<()> {
backend.del_msg(mbox, seq)?; 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. /// 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>, backend: Box<&'a mut B>,
) -> Result<()> { ) -> Result<()> {
backend.move_msg(mbox_src, mbox_dst, seq)?; backend.move_msg(mbox_src, mbox_dst, seq)?;
printer.print(format!( printer.print_struct(format!(
r#"Message {} successfully moved to folder "{}""#, r#"Message {} successfully moved to folder "{}""#,
seq, mbox_dst seq, mbox_dst
)) ))
@ -200,19 +207,20 @@ pub fn read<'a, P: PrinterService, B: Backend<'a> + ?Sized>(
seq: &str, seq: &str,
text_mime: &str, text_mime: &str,
raw: bool, raw: bool,
headers: Vec<&str>,
mbox: &str, mbox: &str,
config: &AccountConfig,
printer: &mut P, printer: &mut P,
backend: Box<&'a mut B>, backend: Box<&'a mut B>,
) -> Result<()> { ) -> Result<()> {
let msg = backend.get_msg(mbox, seq)?; 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. // Emails don't always have valid utf8. Using "lossy" to display what we can.
String::from_utf8_lossy(&msg.raw).into_owned() String::from_utf8_lossy(&msg.raw).into_owned()
} else { } else {
msg.fold_text_parts(text_mime) msg.to_readable_string(text_mime, headers, config)?
}; })
printer.print(msg)
} }
/// Reply to the given message UID. /// 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") .join("\r\n")
}; };
trace!("raw message: {:?}", raw_msg); trace!("raw message: {:?}", raw_msg);
let envelope: lettre::address::Envelope = Msg::from_tpl(&raw_msg)?.try_into()?; let msg = Msg::from_tpl(&raw_msg)?;
trace!("envelope: {:?}", envelope); smtp.send(&config, &msg)?;
smtp.send_raw_msg(&envelope, raw_msg.as_bytes())?;
backend.add_msg(&sent_folder, raw_msg.as_bytes(), "seen")?; backend.add_msg(&sent_folder, raw_msg.as_bytes(), "seen")?;
Ok(()) Ok(())
} }

View file

@ -3,7 +3,7 @@ use log::{debug, trace};
use std::{env, fs, path::PathBuf}; use std::{env, fs, path::PathBuf};
pub fn local_draft_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); trace!("local draft path: {:?}", path);
path path
} }

View file

@ -183,13 +183,13 @@ pub fn subcmds<'a>() -> Vec<App<'a, 'a>> {
.subcommand( .subcommand(
SubCommand::with_name("save") SubCommand::with_name("save")
.about("Saves a message based on the given template") .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)), .arg(Arg::with_name("template").raw(true)),
) )
.subcommand( .subcommand(
SubCommand::with_name("send") SubCommand::with_name("send")
.about("Sends a message based on the given template") .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)), .arg(Arg::with_name("template").raw(true)),
)] )]
} }

View file

@ -21,7 +21,7 @@ pub fn new<'a, P: PrinterService>(
printer: &'a mut P, printer: &'a mut P,
) -> Result<()> { ) -> Result<()> {
let tpl = Msg::default().to_tpl(opts, account)?; let tpl = Msg::default().to_tpl(opts, account)?;
printer.print(tpl) printer.print_struct(tpl)
} }
/// Generate a reply message template. /// Generate a reply message template.
@ -38,7 +38,7 @@ pub fn reply<'a, P: PrinterService, B: Backend<'a> + ?Sized>(
.get_msg(mbox, seq)? .get_msg(mbox, seq)?
.into_reply(all, config)? .into_reply(all, config)?
.to_tpl(opts, config)?; .to_tpl(opts, config)?;
printer.print(tpl) printer.print_struct(tpl)
} }
/// Generate a forward message template. /// Generate a forward message template.
@ -54,7 +54,7 @@ pub fn forward<'a, P: PrinterService, B: Backend<'a> + ?Sized>(
.get_msg(mbox, seq)? .get_msg(mbox, seq)?
.into_forward(config)? .into_forward(config)?
.to_tpl(opts, config)?; .to_tpl(opts, config)?;
printer.print(tpl) printer.print_struct(tpl)
} }
/// Saves a message based on a template. /// 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 msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?;
let raw_msg = msg.into_sendable_msg(config)?.formatted(); let raw_msg = msg.into_sendable_msg(config)?.formatted();
backend.add_msg(mbox, &raw_msg, "seen")?; 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. /// 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") .join("\n")
}; };
let msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?; let msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?;
let sent_msg = smtp.send_msg(account, &msg)?; let sent_msg = smtp.send(account, &msg)?;
backend.add_msg(mbox, &sent_msg.formatted(), "seen")?; backend.add_msg(mbox, &sent_msg, "seen")?;
printer.print("Template successfully sent") printer.print_struct("Template successfully sent")
} }

View file

@ -1,6 +1,9 @@
use anyhow::Result; use anyhow::{anyhow, Context, Result};
use log::debug; use log::debug;
use std::process::Command; use std::{
io::prelude::*,
process::{Command, Stdio},
};
/// TODO: move this in a more approriate place. /// TODO: move this in a more approriate place.
pub fn run_cmd(cmd: &str) -> Result<String> { pub fn run_cmd(cmd: &str) -> Result<String> {
@ -14,3 +17,25 @@ pub fn run_cmd(cmd: &str) -> Result<String> {
Ok(String::from_utf8(output.stdout)?) Ok(String::from_utf8(output.stdout)?)
} }
pub fn pipe_cmd(cmd: &str, data: &[u8]) -> Result<Vec<u8>> {
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)
}

View file

@ -1,23 +1,19 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use log::error;
use crate::output::WriteColor; use crate::output::WriteColor;
pub trait Print { pub trait Print {
fn print(&self, writter: &mut dyn WriteColor) -> Result<()>; fn print(&self, writer: &mut dyn WriteColor) -> Result<()>;
} }
impl Print for &str { impl Print for &str {
fn print(&self, writter: &mut dyn WriteColor) -> Result<()> { fn print(&self, writer: &mut dyn WriteColor) -> Result<()> {
writeln!(writter, "{}", self).with_context(|| { writeln!(writer, "{}", self).context("cannot write string to writer")
error!(r#"cannot write string to writter: "{}""#, self);
"cannot write string to writter"
})
} }
} }
impl Print for String { impl Print for String {
fn print(&self, writter: &mut dyn WriteColor) -> Result<()> { fn print(&self, writer: &mut dyn WriteColor) -> Result<()> {
self.as_str().print(writter) self.as_str().print(writer)
} }
} }

View file

@ -9,7 +9,7 @@ pub trait WriteColor: io::Write + termcolor::WriteColor {}
impl WriteColor for StandardStream {} impl WriteColor for StandardStream {}
pub trait PrintTable { 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> { pub struct PrintTableOpts<'a> {

View file

@ -9,8 +9,9 @@ use termcolor::{ColorChoice, StandardStream};
use crate::output::{OutputFmt, OutputJson, Print, PrintTable, PrintTableOpts, WriteColor}; use crate::output::{OutputFmt, OutputJson, Print, PrintTable, PrintTableOpts, WriteColor};
pub trait PrinterService { pub trait PrinterService {
fn print<T: Debug + Print + serde::Serialize>(&mut self, data: T) -> Result<()>; fn print_str<T: Debug + Print>(&mut self, data: T) -> Result<()>;
fn print_table<T: fmt::Debug + erased_serde::Serialize + PrintTable + ?Sized>( fn print_struct<T: Debug + Print + serde::Serialize>(&mut self, data: T) -> Result<()>;
fn print_table<T: Debug + erased_serde::Serialize + PrintTable + ?Sized>(
&mut self, &mut self,
data: Box<T>, data: Box<T>,
opts: PrintTableOpts, opts: PrintTableOpts,
@ -19,16 +20,23 @@ pub trait PrinterService {
} }
pub struct StdoutPrinter { pub struct StdoutPrinter {
pub writter: Box<dyn WriteColor>, pub writer: Box<dyn WriteColor>,
pub fmt: OutputFmt, pub fmt: OutputFmt,
} }
impl PrinterService for StdoutPrinter { impl PrinterService for StdoutPrinter {
fn print<T: Debug + Print + serde::Serialize>(&mut self, data: T) -> Result<()> { fn print_str<T: Debug + Print>(&mut self, data: T) -> Result<()> {
match self.fmt { match self.fmt {
OutputFmt::Plain => data.print(self.writter.as_mut()), OutputFmt::Plain => data.print(self.writer.as_mut()),
OutputFmt::Json => serde_json::to_writer(self.writter.as_mut(), &OutputJson::new(data)) OutputFmt::Json => Ok(()),
.context("cannot write JSON to writter"), }
}
fn print_struct<T: Debug + Print + serde::Serialize>(&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, opts: PrintTableOpts,
) -> Result<()> { ) -> Result<()> {
match self.fmt { 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 => { 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 <dyn erased_serde::Serializer>::erase(json); let ser = &mut <dyn erased_serde::Serializer>::erase(json);
data.erased_serialize(ser).unwrap(); data.erased_serialize(ser).unwrap();
Ok(()) Ok(())
@ -55,7 +63,7 @@ impl PrinterService for StdoutPrinter {
impl From<OutputFmt> for StdoutPrinter { impl From<OutputFmt> for StdoutPrinter {
fn from(fmt: OutputFmt) -> Self { 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. // Colors should be deactivated if the terminal is not a tty.
ColorChoice::Never ColorChoice::Never
} else { } else {
@ -67,8 +75,8 @@ impl From<OutputFmt> for StdoutPrinter {
// [doc]: https://github.com/BurntSushi/termcolor#automatic-color-selection // [doc]: https://github.com/BurntSushi/termcolor#automatic-color-selection
ColorChoice::Auto ColorChoice::Auto
}); });
let writter = Box::new(writter); let writer = Box::new(writer);
Self { writter, fmt } Self { writer, fmt }
} }
} }

View file

@ -1,4 +1,4 @@
use anyhow::Result; use anyhow::{Context, Result};
use lettre::{ use lettre::{
self, self,
transport::smtp::{ transport::smtp::{
@ -7,13 +7,12 @@ use lettre::{
}, },
Transport, 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 { pub trait SmtpService {
fn send_msg(&mut self, account: &AccountConfig, msg: &Msg) -> Result<lettre::Message>; fn send(&mut self, account: &AccountConfig, msg: &Msg) -> Result<Vec<u8>>;
fn send_raw_msg(&mut self, envelope: &lettre::address::Envelope, msg: &[u8]) -> Result<()>;
} }
pub struct LettreService<'a> { pub struct LettreService<'a> {
@ -21,7 +20,7 @@ pub struct LettreService<'a> {
transport: Option<SmtpTransport>, transport: Option<SmtpTransport>,
} }
impl<'a> LettreService<'a> { impl LettreService<'_> {
fn transport(&mut self) -> Result<&SmtpTransport> { fn transport(&mut self) -> Result<&SmtpTransport> {
if let Some(ref transport) = self.transport { if let Some(ref transport) = self.transport {
Ok(transport) Ok(transport)
@ -55,24 +54,29 @@ impl<'a> LettreService<'a> {
} }
} }
impl<'a> SmtpService for LettreService<'a> { impl SmtpService for LettreService<'_> {
fn send_msg(&mut self, account: &AccountConfig, msg: &Msg) -> Result<lettre::Message> { fn send(&mut self, account: &AccountConfig, msg: &Msg) -> Result<Vec<u8>> {
debug!("sending message…"); let mut raw_msg = msg.into_sendable_msg(account)?.formatted();
let sendable_msg = msg.into_sendable_msg(account)?;
self.transport()?.send(&sendable_msg)?;
Ok(sendable_msg)
}
fn send_raw_msg(&mut self, envelope: &lettre::address::Envelope, msg: &[u8]) -> Result<()> { let envelope: lettre::address::Envelope =
debug!("sending raw message…"); if let Some(cmd) = account.hooks.pre_send.as_deref() {
self.transport()?.send_raw(envelope, msg)?; for cmd in cmd.split('|') {
Ok(()) 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> { impl<'a> From<&'a AccountConfig> for LettreService<'a> {
fn from(account: &'a AccountConfig) -> Self { fn from(account: &'a AccountConfig) -> Self {
debug!("init SMTP service");
Self { Self {
account, account,
transport: None, transport: None,

View file

@ -127,14 +127,14 @@ impl Cell {
/// Makes the cell printable. /// Makes the cell printable.
impl Print for Cell { 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 // Applies colors to the cell
writter writer
.set_color(&self.style) .set_color(&self.style)
.context(format!(r#"cannot apply colors to cell "{}""#, self.value))?; .context(format!(r#"cannot apply colors to cell "{}""#, self.value))?;
// Writes the colorized cell to stdout // 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. /// Defines the row template.
fn row(&self) -> Row; fn row(&self) -> Row;
/// Writes the table to the writter. /// Writes the table to the writer.
fn print(writter: &mut dyn WriteColor, items: &[Self], opts: PrintTableOpts) -> Result<()> { fn print(writer: &mut dyn WriteColor, items: &[Self], opts: PrintTableOpts) -> Result<()> {
let is_format_flowed = matches!(opts.format, Format::Flowed); let is_format_flowed = matches!(opts.format, Format::Flowed);
let max_width = match opts.format { let max_width = match opts.format {
Format::Fixed(width) => opts.max_width.unwrap_or(*width), Format::Fixed(width) => opts.max_width.unwrap_or(*width),
@ -202,7 +202,7 @@ where
for row in table.iter_mut() { for row in table.iter_mut() {
let mut glue = Cell::default(); let mut glue = Cell::default();
for (i, cell) in row.0.iter_mut().enumerate() { for (i, cell) in row.0.iter_mut().enumerate() {
glue.print(writter)?; glue.print(writer)?;
let table_is_overflowing = table_width > max_width; let table_is_overflowing = table_width > max_width;
if table_is_overflowing && !is_format_flowed && cell.is_shrinkable() { if table_is_overflowing && !is_format_flowed && cell.is_shrinkable() {
@ -256,10 +256,10 @@ where
trace!("number of spaces added to value: {}", spaces_count); trace!("number of spaces added to value: {}", spaces_count);
cell.value.push_str(&" ".repeat(spaces_count)); cell.value.push_str(&" ".repeat(spaces_count));
} }
cell.print(writter)?; cell.print(writer)?;
glue = Cell::new("").ansi_256(8); glue = Cell::new("").ansi_256(8);
} }
writeln!(writter)?; writeln!(writer)?;
} }
Ok(()) Ok(())
} }
@ -272,11 +272,11 @@ mod tests {
use super::*; use super::*;
#[derive(Debug, Default)] #[derive(Debug, Default)]
struct StringWritter { struct StringWriter {
content: String, content: String,
} }
impl io::Write for StringWritter { impl io::Write for StringWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> { fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.content self.content
.push_str(&String::from_utf8(buf.to_vec()).unwrap()); .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 { fn supports_color(&self) -> bool {
false false
} }
@ -303,7 +303,7 @@ mod tests {
} }
} }
impl WriteColor for StringWritter {} impl WriteColor for StringWriter {}
struct Item { struct Item {
id: u16, id: u16,
@ -338,16 +338,16 @@ mod tests {
} }
macro_rules! write_items { macro_rules! write_items {
($writter:expr, $($item:expr),*) => { ($writer:expr, $($item:expr),*) => {
Table::print($writter, &[$($item,)*], PrintTableOpts { format: &Format::Auto, max_width: Some(20) }).unwrap(); Table::print($writer, &[$($item,)*], PrintTableOpts { format: &Format::Auto, max_width: Some(20) }).unwrap();
}; };
} }
#[test] #[test]
fn row_smaller_than_head() { fn row_smaller_than_head() {
let mut writter = StringWritter::default(); let mut writer = StringWriter::default();
write_items![ write_items![
&mut writter, &mut writer,
Item::new(1, "a", "aa"), Item::new(1, "a", "aa"),
Item::new(2, "b", "bb"), Item::new(2, "b", "bb"),
Item::new(3, "c", "cc") Item::new(3, "c", "cc")
@ -359,14 +359,14 @@ mod tests {
"2 │b │bb \n", "2 │b │bb \n",
"3 │c │cc \n", "3 │c │cc \n",
]; ];
assert_eq!(expected, writter.content); assert_eq!(expected, writer.content);
} }
#[test] #[test]
fn row_bigger_than_head() { fn row_bigger_than_head() {
let mut writter = StringWritter::default(); let mut writer = StringWriter::default();
write_items![ write_items![
&mut writter, &mut writer,
Item::new(1, "a", "aa"), Item::new(1, "a", "aa"),
Item::new(2222, "bbbbb", "bbbbb"), Item::new(2222, "bbbbb", "bbbbb"),
Item::new(3, "c", "cc") Item::new(3, "c", "cc")
@ -378,11 +378,11 @@ mod tests {
"2222 │bbbbb │bbbbb \n", "2222 │bbbbb │bbbbb \n",
"3 │c │cc \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![ write_items![
&mut writter, &mut writer,
Item::new(1, "a", "aa"), Item::new(1, "a", "aa"),
Item::new(2222, "bbbbb", "bbbbb"), Item::new(2222, "bbbbb", "bbbbb"),
Item::new(3, "cccccc", "cc") Item::new(3, "cccccc", "cc")
@ -394,14 +394,14 @@ mod tests {
"2222 │bbbbb │bbbbb \n", "2222 │bbbbb │bbbbb \n",
"3 │cccccc │cc \n", "3 │cccccc │cc \n",
]; ];
assert_eq!(expected, writter.content); assert_eq!(expected, writer.content);
} }
#[test] #[test]
fn basic_shrink() { fn basic_shrink() {
let mut writter = StringWritter::default(); let mut writer = StringWriter::default();
write_items![ write_items![
&mut writter, &mut writer,
Item::new(1, "", "desc"), Item::new(1, "", "desc"),
Item::new(2, "short", "desc"), Item::new(2, "short", "desc"),
Item::new(3, "loooooong", "desc"), Item::new(3, "loooooong", "desc"),
@ -423,14 +423,14 @@ mod tests {
"7 │😍😍😍😍… │desc \n", "7 │😍😍😍😍… │desc \n",
"8 │!😍😍😍… │desc \n", "8 │!😍😍😍… │desc \n",
]; ];
assert_eq!(expected, writter.content); assert_eq!(expected, writer.content);
} }
#[test] #[test]
fn max_shrink_width() { fn max_shrink_width() {
let mut writter = StringWritter::default(); let mut writer = StringWriter::default();
write_items![ write_items![
&mut writter, &mut writer,
Item::new(1111, "shriiiiiiiink", "desc very looong"), Item::new(1111, "shriiiiiiiink", "desc very looong"),
Item::new(2222, "shriiiiiiiink", "desc very loooooooooong") Item::new(2222, "shriiiiiiiink", "desc very loooooooooong")
]; ];
@ -440,6 +440,6 @@ mod tests {
"1111 │shri… │desc very looong \n", "1111 │shri… │desc very looong \n",
"2222 │shri… │desc very loooooooooong \n", "2222 │shri… │desc very loooooooooong \n",
]; ];
assert_eq!(expected, writter.content); assert_eq!(expected, writer.content);
} }
} }

View file

@ -19,10 +19,7 @@ fn test_maildir_backend() {
// configure accounts // configure accounts
let account_config = AccountConfig { let account_config = AccountConfig {
mailboxes: HashMap::from_iter([ mailboxes: HashMap::from_iter([("subdir".into(), "Subdir".into())]),
("inbox".into(), "INBOX".into()),
("subdir".into(), "Subdir".into()),
]),
..AccountConfig::default() ..AccountConfig::default()
}; };
let mdir_config = MaildirBackendConfig { let mdir_config = MaildirBackendConfig {