implement download attachments feature

This commit is contained in:
Clément DOUIN 2021-01-15 12:21:07 +01:00
parent 6f7ee69cfe
commit 1536fdb894
No known key found for this signature in database
GPG key ID: 69C9B9CFFDEE2DEF
7 changed files with 112 additions and 29 deletions

View file

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

View file

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

View file

@ -77,6 +77,7 @@ impl ServerInfo {
pub struct Config {
pub name: String,
pub email: String,
pub downloads_dir: Option<PathBuf>,
pub imap: ServerInfo,
pub smtp: ServerInfo,
}
@ -91,7 +92,7 @@ impl Config {
Ok(path)
}
fn path_from_home(_err: Error) -> Result<PathBuf> {
fn path_from_home() -> Result<PathBuf> {
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<PathBuf> {
fn path_from_tmp() -> Result<PathBuf> {
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<Self> {
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
}
}

View file

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

View file

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

View file

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

View file

@ -117,6 +117,40 @@ impl<'a> Msg<'a> {
Ok(msg)
}
fn extract_parts_into(part: &mailparse::ParsedMail, parts: &mut Vec<(String, Vec<u8>)>) {
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<Vec<(String, Vec<u8>)>> {
let mut parts = vec![];
Self::extract_parts_into(&self.0, &mut parts);
Ok(parts)
}
pub fn build_new_tpl(config: &Config) -> Result<String> {
let mut tpl = vec![];