use std::{ fs::File, hash::{DefaultHasher, Hash, Hasher}, }; use async_graphql::{ connection::{self, Connection, Edge}, Context, EmptyMutation, EmptySubscription, Error, FieldResult, Object, Schema, SimpleObject, }; use log::info; use mailparse::{addrparse, 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 { 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, } #[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 { info!("{after:?} {before:?} {first:?} {last:?} {query}"); let total = nm.count(&query)?; let (first, last) = if let (None, None) = (first, last) { info!("neither first nor last set, defaulting to 20"); (Some(20), Some(20)) } 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 = if let Some(from) = m.headers.get_first_value("from") { addrparse(&from)?.extract_single_info().map(|si| Email { name: si.display_name, addr: Some(si.addr), }) } else { None }; 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()); messages.push(Message { from, to, cc, subject, timestamp, }); } messages.reverse(); Ok(Thread { messages }) } } 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]; let name = &v[..idx]; addrs.push(Email { name: Some(name.to_string()), addr: Some(addr.to_string()), }); } } else { addrs.push(Email { name: Some(v), addr: None, }); } } } } Ok(addrs) }