308 lines
7.9 KiB
Rust
308 lines
7.9 KiB
Rust
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<String>,
|
|
}
|
|
|
|
#[derive(Debug, SimpleObject)]
|
|
pub struct Thread {
|
|
pub thread_id: String,
|
|
pub subject: String,
|
|
pub messages: Vec<Message>,
|
|
}
|
|
|
|
#[derive(Debug, SimpleObject)]
|
|
pub struct Message {
|
|
// Message-ID for message, prepend `id:<id>` to search in notmuch
|
|
pub id: String,
|
|
// First From header found in email
|
|
pub from: Option<Email>,
|
|
// All To headers found in email
|
|
pub to: Vec<Email>,
|
|
// All CC headers found in email
|
|
pub cc: Vec<Email>,
|
|
// First Subject header found in email
|
|
pub subject: Option<String>,
|
|
// Parsed Date header, if found and valid
|
|
pub timestamp: Option<i64>,
|
|
// Headers
|
|
pub headers: Vec<Header>,
|
|
// The body contents
|
|
pub body: Body,
|
|
// On disk location of message
|
|
pub path: String,
|
|
pub attachments: Vec<Attachment>,
|
|
pub tags: Vec<String>,
|
|
}
|
|
|
|
// Content-Type: image/jpeg; name="PXL_20231125_204826860.jpg"
|
|
// Content-Disposition: attachment; filename="PXL_20231125_204826860.jpg"
|
|
// Content-Transfer-Encoding: base64
|
|
// Content-ID: <f_lponoluo1>
|
|
// X-Attachment-Id: f_lponoluo1
|
|
#[derive(Default, Debug, SimpleObject)]
|
|
pub struct Attachment {
|
|
pub id: String,
|
|
pub idx: String,
|
|
pub filename: Option<String>,
|
|
pub size: usize,
|
|
pub content_type: Option<String>,
|
|
pub content_id: Option<String>,
|
|
pub disposition: DispositionType,
|
|
pub bytes: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub struct Disposition {
|
|
pub r#type: DispositionType,
|
|
pub filename: Option<String>,
|
|
pub size: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)]
|
|
pub enum DispositionType {
|
|
Inline,
|
|
Attachment,
|
|
}
|
|
|
|
impl From<mailparse::DispositionType> 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<Header> {
|
|
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<String>,
|
|
pub addr: Option<String>,
|
|
}
|
|
|
|
#[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<usize, Error> {
|
|
let nm = ctx.data_unchecked::<Notmuch>();
|
|
let pool = ctx.data_unchecked::<PgPool>();
|
|
|
|
// 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<String>,
|
|
before: Option<String>,
|
|
first: Option<i32>,
|
|
last: Option<i32>,
|
|
query: String,
|
|
) -> Result<Connection<usize, ThreadSummary>, Error> {
|
|
info!("search({after:?} {before:?} {first:?} {last:?} {query:?})");
|
|
let nm = ctx.data_unchecked::<Notmuch>();
|
|
let pool = ctx.data_unchecked::<PgPool>();
|
|
|
|
// 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<Vec<Tag>> {
|
|
let nm = ctx.data_unchecked::<Notmuch>();
|
|
let pool = ctx.data_unchecked::<PgPool>();
|
|
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<Thread, Error> {
|
|
let nm = ctx.data_unchecked::<Notmuch>();
|
|
let pool = ctx.data_unchecked::<PgPool>();
|
|
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<bool, Error> {
|
|
let nm = ctx.data_unchecked::<Notmuch>();
|
|
info!("set_read_status({query}, {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<bool, Error> {
|
|
let nm = ctx.data_unchecked::<Notmuch>();
|
|
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<bool, Error> {
|
|
let nm = ctx.data_unchecked::<Notmuch>();
|
|
info!("tag_remove({tag}, {query})");
|
|
nm.tag_remove(&tag, &query)?;
|
|
Ok(true)
|
|
}
|
|
}
|
|
|
|
pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>;
|