From 447a4a3387a77bf55e9040cfa48fe4019e6ed0c2 Mon Sep 17 00:00:00 2001 From: Bill Thiede Date: Sun, 26 Nov 2023 13:13:04 -0800 Subject: [PATCH] server: basic graphql thread show, no body support yet. --- Cargo.lock | 45 +++++++++++++++++ notmuch/src/lib.rs | 5 +- server/Cargo.toml | 2 + server/src/graphql.rs | 111 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 161 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b57218..b706fed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,6 +342,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "charset" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e9079d1a12a2cc2bffb5db039c43661836ead4082120d5844f02555aca2d46" +dependencies = [ + "base64 0.13.1", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.31" @@ -596,6 +606,12 @@ dependencies = [ "syn 2.0.29", ] +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + [[package]] name = "dbg" version = "1.0.4" @@ -1610,6 +1626,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mailparse" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b56570f5f8c0047260d1c8b5b331f62eb9c660b9dd4071a8c46f8c7d3f280aa" +dependencies = [ + "charset", + "data-encoding", + "quoted_printable", +] + [[package]] name = "markup5ever" version = "0.10.1" @@ -1645,6 +1672,16 @@ version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" +[[package]] +name = "memmap" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6585fd95e7bb50d6cc31e20d4cf9afb4e2ba16c5846fc76793f11218da9c475b" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "memoffset" version = "0.6.5" @@ -2222,6 +2259,12 @@ dependencies = [ "proc-macro2 1.0.66", ] +[[package]] +name = "quoted_printable" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3866219251662ec3b26fc217e3e05bf9c4f84325234dfb96bf0bf840889e49" + [[package]] name = "rand" version = "0.7.3" @@ -2797,6 +2840,8 @@ dependencies = [ "async-graphql-rocket", "glog", "log 0.4.20", + "mailparse", + "memmap", "notmuch", "rayon", "rocket 0.5.0", diff --git a/notmuch/src/lib.rs b/notmuch/src/lib.rs index 4068cc6..3203c93 100644 --- a/notmuch/src/lib.rs +++ b/notmuch/src/lib.rs @@ -561,7 +561,10 @@ impl Notmuch { Ok(BufReader::new(child.stdout.take().unwrap()).lines()) } - // TODO(wathiede): implement tags() based on "notmuch search --output=tags '*'" + pub fn files(&self, query: &str) -> Result>, NotmuchError> { + let mut child = self.run_notmuch_pipe(["search", "--output=files", query])?; + Ok(BufReader::new(child.stdout.take().unwrap()).lines()) + } fn run_notmuch(&self, args: I) -> Result, NotmuchError> where diff --git a/server/Cargo.toml b/server/Cargo.toml index c306da6..ab55e37 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -21,6 +21,8 @@ async-graphql = { version = "6.0.11", features = ["log"] } async-graphql-rocket = "6.0.11" rocket_cors = "0.6.0" rayon = "1.8.0" +memmap = "0.7.0" +mailparse = "0.14.0" [dependencies.rocket_contrib] version = "0.4.11" diff --git a/server/src/graphql.rs b/server/src/graphql.rs index b7edf50..83d91da 100644 --- a/server/src/graphql.rs +++ b/server/src/graphql.rs @@ -1,10 +1,15 @@ -use std::hash::{DefaultHasher, Hash, Hasher}; +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::*; @@ -32,6 +37,31 @@ pub struct ThreadSummary { 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, @@ -134,6 +164,85 @@ impl QueryRoot { }) .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) +}