use std::{ fs::File, hash::{DefaultHasher, Hash, Hasher}, }; use async_graphql::{ connection::{self, Connection, Edge}, Context, EmptyMutation, EmptySubscription, Error, FieldResult, Object, Schema, SimpleObject, Union, }; use log::{error, info, warn}; use mailparse::{parse_mail, MailHeaderMap, ParsedMail}; use memmap::MmapOptions; use notmuch::Notmuch; use rayon::prelude::*; pub struct QueryRoot; /// # Number of seconds since the Epoch pub type UnixTime = isize; /// # Thread ID, sans "thread:" pub type ThreadId = String; #[derive(Debug, SimpleObject)] pub struct ThreadSummary { pub thread: ThreadId, pub timestamp: UnixTime, /// user-friendly timestamp pub date_relative: String, /// number of matched messages pub matched: isize, /// total messages in thread pub total: isize, /// comma-separated names with | between matched and unmatched pub authors: String, pub subject: String, pub tags: Vec, } #[derive(Debug, SimpleObject)] pub struct Thread { subject: String, messages: Vec, } #[derive(Debug, SimpleObject)] pub struct Message { // First From header found in email pub from: Option, // All To headers found in email pub to: Vec, // All CC headers found in email pub cc: Vec, // First Subject header found in email pub subject: Option, // Parsed Date header, if found and valid pub timestamp: Option, // The body contents pub body: Body, // On disk location of message pub path: String, } #[derive(Debug)] pub struct UnhandledContentType { text: String, } #[Object] impl UnhandledContentType { async fn contents(&self) -> &str { &self.text } } #[derive(Debug)] pub struct PlainText { text: String, } #[Object] impl PlainText { async fn contents(&self) -> &str { &self.text } } #[derive(Debug)] pub struct Html { html: String, } #[Object] impl Html { async fn contents(&self) -> &str { &self.html } } #[derive(Debug, Union)] pub enum Body { UnhandledContentType(UnhandledContentType), PlainText(PlainText), Html(Html), } #[derive(Debug, SimpleObject)] pub struct Email { pub name: Option, pub addr: Option, } #[derive(SimpleObject)] struct Tag { name: String, fg_color: String, bg_color: String, unread: usize, } #[Object] impl QueryRoot { async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result { let nm = ctx.data_unchecked::(); Ok(nm.count(&query)?) } async fn search<'ctx>( &self, ctx: &Context<'ctx>, after: Option, before: Option, first: Option, last: Option, query: String, ) -> Result, Error> { let nm = ctx.data_unchecked::(); connection::query( after, before, first, last, |after, before, first, last| async move { let total = nm.count(&query)?; let (first, last) = if let (None, None) = (first, last) { info!("neither first nor last set, defaulting first to 20"); (Some(20), None) } else { (first, last) }; let mut start = after.map(|after| after + 1).unwrap_or(0); let mut end = before.unwrap_or(total); if let Some(first) = first { end = (start + first).min(end); } if let Some(last) = last { start = if last > end - start { end } else { end - last }; } let count = end - start; let slice: Vec = nm .search(&query, start, count)? .0 .into_iter() .map(|ts| ThreadSummary { thread: ts.thread, timestamp: ts.timestamp, date_relative: ts.date_relative, matched: ts.matched, total: ts.total, authors: ts.authors, subject: ts.subject, tags: ts.tags, }) .collect(); let mut connection = Connection::new(start > 0, end < total); connection.edges.extend( slice .into_iter() .enumerate() .map(|(idx, item)| Edge::new(start + idx, item)), ); Ok::<_, Error>(connection) }, ) .await } async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult> { let nm = ctx.data_unchecked::(); Ok(nm .tags()? .into_par_iter() .map(|tag| { let mut hasher = DefaultHasher::new(); tag.hash(&mut hasher); let hex = format!("#{:06x}", hasher.finish() % (1 << 24)); let unread = if ctx.look_ahead().field("unread").exists() { nm.count(&format!("tag:{tag} is:unread")).unwrap_or(0) } else { 0 }; Tag { name: tag, fg_color: "white".to_string(), bg_color: hex, unread, } }) .collect()) } async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result { // TODO(wathiede): normalize all email addresses through an address book with preferred // display names (that default to the most commonly seen name). let nm = ctx.data_unchecked::(); let mut messages = Vec::new(); for path in nm.files(&thread_id)? { let path = path?; let file = File::open(&path)?; let mmap = unsafe { MmapOptions::new().map(&file)? }; let m = parse_mail(&mmap)?; let from = email_addresses(&path, &m, "from")?; let from = match from.len() { 0 => None, 1 => from.into_iter().next(), _ => { warn!( "Got {} from addresses in message, truncating: {:?}", from.len(), from ); from.into_iter().next() } }; let to = email_addresses(&path, &m, "to")?; let cc = email_addresses(&path, &m, "cc")?; let subject = m.headers.get_first_value("subject"); let timestamp = m .headers .get_first_value("date") .and_then(|d| mailparse::dateparse(&d).ok()); let body = match extract_body(&m)? { Body::Html(Html { html }) => Body::Html(Html { html: ammonia::clean(&html), }), b => b, }; messages.push(Message { from, to, cc, subject, timestamp, body, path, }); } messages.reverse(); // Find the first subject that's set. After reversing the vec, this should be the oldest // message. let subject: String = messages .iter() .skip_while(|m| m.subject.is_none()) .next() .and_then(|m| m.subject.clone()) .unwrap_or("(NO SUBJECT)".to_string()); Ok(Thread { subject, messages }) } } fn extract_body(m: &ParsedMail) -> Result { let body = m.get_body()?; let ret = match m.ctype.mimetype.as_str() { "text/plain" => return Ok(Body::PlainText(PlainText { text: body })), "text/html" => return Ok(Body::Html(Html { html: body })), "multipart/mixed" => extract_mixed(m), "multipart/alternative" => extract_alternative(m), _ => extract_unhandled(m), }; if let Err(err) = ret { error!("Failed to extract body: {err:?}"); return Ok(extract_unhandled(m)?); } ret } fn extract_unhandled(m: &ParsedMail) -> Result { let msg = format!( "Unhandled body content type:\n{}", render_content_type_tree(m) ); warn!("{}", msg); Ok(Body::UnhandledContentType(UnhandledContentType { text: msg, })) } fn extract_alternative(m: &ParsedMail) -> Result { for sp in &m.subparts { if sp.ctype.mimetype == "text/html" { let body = sp.get_body()?; return Ok(Body::Html(Html { html: body })); } } for sp in &m.subparts { if sp.ctype.mimetype == "text/plain" { let body = sp.get_body()?; return Ok(Body::PlainText(PlainText { text: body })); } } Err("extract_alternative".into()) } fn extract_mixed(m: &ParsedMail) -> Result { for sp in &m.subparts { if sp.ctype.mimetype == "multipart/alternative" { return extract_alternative(sp); } } for sp in &m.subparts { if sp.ctype.mimetype == "multipart/related" { return extract_related(sp); } } for sp in &m.subparts { let body = sp.get_body()?; match sp.ctype.mimetype.as_str() { "text/plain" => return Ok(Body::PlainText(PlainText { text: body })), "text/html" => return Ok(Body::Html(Html { html: body })), _ => (), } } Err("extract_mixed".into()) } fn extract_related(m: &ParsedMail) -> Result { // TODO(wathiede): collect related things and change return type to new Body arm. for sp in &m.subparts { if sp.ctype.mimetype == "text/html" { let body = sp.get_body()?; return Ok(Body::Html(Html { html: body })); } } for sp in &m.subparts { if sp.ctype.mimetype == "text/plain" { let body = sp.get_body()?; return Ok(Body::PlainText(PlainText { text: body })); } } Err("extract_related".into()) } fn render_content_type_tree(m: &ParsedMail) -> String { const WIDTH: usize = 4; fn render_rec(m: &ParsedMail, depth: usize) -> String { let mut parts = Vec::new(); let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype); parts.push(msg); if !m.ctype.charset.is_empty() { parts.push(format!( "{} Character Set: {}", " ".repeat(depth * WIDTH), m.ctype.charset )); } for (k, v) in m.ctype.params.iter() { parts.push(format!("{} {k}: {v}", " ".repeat(depth * WIDTH),)); } for sp in &m.subparts { parts.push(render_rec(sp, depth + 1)) } parts.join("\n") } render_rec(m, 1) } pub type GraphqlSchema = Schema; fn email_addresses(path: &str, m: &ParsedMail, header_name: &str) -> Result, Error> { let mut addrs = Vec::new(); for header_value in m.headers.get_all_values(header_name) { match mailparse::addrparse(&header_value) { Ok(mal) => { for ma in mal.into_inner() { match ma { mailparse::MailAddr::Group(gi) => { if !gi.group_name.contains("ndisclosed") { println!("[{path}][{header_name}] Group: {gi}"); } } mailparse::MailAddr::Single(s) => addrs.push(Email { name: s.display_name, addr: Some(s.addr), }), //println!("Single: {s}"), } } } Err(_) => { let v = header_value; if v.matches('@').count() == 1 { if v.matches('<').count() == 1 && v.ends_with('>') { let idx = v.find('<').unwrap(); let addr = &v[idx + 1..v.len() - 1].trim(); let name = &v[..idx].trim(); addrs.push(Email { name: Some(name.to_string()), addr: Some(addr.to_string()), }); } } else { addrs.push(Email { name: Some(v), addr: None, }); } } } } Ok(addrs) }