use async_graphql::{ connection::{Connection}, Context, EmptySubscription, Enum, Error, FieldResult, Object, Schema, SimpleObject, Union, }; use log::info; use notmuch::Notmuch; use sqlx::postgres::PgPool; use crate::{newsreader, nm}; /// # 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 { 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(crate) struct Tag { pub name: String, pub fg_color: String, pub bg_color: String, pub unread: usize, } pub struct QueryRoot; #[Object] impl QueryRoot { async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result { let nm = ctx.data_unchecked::(); let pool = ctx.data_unchecked::(); // TODO: make this search both copra and merge results if newsreader::is_newsreader_search(&query) { Ok(newsreader::count(pool, &query).await?) } else { Ok(nm::count(nm, &query).await?) } } async fn search<'ctx>( &self, ctx: &Context<'ctx>, after: Option, before: Option, first: Option, last: Option, query: String, ) -> Result, Error> { info!("search({after:?} {before:?} {first:?} {last:?} {query:?})"); let nm = ctx.data_unchecked::(); let pool = ctx.data_unchecked::(); // TODO: make this search both copra and merge results if newsreader::is_newsreader_search(&query) { Ok(newsreader::search(pool, after, before, first, last, query).await?) } else { Ok(nm::search(nm, after, before, first, last, query).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::(); info!("set_read_status({unread})"); if unread { nm.tag_add("unread", &format!("{query}"))?; } else { nm.tag_remove("unread", &format!("{query}"))?; } 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;