release v0.5.5 (#290)

* update main screenshot readme

* add contributing file

* update changelog

* doc: remove roadmap from reame

* improve main comments

* improve arg and handler logs

* fix multiple recipients issue (#288)

* add notify-query config option (#289)

* set up end-to-end encryption (#287)

* init basic pgp encrypt/decrypt

* add small rpgp poc for (#286)

* improve decrypt parts logs

* add pgp-decrypt-cmd to config

* add pgp-encrypt-cmd to config

* init pgp signature

* improve decrypt part readability

* improve encrypt multipart, remove sign

* remove unused md5 lib

* add encrypt arg to reply and forward commands

* fix typos

* prepare v0.5.5
This commit is contained in:
Clément DOUIN 2022-02-08 14:50:09 +01:00 committed by GitHub
parent e33a9a72e9
commit 585fa77af5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 768 additions and 280 deletions

View file

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

42
CONTRIBUTING.md Normal file
View file

@ -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”<br>Don't capitalize first letter<br>No dot (.) at the end
### Pull Request
When you're finished with the changes, create a pull request, also known as a PR.

2
Cargo.lock generated
View file

@ -361,7 +361,7 @@ dependencies = [
[[package]]
name = "himalaya"
version = "0.5.4"
version = "0.5.5"
dependencies = [
"ammonia",
"anyhow",

View file

@ -1,7 +1,7 @@
[package]
name = "himalaya"
description = "Command-line interface for email management"
version = "0.5.4"
version = "0.5.5"
authors = ["soywod <clement.douin@posteo.net>"]
edition = "2018"
license-file = "LICENSE"

View file

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

View file

@ -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<Option<Command<'a>>> {
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)));
};

View file

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

View file

@ -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<String>,
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<String>,
pub pgp_decrypt_cmd: Option<String>,
}
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<Option<String>> {
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<Option<String>> {
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)
}
}

View file

@ -31,6 +31,8 @@ pub struct Config {
pub draft_folder: Option<String>,
/// Defines the notify command.
pub notify_cmd: Option<String>,
/// Customizes the IMAP query used to fetch new messages.
pub notify_query: Option<String>,
/// Defines the watch commands.
pub watch_cmds: Option<Vec<String>>,
@ -56,6 +58,8 @@ pub struct ConfigAccountEntry {
pub sent_folder: Option<String>,
/// Defines a specific draft folder name for this account.
pub draft_folder: Option<String>,
/// Customizes the IMAP query used to fetch new messages.
pub notify_query: Option<String>,
pub watch_cmds: Option<Vec<String>>,
pub default: Option<bool>,
pub email: String,
@ -73,6 +77,9 @@ pub struct ConfigAccountEntry {
pub smtp_insecure: Option<bool>,
pub smtp_login: String,
pub smtp_passwd_cmd: String,
pub pgp_encrypt_cmd: Option<String>,
pub pgp_decrypt_cmd: Option<String>,
}
impl Config {

View file

@ -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<Option<Command>> {
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)));

View file

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

View file

@ -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<TlsStream<TcpStream>>;
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<Mboxes>;
fn fetch_envelopes(&mut self, page_size: &usize, page: &usize) -> Result<Envelopes>;
@ -31,9 +26,9 @@ pub trait ImapServiceInterface<'a> {
page_size: &usize,
page: &usize,
) -> Result<Envelopes>;
fn find_msg(&mut self, seq: &str) -> Result<Msg>;
fn find_msg(&mut self, account: &Account, seq: &str) -> Result<Msg>;
fn find_raw_msg(&mut self, seq: &str) -> Result<Vec<u8>>;
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<Vec<u32>> {
fn search_new_msgs(&mut self, account: &Account) -> Result<Vec<u32>> {
let uids: Vec<u32> = 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<Msg> {
fn find_msg(&mut self, account: &Account, seq: &str) -> Result<Msg> {
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<Vec<u8>> {
@ -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<u8> = (&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<u32> = self
.search_new_msgs()?
.search_new_msgs(account)?
.iter()
.cloned()
.collect::<HashSet<_>>();
@ -281,7 +276,7 @@ impl<'a> ImapServiceInterface<'a> for ImapService<'a> {
.context("cannot start the idle mode")?;
let uids: Vec<u32> = self
.search_new_msgs()?
.search_new_msgs(account)?
.into_iter()
.filter(|uid| -> bool { msgs_set.get(uid).is_none() })
.collect();

View file

@ -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<Option<Cmd>> {
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::<usize>().ok());
trace!(r#"max table width: "{:?}""#, max_table_width);
debug!("max table width: {:?}", max_table_width);
return Ok(Some(Cmd::List(max_table_width)));
}

View file

@ -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<Envelopes> {
unimplemented!()
}
fn find_msg(&mut self, _: &str) -> Result<Msg> {
fn find_msg(&mut self, _: &Account, _: &str) -> Result<Msg> {
unimplemented!()
}
fn find_raw_msg(&mut self, _: &str) -> Result<Vec<u8>> {
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<()> {

View file

@ -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<Option<Command<'a>>> {
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)));
}

View file

@ -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<usize>;
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<PageSize>, 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<PageSize>, Page),
Send(RawMsg<'a>),
Write(AttachmentPaths<'a>),
Write(AttachmentPaths<'a>, Encrypt),
Flag(Option<flag_arg::Command<'a>>),
Tpl(Option<tpl_arg::Command<'a>>),
@ -47,46 +48,50 @@ pub enum Command<'a> {
/// Message command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result<Option<Command<'a>>> {
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::<usize>().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<Option<Command<'a>>> {
.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::<usize>().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<Option<Command<'a>>> {
.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<Option<Command<'a>>> {
})
.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<Option<Command<'a>>> {
}
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<Option<Command<'a>>> {
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<App<'a, 'a>> {
vec![
@ -297,7 +315,8 @@ pub fn subcmds<'a>() -> Vec<App<'a, 'a>> {
),
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<App<'a, 'a>> {
.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")

View file

@ -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<DateTime<FixedOffset>>,
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<Self> {
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<Self> {
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::<Vec<_>>(),
);
}
"to" => {
msg.to = Some(
val.split(',')
.filter_map(|addr| addr.parse().ok())
.collect::<Vec<_>>(),
);
}
"reply-to" => {
msg.reply_to = Some(
val.split(',')
.filter_map(|addr| addr.parse().ok())
.collect::<Vec<_>>(),
);
}
"in-reply-to" => msg.in_reply_to = Some(val.to_owned()),
"cc" => {
msg.cc = Some(
val.split(',')
.filter_map(|addr| addr.parse().ok())
.collect::<Vec<_>>(),
);
}
"bcc" => {
msg.bcc = Some(
val.split(',')
.filter_map(|addr| addr.parse().ok())
.collect::<Vec<_>>(),
);
}
"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<lettre::address::Envelope> for Msg {
type Error = Error;
fn try_into(self) -> Result<lettre::address::Envelope> {
let from: Option<lettre::Address> = 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<lettre::Message> for &Msg {
type Error = Error;
fn try_into(self) -> Result<lettre::Message> {
pub fn into_sendable_msg(&self, account: &Account) -> Result<lettre::Message> {
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<lettre::Message> 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<lettre::Message> for &Msg {
}
}
impl TryInto<Vec<u8>> for &Msg {
impl TryInto<lettre::address::Envelope> for Msg {
type Error = Error;
fn try_into(self) -> Result<Vec<u8>> {
let msg: lettre::Message = self.try_into()?;
Ok(msg.formatted())
fn try_into(self) -> Result<lettre::address::Envelope> {
let from: Option<lettre::Address> = 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<Msg> {
fn try_from((account, fetch): (&'a Account, &'a imap::types::Fetch)) -> Result<Msg> {
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<Addr> {
pub fn parse_addr<S: AsRef<str> + Debug>(raw_addr: S) -> Result<Addr> {
raw_addr
.as_ref()
.trim()
.parse()
.context(format!("cannot parse address {:?}", raw_addr))
}
pub fn parse_addrs<S: AsRef<str> + Debug>(raw_addrs: S) -> Result<Option<Vec<Addr>>> {
let mut addrs: Vec<Addr> = 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<Addr> {
let name = addr
.name
.as_ref()
@ -839,17 +876,16 @@ pub fn parse_addr(addr: &imap_proto::Address) -> Result<Addr> {
Ok(Addr::new(name, lettre::Address::new(mbox, host)?))
}
pub fn parse_addrs(addrs: &[imap_proto::Address]) -> Result<Vec<Addr>> {
pub fn to_addrs(addrs: &[imap_proto::Address]) -> Result<Vec<Addr>> {
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<Vec<imap_proto::Address>>) -> Result<Option<Vec<Addr>>> {
Ok(match addrs.as_deref().map(parse_addrs) {
pub fn to_some_addrs(addrs: &Option<Vec<imap_proto::Address>>) -> Result<Option<Vec<Addr>>> {
Ok(match addrs.as_deref().map(to_addrs) {
Some(addrs) => Some(addrs?),
None => None,
})

View file

@ -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<lettre::message::Mailbox> = 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)
}

View file

@ -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<Self> {
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<Part>) {
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<Part>,
) -> 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<Part>) {
}
// 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<Part>) {
}
};
} 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<String> {
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"))
}

View file

@ -51,15 +51,17 @@ pub enum Command<'a> {
/// Message template command matcher.
pub fn matches<'a>(m: &'a ArgMatches) -> Result<Option<Command<'a>>> {
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<Option<Command<'a>>> {
}
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<Option<Command<'a>>> {
}
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<Option<Command<'a>>> {
}
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();

View file

@ -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<u8> = 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")

View file

@ -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<lettre::Message>;
fn send_msg(&mut self, account: &Account, msg: &Msg) -> Result<lettre::Message>;
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<lettre::Message> {
fn send_msg(&mut self, account: &Account, msg: &Msg) -> Result<lettre::Message> {
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)
}

View file

@ -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<String> = 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,
);
}
_ => (),
},

View file

@ -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<String> {
debug!("running command: {}", cmd);
let output = if cfg!(target_os = "windows") {
Command::new("cmd").args(&["/C", cmd]).output()
} else {

View file

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

81
tests/keys/alice.asc Normal file
View file

@ -0,0 +1,81 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
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
=XrAt
-----END PGP PRIVATE KEY BLOCK-----

41
tests/keys/alice.pub.asc Normal file
View file

@ -0,0 +1,41 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
qtuSHJqRM01nuXfcSw==
=JGp0
-----END PGP PUBLIC KEY BLOCK-----

81
tests/keys/patrick.asc Normal file
View file

@ -0,0 +1,81 @@
-----BEGIN PGP PRIVATE KEY BLOCK-----
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
=OpBu
-----END PGP PRIVATE KEY BLOCK-----

View file

@ -0,0 +1,41 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
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
dnvixO4Xra6y6ZQzUoduR4I=
=CQBw
-----END PGP PUBLIC KEY BLOCK-----