use async_graphql::{ connection::{self, Connection, Edge, OpaqueCursor}, Context, EmptySubscription, Enum, Error, FieldResult, InputObject, Object, Schema, SimpleObject, Union, }; use log::info; use notmuch::Notmuch; use serde::{Deserialize, Serialize}; use sqlx::postgres::PgPool; use crate::{newsreader, nm, Query}; /// # 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, Union)] pub enum Thread { Email(EmailThread), News(NewsPost), } #[derive(Debug, SimpleObject)] pub struct NewsPost { pub thread_id: String, pub is_read: bool, pub slug: String, pub site: String, pub title: String, pub body: String, pub url: String, pub timestamp: i64, } #[derive(Debug, SimpleObject)] pub struct EmailThread { pub thread_id: String, pub subject: String, pub messages: Vec, } #[derive(Debug, SimpleObject)] pub struct Message { // Message-ID for message, prepend `id:` to search in notmuch pub id: String, // 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, // Headers pub headers: Vec
, // The body contents pub body: Body, // On disk location of message pub path: String, pub attachments: Vec, pub tags: Vec, } // Content-Type: image/jpeg; name="PXL_20231125_204826860.jpg" // Content-Disposition: attachment; filename="PXL_20231125_204826860.jpg" // Content-Transfer-Encoding: base64 // Content-ID: // X-Attachment-Id: f_lponoluo1 #[derive(Default, Debug, SimpleObject)] pub struct Attachment { pub id: String, pub idx: String, pub filename: Option, pub size: usize, pub content_type: Option, pub content_id: Option, pub disposition: DispositionType, pub bytes: Vec, } #[derive(Debug, Clone, Eq, PartialEq)] pub struct Disposition { pub r#type: DispositionType, pub filename: Option, pub size: Option, } #[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)] pub enum DispositionType { Inline, Attachment, } impl From for DispositionType { fn from(value: mailparse::DispositionType) -> Self { match value { mailparse::DispositionType::Inline => DispositionType::Inline, mailparse::DispositionType::Attachment => DispositionType::Attachment, dt => panic!("unhandled DispositionType {dt:?}"), } } } impl Default for DispositionType { fn default() -> Self { DispositionType::Attachment } } #[derive(Debug, SimpleObject)] pub struct Header { pub key: String, pub value: String, } #[derive(Debug)] pub struct UnhandledContentType { pub text: String, pub content_tree: String, } #[Object] impl UnhandledContentType { async fn contents(&self) -> &str { &self.text } async fn content_tree(&self) -> &str { &self.content_tree } } #[derive(Debug)] pub struct PlainText { pub text: String, pub content_tree: String, } #[Object] impl PlainText { async fn contents(&self) -> &str { &self.text } async fn content_tree(&self) -> &str { &self.content_tree } } #[derive(Debug)] pub struct Html { pub html: String, pub content_tree: String, } #[Object] impl Html { async fn contents(&self) -> &str { &self.html } async fn content_tree(&self) -> &str { &self.content_tree } async fn headers(&self) -> Vec
{ Vec::new() } } #[derive(Debug, Union)] pub enum Body { UnhandledContentType(UnhandledContentType), PlainText(PlainText), Html(Html), } impl Body { pub fn html(html: String) -> Body { Body::Html(Html { html, content_tree: "".to_string(), }) } pub fn text(text: String) -> Body { Body::PlainText(PlainText { text, content_tree: "".to_string(), }) } } #[derive(Debug, SimpleObject)] pub struct Email { pub name: Option, pub addr: Option, } #[derive(SimpleObject)] pub struct Tag { pub name: String, pub fg_color: String, pub bg_color: String, pub unread: usize, } #[derive(Serialize, Deserialize, Debug, InputObject)] struct SearchCursor { newsreader_offset: i32, notmuch_offset: i32, } pub struct QueryRoot; #[Object] impl QueryRoot { async fn version<'ctx>(&self, _ctx: &Context<'ctx>) -> Result { build_info::build_info!(fn bi); Ok(shared::build_version(bi)) } async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result { let nm = ctx.data_unchecked::(); let pool = ctx.data_unchecked::(); let newsreader_query: Query = query.parse()?; let newsreader_count = newsreader::count(pool, &newsreader_query).await?; let notmuch_count = nm::count(nm, &newsreader_query.to_notmuch()).await?; info!("count {newsreader_query:?} newsreader count {newsreader_count} notmuch count {notmuch_count}"); Ok(newsreader_count + notmuch_count) } async fn search<'ctx>( &self, ctx: &Context<'ctx>, after: Option, before: Option, first: Option, last: Option, query: String, ) -> Result, ThreadSummary>, Error> { // TODO: add keywords to limit search to one corpus, i.e. is:news or is:mail info!("search({after:?} {before:?} {first:?} {last:?} {query:?})",); let nm = ctx.data_unchecked::(); let pool = ctx.data_unchecked::(); enum ThreadSummaryCursor { Newsreader(i32, ThreadSummary), Notmuch(i32, ThreadSummary), } Ok(connection::query( after, before, first, last, |after: Option>, before: Option>, first: Option, last: Option| async move { info!( "search({:?} {:?} {first:?} {last:?} {query:?})", after.as_ref().map(|v| &v.0), before.as_ref().map(|v| &v.0) ); let newsreader_after = after.as_ref().map(|sc| sc.newsreader_offset); let notmuch_after = after.as_ref().map(|sc| sc.notmuch_offset); let newsreader_before = before.as_ref().map(|sc| sc.newsreader_offset); let notmuch_before = before.as_ref().map(|sc| sc.notmuch_offset); let newsreader_query: Query = query.parse()?; info!("newsreader_query {newsreader_query:?}"); let newsreader_results = if newsreader_query.is_newsreader { newsreader::search( pool, newsreader_after, newsreader_before, first.map(|v| v as i32), last.map(|v| v as i32), &newsreader_query, ) .await? .into_iter() .map(|(cur, ts)| ThreadSummaryCursor::Newsreader(cur, ts)) .collect() } else { Vec::new() }; let notmuch_results = if newsreader_query.is_notmuch { nm::search( nm, notmuch_after, notmuch_before, first.map(|v| v as i32), last.map(|v| v as i32), newsreader_query.to_notmuch(), ) .await? .into_iter() .map(|(cur, ts)| ThreadSummaryCursor::Notmuch(cur, ts)) .collect() } else { Vec::new() }; let mut results: Vec<_> = newsreader_results .into_iter() .chain(notmuch_results) .collect(); // The leading '-' is to reverse sort results.sort_by_key(|item| match item { ThreadSummaryCursor::Newsreader(_, ts) => -ts.timestamp, ThreadSummaryCursor::Notmuch(_, ts) => -ts.timestamp, }); let mut has_next_page = before.is_some(); if let Some(first) = first { if results.len() > first { has_next_page = true; results.truncate(first); } } let mut has_previous_page = after.is_some(); if let Some(last) = last { if results.len() > last { has_previous_page = true; results.truncate(last); } } let mut connection = Connection::new(has_previous_page, has_next_page); let mut newsreader_offset = 0; let mut notmuch_offset = 0; connection.edges.extend(results.into_iter().map(|item| { let thread_summary; match item { ThreadSummaryCursor::Newsreader(offset, ts) => { thread_summary = ts; newsreader_offset = offset; } ThreadSummaryCursor::Notmuch(offset, ts) => { thread_summary = ts; notmuch_offset = offset; } } let cur = OpaqueCursor(SearchCursor { newsreader_offset, notmuch_offset, }); Edge::new(cur, thread_summary) })); Ok::<_, async_graphql::Error>(connection) }, ) .await?) } async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult> { let nm = ctx.data_unchecked::(); let pool = ctx.data_unchecked::(); let needs_unread = ctx.look_ahead().field("unread").exists(); let mut tags = newsreader::tags(pool, needs_unread).await?; tags.append(&mut nm::tags(nm, needs_unread)?); Ok(tags) } async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result { let nm = ctx.data_unchecked::(); let pool = ctx.data_unchecked::(); let debug_content_tree = ctx .look_ahead() .field("messages") .field("body") .field("contentTree") .exists(); // TODO: look at thread_id and conditionally load newsreader if newsreader::is_newsreader_thread(&thread_id) { Ok(newsreader::thread(pool, thread_id).await?) } else { Ok(nm::thread(nm, thread_id, debug_content_tree).await?) } } } pub struct Mutation; #[Object] impl Mutation { async fn set_read_status<'ctx>( &self, ctx: &Context<'ctx>, query: String, unread: bool, ) -> Result { let nm = ctx.data_unchecked::(); let pool = ctx.data_unchecked::(); for q in query.split_whitespace() { if newsreader::is_newsreader_thread(&q) { newsreader::set_read_status(pool, &q, unread).await?; } else { nm::set_read_status(nm, q, unread).await?; } } Ok(true) } async fn tag_add<'ctx>( &self, ctx: &Context<'ctx>, query: String, tag: String, ) -> Result { let nm = ctx.data_unchecked::(); info!("tag_add({tag}, {query})"); nm.tag_add(&tag, &query)?; Ok(true) } async fn tag_remove<'ctx>( &self, ctx: &Context<'ctx>, query: String, tag: String, ) -> Result { let nm = ctx.data_unchecked::(); info!("tag_remove({tag}, {query})"); nm.tag_remove(&tag, &query)?; Ok(true) } } pub type GraphqlSchema = Schema;