335 lines
10 KiB
Rust
335 lines
10 KiB
Rust
use std::collections::HashSet;
|
|
use std::env;
|
|
use std::fs::File;
|
|
use std::io::prelude::*;
|
|
use std::path;
|
|
|
|
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer, Responder};
|
|
use ammonia;
|
|
use async_std::sync::{Arc, Mutex};
|
|
use mailparse::body::Body;
|
|
use notmuch;
|
|
use percent_encoding;
|
|
use serde::Serialize;
|
|
|
|
#[derive(Clone)]
|
|
struct Data {
|
|
db: Arc<Mutex<notmuch::Database>>,
|
|
domains: Vec<&'static str>,
|
|
}
|
|
|
|
#[actix_rt::main]
|
|
async fn main() -> std::io::Result<()> {
|
|
let home = env::var("HOME").expect("no home for mails?");
|
|
let mut maildir = path::PathBuf::from(home);
|
|
maildir.push("Mail");
|
|
let db = notmuch::Database::open(&maildir, notmuch::DatabaseMode::ReadOnly).unwrap();
|
|
let db = Arc::new(Mutex::new(db));
|
|
let data = Data {
|
|
db,
|
|
domains: vec!["f2o.io", "f2o.at"],
|
|
};
|
|
|
|
HttpServer::new(move || {
|
|
App::new()
|
|
.data(data.clone())
|
|
.route("/", web::get().to(index))
|
|
.route("/autocomplete/{kind}", web::get().to(autocomplete))
|
|
.route("/autocomplete/{kind}/", web::get().to(autocomplete))
|
|
.route("/autocomplete/{kind}/{query}", web::get().to(autocomplete))
|
|
.route("/search/{query}", web::get().to(search))
|
|
.route("/search/{query}/", web::get().to(search))
|
|
.route("/search/{query}/{start}", web::get().to(search))
|
|
.route("/search/{query}/{start}/", web::get().to(search))
|
|
.route("/search/{query}/{start}/{count}", web::get().to(search))
|
|
.route("/thread/{thread_id}", web::get().to(thread))
|
|
.route("/message/{message_id}", web::get().to(message))
|
|
})
|
|
.bind("127.0.0.1:8088")?
|
|
.run()
|
|
.await
|
|
}
|
|
|
|
enum Autocomplete {
|
|
Tag,
|
|
From,
|
|
To,
|
|
Other,
|
|
}
|
|
|
|
impl From<&str> for Autocomplete {
|
|
fn from(kind: &str) -> Self {
|
|
match kind {
|
|
"tag" => Autocomplete::Tag,
|
|
"from" => Autocomplete::From,
|
|
"to" => Autocomplete::To,
|
|
_ => Autocomplete::Other,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct AutocompleteResults {
|
|
results: Vec<String>,
|
|
}
|
|
|
|
impl From<Vec<String>> for AutocompleteResults {
|
|
fn from(results: Vec<String>) -> Self {
|
|
AutocompleteResults { results }
|
|
}
|
|
}
|
|
|
|
async fn autocomplete(data: web::Data<Data>, req: HttpRequest) -> impl Responder {
|
|
let kind = Autocomplete::from(req.match_info().get("kind").unwrap());
|
|
let query = req.match_info().get("query").unwrap_or(""); // TODO: use
|
|
let mut results = HashSet::new();
|
|
let db = data.db.lock().await;
|
|
match kind {
|
|
Autocomplete::Other => return HttpResponse::Ok().json(()),
|
|
Autocomplete::Tag => db
|
|
.create_query("")
|
|
.unwrap()
|
|
.search_messages()
|
|
.unwrap()
|
|
.take(1000)
|
|
.for_each(|msg| {
|
|
msg.tags().for_each(|tag| {
|
|
results.insert(tag);
|
|
})
|
|
}),
|
|
Autocomplete::From => db
|
|
.create_query(
|
|
&data
|
|
.domains
|
|
.iter()
|
|
.map(|d| format!("to:{}", d))
|
|
.collect::<Vec<String>>()
|
|
.join(" or "),
|
|
)
|
|
.unwrap()
|
|
.search_messages()
|
|
.unwrap()
|
|
.take(1000)
|
|
.map(|msg| msg.header("From").unwrap().unwrap().to_string())
|
|
.for_each(|from| {
|
|
results.insert(from);
|
|
}),
|
|
Autocomplete::To => db
|
|
.create_query(
|
|
&data
|
|
.domains
|
|
.iter()
|
|
.map(|d| format!("from:{}", d))
|
|
.collect::<Vec<String>>()
|
|
.join(" or "),
|
|
)
|
|
.unwrap()
|
|
.search_messages()
|
|
.unwrap()
|
|
.take(1000)
|
|
.map(|msg| msg.header("From").unwrap().unwrap().to_string())
|
|
.for_each(|from| {
|
|
results.insert(from);
|
|
}),
|
|
};
|
|
let results = AutocompleteResults::from(results.into_iter().collect::<Vec<String>>());
|
|
HttpResponse::Ok().json(results)
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct Thread {
|
|
thread_id: String,
|
|
subject: String,
|
|
count: usize,
|
|
date: i64,
|
|
tags: Vec<String>,
|
|
}
|
|
|
|
impl From<¬much::Thread<'_, '_>> for Thread {
|
|
fn from(thread: ¬much::Thread) -> Self {
|
|
Thread {
|
|
thread_id: thread.id().to_string(),
|
|
subject: thread.subject().to_string(),
|
|
count: thread.total_messages() as usize,
|
|
date: thread.newest_date(),
|
|
tags: thread.tags().collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct SearchResult {
|
|
count: usize,
|
|
threads: Vec<Thread>,
|
|
}
|
|
|
|
impl From<(usize, Vec<Thread>)> for SearchResult {
|
|
fn from(search_result: (usize, Vec<Thread>)) -> Self {
|
|
SearchResult {
|
|
count: search_result.0,
|
|
threads: search_result.1,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn search(data: web::Data<Data>, req: HttpRequest) -> impl Responder {
|
|
let query: &str = req.match_info().get("query").unwrap();
|
|
let start: usize = req
|
|
.match_info()
|
|
.get("start")
|
|
.unwrap_or("0")
|
|
.parse()
|
|
.unwrap();
|
|
let count: usize = req
|
|
.match_info()
|
|
.get("count")
|
|
.unwrap_or("20")
|
|
.parse()
|
|
.unwrap();
|
|
let db = data.db.lock().await;
|
|
let query = db.create_query(query).unwrap();
|
|
// TODO: load count in a diffrent call, its slow
|
|
let total = query.count_threads().unwrap() as usize;
|
|
let threads: Vec<Thread> = query
|
|
.search_threads()
|
|
.unwrap()
|
|
.map(|thread| thread)
|
|
.skip(start)
|
|
.take(count)
|
|
.map(|thread| Thread::from(&thread))
|
|
.collect();
|
|
HttpResponse::Ok().json(SearchResult::from((total, threads)))
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ThreadSpec {
|
|
thread_id: String,
|
|
count: usize,
|
|
date: i64,
|
|
subject: String,
|
|
tags: Vec<String>,
|
|
message_ids: Vec<String>,
|
|
}
|
|
|
|
impl From<¬much::Thread<'_, '_>> for ThreadSpec {
|
|
fn from(thread: ¬much::Thread) -> Self {
|
|
let t = Thread::from(thread);
|
|
let mut message_ids: Vec<String> =
|
|
thread.messages().map(|msg| msg.id().to_string()).collect();
|
|
message_ids.reverse();
|
|
ThreadSpec {
|
|
thread_id: t.thread_id,
|
|
count: t.count,
|
|
date: t.date,
|
|
subject: t.subject,
|
|
tags: t.tags,
|
|
message_ids,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn thread(data: web::Data<Data>, req: HttpRequest) -> impl Responder {
|
|
let thread_id = req.match_info().get("thread_id").unwrap();
|
|
let db = data.db.lock().await;
|
|
let query = db.create_query(&format!("thread:{}", thread_id)).unwrap();
|
|
let thread = query.search_threads().unwrap().next().unwrap();
|
|
HttpResponse::Ok().json(ThreadSpec::from(&thread))
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct Message {
|
|
from: String,
|
|
to: String,
|
|
cc: String,
|
|
bcc: String,
|
|
date: i64,
|
|
subject: String,
|
|
tags: Vec<String>,
|
|
body: String,
|
|
attachments: Vec<String>,
|
|
}
|
|
|
|
impl<O> From<¬much::Message<'_, O>> for Message
|
|
where
|
|
O: notmuch::MessageOwner,
|
|
{
|
|
fn from(message: ¬much::Message<O>) -> Self {
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
File::open(message.filename())
|
|
.unwrap()
|
|
.read_to_end(&mut buf)
|
|
.unwrap();
|
|
let mut mail = &mailparse::parse_mail(&buf).unwrap();
|
|
if mail.ctype.mimetype.starts_with("multipart/") {
|
|
mail = mail
|
|
.subparts
|
|
.iter()
|
|
.filter(|mail| mail.ctype.mimetype == "text/html")
|
|
.next()
|
|
.unwrap_or_else(|| {
|
|
mail.subparts
|
|
.iter()
|
|
.filter(|m| m.ctype.mimetype == "text/plain")
|
|
.next()
|
|
.unwrap()
|
|
})
|
|
}
|
|
let body = match mail.get_body_encoded() {
|
|
Body::Base64(body) | Body::QuotedPrintable(body) => {
|
|
body.get_decoded_as_string().unwrap()
|
|
}
|
|
Body::SevenBit(body) | Body::EightBit(body) => body.get_as_string().unwrap(),
|
|
_ => panic!("unsupported body"),
|
|
};
|
|
//let body = ammonia::clean(&body);
|
|
let mut body = ammonia::Builder::new()
|
|
.attribute_filter(|tag, attr, val| match (tag, attr) {
|
|
("img", "src") => None,
|
|
_ => Some(val.into()),
|
|
})
|
|
.clean(&body)
|
|
.to_string();
|
|
if mail.ctype.mimetype != "text/html" {
|
|
body = format!("<div style=\"white-space: pre-wrap\">{}</div>", body);
|
|
}
|
|
Message {
|
|
from: message.header("from").unwrap().unwrap().to_string(),
|
|
to: message.header("to").unwrap().unwrap().to_string(),
|
|
cc: message
|
|
.header("cc")
|
|
.unwrap()
|
|
.unwrap_or("".into())
|
|
.to_string(),
|
|
bcc: message
|
|
.header("bcc")
|
|
.unwrap()
|
|
.unwrap_or("".into())
|
|
.to_string(),
|
|
date: message.date(),
|
|
subject: message.header("subject").unwrap().unwrap().to_string(),
|
|
tags: message.tags().collect(),
|
|
body: body,
|
|
attachments: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn message(data: web::Data<Data>, req: HttpRequest) -> impl Responder {
|
|
let message_id = req.match_info().get("message_id").unwrap();
|
|
let message_id = percent_encoding::percent_decode_str(&message_id).decode_utf8_lossy();
|
|
let db = data.db.lock().await;
|
|
let query = db.create_query(&format!("mid:{}", message_id)).unwrap();
|
|
let message = query.search_messages().unwrap().next().unwrap();
|
|
HttpResponse::Ok()
|
|
.content_type("text/html;charset=utf-8")
|
|
.json(Message::from(&message))
|
|
}
|
|
|
|
async fn index() -> impl Responder {
|
|
let mut body = String::new();
|
|
File::open("web/index.html")
|
|
.unwrap()
|
|
.read_to_string(&mut body)
|
|
.unwrap();
|
|
HttpResponse::Ok().body(body)
|
|
}
|