From 10c523fd2cae515207ecff7b12e792f7811adf97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Wed, 10 Mar 2021 16:46:47 +0100 Subject: [PATCH] improve read and attachments commands --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 51 ++++++++++----- src/msg.rs | 178 ++++++++++++++++++++++++++++++++++++++++++---------- 4 files changed, 180 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6330f3..ca42f10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,6 +240,7 @@ dependencies = [ "serde_json", "terminal_size", "toml", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 44fd112..d1ab2e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,4 @@ serde = { version = "1.0.118", features = ["derive"] } serde_json = "1.0.61" terminal_size = "0.1.15" toml = "0.5.8" +uuid = { version = "0.8", features = ["v4"] } diff --git a/src/main.rs b/src/main.rs index 88c1e16..08baa22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,12 +8,11 @@ mod smtp; mod table; use clap::{App, AppSettings, Arg, SubCommand}; -use serde_json::json; use std::{fmt, fs, process::exit, result}; use crate::config::Config; use crate::imap::ImapConnector; -use crate::msg::Msg; +use crate::msg::{Attachments, Msg, ReadableMsg}; use crate::output::print; const DEFAULT_PAGE_SIZE: usize = 10; @@ -343,14 +342,15 @@ fn run() -> Result<()> { let config = Config::new_from_file()?; let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; + let mbox = matches.value_of("mailbox").unwrap(); let uid = matches.value_of("uid").unwrap(); let mime = format!("text/{}", matches.value_of("mime-type").unwrap()); - let msg = Msg::from(imap_conn.read_msg(&mbox, &uid)?); - let text_bodies = msg.text_bodies(&mime)?; - print(&output_type, json!({ "content": text_bodies }))?; + let msg = imap_conn.read_msg(&mbox, &uid)?; + let msg = ReadableMsg::from_bytes(&mime, &msg)?; + print(&output_type, msg)?; imap_conn.logout(); } @@ -358,21 +358,38 @@ fn run() -> Result<()> { let config = Config::new_from_file()?; let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; + let mbox = matches.value_of("mailbox").unwrap(); let uid = matches.value_of("uid").unwrap(); - let msg = Msg::from(imap_conn.read_msg(&mbox, &uid)?); - let parts = msg.extract_attachments()?; - if parts.is_empty() { - println!("No attachment found for message {}", uid); - } else { - println!("{} attachment(s) found for message {}", parts.len(), uid); - parts.iter().for_each(|(filename, bytes)| { - let filepath = config.downloads_filepath(&account, &filename); - println!("Downloading {}…", filename); - fs::write(filepath, bytes).unwrap() - }); - println!("Done!"); + let msg = imap_conn.read_msg(&mbox, &uid)?; + let attachments = Attachments::from_bytes(&msg)?; + + match output_type.as_str() { + "text" => { + println!( + "{} attachment(s) found for message {}", + attachments.0.len(), + uid + ); + + attachments.0.iter().for_each(|attachment| { + let filepath = config.downloads_filepath(&account, &attachment.filename); + println!("Downloading {}…", &attachment.filename); + fs::write(filepath, &attachment.raw).unwrap() + }); + + println!("Done!"); + } + "json" => { + attachments.0.iter().for_each(|attachment| { + let filepath = config.downloads_filepath(&account, &attachment.filename); + fs::write(filepath, &attachment.raw).unwrap() + }); + + print!("{{}}"); + } + _ => (), } imap_conn.logout(); diff --git a/src/msg.rs b/src/msg.rs index b93c76b..5a340ad 100644 --- a/src/msg.rs +++ b/src/msg.rs @@ -6,6 +6,7 @@ use serde::{ Serialize, }; use std::{fmt, result}; +use uuid::Uuid; use crate::config::{Account, Config}; use crate::table::{self, DisplayRow, DisplayTable}; @@ -66,6 +67,133 @@ impl Serialize for Tpl { } } +// Attachments + +#[derive(Debug)] +pub struct Attachment { + pub filename: String, + pub raw: Vec, +} + +impl<'a> Attachment { + // TODO: put in common with ReadableMsg + pub fn from_part(part: &'a mailparse::ParsedMail) -> Self { + Self { + filename: part + .get_content_disposition() + .params + .get("filename") + .unwrap_or(&Uuid::new_v4().to_simple().to_string()) + .to_owned(), + raw: part.get_body_raw().unwrap_or_default(), + } + } +} + +#[derive(Debug)] +pub struct Attachments(pub Vec); + +impl<'a> Attachments { + fn extract_from_part(&'a mut self, part: &'a mailparse::ParsedMail) { + if part.subparts.is_empty() { + let ctype = part + .get_headers() + .get_first_value("content-type") + .unwrap_or_default(); + + if !ctype.starts_with("text") { + self.0.push(Attachment::from_part(part)); + } + } else { + part.subparts + .iter() + .for_each(|part| self.extract_from_part(part)); + } + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + let msg = mailparse::parse_mail(bytes)?; + let mut attachments = Self(vec![]); + attachments.extract_from_part(&msg); + Ok(attachments) + } +} + +// Readable message + +#[derive(Debug)] +pub struct ReadableMsg { + pub content: String, + pub has_attachment: bool, +} + +impl Serialize for ReadableMsg { + fn serialize(&self, serializer: S) -> result::Result + where + S: ser::Serializer, + { + let mut state = serializer.serialize_struct("ReadableMsg", 2)?; + state.serialize_field("content", &self.content)?; + state.serialize_field("hasAttachment", if self.has_attachment { &1 } else { &0 })?; + state.end() + } +} + +impl fmt::Display for ReadableMsg { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.content) + } +} + +impl<'a> ReadableMsg { + fn flatten_parts(part: &'a mailparse::ParsedMail) -> Vec<&'a mailparse::ParsedMail<'a>> { + if part.subparts.is_empty() { + vec![part] + } else { + part.subparts + .iter() + .flat_map(Self::flatten_parts) + .collect::>() + } + } + + pub fn from_bytes(mime: &str, bytes: &[u8]) -> Result { + let msg = mailparse::parse_mail(bytes)?; + let (text_part, html_part, has_attachment) = Self::flatten_parts(&msg).into_iter().fold( + (None, None, false), + |(mut text_part, mut html_part, mut has_attachment), part| { + let ctype = part + .get_headers() + .get_first_value("content-type") + .unwrap_or_default(); + + if text_part.is_none() && ctype.starts_with("text/plain") { + text_part = part.get_body().ok(); + } else { + if html_part.is_none() && ctype.starts_with("text/html") { + html_part = part.get_body().ok(); + } else { + has_attachment = true + }; + }; + + (text_part, html_part, has_attachment) + }, + ); + + let content = if mime == "text/plain" { + text_part.or(html_part).unwrap_or_default() + } else { + html_part.or(text_part).unwrap_or_default() + }; + + Ok(Self { + content, + has_attachment, + }) + } +} + // Message #[derive(Debug, Serialize)] @@ -219,43 +347,13 @@ impl<'a> Msg { Ok(text_bodies.join("\r\n")) } - fn extract_attachments_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_attachments_into(part, parts)); - } - } - } - - pub fn extract_attachments(&self) -> Result)>> { - let mut parts = vec![]; - Self::extract_attachments_into(&self.parse()?, &mut parts); - Ok(parts) - } - pub fn build_new_tpl(config: &Config, account: &Account) -> Result { let mut tpl = vec![]; + // "Content" headers + tpl.push("Content-Type: text/plain; charset=utf-8".to_string()); + tpl.push("Content-Transfer-Encoding: 8bit".to_string()); + // "From" header tpl.push(format!("From: {}", config.address(account))); @@ -273,6 +371,10 @@ impl<'a> Msg { let headers = msg.get_headers(); let mut tpl = vec![]; + // "Content" headers + tpl.push("Content-Type: text/plain; charset=utf-8".to_string()); + tpl.push("Content-Transfer-Encoding: 8bit".to_string()); + // "From" header tpl.push(format!("From: {}", config.address(account))); @@ -313,6 +415,10 @@ impl<'a> Msg { let headers = msg.get_headers(); let mut tpl = vec![]; + // "Content" headers + tpl.push("Content-Type: text/plain; charset=utf-8".to_string()); + tpl.push("Content-Transfer-Encoding: 8bit".to_string()); + // "From" header tpl.push(format!("From: {}", config.address(account))); @@ -394,6 +500,10 @@ impl<'a> Msg { let headers = msg.get_headers(); let mut tpl = vec![]; + // "Content" headers + tpl.push("Content-Type: text/plain; charset=utf-8".to_string()); + tpl.push("Content-Transfer-Encoding: 8bit".to_string()); + // "From" header tpl.push(format!("From: {}", config.address(account)));