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]
## [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

9
Cargo.lock generated
View file

@ -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",

View file

@ -1,7 +1,7 @@
[package]
name = "himalaya"
description = "Command-line interface for email management"
version = "0.5.8"
version = "0.5.9"
authors = ["soywod <clement.douin@posteo.net>"]
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"

View file

@ -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;

View file

@ -15,21 +15,24 @@ use super::{ImapFlag, ImapFlags};
/// Represents a list of IMAP envelopes.
#[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 {
type Target = Vec<ImapEnvelope>;
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<RawImapEnvelopes> 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 })
}
}

View file

@ -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<ImapMbox>);
#[derive(Debug, Default, Serialize)]
pub struct ImapMboxes {
#[serde(rename = "response")]
pub mboxes: Vec<ImapMbox>,
}
impl Deref for ImapMboxes {
type Target = Vec<ImapMbox>;
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<Vec<RawImapMbox>>;
impl<'a> From<RawImapMboxes> 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(),
}
}
}

View file

@ -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<maildir::Maildir> {
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

View file

@ -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<MaildirEnvelope>);
pub struct MaildirEnvelopes {
#[serde(rename = "response")]
pub envelopes: Vec<MaildirEnvelope>,
}
impl Deref for MaildirEnvelopes {
type Target = Vec<MaildirEnvelope>;
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<RawMaildirEnvelopes> for MaildirEnvelopes {
envelopes.push(envelope);
}
Ok(MaildirEnvelopes(envelopes))
Ok(MaildirEnvelopes { envelopes })
}
}
@ -125,7 +128,7 @@ impl<'a> TryFrom<RawMaildirEnvelope> for MaildirEnvelope {
type Error = 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();
@ -139,14 +142,14 @@ impl<'a> TryFrom<RawMaildirEnvelope> 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<RawMaildirEnvelope> 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)
}
}

View file

@ -19,21 +19,24 @@ use crate::{
/// Represents a list of Maildir mailboxes.
#[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 {
type Target = Vec<MaildirMbox>;
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<RawMaildirMboxes> for MaildirMboxes {
for entry in mail_entries {
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());
// 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<Box<dyn Mboxes>> {
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 {:?}",

View file

@ -19,27 +19,30 @@ use crate::{
/// Represents a list of envelopes.
#[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 {
type Target = Vec<NotmuchEnvelope>;
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<RawNotmuchEnvelopes> for NotmuchEnvelopes {
.context("cannot parse notmuch mail entry")?;
envelopes.push(envelope);
}
Ok(NotmuchEnvelopes(envelopes))
Ok(NotmuchEnvelopes { envelopes })
}
}

View file

@ -17,21 +17,24 @@ use crate::{
/// Represents a list of Notmuch mailboxes.
#[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 {
type Target = Vec<NotmuchMbox>;
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(())
}
}

View file

@ -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(())
}
}

View file

@ -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<String>,
/// Represents mailbox aliases.
pub mailboxes: HashMap<String, String>,
/// 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<MailAddr> {
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<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).

View file

@ -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<usize> {
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<T>,
opts: PrintTableOpts,
) -> Result<()> {
data.print_table(&mut self.writter, opts)?;
data.print_table(&mut self.writer, opts)?;
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!()
}
fn is_json(&self) -> bool {
@ -126,7 +132,7 @@ mod tests {
"account-1 │imap │yes \n",
"\n"
],
printer.writter.content
printer.writer.content
);
}
}

View file

@ -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<String>,
/// Overrides the signature delimiter for this account.
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>,
/// Overrides the notify command for this account.
pub notify_cmd: Option<String>,
@ -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<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.
pub default: Option<bool>,
@ -84,6 +88,9 @@ macro_rules! make_account_config {
#[serde(default)]
pub mailboxes: HashMap<String, String>,
/// Represents hooks.
pub hooks: Option<Hooks>,
$(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(),
}
}
}

View file

@ -23,7 +23,7 @@ pub struct DeserializedConfig {
pub downloads_dir: Option<PathBuf>,
/// Represents the signature of the user.
pub signature: Option<String>,
/// Overrides the default signature delimiter "`--\n `".
/// Overrides the default signature delimiter "`-- \n`".
pub signature_delimiter: Option<String>,
/// Represents the default page size for listings.
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 use format::*;
pub mod hooks;
pub use hooks::*;
}
pub mod compl;

View file

@ -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(

View file

@ -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<usize> {
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<T>,
opts: PrintTableOpts,
) -> Result<()> {
data.print_table(&mut self.writter, opts)?;
data.print_table(&mut self.writer, opts)?;
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!()
}
fn is_json(&self) -> bool {
@ -109,21 +115,23 @@ mod tests {
unimplemented!();
}
fn get_mboxes(&mut self) -> Result<Box<dyn Mboxes>> {
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
);
}
}

View file

@ -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
))

View file

@ -25,6 +25,7 @@ type AttachmentPaths<'a> = Vec<&'a str>;
type MaxTableWidth = Option<usize>;
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<PageSize>, 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<PageSize>, Page),
@ -121,7 +122,9 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result<Option<Cmd<'a>>> {
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<App<'a, 'a>> {
),
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<App<'a, 'a>> {
.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"])

View file

@ -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<Addrs>,
pub in_reply_to: Option<String>,
pub message_id: Option<String>,
pub headers: HashMap<String, String>,
/// The internal date of the message.
///
/// [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 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<Self> {
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::<Vec<_>>().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::<Vec<_>>()
.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<Self> {
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<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 {
type Error = Error;
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),
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")?)
}
}
#[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 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(())
}

View file

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

View file

@ -183,13 +183,13 @@ pub fn subcmds<'a>() -> Vec<App<'a, 'a>> {
.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)),
)]
}

View file

@ -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")
}

View file

@ -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<String> {
@ -14,3 +17,25 @@ pub fn run_cmd(cmd: &str) -> Result<String> {
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 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)
}
}

View file

@ -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> {

View file

@ -9,8 +9,9 @@ use termcolor::{ColorChoice, StandardStream};
use crate::output::{OutputFmt, OutputJson, Print, PrintTable, PrintTableOpts, WriteColor};
pub trait PrinterService {
fn print<T: Debug + Print + serde::Serialize>(&mut self, data: T) -> Result<()>;
fn print_table<T: fmt::Debug + erased_serde::Serialize + PrintTable + ?Sized>(
fn print_str<T: Debug + Print>(&mut self, data: T) -> Result<()>;
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,
data: Box<T>,
opts: PrintTableOpts,
@ -19,16 +20,23 @@ pub trait PrinterService {
}
pub struct StdoutPrinter {
pub writter: Box<dyn WriteColor>,
pub writer: Box<dyn WriteColor>,
pub fmt: OutputFmt,
}
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 {
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<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,
) -> 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 <dyn erased_serde::Serializer>::erase(json);
data.erased_serialize(ser).unwrap();
Ok(())
@ -55,7 +63,7 @@ impl PrinterService for StdoutPrinter {
impl From<OutputFmt> 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<OutputFmt> 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 }
}
}

View file

@ -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<lettre::Message>;
fn send_raw_msg(&mut self, envelope: &lettre::address::Envelope, msg: &[u8]) -> Result<()>;
fn send(&mut self, account: &AccountConfig, msg: &Msg) -> Result<Vec<u8>>;
}
pub struct LettreService<'a> {
@ -21,7 +20,7 @@ pub struct LettreService<'a> {
transport: Option<SmtpTransport>,
}
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<lettre::Message> {
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<Vec<u8>> {
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,

View file

@ -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<usize> {
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);
}
}

View file

@ -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 {