diff --git a/CHANGELOG.md b/CHANGELOG.md index a751697..ca64fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - List new emails [#6] - Set up CLI arg parser [#15] - List mailboxes command [#5] +- Text and HTML previews [#12] [#13] [unreleased]: https://github.com/soywod/himalaya @@ -22,4 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#2]: https://github.com/soywod/himalaya/issues/2 [#3]: https://github.com/soywod/himalaya/issues/3 [#5]: https://github.com/soywod/himalaya/issues/5 +[#12]: https://github.com/soywod/himalaya/issues/12 +[#13]: https://github.com/soywod/himalaya/issues/13 [#15]: https://github.com/soywod/himalaya/issues/15 diff --git a/Cargo.lock b/Cargo.lock index 0b86a8d..3d24b10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + [[package]] name = "base64" version = "0.13.0" @@ -196,6 +202,7 @@ version = "0.1.0" dependencies = [ "clap", "imap", + "mailparse", "native-tls", "rfc2047-decoder", "serde", @@ -261,6 +268,17 @@ dependencies = [ "cfg-if 0.1.10", ] +[[package]] +name = "mailparse" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a77a7f161b32d0314404306b8ed5966b34b797fc9ef6bcf6686935162da3c" +dependencies = [ + "base64 0.12.3", + "charset", + "quoted_printable", +] + [[package]] name = "memchr" version = "2.3.4" diff --git a/Cargo.toml b/Cargo.toml index d4cd95a..faa443a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ edition = "2018" [dependencies] clap = "2.33.3" imap = "2.4.0" +mailparse = "0.13.1" native-tls = "0.2" rfc2047-decoder = "0.1.2" serde = { version = "1.0.118", features = ["derive"] } diff --git a/src/imap.rs b/src/imap.rs index 03cdf70..cf6b7d0 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -1,4 +1,5 @@ use imap; +use mailparse::{self, MailHeaderMap}; use native_tls::{TlsConnector, TlsStream}; use rfc2047_decoder; use std::net::TcpStream; @@ -99,13 +100,17 @@ fn date_from_fetch(fetch: &imap::types::Fetch) -> String { pub fn read_emails(imap_sess: &mut ImapSession, mbox: &str, query: &str) -> imap::Result<()> { imap_sess.select(mbox)?; - let seqs = imap_sess - .search(query)? + let uids = imap_sess + .uid_search(query)? .iter() .map(|n| n.to_string()) .collect::>(); let table_head = vec![ + table::Cell::new( + vec![table::BOLD, table::UNDERLINE, table::WHITE], + String::from("ID"), + ), table::Cell::new( vec![table::BOLD, table::UNDERLINE, table::WHITE], String::from("FLAGS"), @@ -125,13 +130,14 @@ pub fn read_emails(imap_sess: &mut ImapSession, mbox: &str, query: &str) -> imap ]; let mut table_rows = imap_sess - .fetch( - seqs[..20.min(seqs.len())].join(","), - "(INTERNALDATE ENVELOPE)", + .uid_fetch( + uids[..20.min(uids.len())].join(","), + "(INTERNALDATE ENVELOPE UID)", )? .iter() .map(|fetch| { vec![ + table::Cell::new(vec![table::RED], fetch.uid.unwrap_or(0).to_string()), table::Cell::new(vec![table::WHITE], String::from("!@")), table::Cell::new(vec![table::BLUE], first_addr_from_fetch(fetch)), table::Cell::new(vec![table::GREEN], subject_from_fetch(fetch)), @@ -186,9 +192,58 @@ pub fn list_mailboxes(imap_sess: &mut ImapSession) -> imap::Result<()> { }) .collect::>(); - table_rows.insert(0, table_head); - - println!("{}", table::render(table_rows)); + if table_rows.len() == 0 { + println!("No email found!"); + } else { + table_rows.insert(0, table_head); + println!("{}", table::render(table_rows)); + } + + Ok(()) +} + +fn extract_subparts_by_mime(mime: &str, part: &mailparse::ParsedMail, parts: &mut Vec) { + match part.subparts.len() { + 0 => { + if part + .get_headers() + .get_first_value("content-type") + .and_then(|v| if v.starts_with(mime) { Some(()) } else { None }) + .is_some() + { + parts.push(part.get_body().unwrap_or(String::new())) + } + } + _ => { + part.subparts + .iter() + .for_each(|p| extract_subparts_by_mime(mime, p, parts)); + } + } +} + +pub fn read_email( + imap_sess: &mut ImapSession, + mbox: &str, + uid: &str, + mime: &str, +) -> imap::Result<()> { + imap_sess.select(mbox)?; + + match imap_sess.uid_fetch(uid, "BODY[]")?.first() { + None => println!("No email found in mailbox {} with UID {}", mbox, uid), + Some(email_raw) => { + let email = mailparse::parse_mail(email_raw.body().unwrap_or(&[])).unwrap(); + let mut parts = vec![]; + extract_subparts_by_mime(mime, &email, &mut parts); + + if parts.len() == 0 { + println!("No {} content found for email {}!", mime, uid); + } else { + println!("{}", parts.join("\r\n")); + } + } + } Ok(()) } diff --git a/src/main.rs b/src/main.rs index f6e23f5..951e300 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,9 +6,11 @@ use clap::{App, Arg, SubCommand}; fn mailbox_arg() -> Arg<'static, 'static> { Arg::with_name("mailbox") + .short("m") + .long("mailbox") .help("Name of the targeted mailbox") - .value_name("MAILBOX") - .required(true) + .value_name("STRING") + .default_value("INBOX") } fn uid_arg() -> Arg<'static, 'static> { @@ -26,37 +28,46 @@ fn main() { .version("0.1.0") .about("📫 Minimalist CLI email client") .author("soywod ") + .subcommand(SubCommand::with_name("list").about("Lists all available mailboxes")) .subcommand( - SubCommand::with_name("query") - .about("Prints emails filtered by the given IMAP query") + SubCommand::with_name("search") + .about("Lists emails matching the given IMAP query") .arg(mailbox_arg()) .arg( Arg::with_name("query") .help("IMAP query (see https://tools.ietf.org/html/rfc3501#section-6.4.4)") - .value_name("COMMANDS") + .value_name("QUERY") .multiple(true) .required(true), ), ) - .subcommand(SubCommand::with_name("list").about("Lists all available mailboxes")) .subcommand( SubCommand::with_name("read") .about("Reads an email by its UID") + .arg(uid_arg()) .arg(mailbox_arg()) - .arg(uid_arg()), + .arg( + Arg::with_name("mime-type") + .help("MIME type to use") + .short("t") + .long("mime-type") + .value_name("STRING") + .possible_values(&["text/plain", "text/html"]) + .default_value("text/plain"), + ), ) .subcommand(SubCommand::with_name("write").about("Writes a new email")) .subcommand( SubCommand::with_name("forward") .about("Forwards an email by its UID") - .arg(mailbox_arg()) - .arg(uid_arg()), + .arg(uid_arg()) + .arg(mailbox_arg()), ) .subcommand( SubCommand::with_name("reply") .about("Replies to an email by its UID") - .arg(mailbox_arg()) .arg(uid_arg()) + .arg(mailbox_arg()) .arg( Arg::with_name("reply all") .help("Replies to all recipients") @@ -66,8 +77,8 @@ fn main() { ) .get_matches(); - if let Some(matches) = matches.subcommand_matches("query") { - let mbox = matches.value_of("mailbox").unwrap_or("inbox"); + if let Some(matches) = matches.subcommand_matches("search") { + let mbox = matches.value_of("mailbox").unwrap(); if let Some(matches) = matches.values_of("query") { let query = matches @@ -100,4 +111,13 @@ fn main() { if let Some(_) = matches.subcommand_matches("list") { imap::list_mailboxes(&mut imap_sess).unwrap(); } + + if let Some(matches) = matches.subcommand_matches("read") { + let mbox = matches.value_of("mailbox").unwrap(); + let mime = matches.value_of("mime-type").unwrap(); + + if let Some(uid) = matches.value_of("uid") { + imap::read_email(&mut imap_sess, mbox, uid, mime).unwrap(); + } + } }