diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37bc20d..464e644 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [0.5.5] - 2022-02-08
+
+### Added
+
+- [Contributing guide](https://github.com/soywod/himalaya/blob/master/CONTRIBUTING.md) [#256]
+- Notify query config option [#289]
+- End-to-end encryption *(EXPERIMENTAL)* [#54]
+
+### Fixed
+
+- Multiple recipients issue [#288]
+
## [0.5.4] - 2022-02-05
### Fixed
@@ -280,7 +292,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.4...HEAD
+[unreleased]: https://github.com/soywod/himalaya/compare/v0.5.5...HEAD
+[0.5.5]: https://github.com/soywod/himalaya/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/soywod/himalaya/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/soywod/himalaya/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/soywod/himalaya/compare/v0.5.1...v0.5.2
@@ -336,6 +349,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#47]: https://github.com/soywod/himalaya/issues/47
[#48]: https://github.com/soywod/himalaya/issues/48
[#50]: https://github.com/soywod/himalaya/issues/50
+[#54]: https://github.com/soywod/himalaya/issues/54
[#58]: https://github.com/soywod/himalaya/issues/58
[#59]: https://github.com/soywod/himalaya/issues/59
[#61]: https://github.com/soywod/himalaya/issues/61
@@ -392,6 +406,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[#228]: https://github.com/soywod/himalaya/issues/228
[#229]: https://github.com/soywod/himalaya/issues/229
[#249]: https://github.com/soywod/himalaya/issues/249
+[#256]: https://github.com/soywod/himalaya/issues/256
[#259]: https://github.com/soywod/himalaya/issues/259
[#268]: https://github.com/soywod/himalaya/issues/268
[#272]: https://github.com/soywod/himalaya/issues/272
@@ -400,3 +415,5 @@ 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
+[#288]: https://github.com/soywod/himalaya/issues/288
+[#289]: https://github.com/soywod/himalaya/issues/289
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..cc72118
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,42 @@
+# Himalaya contributing guide
+
+Thank you for investing your time in contributing to Himalaya!
+
+In this guide you will get an overview of the contribution workflow from opening an issue, creating a PR, reviewing, and merging the PR.
+
+## New contributor guide
+
+To get an overview of the project, read the [README](README.md). To get more information about the project, read the [wiki](https://github.com/soywod/himalaya/wiki).
+
+## Getting started
+
+### Issues
+
+#### Create a new issue
+
+If you spot a problem with the docs, [search if an issue already exists](https://github.com/soywod/himalaya/issues). If a related issue doesn't exist, you can open a new issue using a relevant [issue form](https://github.com/soywod/himalaya/issues/new/choose).
+
+#### Solve an issue
+
+Scan through our [existing issues](https://github.com/soywod/himalaya/issues) to find one that interests you. You can narrow down the search using `labels` as filters. If you find an issue to work on, you are welcome to open a PR with a fix.
+
+### Make Changes
+
+#### Make changes in the UI
+
+Click **Make a contribution** at the bottom of any docs page to make small changes such as a typo, sentence fix, or a broken link. This takes you to the `.md` file where you can make your changes and [create a pull request](#pull-request) for a review.
+
+#### Make changes locally
+
+First, follow the instructions on [how to install Himalaya from sources](https://github.com/soywod/himalaya/wiki/Installation:sources). Then, create a working branch and start with your changes!
+
+### Commit your update
+
+Commit the changes once you are happy with them. Commit messages follow the [Angular Convention](https://gist.github.com/stephenparish/9941e89d80e2bc58a153), but contain only a subject. The subject can be prefixed with a custom context like `msg: `, `mbox: `, `imap: ` etc.
+
+ > Use imperative, present tense: “change” not “changed” nor
+ > “changes” Don't capitalize first letter No dot (.) at the end
+
+### Pull Request
+
+When you're finished with the changes, create a pull request, also known as a PR.
diff --git a/Cargo.lock b/Cargo.lock
index 89d3fe9..e3a66af 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -361,7 +361,7 @@ dependencies = [
[[package]]
name = "himalaya"
-version = "0.5.4"
+version = "0.5.5"
dependencies = [
"ammonia",
"anyhow",
diff --git a/Cargo.toml b/Cargo.toml
index 00d25e9..62adba0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,7 +1,7 @@
[package]
name = "himalaya"
description = "Command-line interface for email management"
-version = "0.5.4"
+version = "0.5.5"
authors = ["soywod "]
edition = "2018"
license-file = "LICENSE"
diff --git a/README.md b/README.md
index 14be477..403efd0 100644
--- a/README.md
+++ b/README.md
@@ -2,22 +2,15 @@
Command-line interface for email management
-*The project is under active development. Do not use in production before the
-`v1.0.0` (see the [roadmap](https://github.com/soywod/himalaya/milestone/5)).*
+*The project is under active development. Do not use in production before the `v1.0.0`.*
-![image](https://user-images.githubusercontent.com/10437171/115144003-8a1b4880-a04a-11eb-80d2-245027e28591.png)
+![image](https://user-images.githubusercontent.com/10437171/138774902-7b9de5a3-93eb-44b0-8cfb-6d2e11e3b1aa.png)
## Motivation
-Bringing emails to the terminal is a *pain*. First, because they are sensitive
-data. Secondly, the existing TUIs ([Mutt](http://www.mutt.org/),
-[NeoMutt](https://neomutt.org/), [Alpine](https://alpine.x10host.com/),
-[aerc](https://aerc-mail.org/)…) are really hard to configure. They require time
-and patience.
+Bringing emails to the terminal is a *pain*. First, because they are sensitive data. Secondly, the existing TUIs ([Mutt](http://www.mutt.org/), [NeoMutt](https://neomutt.org/), [Alpine](https://alpine.x10host.com/), [aerc](https://aerc-mail.org/)…) are really hard to configure. They require time and patience.
-The aim of Himalaya is to extract the email logic into a simple (yet solid) CLI
-API that can be used directly from the terminal, from scripts, from UIs…
-Possibilities are endless!
+The aim of Himalaya is to extract the email logic into a simple (yet solid) CLI API that can be used directly from the terminal, from scripts, from UIs… Possibilities are endless!
## Installation
@@ -28,9 +21,7 @@ Possibilities are endless!
curl -sSL https://raw.githubusercontent.com/soywod/himalaya/master/install.sh | PREFIX=~/.local sh
```
-*See the
-[wiki](https://github.com/soywod/himalaya/wiki/Installation:from-binary) for
-other installation methods.*
+*See the [wiki](https://github.com/soywod/himalaya/wiki/Installation:from-binary) for other installation methods.*
## Configuration
@@ -59,9 +50,7 @@ smtp-login = "your.email@gmail.com"
smtp-passwd-cmd = "security find-internet-password -gs gmail -w"
```
-*See the
-[wiki](https://github.com/soywod/himalaya/wiki/Configuration:config-file) for
-all the options.*
+*See the [wiki](https://github.com/soywod/himalaya/wiki/Configuration:config-file) for all the options.*
## Features
@@ -76,8 +65,7 @@ all the options.*
- JSON output
- …
-*See the [wiki](https://github.com/soywod/himalaya/wiki/Usage:msg:list) for all
-the features.*
+*See the [wiki](https://github.com/soywod/himalaya/wiki/Usage:msg:list) for all the features.*
## Sponsoring
diff --git a/src/compl/compl_arg.rs b/src/compl/compl_arg.rs
index eed6f51..37d39a1 100644
--- a/src/compl/compl_arg.rs
+++ b/src/compl/compl_arg.rs
@@ -4,7 +4,7 @@
use anyhow::Result;
use clap::{self, App, Arg, ArgMatches, Shell, SubCommand};
-use log::debug;
+use log::{debug, info};
type OptionShell<'a> = Option<&'a str>;
@@ -16,10 +16,12 @@ pub enum Command<'a> {
/// Completion command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result>> {
+ info!("entering completion command matcher");
+
if let Some(m) = m.subcommand_matches("completion") {
- debug!("completion command matched");
+ info!("completion command matched");
let shell = m.value_of("shell");
- debug!("shell: `{:?}`", shell);
+ debug!("shell: {:?}", shell);
return Ok(Some(Command::Generate(shell)));
};
diff --git a/src/compl/compl_handler.rs b/src/compl/compl_handler.rs
index 2eda507..d504f27 100644
--- a/src/compl/compl_handler.rs
+++ b/src/compl/compl_handler.rs
@@ -4,13 +4,18 @@
use anyhow::{anyhow, Context, Result};
use clap::{App, Shell};
+use log::{debug, info};
use std::{io, str::FromStr};
-/// Generate completion script from the given [`clap::App`] for the given shell slice.
+/// Generates completion script from the given [`clap::App`] for the given shell slice.
pub fn generate<'a>(mut app: App<'a, 'a>, shell: Option<&'a str>) -> Result<()> {
+ info!("entering generate completion handler");
+
let shell = Shell::from_str(shell.unwrap_or_default())
.map_err(|err| anyhow!(err))
.context("cannot parse shell")?;
+ debug!("shell: {}", shell);
+
app.gen_completions_to("himalaya", shell, &mut io::stdout());
Ok(())
}
diff --git a/src/config/account_entity.rs b/src/config/account_entity.rs
index a952daf..15667ed 100644
--- a/src/config/account_entity.rs
+++ b/src/config/account_entity.rs
@@ -26,6 +26,8 @@ pub struct Account {
pub sent_folder: String,
/// Defines the draft folder name for this account
pub draft_folder: String,
+ /// Defines the IMAP query used to fetch new messages.
+ pub notify_query: String,
pub watch_cmds: Vec,
pub default: bool,
pub email: String,
@@ -43,6 +45,9 @@ pub struct Account {
pub smtp_insecure: bool,
pub smtp_login: String,
pub smtp_passwd_cmd: String,
+
+ pub pgp_encrypt_cmd: Option,
+ pub pgp_decrypt_cmd: Option,
}
impl Account {
@@ -77,6 +82,30 @@ impl Account {
Ok(SmtpCredentials::new(self.smtp_login.to_owned(), passwd))
}
+
+ pub fn pgp_encrypt_file(&self, addr: &str, path: PathBuf) -> Result> {
+ if let Some(cmd) = self.pgp_encrypt_cmd.as_ref() {
+ let encrypt_file_cmd = format!("{} {} {:?}", cmd, addr, path);
+ run_cmd(&encrypt_file_cmd).map(Some).context(format!(
+ "cannot run pgp encrypt command {:?}",
+ encrypt_file_cmd
+ ))
+ } else {
+ Ok(None)
+ }
+ }
+
+ pub fn pgp_decrypt_file(&self, path: PathBuf) -> Result > {
+ if let Some(cmd) = self.pgp_decrypt_cmd.as_ref() {
+ let decrypt_file_cmd = format!("{} {:?}", cmd, path);
+ run_cmd(&decrypt_file_cmd).map(Some).context(format!(
+ "cannot run pgp decrypt command {:?}",
+ decrypt_file_cmd
+ ))
+ } else {
+ Ok(None)
+ }
+ }
}
impl<'a> TryFrom<(&'a Config, Option<&str>)> for Account {
@@ -162,6 +191,12 @@ impl<'a> TryFrom<(&'a Config, Option<&str>)> for Account {
.or_else(|| config.draft_folder.as_deref())
.unwrap_or(DEFAULT_DRAFT_FOLDER)
.to_string(),
+ notify_query: account
+ .notify_query
+ .as_ref()
+ .or_else(|| config.notify_query.as_ref())
+ .unwrap_or(&String::from("NEW"))
+ .to_owned(),
watch_cmds: account
.watch_cmds
.as_ref()
@@ -184,9 +219,12 @@ impl<'a> TryFrom<(&'a Config, Option<&str>)> for Account {
smtp_insecure: account.smtp_insecure.unwrap_or_default(),
smtp_login: account.smtp_login.to_owned(),
smtp_passwd_cmd: account.smtp_passwd_cmd.to_owned(),
+
+ pgp_encrypt_cmd: account.pgp_encrypt_cmd.to_owned(),
+ pgp_decrypt_cmd: account.pgp_decrypt_cmd.to_owned(),
};
- trace!("{:#?}", account);
+ trace!("account: {:?}", account);
Ok(account)
}
}
diff --git a/src/config/config_entity.rs b/src/config/config_entity.rs
index dd57821..c96f354 100644
--- a/src/config/config_entity.rs
+++ b/src/config/config_entity.rs
@@ -31,6 +31,8 @@ pub struct Config {
pub draft_folder: Option,
/// Defines the notify command.
pub notify_cmd: Option,
+ /// Customizes the IMAP query used to fetch new messages.
+ pub notify_query: Option,
/// Defines the watch commands.
pub watch_cmds: Option>,
@@ -56,6 +58,8 @@ pub struct ConfigAccountEntry {
pub sent_folder: Option,
/// Defines a specific draft folder name for this account.
pub draft_folder: Option,
+ /// Customizes the IMAP query used to fetch new messages.
+ pub notify_query: Option,
pub watch_cmds: Option>,
pub default: Option,
pub email: String,
@@ -73,6 +77,9 @@ pub struct ConfigAccountEntry {
pub smtp_insecure: Option,
pub smtp_login: String,
pub smtp_passwd_cmd: String,
+
+ pub pgp_encrypt_cmd: Option,
+ pub pgp_decrypt_cmd: Option,
}
impl Config {
diff --git a/src/domain/imap/imap_arg.rs b/src/domain/imap/imap_arg.rs
index 257f1d4..6e8eab4 100644
--- a/src/domain/imap/imap_arg.rs
+++ b/src/domain/imap/imap_arg.rs
@@ -4,7 +4,7 @@
use anyhow::Result;
use clap::{App, ArgMatches};
-use log::debug;
+use log::{debug, info};
type Keepalive = u64;
@@ -19,15 +19,17 @@ pub enum Command {
/// IMAP command matcher.
pub fn matches(m: &ArgMatches) -> Result> {
+ info!("entering imap command matcher");
+
if let Some(m) = m.subcommand_matches("notify") {
- debug!("notify command matched");
+ info!("notify command matched");
let keepalive = clap::value_t_or_exit!(m.value_of("keepalive"), u64);
debug!("keepalive: {}", keepalive);
return Ok(Some(Command::Notify(keepalive)));
}
if let Some(m) = m.subcommand_matches("watch") {
- debug!("watch command matched");
+ info!("watch command matched");
let keepalive = clap::value_t_or_exit!(m.value_of("keepalive"), u64);
debug!("keepalive: {}", keepalive);
return Ok(Some(Command::Watch(keepalive)));
diff --git a/src/domain/imap/imap_handler.rs b/src/domain/imap/imap_handler.rs
index 276c952..3bb7efc 100644
--- a/src/domain/imap/imap_handler.rs
+++ b/src/domain/imap/imap_handler.rs
@@ -9,16 +9,15 @@ use crate::{
domain::imap::ImapServiceInterface,
};
-/// Notify handler.
pub fn notify<'a, ImapService: ImapServiceInterface<'a>>(
keepalive: u64,
config: &Config,
+ account: &Account,
imap: &mut ImapService,
) -> Result<()> {
- imap.notify(config, keepalive)
+ imap.notify(config, account, keepalive)
}
-/// Watch handler.
pub fn watch<'a, ImapService: ImapServiceInterface<'a>>(
keepalive: u64,
account: &Account,
diff --git a/src/domain/imap/imap_service.rs b/src/domain/imap/imap_service.rs
index 7b5cfea..f479bf5 100644
--- a/src/domain/imap/imap_service.rs
+++ b/src/domain/imap/imap_service.rs
@@ -5,12 +5,7 @@
use anyhow::{anyhow, Context, Result};
use log::{debug, log_enabled, trace, Level};
use native_tls::{TlsConnector, TlsStream};
-use std::{
- collections::HashSet,
- convert::{TryFrom, TryInto},
- net::TcpStream,
- thread,
-};
+use std::{collections::HashSet, convert::TryFrom, net::TcpStream, thread};
use crate::{
config::{Account, Config},
@@ -21,7 +16,7 @@ use crate::{
type ImapSession = imap::Session>;
pub trait ImapServiceInterface<'a> {
- fn notify(&mut self, config: &Config, keepalive: u64) -> Result<()>;
+ fn notify(&mut self, config: &Config, account: &Account, keepalive: u64) -> Result<()>;
fn watch(&mut self, account: &Account, keepalive: u64) -> Result<()>;
fn fetch_mboxes(&'a mut self) -> Result;
fn fetch_envelopes(&mut self, page_size: &usize, page: &usize) -> Result;
@@ -31,9 +26,9 @@ pub trait ImapServiceInterface<'a> {
page_size: &usize,
page: &usize,
) -> Result;
- fn find_msg(&mut self, seq: &str) -> Result;
+ fn find_msg(&mut self, account: &Account, seq: &str) -> Result;
fn find_raw_msg(&mut self, seq: &str) -> Result>;
- fn append_msg(&mut self, mbox: &Mbox, msg: Msg) -> Result<()>;
+ fn append_msg(&mut self, mbox: &Mbox, account: &Account, msg: Msg) -> Result<()>;
fn append_raw_msg_with_flags(&mut self, mbox: &Mbox, msg: &[u8], flags: Flags) -> Result<()>;
fn expunge(&mut self) -> Result<()>;
fn logout(&mut self) -> Result<()>;
@@ -98,10 +93,10 @@ impl<'a> ImapService<'a> {
}
}
- fn search_new_msgs(&mut self) -> Result> {
+ fn search_new_msgs(&mut self, account: &Account) -> Result> {
let uids: Vec = self
.sess()?
- .uid_search("NEW")
+ .uid_search(&account.notify_query)
.context("cannot search new messages")?
.into_iter()
.collect();
@@ -197,11 +192,11 @@ impl<'a> ImapServiceInterface<'a> for ImapService<'a> {
}
/// Find a message by sequence number.
- fn find_msg(&mut self, seq: &str) -> Result {
+ fn find_msg(&mut self, account: &Account, seq: &str) -> Result {
let mbox = self.mbox.to_owned();
self.sess()?
.select(&mbox.name)
- .context(format!(r#"cannot select mailbox "{}""#, self.mbox.name))?;
+ .context(format!("cannot select mailbox {}", self.mbox.name))?;
let fetches = self
.sess()?
.fetch(seq, "(ENVELOPE FLAGS INTERNALDATE BODY[])")
@@ -210,7 +205,7 @@ impl<'a> ImapServiceInterface<'a> for ImapService<'a> {
.first()
.ok_or_else(|| anyhow!(r#"cannot find message "{}"#, seq))?;
- Msg::try_from(fetch)
+ Msg::try_from((account, fetch))
}
fn find_raw_msg(&mut self, seq: &str) -> Result> {
@@ -238,8 +233,8 @@ impl<'a> ImapServiceInterface<'a> for ImapService<'a> {
Ok(())
}
- fn append_msg(&mut self, mbox: &Mbox, msg: Msg) -> Result<()> {
- let msg_raw: Vec = (&msg).try_into()?;
+ fn append_msg(&mut self, mbox: &Mbox, account: &Account, msg: Msg) -> Result<()> {
+ let msg_raw = msg.into_sendable_msg(account)?.formatted();
self.sess()?
.append(&mbox.name, &msg_raw)
.flags(msg.flags.0)
@@ -248,7 +243,7 @@ impl<'a> ImapServiceInterface<'a> for ImapService<'a> {
Ok(())
}
- fn notify(&mut self, config: &Config, keepalive: u64) -> Result<()> {
+ fn notify(&mut self, config: &Config, account: &Account, keepalive: u64) -> Result<()> {
debug!("notify");
let mbox = self.mbox.to_owned();
@@ -260,7 +255,7 @@ impl<'a> ImapServiceInterface<'a> for ImapService<'a> {
debug!("init messages hashset");
let mut msgs_set: HashSet = self
- .search_new_msgs()?
+ .search_new_msgs(account)?
.iter()
.cloned()
.collect::>();
@@ -281,7 +276,7 @@ impl<'a> ImapServiceInterface<'a> for ImapService<'a> {
.context("cannot start the idle mode")?;
let uids: Vec = self
- .search_new_msgs()?
+ .search_new_msgs(account)?
.into_iter()
.filter(|uid| -> bool { msgs_set.get(uid).is_none() })
.collect();
diff --git a/src/domain/mbox/mbox_arg.rs b/src/domain/mbox/mbox_arg.rs
index 382cd34..19f1456 100644
--- a/src/domain/mbox/mbox_arg.rs
+++ b/src/domain/mbox/mbox_arg.rs
@@ -5,7 +5,7 @@
use anyhow::Result;
use clap;
-use log::trace;
+use log::{debug, info};
use crate::ui::table_arg;
@@ -20,12 +20,14 @@ pub enum Cmd {
/// Defines the mailbox command matcher.
pub fn matches(m: &clap::ArgMatches) -> Result> {
+ info!("entering mailbox command matcher");
+
if let Some(m) = m.subcommand_matches("mailboxes") {
- trace!("mailboxes subcommand matched");
+ info!("mailboxes command matched");
let max_table_width = m
.value_of("max-table-width")
.and_then(|width| width.parse::().ok());
- trace!(r#"max table width: "{:?}""#, max_table_width);
+ debug!("max table width: {:?}", max_table_width);
return Ok(Some(Cmd::List(max_table_width)));
}
diff --git a/src/domain/mbox/mbox_handler.rs b/src/domain/mbox/mbox_handler.rs
index 314227b..b08f8c3 100644
--- a/src/domain/mbox/mbox_handler.rs
+++ b/src/domain/mbox/mbox_handler.rs
@@ -3,7 +3,7 @@
//! This module gathers all mailbox actions triggered by the CLI.
use anyhow::Result;
-use log::trace;
+use log::{info, trace};
use crate::{
domain::ImapServiceInterface,
@@ -16,8 +16,9 @@ pub fn list<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>(
printer: &mut Printer,
imap: &'a mut ImapService,
) -> Result<()> {
+ info!("entering list mailbox handler");
let mboxes = imap.fetch_mboxes()?;
- trace!("mailboxes: {:#?}", mboxes);
+ trace!("mailboxes: {:?}", mboxes);
printer.print_table(mboxes, PrintTableOpts { max_width })
}
@@ -114,7 +115,7 @@ mod tests {
]))
}
- fn notify(&mut self, _: &Config, _: u64) -> Result<()> {
+ fn notify(&mut self, _: &Config, _: &Account, _: u64) -> Result<()> {
unimplemented!()
}
fn watch(&mut self, _: &Account, _: u64) -> Result<()> {
@@ -126,13 +127,13 @@ mod tests {
fn fetch_envelopes_with(&mut self, _: &str, _: &usize, _: &usize) -> Result {
unimplemented!()
}
- fn find_msg(&mut self, _: &str) -> Result {
+ fn find_msg(&mut self, _: &Account, _: &str) -> Result {
unimplemented!()
}
fn find_raw_msg(&mut self, _: &str) -> Result> {
unimplemented!()
}
- fn append_msg(&mut self, _: &Mbox, _: Msg) -> Result<()> {
+ fn append_msg(&mut self, _: &Mbox, _: &Account, _: Msg) -> Result<()> {
unimplemented!()
}
fn append_raw_msg_with_flags(&mut self, _: &Mbox, _: &[u8], _: Flags) -> Result<()> {
diff --git a/src/domain/msg/flag_arg.rs b/src/domain/msg/flag_arg.rs
index c9ae723..a920c26 100644
--- a/src/domain/msg/flag_arg.rs
+++ b/src/domain/msg/flag_arg.rs
@@ -5,7 +5,7 @@
use anyhow::Result;
use clap::{self, App, AppSettings, Arg, ArgMatches, SubCommand};
-use log::{debug, trace};
+use log::{debug, info};
use crate::domain::msg::msg_arg;
@@ -24,30 +24,32 @@ pub enum Command<'a> {
/// Defines the flag command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result>> {
+ info!("entering message flag command matcher");
+
if let Some(m) = m.subcommand_matches("add") {
- debug!("add subcommand matched");
+ info!("add subcommand matched");
let seq_range = m.value_of("seq-range").unwrap();
- trace!(r#"seq range: "{}""#, seq_range);
+ debug!("seq range: {}", seq_range);
let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect();
- trace!(r#"flags: "{:?}""#, flags);
+ debug!("flags: {:?}", flags);
return Ok(Some(Command::Add(seq_range, flags)));
}
if let Some(m) = m.subcommand_matches("set") {
- debug!("set subcommand matched");
+ info!("set subcommand matched");
let seq_range = m.value_of("seq-range").unwrap();
- trace!(r#"seq range: "{}""#, seq_range);
+ debug!("seq range: {}", seq_range);
let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect();
- trace!(r#"flags: "{:?}""#, flags);
+ debug!("flags: {:?}", flags);
return Ok(Some(Command::Set(seq_range, flags)));
}
if let Some(m) = m.subcommand_matches("remove") {
- trace!("remove subcommand matched");
+ info!("remove subcommand matched");
let seq_range = m.value_of("seq-range").unwrap();
- trace!(r#"seq range: "{}""#, seq_range);
+ debug!("seq range: {}", seq_range);
let flags: Vec<&str> = m.values_of("flags").unwrap_or_default().collect();
- trace!(r#"flags: "{:?}""#, flags);
+ debug!("flags: {:?}", flags);
return Ok(Some(Command::Remove(seq_range, flags)));
}
diff --git a/src/domain/msg/msg_arg.rs b/src/domain/msg/msg_arg.rs
index a708086..f6b3677 100644
--- a/src/domain/msg/msg_arg.rs
+++ b/src/domain/msg/msg_arg.rs
@@ -4,7 +4,7 @@
use anyhow::Result;
use clap::{self, App, Arg, ArgMatches, SubCommand};
-use log::{debug, trace};
+use log::{debug, info, trace};
use crate::{
domain::{
@@ -25,21 +25,22 @@ type RawMsg<'a> = &'a str;
type Query = String;
type AttachmentPaths<'a> = Vec<&'a str>;
type MaxTableWidth = Option;
+type Encrypt = bool;
/// Message commands.
pub enum Command<'a> {
Attachments(Seq<'a>),
Copy(Seq<'a>, Mbox<'a>),
Delete(Seq<'a>),
- Forward(Seq<'a>, AttachmentPaths<'a>),
+ Forward(Seq<'a>, AttachmentPaths<'a>, Encrypt),
List(MaxTableWidth, Option, Page),
Move(Seq<'a>, Mbox<'a>),
Read(Seq<'a>, TextMime<'a>, Raw),
- Reply(Seq<'a>, All, AttachmentPaths<'a>),
+ Reply(Seq<'a>, All, AttachmentPaths<'a>, Encrypt),
Save(RawMsg<'a>),
Search(Query, MaxTableWidth, Option, Page),
Send(RawMsg<'a>),
- Write(AttachmentPaths<'a>),
+ Write(AttachmentPaths<'a>, Encrypt),
Flag(Option>),
Tpl(Option>),
@@ -47,46 +48,50 @@ pub enum Command<'a> {
/// Message command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result>> {
+ info!("entering message command matcher");
+
if let Some(m) = m.subcommand_matches("attachments") {
- debug!("attachments command matched");
+ info!("attachments command matched");
let seq = m.value_of("seq").unwrap();
- trace!("seq: {}", seq);
+ debug!("seq: {}", seq);
return Ok(Some(Command::Attachments(seq)));
}
if let Some(m) = m.subcommand_matches("copy") {
- debug!("copy command matched");
+ info!("copy command matched");
let seq = m.value_of("seq").unwrap();
- trace!("seq: {}", seq);
+ debug!("seq: {}", seq);
let mbox = m.value_of("mbox-target").unwrap();
- trace!(r#"target mailbox: "{:?}""#, mbox);
+ debug!(r#"target mailbox: "{:?}""#, mbox);
return Ok(Some(Command::Copy(seq, mbox)));
}
if let Some(m) = m.subcommand_matches("delete") {
- debug!("copy command matched");
+ info!("copy command matched");
let seq = m.value_of("seq").unwrap();
- trace!("seq: {}", seq);
+ debug!("seq: {}", seq);
return Ok(Some(Command::Delete(seq)));
}
if let Some(m) = m.subcommand_matches("forward") {
- debug!("forward command matched");
+ info!("forward command matched");
let seq = m.value_of("seq").unwrap();
- trace!("seq: {}", seq);
+ debug!("seq: {}", seq);
let paths: Vec<&str> = m.values_of("attachments").unwrap_or_default().collect();
- trace!("attachments paths: {:?}", paths);
- return Ok(Some(Command::Forward(seq, paths)));
+ debug!("attachments paths: {:?}", paths);
+ let encrypt = m.is_present("encrypt");
+ debug!("encrypt: {}", encrypt);
+ return Ok(Some(Command::Forward(seq, paths, encrypt)));
}
if let Some(m) = m.subcommand_matches("list") {
- debug!("list command matched");
+ info!("list command matched");
let max_table_width = m
.value_of("max-table-width")
.and_then(|width| width.parse::().ok());
- trace!(r#"max table width: "{:?}""#, max_table_width);
+ debug!("max table width: {:?}", max_table_width);
let page_size = m.value_of("page-size").and_then(|s| s.parse().ok());
- trace!(r#"page size: "{:?}""#, page_size);
+ debug!("page size: {:?}", page_size);
let page = m
.value_of("page")
.unwrap_or("1")
@@ -94,56 +99,59 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> {
.ok()
.map(|page| 1.max(page) - 1)
.unwrap_or_default();
- trace!(r#"page: "{:?}""#, page);
+ debug!("page: {}", page);
return Ok(Some(Command::List(max_table_width, page_size, page)));
}
if let Some(m) = m.subcommand_matches("move") {
- debug!("move command matched");
+ info!("move command matched");
let seq = m.value_of("seq").unwrap();
- trace!("seq: {}", seq);
+ debug!("seq: {}", seq);
let mbox = m.value_of("mbox-target").unwrap();
- trace!(r#"target mailbox: "{:?}""#, mbox);
+ debug!("target mailbox: {:?}", mbox);
return Ok(Some(Command::Move(seq, mbox)));
}
if let Some(m) = m.subcommand_matches("read") {
- debug!("read command matched");
+ info!("read command matched");
let seq = m.value_of("seq").unwrap();
- trace!("seq: {}", seq);
+ debug!("seq: {}", seq);
let mime = m.value_of("mime-type").unwrap();
- trace!("text mime: {}", mime);
+ debug!("text mime: {}", mime);
let raw = m.is_present("raw");
- trace!("raw: {}", raw);
+ debug!("raw: {}", raw);
return Ok(Some(Command::Read(seq, mime, raw)));
}
if let Some(m) = m.subcommand_matches("reply") {
- debug!("reply command matched");
+ info!("reply command matched");
let seq = m.value_of("seq").unwrap();
- trace!("seq: {}", seq);
+ debug!("seq: {}", seq);
let all = m.is_present("reply-all");
- trace!("reply all: {}", all);
+ debug!("reply all: {}", all);
let paths: Vec<&str> = m.values_of("attachments").unwrap_or_default().collect();
- trace!("attachments paths: {:#?}", paths);
- return Ok(Some(Command::Reply(seq, all, paths)));
+ debug!("attachments paths: {:?}", paths);
+ let encrypt = m.is_present("encrypt");
+ debug!("encrypt: {}", encrypt);
+
+ return Ok(Some(Command::Reply(seq, all, paths, encrypt)));
}
if let Some(m) = m.subcommand_matches("save") {
- debug!("save command matched");
+ info!("save command matched");
let msg = m.value_of("message").unwrap_or_default();
trace!("message: {}", msg);
return Ok(Some(Command::Save(msg)));
}
if let Some(m) = m.subcommand_matches("search") {
- debug!("search command matched");
+ info!("search command matched");
let max_table_width = m
.value_of("max-table-width")
.and_then(|width| width.parse::().ok());
- trace!(r#"max table width: "{:?}""#, max_table_width);
+ debug!("max table width: {:?}", max_table_width);
let page_size = m.value_of("page-size").and_then(|s| s.parse().ok());
- trace!(r#"page size: "{:?}""#, page_size);
+ debug!("page size: {:?}", page_size);
let page = m
.value_of("page")
.unwrap()
@@ -151,7 +159,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result>> {
.ok()
.map(|page| 1.max(page) - 1)
.unwrap_or_default();
- trace!(r#"page: "{:?}""#, page);
+ debug!("page: {}", page);
let query = m
.values_of("query")
.unwrap_or_default()
@@ -176,7 +184,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result >> {
})
.1
.join(" ");
- trace!(r#"query: "{:?}""#, query);
+ debug!("query: {}", query);
return Ok(Some(Command::Search(
query,
max_table_width,
@@ -186,17 +194,19 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result >> {
}
if let Some(m) = m.subcommand_matches("send") {
- debug!("send command matched");
+ info!("send command matched");
let msg = m.value_of("message").unwrap_or_default();
trace!("message: {}", msg);
return Ok(Some(Command::Send(msg)));
}
if let Some(m) = m.subcommand_matches("write") {
- debug!("write command matched");
+ info!("write command matched");
let attachment_paths: Vec<&str> = m.values_of("attachments").unwrap_or_default().collect();
- trace!("attachments paths: {:?}", attachment_paths);
- return Ok(Some(Command::Write(attachment_paths)));
+ debug!("attachments paths: {:?}", attachment_paths);
+ let encrypt = m.is_present("encrypt");
+ debug!("encrypt: {}", encrypt);
+ return Ok(Some(Command::Write(attachment_paths, encrypt)));
}
if let Some(m) = m.subcommand_matches("template") {
@@ -207,7 +217,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result >> {
return Ok(Some(Command::Flag(flag_arg::matches(m)?)));
}
- debug!("default list command matched");
+ info!("default list command matched");
Ok(Some(Command::List(None, None, 0)))
}
@@ -265,6 +275,14 @@ pub fn attachment_arg<'a>() -> Arg<'a, 'a> {
.multiple(true)
}
+/// Message encrypt argument.
+pub fn encrypt_arg<'a>() -> Arg<'a, 'a> {
+ Arg::with_name("encrypt")
+ .help("Encrypts the message")
+ .short("e")
+ .long("encrypt")
+}
+
/// Message subcommands.
pub fn subcmds<'a>() -> Vec> {
vec![
@@ -297,7 +315,8 @@ pub fn subcmds<'a>() -> Vec> {
),
SubCommand::with_name("write")
.about("Writes a new message")
- .arg(attachment_arg()),
+ .arg(attachment_arg())
+ .arg(encrypt_arg()),
SubCommand::with_name("send")
.about("Sends a raw message")
.arg(Arg::with_name("message").raw(true).last(true)),
@@ -327,12 +346,14 @@ pub fn subcmds<'a>() -> Vec> {
.about("Answers to a message")
.arg(seq_arg())
.arg(reply_all_arg())
- .arg(attachment_arg()),
+ .arg(attachment_arg())
+ .arg(encrypt_arg()),
SubCommand::with_name("forward")
.aliases(&["fwd", "f"])
.about("Forwards a message")
.arg(seq_arg())
- .arg(attachment_arg()),
+ .arg(attachment_arg())
+ .arg(encrypt_arg()),
SubCommand::with_name("copy")
.aliases(&["cp", "c"])
.about("Copies a message to the targetted mailbox")
diff --git a/src/domain/msg/msg_entity.rs b/src/domain/msg/msg_entity.rs
index 6676b2c..c5eb515 100644
--- a/src/domain/msg/msg_entity.rs
+++ b/src/domain/msg/msg_entity.rs
@@ -3,16 +3,19 @@ use anyhow::{anyhow, Context, Error, Result};
use chrono::{DateTime, FixedOffset};
use html_escape;
use imap::types::Flag;
-use lettre::message::{Attachment, MultiPart, SinglePart};
-use log::trace;
+use lettre::message::{header::ContentType, Attachment, MultiPart, SinglePart};
+use log::{debug, info, trace};
use regex::Regex;
use rfc2047_decoder;
use std::{
collections::HashSet,
convert::{TryFrom, TryInto},
+ env::temp_dir,
+ fmt::Debug,
fs,
path::PathBuf,
};
+use uuid::Uuid;
use crate::{
config::{Account, DEFAULT_SIG_DELIM},
@@ -58,6 +61,8 @@ pub struct Msg {
/// [RFC3501]: https://datatracker.ietf.org/doc/html/rfc3501#section-2.3.3
pub date: Option>,
pub parts: Parts,
+
+ pub encrypt: bool,
}
impl Msg {
@@ -71,7 +76,7 @@ impl Msg {
.collect()
}
- /// Fold 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 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 {
@@ -334,6 +339,8 @@ impl Msg {
imap: &mut ImapService,
smtp: &mut SmtpService,
) -> Result<()> {
+ info!("start editing with editor");
+
let draft = msg_utils::local_draft_path();
if draft.exists() {
loop {
@@ -364,7 +371,7 @@ impl Msg {
match choice::post_edit() {
Ok(PostEditChoice::Send) => {
let mbox = Mbox::new(&account.sent_folder);
- let sent_msg = smtp.send_msg(&self)?;
+ let sent_msg = smtp.send_msg(account, &self)?;
let flags = Flags::try_from(vec![Flag::Seen])?;
imap.append_raw_msg_with_flags(&mbox, &sent_msg.formatted(), flags)?;
msg_utils::remove_local_draft()?;
@@ -405,6 +412,11 @@ impl Msg {
Ok(())
}
+ pub fn encrypt(mut self, encrypt: bool) -> Self {
+ self.encrypt = encrypt;
+ self
+ }
+
pub fn add_attachments(mut self, attachments_paths: Vec<&str>) -> Result {
for path in attachments_paths {
let path = shellexpand::full(path)
@@ -547,99 +559,73 @@ impl Msg {
tpl.push('\n');
- trace!("template: {:#?}", tpl);
+ trace!("template: {:?}", tpl);
tpl
}
pub fn from_tpl(tpl: &str) -> Result {
+ info!("begin: building message from template");
+ trace!("template: {:?}", tpl);
+
let mut msg = Msg::default();
+ let parsed_msg = mailparse::parse_mail(tpl.as_bytes()).context("cannot parse template")?;
- let parsed_msg =
- mailparse::parse_mail(tpl.as_bytes()).context("cannot parse message from template")?;
-
+ debug!("parsing headers");
for header in parsed_msg.get_headers() {
let key = header.get_key();
+ debug!("header key: {:?}", key);
+
+ let val = header.get_value();
let val = String::from_utf8(header.get_value_raw().to_vec())
- .map(|val| val.trim().to_string())?;
+ .map(|val| val.trim().to_string())
+ .context(format!(
+ "cannot decode value {:?} from header {:?}",
+ key, val
+ ))?;
+ debug!("header value: {:?}", val);
match key.to_lowercase().as_str() {
- "message-id" => msg.message_id = Some(val.to_owned()),
- "from" => {
- msg.from = Some(
- val.split(',')
- .filter_map(|addr| addr.parse().ok())
- .collect::>(),
- );
- }
- "to" => {
- msg.to = Some(
- val.split(',')
- .filter_map(|addr| addr.parse().ok())
- .collect::>(),
- );
- }
- "reply-to" => {
- msg.reply_to = Some(
- val.split(',')
- .filter_map(|addr| addr.parse().ok())
- .collect::>(),
- );
- }
- "in-reply-to" => msg.in_reply_to = Some(val.to_owned()),
- "cc" => {
- msg.cc = Some(
- val.split(',')
- .filter_map(|addr| addr.parse().ok())
- .collect::>(),
- );
- }
- "bcc" => {
- msg.bcc = Some(
- val.split(',')
- .filter_map(|addr| addr.parse().ok())
- .collect::>(),
- );
- }
+ "message-id" => msg.message_id = Some(val),
+ "in-reply-to" => msg.in_reply_to = Some(val),
"subject" => {
msg.subject = val;
}
+ "from" => {
+ msg.from = parse_addrs(val).context(format!("cannot parse header {:?}", key))?
+ }
+ "to" => {
+ msg.to = parse_addrs(val).context(format!("cannot parse header {:?}", key))?
+ }
+ "reply-to" => {
+ msg.reply_to =
+ parse_addrs(val).context(format!("cannot parse header {:?}", key))?
+ }
+ "cc" => {
+ msg.cc = parse_addrs(val).context(format!("cannot parse header {:?}", key))?
+ }
+ "bcc" => {
+ msg.bcc = parse_addrs(val).context(format!("cannot parse header {:?}", key))?
+ }
_ => (),
}
}
- let content = parsed_msg
+ debug!("parsing body");
+ let body = parsed_msg
.get_body_raw()
- .context("cannot get body from parsed message")?;
- let content = String::from_utf8(content).context("cannot decode body from utf-8")?;
- msg.parts.push(Part::TextPlain(TextPlainPart { content }));
+ .context("cannot get raw body from message")
+ .and_then(|body| String::from_utf8(body).context("cannot decode body from utf8"))?;
+ trace!("body: {:?}", body);
+ msg.parts
+ .push(Part::TextPlain(TextPlainPart { content: body }));
+
+ info!("end: building message from template");
+ trace!("message: {:?}", msg);
Ok(msg)
}
-}
-impl TryInto for Msg {
- type Error = Error;
-
- fn try_into(self) -> Result {
- let from: Option = self
- .from
- .and_then(|addrs| addrs.into_iter().next())
- .map(|addr| addr.email);
- let to = self
- .to
- .map(|addrs| addrs.into_iter().map(|addr| addr.email).collect())
- .unwrap_or_default();
- let envelope =
- lettre::address::Envelope::new(from, to).context("cannot create envelope")?;
-
- Ok(envelope)
- }
-}
-
-impl TryInto for &Msg {
- type Error = Error;
-
- fn try_into(self) -> Result {
+ pub fn into_sendable_msg(&self, account: &Account) -> Result {
let mut msg_builder = lettre::Message::builder()
.message_id(self.message_id.to_owned())
.subject(self.subject.to_owned());
@@ -678,17 +664,42 @@ impl TryInto for &Msg {
.fold(msg_builder, |builder, addr| builder.bcc(addr.to_owned()))
};
- let mut multipart =
- MultiPart::mixed().singlepart(SinglePart::plain(self.fold_text_plain_parts()));
+ let mut multipart = {
+ let mut multipart =
+ MultiPart::mixed().singlepart(SinglePart::plain(self.fold_text_plain_parts()));
+ for part in self.attachments() {
+ multipart = multipart.singlepart(Attachment::new(part.filename.clone()).body(
+ part.content,
+ part.mime.parse().context(format!(
+ "cannot parse content type of attachment {}",
+ part.filename
+ ))?,
+ ))
+ }
+ multipart
+ };
- for part in self.attachments() {
- let filename = part.filename;
- let content = part.content;
- let mime = part.mime.parse().context(format!(
- r#"cannot parse content type of attachment "{}""#,
- filename
- ))?;
- multipart = multipart.singlepart(Attachment::new(filename).body(content, mime))
+ if self.encrypt {
+ let multipart_buffer = temp_dir().join(Uuid::new_v4().to_string());
+ fs::write(multipart_buffer.clone(), multipart.formatted())?;
+ let encrypted_multipart = account
+ .pgp_encrypt_file(
+ &self.to.as_ref().unwrap().first().unwrap().email.to_string(),
+ multipart_buffer.clone(),
+ )?
+ .ok_or_else(|| anyhow!("cannot find pgp encrypt command in config"))?;
+ trace!("encrypted multipart: {:#?}", encrypted_multipart);
+ multipart = MultiPart::encrypted(String::from("application/pgp-encrypted"))
+ .singlepart(
+ SinglePart::builder()
+ .header(ContentType::parse("application/pgp-encrypted").unwrap())
+ .body(String::from("Version: 1")),
+ )
+ .singlepart(
+ SinglePart::builder()
+ .header(ContentType::parse("application/octet-stream").unwrap())
+ .body(encrypted_multipart),
+ )
}
msg_builder
@@ -697,19 +708,29 @@ impl TryInto for &Msg {
}
}
-impl TryInto> for &Msg {
+impl TryInto for Msg {
type Error = Error;
- fn try_into(self) -> Result> {
- let msg: lettre::Message = self.try_into()?;
- Ok(msg.formatted())
+ fn try_into(self) -> Result {
+ let from: Option = self
+ .from
+ .and_then(|addrs| addrs.into_iter().next())
+ .map(|addr| addr.email);
+ let to = self
+ .to
+ .map(|addrs| addrs.into_iter().map(|addr| addr.email).collect())
+ .unwrap_or_default();
+ let envelope =
+ lettre::address::Envelope::new(from, to).context("cannot create envelope")?;
+
+ Ok(envelope)
}
}
-impl<'a> TryFrom<&'a imap::types::Fetch> for Msg {
+impl<'a> TryFrom<(&'a Account, &'a imap::types::Fetch)> for Msg {
type Error = Error;
- fn try_from(fetch: &'a imap::types::Fetch) -> Result {
+ fn try_from((account, fetch): (&'a Account, &'a imap::types::Fetch)) -> Result {
let envelope = fetch
.envelope()
.ok_or_else(|| anyhow!("cannot get envelope of message {}", fetch.message))?;
@@ -737,28 +758,28 @@ impl<'a> TryFrom<&'a imap::types::Fetch> for Msg {
.sender
.as_deref()
.or_else(|| envelope.from.as_deref())
- .map(parse_addrs)
+ .map(to_addrs)
{
Some(addrs) => Some(addrs?),
None => None,
};
// Get the "Reply-To" address(es)
- let reply_to = parse_some_addrs(&envelope.reply_to).context(format!(
+ let reply_to = to_some_addrs(&envelope.reply_to).context(format!(
r#"cannot parse "reply to" address of message {}"#,
id
))?;
// Get the recipient(s) address(es)
- let to = parse_some_addrs(&envelope.to)
+ let to = to_some_addrs(&envelope.to)
.context(format!(r#"cannot parse "to" address of message {}"#, id))?;
// Get the "Cc" recipient(s) address(es)
- let cc = parse_some_addrs(&envelope.cc)
+ let cc = to_some_addrs(&envelope.cc)
.context(format!(r#"cannot parse "cc" address of message {}"#, id))?;
// Get the "Bcc" recipient(s) address(es)
- let bcc = parse_some_addrs(&envelope.bcc)
+ let bcc = to_some_addrs(&envelope.bcc)
.context(format!(r#"cannot parse "bcc" address of message {}"#, id))?;
// Get the "In-Reply-To" message identifier
@@ -785,14 +806,12 @@ impl<'a> TryFrom<&'a imap::types::Fetch> for Msg {
let date = fetch.internal_date();
// Get all parts
- let parts = Parts::from(
- &mailparse::parse_mail(
- fetch
- .body()
- .ok_or_else(|| anyhow!("cannot get body of message {}", id))?,
- )
- .context(format!("cannot parse body of message {}", id))?,
- );
+ let body = fetch
+ .body()
+ .ok_or_else(|| anyhow!("cannot get body of message {}", id))?;
+ let parsed_mail =
+ mailparse::parse_mail(body).context(format!("cannot parse body of message {}", id))?;
+ let parts = Parts::from_parsed_mail(account, &parsed_mail)?;
Ok(Self {
id,
@@ -807,11 +826,29 @@ impl<'a> TryFrom<&'a imap::types::Fetch> for Msg {
message_id,
date,
parts,
+ encrypt: false,
})
}
}
-pub fn parse_addr(addr: &imap_proto::Address) -> Result {
+pub fn parse_addr + Debug>(raw_addr: S) -> Result {
+ raw_addr
+ .as_ref()
+ .trim()
+ .parse()
+ .context(format!("cannot parse address {:?}", raw_addr))
+}
+
+pub fn parse_addrs + Debug>(raw_addrs: S) -> Result>> {
+ let mut addrs: Vec = vec![];
+ for raw_addr in raw_addrs.as_ref().split(',') {
+ addrs
+ .push(parse_addr(raw_addr).context(format!("cannot parse addresses {:?}", raw_addrs))?);
+ }
+ Ok(if addrs.is_empty() { None } else { Some(addrs) })
+}
+
+pub fn to_addr(addr: &imap_proto::Address) -> Result {
let name = addr
.name
.as_ref()
@@ -839,17 +876,16 @@ pub fn parse_addr(addr: &imap_proto::Address) -> Result {
Ok(Addr::new(name, lettre::Address::new(mbox, host)?))
}
-pub fn parse_addrs(addrs: &[imap_proto::Address]) -> Result> {
+pub fn to_addrs(addrs: &[imap_proto::Address]) -> Result> {
let mut parsed_addrs = vec![];
for addr in addrs {
- parsed_addrs
- .push(parse_addr(addr).context(format!(r#"cannot parse address "{:?}""#, addr))?);
+ parsed_addrs.push(to_addr(addr).context(format!(r#"cannot parse address "{:?}""#, addr))?);
}
Ok(parsed_addrs)
}
-pub fn parse_some_addrs(addrs: &Option>) -> Result>> {
- Ok(match addrs.as_deref().map(parse_addrs) {
+pub fn to_some_addrs(addrs: &Option>) -> Result>> {
+ Ok(match addrs.as_deref().map(to_addrs) {
Some(addrs) => Some(addrs?),
None => None,
})
diff --git a/src/domain/msg/msg_handler.rs b/src/domain/msg/msg_handler.rs
index b1b7fd4..12a0572 100644
--- a/src/domain/msg/msg_handler.rs
+++ b/src/domain/msg/msg_handler.rs
@@ -33,7 +33,7 @@ pub fn attachments<'a, Printer: PrinterService, ImapService: ImapServiceInterfac
printer: &mut Printer,
imap: &mut ImapService,
) -> Result<()> {
- let attachments = imap.find_msg(seq)?.attachments();
+ let attachments = imap.find_msg(account, seq)?.attachments();
let attachments_len = attachments.len();
debug!(
r#"{} attachment(s) found for message "{}""#,
@@ -91,14 +91,16 @@ pub fn forward<
>(
seq: &str,
attachments_paths: Vec<&str>,
+ encrypt: bool,
account: &Account,
printer: &mut Printer,
imap: &mut ImapService,
smtp: &mut SmtpService,
) -> Result<()> {
- imap.find_msg(seq)?
+ imap.find_msg(account, seq)?
.into_forward(account)?
.add_attachments(attachments_paths)?
+ .encrypt(encrypt)
.edit_with_editor(account, printer, imap, smtp)
}
@@ -119,7 +121,7 @@ pub fn list<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>(
printer.print_table(msgs, PrintTableOpts { max_width })
}
-/// Parse and edit a message from a [mailto] URL string.
+/// Parses and edits a message from a [mailto] URL string.
///
/// [mailto]: https://en.wikipedia.org/wiki/Mailto
pub fn mailto<
@@ -134,6 +136,8 @@ pub fn mailto<
imap: &mut ImapService,
smtp: &mut SmtpService,
) -> Result<()> {
+ info!("entering mailto command handler");
+
let to: Vec = url
.path()
.split(';')
@@ -173,6 +177,7 @@ pub fn mailto<
})]),
..Msg::default()
};
+ trace!("message: {:?}", msg);
msg.edit_with_editor(account, printer, imap, smtp)
}
@@ -208,6 +213,7 @@ pub fn read<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>(
seq: &str,
text_mime: &str,
raw: bool,
+ account: &Account,
printer: &mut Printer,
imap: &mut ImapService,
) -> Result<()> {
@@ -215,7 +221,7 @@ pub fn read<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>(
// Emails don't always have valid utf8. Using "lossy" to display what we can.
String::from_utf8_lossy(&imap.find_raw_msg(seq)?).into_owned()
} else {
- imap.find_msg(seq)?.fold_text_parts(text_mime)
+ imap.find_msg(account, seq)?.fold_text_parts(text_mime)
};
printer.print(msg)
@@ -231,14 +237,16 @@ pub fn reply<
seq: &str,
all: bool,
attachments_paths: Vec<&str>,
+ encrypt: bool,
account: &Account,
printer: &mut Printer,
imap: &mut ImapService,
smtp: &mut SmtpService,
) -> Result<()> {
- imap.find_msg(seq)?
+ imap.find_msg(account, seq)?
.into_reply(all, account)?
.add_attachments(attachments_paths)?
+ .encrypt(encrypt)
.edit_with_editor(account, printer, imap, smtp)?;
let flags = Flags::try_from(vec![Flag::Answered])?;
imap.add_flags(seq, &flags)
@@ -344,6 +352,7 @@ pub fn write<
SmtpService: SmtpServiceInterface,
>(
attachments_paths: Vec<&str>,
+ encrypt: bool,
account: &Account,
printer: &mut Printer,
imap: &mut ImapService,
@@ -351,5 +360,6 @@ pub fn write<
) -> Result<()> {
Msg::default()
.add_attachments(attachments_paths)?
+ .encrypt(encrypt)
.edit_with_editor(account, printer, imap, smtp)
}
diff --git a/src/domain/msg/parts_entity.rs b/src/domain/msg/parts_entity.rs
index 51e80be..07875ef 100644
--- a/src/domain/msg/parts_entity.rs
+++ b/src/domain/msg/parts_entity.rs
@@ -1,6 +1,13 @@
+use anyhow::{anyhow, Context, Result};
use mailparse::MailHeaderMap;
use serde::Serialize;
-use std::ops::{Deref, DerefMut};
+use std::{
+ env, fs,
+ ops::{Deref, DerefMut},
+};
+use uuid::Uuid;
+
+use crate::config::Account;
#[derive(Debug, Clone, Default, Serialize)]
pub struct TextPlainPart {
@@ -43,9 +50,13 @@ impl Parts {
self.push(Part::TextPlain(part));
}
- pub fn replace_text_html_parts_with(&mut self, part: TextHtmlPart) {
- self.retain(|part| !matches!(part, Part::TextHtml(_)));
- self.push(Part::TextHtml(part));
+ pub fn from_parsed_mail<'a>(
+ account: &'a Account,
+ part: &'a mailparse::ParsedMail<'a>,
+ ) -> Result {
+ let mut parts = vec![];
+ build_parts_map_rec(account, part, &mut parts)?;
+ Ok(Self(parts))
}
}
@@ -63,25 +74,21 @@ impl DerefMut for Parts {
}
}
-impl<'a> From<&'a mailparse::ParsedMail<'a>> for Parts {
- fn from(part: &'a mailparse::ParsedMail<'a>) -> Self {
- let mut parts = vec![];
- build_parts_map_rec(part, &mut parts);
- Self(parts)
- }
-}
-
-fn build_parts_map_rec(part: &mailparse::ParsedMail, parts: &mut Vec) {
- if part.subparts.is_empty() {
- let content_disp = part.get_content_disposition();
- match content_disp.disposition {
+fn build_parts_map_rec(
+ account: &Account,
+ parsed_mail: &mailparse::ParsedMail,
+ parts: &mut Vec,
+) -> Result<()> {
+ if parsed_mail.subparts.is_empty() {
+ let cdisp = parsed_mail.get_content_disposition();
+ match cdisp.disposition {
mailparse::DispositionType::Attachment => {
- let filename = content_disp
+ let filename = cdisp
.params
.get("filename")
.map(String::from)
.unwrap_or_else(|| String::from("noname"));
- let content = part.get_body_raw().unwrap_or_default();
+ let content = parsed_mail.get_body_raw().unwrap_or_default();
let mime = tree_magic::from_u8(&content);
parts.push(Part::Binary(BinaryPart {
filename,
@@ -91,8 +98,8 @@ fn build_parts_map_rec(part: &mailparse::ParsedMail, parts: &mut Vec) {
}
// TODO: manage other use cases
_ => {
- if let Some(ctype) = part.get_headers().get_first_value("content-type") {
- let content = part.get_body().unwrap_or_default();
+ if let Some(ctype) = parsed_mail.get_headers().get_first_value("content-type") {
+ let content = parsed_mail.get_body().unwrap_or_default();
if ctype.starts_with("text/plain") {
parts.push(Part::TextPlain(TextPlainPart { content }))
} else if ctype.starts_with("text/html") {
@@ -102,8 +109,38 @@ fn build_parts_map_rec(part: &mailparse::ParsedMail, parts: &mut Vec) {
}
};
} else {
- part.subparts
- .iter()
- .for_each(|part| build_parts_map_rec(part, parts));
+ let ctype = parsed_mail
+ .get_headers()
+ .get_first_value("content-type")
+ .ok_or_else(|| anyhow!("cannot get content type of multipart"))?;
+ if ctype.starts_with("multipart/encrypted") {
+ let decrypted_part = parsed_mail
+ .subparts
+ .get(1)
+ .ok_or_else(|| anyhow!("cannot find encrypted part of multipart"))
+ .and_then(|part| decrypt_part(account, part))
+ .context("cannot decrypt part of multipart")?;
+ let parsed_mail = mailparse::parse_mail(decrypted_part.as_bytes())
+ .context("cannot parse decrypted part of multipart")?;
+ build_parts_map_rec(account, &parsed_mail, parts)?;
+ } else {
+ for part in parsed_mail.subparts.iter() {
+ build_parts_map_rec(account, part, parts)?;
+ }
+ }
}
+
+ Ok(())
+}
+
+fn decrypt_part(account: &Account, msg: &mailparse::ParsedMail) -> Result {
+ let msg_path = env::temp_dir().join(Uuid::new_v4().to_string());
+ let msg_body = msg
+ .get_body()
+ .context("cannot get body from encrypted part")?;
+ fs::write(msg_path.clone(), &msg_body)
+ .context(format!("cannot write encrypted part to temporary file"))?;
+ account
+ .pgp_decrypt_file(msg_path.clone())?
+ .ok_or_else(|| anyhow!("cannot find pgp decrypt command in config"))
}
diff --git a/src/domain/msg/tpl_arg.rs b/src/domain/msg/tpl_arg.rs
index ccc4e86..8816d09 100644
--- a/src/domain/msg/tpl_arg.rs
+++ b/src/domain/msg/tpl_arg.rs
@@ -51,15 +51,17 @@ pub enum Command<'a> {
/// Message template command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result>> {
+ info!("entering message template command matcher");
+
if let Some(m) = m.subcommand_matches("new") {
- info!("new command matched");
+ info!("new subcommand matched");
let tpl = TplOverride::from(m);
trace!("template override: {:?}", tpl);
return Ok(Some(Command::New(tpl)));
}
if let Some(m) = m.subcommand_matches("reply") {
- info!("reply command matched");
+ info!("reply subcommand matched");
let seq = m.value_of("seq").unwrap();
debug!("sequence: {}", seq);
let all = m.is_present("reply-all");
@@ -70,7 +72,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result >> {
}
if let Some(m) = m.subcommand_matches("forward") {
- info!("forward command matched");
+ info!("forward subcommand matched");
let seq = m.value_of("seq").unwrap();
debug!("sequence: {}", seq);
let tpl = TplOverride::from(m);
@@ -79,7 +81,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result >> {
}
if let Some(m) = m.subcommand_matches("save") {
- info!("save command matched");
+ info!("save subcommand matched");
let attachment_paths: Vec<&str> = m.values_of("attachments").unwrap_or_default().collect();
trace!("attachments paths: {:?}", attachment_paths);
let tpl = m.value_of("template").unwrap_or_default();
@@ -88,7 +90,7 @@ pub fn matches<'a>(m: &'a ArgMatches) -> Result >> {
}
if let Some(m) = m.subcommand_matches("send") {
- info!("send command matched");
+ info!("send subcommand matched");
let attachment_paths: Vec<&str> = m.values_of("attachments").unwrap_or_default().collect();
trace!("attachments paths: {:?}", attachment_paths);
let tpl = m.value_of("template").unwrap_or_default();
diff --git a/src/domain/msg/tpl_handler.rs b/src/domain/msg/tpl_handler.rs
index fbfe5e2..c809e4d 100644
--- a/src/domain/msg/tpl_handler.rs
+++ b/src/domain/msg/tpl_handler.rs
@@ -6,7 +6,7 @@ use anyhow::Result;
use atty::Stream;
use imap::types::Flag;
use std::{
- convert::{TryFrom, TryInto},
+ convert::TryFrom,
io::{self, BufRead},
};
@@ -40,7 +40,7 @@ pub fn reply<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>
imap: &'a mut ImapService,
) -> Result<()> {
let tpl = imap
- .find_msg(seq)?
+ .find_msg(account, seq)?
.into_reply(all, account)?
.to_tpl(opts, account);
printer.print(tpl)
@@ -55,7 +55,7 @@ pub fn forward<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a
imap: &'a mut ImapService,
) -> Result<()> {
let tpl = imap
- .find_msg(seq)?
+ .find_msg(account, seq)?
.into_forward(account)?
.to_tpl(opts, account);
printer.print(tpl)
@@ -64,6 +64,7 @@ pub fn forward<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a
/// Saves a message based on a template.
pub fn save<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>(
mbox: &Mbox,
+ account: &Account,
attachments_paths: Vec<&str>,
tpl: &str,
printer: &mut Printer,
@@ -80,7 +81,7 @@ pub fn save<'a, Printer: PrinterService, ImapService: ImapServiceInterface<'a>>(
.join("\n")
};
let msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?;
- let raw_msg: Vec = TryInto::try_into(&msg)?;
+ let raw_msg = msg.into_sendable_msg(account)?.formatted();
let flags = Flags::try_from(vec![Flag::Seen])?;
imap.append_raw_msg_with_flags(mbox, &raw_msg, flags)?;
printer.print("Template successfully saved")
@@ -94,6 +95,7 @@ pub fn send<
SmtpService: SmtpServiceInterface,
>(
mbox: &Mbox,
+ account: &Account,
attachments_paths: Vec<&str>,
tpl: &str,
printer: &mut Printer,
@@ -111,7 +113,7 @@ pub fn send<
.join("\n")
};
let msg = Msg::from_tpl(&tpl)?.add_attachments(attachments_paths)?;
- let sent_msg = smtp.send_msg(&msg)?;
+ let sent_msg = smtp.send_msg(account, &msg)?;
let flags = Flags::try_from(vec![Flag::Seen])?;
imap.append_raw_msg_with_flags(mbox, &sent_msg.formatted(), flags)?;
printer.print("Template successfully sent")
diff --git a/src/domain/smtp/smtp_service.rs b/src/domain/smtp/smtp_service.rs
index eae8aa0..0817503 100644
--- a/src/domain/smtp/smtp_service.rs
+++ b/src/domain/smtp/smtp_service.rs
@@ -8,12 +8,11 @@ use lettre::{
Transport,
};
use log::debug;
-use std::convert::TryInto;
use crate::{config::Account, domain::msg::Msg};
pub trait SmtpServiceInterface {
- fn send_msg(&mut self, msg: &Msg) -> Result;
+ fn send_msg(&mut self, account: &Account, msg: &Msg) -> Result;
fn send_raw_msg(&mut self, envelope: &lettre::address::Envelope, msg: &[u8]) -> Result<()>;
}
@@ -57,9 +56,9 @@ impl<'a> SmtpService<'a> {
}
impl<'a> SmtpServiceInterface for SmtpService<'a> {
- fn send_msg(&mut self, msg: &Msg) -> Result {
+ fn send_msg(&mut self, account: &Account, msg: &Msg) -> Result {
debug!("sending message…");
- let sendable_msg: lettre::Message = msg.try_into()?;
+ let sendable_msg = msg.into_sendable_msg(account)?;
self.transport()?.send(&sendable_msg)?;
Ok(sendable_msg)
}
diff --git a/src/main.rs b/src/main.rs
index 76863ee..8ea73d2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -36,10 +36,8 @@ fn create_app<'a>() -> clap::App<'a, 'a> {
#[allow(clippy::single_match)]
fn main() -> Result<()> {
- // Init env logger
- env_logger::init_from_env(
- env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "off"),
- );
+ let default_env_filter = env_logger::DEFAULT_FILTER_ENV;
+ env_logger::init_from_env(env_logger::Env::default().filter_or(default_env_filter, "off"));
// Check mailto command BEFORE app initialization.
let raw_args: Vec = env::args().collect();
@@ -77,7 +75,7 @@ fn main() -> Result<()> {
// Check IMAP commands.
match imap_arg::matches(&m)? {
Some(imap_arg::Command::Notify(keepalive)) => {
- return imap_handler::notify(keepalive, &config, &mut imap);
+ return imap_handler::notify(keepalive, &config, &account, &mut imap);
}
Some(imap_arg::Command::Watch(keepalive)) => {
return imap_handler::watch(keepalive, &account, &mut imap);
@@ -104,8 +102,16 @@ fn main() -> Result<()> {
Some(msg_arg::Command::Delete(seq)) => {
return msg_handler::delete(seq, &mut printer, &mut imap);
}
- Some(msg_arg::Command::Forward(seq, atts)) => {
- return msg_handler::forward(seq, atts, &account, &mut printer, &mut imap, &mut smtp);
+ Some(msg_arg::Command::Forward(seq, attachment_paths, encrypt)) => {
+ return msg_handler::forward(
+ seq,
+ attachment_paths,
+ encrypt,
+ &account,
+ &mut printer,
+ &mut imap,
+ &mut smtp,
+ );
}
Some(msg_arg::Command::List(max_width, page_size, page)) => {
return msg_handler::list(
@@ -121,13 +127,14 @@ fn main() -> Result<()> {
return msg_handler::move_(seq, mbox, &mut printer, &mut imap);
}
Some(msg_arg::Command::Read(seq, text_mime, raw)) => {
- return msg_handler::read(seq, text_mime, raw, &mut printer, &mut imap);
+ return msg_handler::read(seq, text_mime, raw, &account, &mut printer, &mut imap);
}
- Some(msg_arg::Command::Reply(seq, all, atts)) => {
+ Some(msg_arg::Command::Reply(seq, all, attachment_paths, encrypt)) => {
return msg_handler::reply(
seq,
all,
- atts,
+ attachment_paths,
+ encrypt,
&account,
&mut printer,
&mut imap,
@@ -151,8 +158,8 @@ fn main() -> Result<()> {
Some(msg_arg::Command::Send(raw_msg)) => {
return msg_handler::send(raw_msg, &account, &mut printer, &mut imap, &mut smtp);
}
- Some(msg_arg::Command::Write(atts)) => {
- return msg_handler::write(atts, &account, &mut printer, &mut imap, &mut smtp);
+ Some(msg_arg::Command::Write(atts, encrypt)) => {
+ return msg_handler::write(atts, encrypt, &account, &mut printer, &mut imap, &mut smtp);
}
Some(msg_arg::Command::Flag(m)) => match m {
Some(flag_arg::Command::Set(seq_range, flags)) => {
@@ -177,10 +184,18 @@ fn main() -> Result<()> {
return tpl_handler::forward(seq, tpl, &account, &mut printer, &mut imap);
}
Some(tpl_arg::Command::Save(atts, tpl)) => {
- return tpl_handler::save(&mbox, atts, tpl, &mut printer, &mut imap);
+ return tpl_handler::save(&mbox, &account, atts, tpl, &mut printer, &mut imap);
}
Some(tpl_arg::Command::Send(atts, tpl)) => {
- return tpl_handler::send(&mbox, atts, tpl, &mut printer, &mut imap, &mut smtp);
+ return tpl_handler::send(
+ &mbox,
+ &account,
+ atts,
+ tpl,
+ &mut printer,
+ &mut imap,
+ &mut smtp,
+ );
}
_ => (),
},
diff --git a/src/output/output_utils.rs b/src/output/output_utils.rs
index 4f44494..779089b 100644
--- a/src/output/output_utils.rs
+++ b/src/output/output_utils.rs
@@ -1,8 +1,11 @@
use anyhow::Result;
+use log::debug;
use std::process::Command;
/// TODO: move this in a more approriate place.
pub fn run_cmd(cmd: &str) -> Result {
+ debug!("running command: {}", cmd);
+
let output = if cfg!(target_os = "windows") {
Command::new("cmd").args(&["/C", cmd]).output()
} else {
diff --git a/tests/emails/alice-to-patrick-encrypted.eml b/tests/emails/alice-to-patrick-encrypted.eml
new file mode 100644
index 0000000..8d2c631
--- /dev/null
+++ b/tests/emails/alice-to-patrick-encrypted.eml
@@ -0,0 +1,19 @@
+From: alice@localhost
+To: patrick@localhost
+Subject: Encrypted message
+Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; boundary="boundary"
+
+--boundary
+Content-Type: application/pgp-encrypted
+
+Version: 1
+
+--boundary
+Content-Type: application/octet-stream
+
+-----BEGIN PGP MESSAGE-----
+
+
+-----END PGP MESSAGE-----
+
+--boundary
\ No newline at end of file
diff --git a/tests/keys/alice.asc b/tests/keys/alice.asc
new file mode 100644
index 0000000..e389b19
--- /dev/null
+++ b/tests/keys/alice.asc
@@ -0,0 +1,81 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
+=XrAt
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/tests/keys/alice.pub.asc b/tests/keys/alice.pub.asc
new file mode 100644
index 0000000..728c8ff
--- /dev/null
+++ b/tests/keys/alice.pub.asc
@@ -0,0 +1,41 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+qtuSHJqRM01nuXfcSw==
+=JGp0
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/tests/keys/patrick.asc b/tests/keys/patrick.asc
new file mode 100644
index 0000000..3461a66
--- /dev/null
+++ b/tests/keys/patrick.asc
@@ -0,0 +1,81 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+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
+=OpBu
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/tests/keys/patrick.pub.asc b/tests/keys/patrick.pub.asc
new file mode 100644
index 0000000..ffe351d
--- /dev/null
+++ b/tests/keys/patrick.pub.asc
@@ -0,0 +1,41 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+dnvixO4Xra6y6ZQzUoduR4I=
+=CQBw
+-----END PGP PUBLIC KEY BLOCK-----