diff --git a/server/src/graphql.rs b/server/src/graphql.rs index 83d91da..f72a3e2 100644 --- a/server/src/graphql.rs +++ b/server/src/graphql.rs @@ -7,8 +7,8 @@ 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 log::{info, warn}; +use mailparse::{parse_mail, MailHeaderMap, ParsedMail}; use memmap::MmapOptions; use notmuch::Notmuch; use rayon::prelude::*; @@ -39,6 +39,7 @@ pub struct ThreadSummary { #[derive(Debug, SimpleObject)] pub struct Thread { + subject: String, messages: Vec, } @@ -174,13 +175,18 @@ impl QueryRoot { 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 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")?; @@ -198,7 +204,15 @@ impl QueryRoot { }); } messages.reverse(); - Ok(Thread { messages }) + // 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 }) } } @@ -228,8 +242,8 @@ fn email_addresses(path: &str, m: &ParsedMail, header_name: &str) -> Result') { let idx = v.find('<').unwrap(); - let addr = &v[idx + 1..v.len() - 1]; - let name = &v[..idx]; + 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()), diff --git a/web/graphql/schema.json b/web/graphql/schema.json index 69966d6..7d70a6f 100644 --- a/web/graphql/schema.json +++ b/web/graphql/schema.json @@ -69,6 +69,41 @@ "name": "Boolean", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "name", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "addr", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Email", + "possibleTypes": null + }, { "description": "The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", "enumValues": null, @@ -99,6 +134,101 @@ "name": "Int", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "from", + "type": { + "kind": "OBJECT", + "name": "Email", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "to", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Email", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "cc", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Email", + "ofType": null + } + } + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "subject", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "timestamp", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Message", + "possibleTypes": null + }, { "description": "Information about pagination in a connection", "enumValues": null, @@ -295,6 +425,37 @@ } } } + }, + { + "args": [ + { + "defaultValue": null, + "description": null, + "name": "threadId", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "thread", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Thread", + "ofType": null + } + } } ], "inputFields": null, @@ -388,6 +549,57 @@ "name": "Tag", "possibleTypes": null }, + { + "description": null, + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "subject", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "messages", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Message", + "ofType": null + } + } + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Thread", + "possibleTypes": null + }, { "description": null, "enumValues": null, diff --git a/web/graphql/show_thread.graphql b/web/graphql/show_thread.graphql new file mode 100644 index 0000000..e0350e6 --- /dev/null +++ b/web/graphql/show_thread.graphql @@ -0,0 +1,21 @@ +query ShowThreadQuery($threadId: String!) { + thread(threadId: $threadId) { + subject + messages { + subject + from { + name + addr + } + to { + name + addr + } + cc { + name + addr + } + timestamp + } + } +} diff --git a/web/src/graphql.rs b/web/src/graphql.rs index 2b3b4ca..13d6ff9 100644 --- a/web/src/graphql.rs +++ b/web/src/graphql.rs @@ -3,7 +3,7 @@ use seed::{ fetch, fetch::{Header, Method, Request}, }; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Serialize}; // The paths are relative to the directory where your `Cargo.toml` is located. // Both json and the GraphQL schema language are supported as sources for the schema @@ -15,6 +15,14 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; )] pub struct FrontPageQuery; +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "graphql/schema.json", + query_path = "graphql/show_thread.graphql", + response_derives = "Debug" +)] +pub struct ShowThreadQuery; + pub async fn send_graphql(body: Body) -> fetch::Result> where Body: Serialize, diff --git a/web/src/lib.rs b/web/src/lib.rs index 87c83ee..650f228 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -17,7 +17,7 @@ use serde::de::Deserialize; use thiserror::Error; use wasm_timer::Instant; -use crate::graphql::{front_page_query::*, send_graphql}; +use crate::graphql::{front_page_query::*, send_graphql, show_thread_query::*}; mod graphql; @@ -67,7 +67,15 @@ fn on_url_changed(uc: subs::UrlChanged) -> Msg { ); let hpp = url.remaining_hash_path_parts(); match hpp.as_slice() { - ["t", tid] => Msg::ShowPrettyRequest(tid.to_string()), + ["t", tid] => { + if USE_GRAPHQL { + Msg::ShowThreadRequest { + thread_id: tid.to_string(), + } + } else { + Msg::ShowPrettyRequest(tid.to_string()) + } + } ["s", query] => { let query = Url::decode_uri_component(query).unwrap_or("".to_string()); if USE_GRAPHQL { @@ -156,6 +164,7 @@ enum Context { pager: FrontPageQuerySearchPageInfo, }, Thread(ThreadSet), + ThreadResult(ShowThreadQueryThread), } // `Model` describes our app state. @@ -210,6 +219,12 @@ enum Msg { FrontPageResult( fetch::Result>, ), + ShowThreadRequest { + thread_id: String, + }, + ShowThreadResult( + fetch::Result>, + ), } // `update` describes how to handle each `Msg`. @@ -294,8 +309,9 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { } }); } - Context::Thread(_) => (), // do nothing (yet?) - Context::None => (), // do nothing (yet?) + Context::Thread(_) => (), // do nothing (yet?) + Context::ThreadResult(_) => (), // do nothing (yet?) + Context::None => (), // do nothing (yet?) }; } Msg::PreviousPage => { @@ -317,8 +333,9 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { }); } - Context::Thread(_) => (), // do nothing (yet?) - Context::None => (), // do nothing (yet?) + Context::Thread(_) => (), // do nothing (yet?) + Context::ThreadResult(_) => (), // do nothing (yet?) + Context::None => (), // do nothing (yet?) }; } @@ -372,6 +389,25 @@ fn update(msg: Msg, model: &mut Model, orders: &mut impl Orders) { pager: data.search.page_info, }; } + + Msg::ShowThreadRequest { thread_id } => { + orders.skip().perform_cmd(async move { + Msg::ShowThreadResult( + send_graphql(graphql::ShowThreadQuery::build_query( + graphql::show_thread_query::Variables { thread_id }, + )) + .await, + ) + }); + } + Msg::ShowThreadResult(Ok(graphql_client::Response { + data: Some(data), .. + })) => { + model.context = Context::ThreadResult(data.thread); + } + Msg::ShowThreadResult(bad) => { + error!("show_thread_query error: {bad:?}"); + } } } @@ -923,7 +959,108 @@ fn view_search_pager_legacy(start: usize, count: usize, total: usize) -> Node Node { +trait Email { + fn name(&self) -> &Option; + fn addr(&self) -> &Option; +} + +impl Email for &'_ T { + fn name(&self) -> &Option { + return (*self).name(); + } + fn addr(&self) -> &Option { + return (*self).addr(); + } +} + +impl Email for ShowThreadQueryThreadMessagesCc { + fn name(&self) -> &Option { + return &self.name; + } + fn addr(&self) -> &Option { + return &self.addr; + } +} +impl Email for ShowThreadQueryThreadMessagesFrom { + fn name(&self) -> &Option { + return &self.name; + } + fn addr(&self) -> &Option { + return &self.addr; + } +} +impl Email for ShowThreadQueryThreadMessagesTo { + fn name(&self) -> &Option { + return &self.name; + } + fn addr(&self) -> &Option { + return &self.addr; + } +} + +fn view_addresses(addrs: &[E]) -> Vec> { + addrs + .into_iter() + .map(|address| { + span![ + C!["tag", "is-black"], + address.addr().as_ref().map(|a| attrs! {At::Title=>a}), + address + .name() + .as_ref() + .unwrap_or(address.addr().as_ref().unwrap_or(&"(UNKNOWN)".to_string())) + ] + }) + .collect::>() +} + +fn view_thread(thread: &ShowThreadQueryThread) -> Node { + // TODO(wathiede): show per-message subject if it changes significantly from top-level subject + set_title(&thread.subject); + let messages = thread.messages.iter().map(|msg| { + div![ + C!["message"], + /* TODO(wathiede): collect all the tags and show them here. */ + /* TODO(wathiede): collect all the attachments from all the subparts */ + msg.from + .as_ref() + .map(|from| div![C!["header"], "From: ", view_addresses(&[from])]), + msg.timestamp + .map(|ts| div![C!["header"], "Date: ", human_age(ts)]), + IF!(!msg.to.is_empty() => div![C!["header"], "To: ", view_addresses(&msg.to)]), + IF!(!msg.cc.is_empty() => div![C!["header"], "CC: ", view_addresses(&msg.cc)]), + /* + div![ + C!["body"], + match &message.body { + Some(body) => view_body(body.as_slice()), + None => div![""], + }, + ], + */ + ] + }); + div![ + C!["container"], + h1![C!["title"], &thread.subject], + messages, + /* TODO(wathiede): plumb in orignal id + a![ + attrs! {At::Href=>api::original(&thread_node.0.as_ref().expect("message missing").id)}, + "Original" + ], + */ + /* + div![ + C!["debug"], + "Add zippy for debug dump", + view_debug_thread_set(thread_set) + ] /* pre![format!("Thread: {:#?}", thread_set).replace(" ", " ")] */ + */ + ] +} + +fn view_thread_legacy(thread_set: &ThreadSet) -> Node { assert_eq!(thread_set.0.len(), 1); let thread = &thread_set.0[0]; assert_eq!(thread.0.len(), 1); @@ -1058,7 +1195,8 @@ fn view_desktop(model: &Model) -> Node { // Do two queries, one without `unread` so it loads fast, then a second with unread. let content = match &model.context { Context::None => div![h1!["Loading"]], - Context::Thread(thread_set) => view_thread(thread_set), + Context::Thread(thread_set) => view_thread_legacy(thread_set), + Context::ThreadResult(thread) => view_thread(thread), Context::Search(search_results) => view_search_results_legacy(&model.query, search_results), Context::SearchResult { query, @@ -1097,7 +1235,8 @@ fn view_desktop(model: &Model) -> Node { fn view_mobile(model: &Model) -> Node { let content = match &model.context { Context::None => div![h1!["Loading"]], - Context::Thread(thread_set) => view_thread(thread_set), + Context::Thread(thread_set) => view_thread_legacy(thread_set), + Context::ThreadResult(thread) => view_thread(thread), Context::Search(search_results) => { view_mobile_search_results_legacy(&model.query, search_results) }