letterbox/server/src/graphql.rs

416 lines
13 KiB
Rust

use std::{
fs::File,
hash::{DefaultHasher, Hash, Hasher},
};
use async_graphql::{
connection::{self, Connection, Edge},
Context, EmptyMutation, EmptySubscription, Error, FieldResult, Object, Schema, SimpleObject,
Union,
};
use log::{error, info, warn};
use mailparse::{parse_mail, MailHeaderMap, ParsedMail};
use memmap::MmapOptions;
use notmuch::Notmuch;
use rayon::prelude::*;
pub struct QueryRoot;
/// # 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 {
subject: String,
messages: Vec<Message>,
}
#[derive(Debug, SimpleObject)]
pub struct Message {
// 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>,
// The body contents
pub body: Body,
// On disk location of message
pub path: String,
}
#[derive(Debug)]
struct UnhandledContentType {
text: String,
}
#[Object]
impl UnhandledContentType {
async fn contents(&self) -> &str {
&self.text
}
}
#[derive(Debug)]
struct PlainText {
text: String,
}
#[Object]
impl PlainText {
async fn contents(&self) -> &str {
&self.text
}
}
#[derive(Debug)]
struct Html {
html: String,
}
#[Object]
impl Html {
async fn contents(&self) -> &str {
&self.html
}
}
#[derive(Debug, Union)]
pub enum Body {
UnhandledContentType(UnhandledContentType),
PlainText(PlainText),
Html(Html),
}
#[derive(Debug, SimpleObject)]
pub struct Email {
pub name: Option<String>,
pub addr: Option<String>,
}
#[derive(SimpleObject)]
struct Tag {
name: String,
fg_color: String,
bg_color: String,
unread: usize,
}
#[Object]
impl QueryRoot {
async fn count<'ctx>(&self, ctx: &Context<'ctx>, query: String) -> Result<usize, Error> {
let nm = ctx.data_unchecked::<Notmuch>();
Ok(nm.count(&query)?)
}
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> {
let nm = ctx.data_unchecked::<Notmuch>();
connection::query(
after,
before,
first,
last,
|after, before, first, last| async move {
info!("{after:?} {before:?} {first:?} {last:?} {query}");
let total = nm.count(&query)?;
let (first, last) = if let (None, None) = (first, last) {
info!("neither first nor last set, defaulting to 20");
(Some(20), Some(20))
} else {
(first, last)
};
let mut start = after.map(|after| after + 1).unwrap_or(0);
let mut end = before.unwrap_or(total);
if let Some(first) = first {
end = (start + first).min(end);
}
if let Some(last) = last {
start = if last > end - start { end } else { end - last };
}
let count = end - start;
let slice: Vec<ThreadSummary> = nm
.search(&query, start, count)?
.0
.into_iter()
.map(|ts| ThreadSummary {
thread: ts.thread,
timestamp: ts.timestamp,
date_relative: ts.date_relative,
matched: ts.matched,
total: ts.total,
authors: ts.authors,
subject: ts.subject,
tags: ts.tags,
})
.collect();
let mut connection = Connection::new(start > 0, end < total);
connection.edges.extend(
slice
.into_iter()
.enumerate()
.map(|(idx, item)| Edge::new(start + idx, item)),
);
Ok::<_, Error>(connection)
},
)
.await
}
async fn tags<'ctx>(&self, ctx: &Context<'ctx>) -> FieldResult<Vec<Tag>> {
let nm = ctx.data_unchecked::<Notmuch>();
Ok(nm
.tags()?
.into_par_iter()
.map(|tag| {
let mut hasher = DefaultHasher::new();
tag.hash(&mut hasher);
let hex = format!("#{:06x}", hasher.finish() % (1 << 24));
let unread = if ctx.look_ahead().field("unread").exists() {
nm.count(&format!("tag:{tag} is:unread")).unwrap_or(0)
} else {
0
};
Tag {
name: tag,
fg_color: "white".to_string(),
bg_color: hex,
unread,
}
})
.collect())
}
async fn thread<'ctx>(&self, ctx: &Context<'ctx>, thread_id: String) -> Result<Thread, Error> {
// 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::<Notmuch>();
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 = 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")?;
let subject = m.headers.get_first_value("subject");
let timestamp = m
.headers
.get_first_value("date")
.and_then(|d| mailparse::dateparse(&d).ok());
let body = match extract_body(&m)? {
Body::Html(Html { html }) => Body::Html(Html {
html: ammonia::clean(&html),
}),
b => b,
};
messages.push(Message {
from,
to,
cc,
subject,
timestamp,
body,
path,
});
}
messages.reverse();
// 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 })
}
}
fn extract_body(m: &ParsedMail) -> Result<Body, Error> {
let body = m.get_body()?;
let ret = match m.ctype.mimetype.as_str() {
"text/plain" => return Ok(Body::PlainText(PlainText { text: body })),
"text/html" => return Ok(Body::Html(Html { html: body })),
"multipart/mixed" => extract_mixed(m),
"multipart/alternative" => extract_alternative(m),
_ => extract_unhandled(m),
};
if let Err(err) = ret {
error!("Failed to extract body: {err:?}");
return Ok(extract_unhandled(m)?);
}
ret
}
fn extract_unhandled(m: &ParsedMail) -> Result<Body, Error> {
let msg = format!(
"Unhandled body content type:\n{}",
render_content_type_tree(m)
);
warn!("{}", msg);
Ok(Body::UnhandledContentType(UnhandledContentType {
text: msg,
}))
}
fn extract_alternative(m: &ParsedMail) -> Result<Body, Error> {
for sp in &m.subparts {
if sp.ctype.mimetype == "text/html" {
let body = sp.get_body()?;
return Ok(Body::Html(Html { html: body }));
}
}
for sp in &m.subparts {
if sp.ctype.mimetype == "text/plain" {
let body = sp.get_body()?;
return Ok(Body::PlainText(PlainText { text: body }));
}
}
Err("extract_alternative".into())
}
fn extract_mixed(m: &ParsedMail) -> Result<Body, Error> {
for sp in &m.subparts {
if sp.ctype.mimetype == "multipart/alternative" {
return extract_alternative(sp);
}
}
for sp in &m.subparts {
if sp.ctype.mimetype == "multipart/related" {
return extract_related(sp);
}
}
Err("extract_mixed".into())
}
fn extract_related(m: &ParsedMail) -> Result<Body, Error> {
// TODO(wathiede): collect related things and change return type to new Body arm.
for sp in &m.subparts {
if sp.ctype.mimetype == "text/html" {
let body = sp.get_body()?;
return Ok(Body::Html(Html { html: body }));
}
}
for sp in &m.subparts {
if sp.ctype.mimetype == "text/plain" {
let body = sp.get_body()?;
return Ok(Body::PlainText(PlainText { text: body }));
}
}
Err("extract_related".into())
}
fn render_content_type_tree(m: &ParsedMail) -> String {
const WIDTH: usize = 4;
fn render_rec(m: &ParsedMail, depth: usize) -> String {
let mut parts = Vec::new();
let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
parts.push(msg);
if !m.ctype.charset.is_empty() {
parts.push(format!(
"{} Character Set: {}",
" ".repeat(depth * WIDTH),
m.ctype.charset
));
}
for (k, v) in m.ctype.params.iter() {
parts.push(format!("{} {k}: {v}", " ".repeat(depth * WIDTH),));
}
for sp in &m.subparts {
parts.push(render_rec(sp, depth + 1))
}
parts.join("\n")
}
render_rec(m, 1)
}
pub type GraphqlSchema = Schema<QueryRoot, EmptyMutation, EmptySubscription>;
fn email_addresses(path: &str, m: &ParsedMail, header_name: &str) -> Result<Vec<Email>, 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].trim();
let name = &v[..idx].trim();
addrs.push(Email {
name: Some(name.to_string()),
addr: Some(addr.to_string()),
});
}
} else {
addrs.push(Email {
name: Some(v),
addr: None,
});
}
}
}
}
Ok(addrs)
}