From 1536fdb894adfb40f22bc33b56ed528fd3d16cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Fri, 15 Jan 2021 12:21:07 +0100 Subject: [PATCH] implement download attachments feature --- CHANGELOG.md | 7 +++++++ README.md | 15 ++++++++------- src/config.rs | 22 ++++++++++++++------- src/email.rs | 6 +++--- src/imap.rs | 4 ++++ src/main.rs | 53 +++++++++++++++++++++++++++++++++++++++------------ src/msg.rs | 34 +++++++++++++++++++++++++++++++++ 7 files changed, 112 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc62805..4b673648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Text and HTML previews [#12] [#13] - Set up SMTP connection [#4] - Write new email [#8] +- Write new email [#8] +- Reply, reply all and forward [#9] [#10] [#11] +- Download attachments [#14] [unreleased]: https://github.com/soywod/himalaya @@ -27,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#4]: https://github.com/soywod/himalaya/issues/4 [#5]: https://github.com/soywod/himalaya/issues/5 [#8]: https://github.com/soywod/himalaya/issues/8 +[#9]: https://github.com/soywod/himalaya/issues/9 +[#10]: https://github.com/soywod/himalaya/issues/10 +[#11]: https://github.com/soywod/himalaya/issues/11 [#12]: https://github.com/soywod/himalaya/issues/12 [#13]: https://github.com/soywod/himalaya/issues/13 +[#14]: https://github.com/soywod/himalaya/issues/14 [#15]: https://github.com/soywod/himalaya/issues/15 diff --git a/README.md b/README.md index dbf25263..e2a89aa8 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,12 @@ FLAGS: -V, --version Prints version information SUBCOMMANDS: - forward Forwards an email by its UID - help Prints this message or the help of the given subcommand(s) - list Lists all available mailboxes - read Reads an email by its UID - reply Replies to an email by its UID - search Lists emails matching the given IMAP query - write Writes a new email + attachments Downloads all attachments from an email + forward Forwards an email + help Prints this message or the help of the given subcommand(s) + list Lists all available mailboxes + read Reads text bodies of an email + reply Answers to an email + search Lists emails matching the given IMAP query + write Writes a new email ``` diff --git a/src/config.rs b/src/config.rs index b83c9313..33365d7a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -77,6 +77,7 @@ impl ServerInfo { pub struct Config { pub name: String, pub email: String, + pub downloads_dir: Option, pub imap: ServerInfo, pub smtp: ServerInfo, } @@ -91,7 +92,7 @@ impl Config { Ok(path) } - fn path_from_home(_err: Error) -> Result { + fn path_from_home() -> Result { let path = env::var("HOME")?; let mut path = PathBuf::from(path); path.push(".config"); @@ -101,7 +102,7 @@ impl Config { Ok(path) } - fn path_from_tmp(_err: Error) -> Result { + fn path_from_tmp() -> Result { let mut path = env::temp_dir(); path.push("himalaya"); path.push("config.toml"); @@ -112,18 +113,25 @@ impl Config { pub fn new_from_file() -> Result { let mut file = File::open( Self::path_from_xdg() - .or_else(Self::path_from_home) - .or_else(Self::path_from_tmp) + .or_else(|_| Self::path_from_home()) + .or_else(|_| Self::path_from_tmp()) .or_else(|_| Err(Error::GetPathNotFoundError))?, )?; - let mut content = String::new(); - file.read_to_string(&mut content)?; + let mut content = vec![]; + file.read_to_end(&mut content)?; - Ok(toml::from_str(&content)?) + Ok(toml::from_slice(&content)?) } pub fn email_full(&self) -> String { format!("{} <{}>", self.name, self.email) } + + pub fn downloads_filepath(&self, filename: &str) -> PathBuf { + let temp_dir = env::temp_dir(); + let mut full_path = self.downloads_dir.as_ref().unwrap_or(&temp_dir).to_owned(); + full_path.push(filename); + full_path + } } diff --git a/src/email.rs b/src/email.rs index 257253e1..390b1cca 100644 --- a/src/email.rs +++ b/src/email.rs @@ -205,7 +205,7 @@ fn extract_text_bodies_into(mime: &str, part: &mailparse::ParsedMail, parts: &mu if part .get_headers() .get_first_value("content-type") - .and_then(|v| if v.starts_with(mime) { Some(()) } else { None }) + .and_then(|v| if v.starts_with(&mime) { Some(()) } else { None }) .is_some() { parts.push(part.get_body().unwrap_or(String::new())) @@ -214,13 +214,13 @@ fn extract_text_bodies_into(mime: &str, part: &mailparse::ParsedMail, parts: &mu _ => { part.subparts .iter() - .for_each(|part| extract_text_bodies_into(mime, part, parts)); + .for_each(|part| extract_text_bodies_into(&mime, part, parts)); } } } pub fn extract_text_bodies(mime: &str, email: &mailparse::ParsedMail) -> String { let mut parts = vec![]; - extract_text_bodies_into(mime, email, &mut parts); + extract_text_bodies_into(&mime, email, &mut parts); parts.join("\r\n") } diff --git a/src/imap.rs b/src/imap.rs index 57d7968c..1dcfab62 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -15,6 +15,7 @@ pub enum Error { ParseEmailError(mailparse::MailParseError), ReadEmailNotFoundError(String), ReadEmailEmptyPartError(String, String), + ExtractAttachmentsEmptyError(String), } impl fmt::Display for Error { @@ -30,6 +31,9 @@ impl fmt::Display for Error { Error::ReadEmailEmptyPartError(uid, mime) => { write!(f, "no {} content found for uid {}", mime, uid) } + Error::ExtractAttachmentsEmptyError(uid) => { + write!(f, "no attachment found for uid {}", uid) + } } } } diff --git a/src/main.rs b/src/main.rs index b5584c0d..eaeaeeb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ mod smtp; mod table; use clap::{App, AppSettings, Arg, SubCommand}; -use std::{fmt, process::exit, result}; +use std::{fmt, fs, process::exit, result}; use crate::config::Config; use crate::imap::ImapConnector; @@ -76,14 +76,14 @@ fn mailbox_arg() -> Arg<'static, 'static> { Arg::with_name("mailbox") .short("m") .long("mailbox") - .help("Name of the targeted mailbox") + .help("Name of the mailbox") .value_name("STRING") .default_value("INBOX") } fn uid_arg() -> Arg<'static, 'static> { Arg::with_name("uid") - .help("UID of the targeted email") + .help("UID of the email") .value_name("UID") .required(true) } @@ -109,7 +109,7 @@ fn run() -> Result<()> { ) .subcommand( SubCommand::with_name("read") - .about("Reads an email by its UID") + .about("Reads text bodies of an email") .arg(uid_arg()) .arg(mailbox_arg()) .arg( @@ -118,26 +118,32 @@ fn run() -> Result<()> { .short("t") .long("mime-type") .value_name("STRING") - .possible_values(&["text/plain", "text/html"]) - .default_value("text/plain"), + .possible_values(&["plain", "html"]) + .default_value("plain"), ), ) + .subcommand( + SubCommand::with_name("attachments") + .about("Downloads all attachments from an email") + .arg(uid_arg()) + .arg(mailbox_arg()), + ) .subcommand(SubCommand::with_name("write").about("Writes a new email")) .subcommand( SubCommand::with_name("reply") - .about("Replies to an email by its UID") + .about("Answers to an email") .arg(uid_arg()) .arg(mailbox_arg()) .arg( - Arg::with_name("reply all") - .help("Replies to all recipients") + Arg::with_name("reply-all") + .help("Including all recipients") .short("a") .long("all"), ), ) .subcommand( SubCommand::with_name("forward") - .about("Forwards an email by its UID") + .about("Forwards an email") .arg(uid_arg()) .arg(mailbox_arg()), ) @@ -190,12 +196,35 @@ fn run() -> Result<()> { let config = Config::new_from_file()?; let mbox = matches.value_of("mailbox").unwrap(); let uid = matches.value_of("uid").unwrap(); - let mime = matches.value_of("mime-type").unwrap(); + let mime = format!("text/{}", matches.value_of("mime-type").unwrap()); let body = ImapConnector::new(&config.imap)?.read_email_body(&mbox, &uid, &mime)?; println!("{}", body); } + if let Some(matches) = matches.subcommand_matches("attachments") { + let config = Config::new_from_file()?; + let mbox = matches.value_of("mailbox").unwrap(); + let uid = matches.value_of("uid").unwrap(); + let mut imap_conn = ImapConnector::new(&config.imap)?; + + let msg = imap_conn.read_msg(&mbox, &uid)?; + let msg = Msg::from(&msg)?; + let parts = msg.extract_parts()?; + + if parts.is_empty() { + println!("No attachment found for message {}", uid); + } else { + println!("{} attachment(s) found for message {}", parts.len(), uid); + msg.extract_parts()?.iter().for_each(|(filename, bytes)| { + let filepath = config.downloads_filepath(&filename); + println!("Downloading {} …", filename); + fs::write(filepath, bytes).unwrap() + }); + println!("Done!"); + } + } + if let Some(_) = matches.subcommand_matches("write") { let config = Config::new_from_file()?; let mut imap_conn = ImapConnector::new(&config.imap)?; @@ -220,7 +249,7 @@ fn run() -> Result<()> { let msg = imap_conn.read_msg(&mbox, &uid)?; let msg = Msg::from(&msg)?; - let tpl = if matches.is_present("reply all") { + let tpl = if matches.is_present("reply-all") { msg.build_reply_all_tpl(&config)? } else { msg.build_reply_tpl(&config)? diff --git a/src/msg.rs b/src/msg.rs index be6dca00..efdbedbc 100644 --- a/src/msg.rs +++ b/src/msg.rs @@ -117,6 +117,40 @@ impl<'a> Msg<'a> { Ok(msg) } + fn extract_parts_into(part: &mailparse::ParsedMail, parts: &mut Vec<(String, Vec)>) { + match part.subparts.len() { + 0 => { + let content_disp = part.get_content_disposition(); + let content_type = part + .get_headers() + .get_first_value("content-type") + .unwrap_or_default(); + + let default_attachment_name = format!("attachment-{}", parts.len()); + let attachment_name = content_disp + .params + .get("filename") + .unwrap_or(&default_attachment_name) + .to_owned(); + + if !content_type.starts_with("text") { + parts.push((attachment_name, part.get_body_raw().unwrap_or_default())) + } + } + _ => { + part.subparts + .iter() + .for_each(|part| Self::extract_parts_into(part, parts)); + } + } + } + + pub fn extract_parts(&self) -> Result)>> { + let mut parts = vec![]; + Self::extract_parts_into(&self.0, &mut parts); + Ok(parts) + } + pub fn build_new_tpl(config: &Config) -> Result { let mut tpl = vec![];