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