249 lines
8.1 KiB
Rust
249 lines
8.1 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,
|
|
};
|
|
use log::info;
|
|
use mailparse::{addrparse, 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 {
|
|
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>,
|
|
}
|
|
|
|
#[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 = 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 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());
|
|
messages.push(Message {
|
|
from,
|
|
to,
|
|
cc,
|
|
subject,
|
|
timestamp,
|
|
});
|
|
}
|
|
messages.reverse();
|
|
Ok(Thread { messages })
|
|
}
|
|
}
|
|
|
|
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];
|
|
let name = &v[..idx];
|
|
addrs.push(Email {
|
|
name: Some(name.to_string()),
|
|
addr: Some(addr.to_string()),
|
|
});
|
|
}
|
|
} else {
|
|
addrs.push(Email {
|
|
name: Some(v),
|
|
addr: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(addrs)
|
|
}
|