its something
This commit is contained in:
commit
867a2c8ebf
5 changed files with 2401 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
1859
Cargo.lock
generated
Normal file
1859
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
17
Cargo.toml
Normal file
17
Cargo.toml
Normal file
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "notrust"
|
||||
version = "0.1.0"
|
||||
authors = ["Stefan Schwarz <stefan@f2o.io>"]
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-rt = "1.0"
|
||||
actix-web = "2.0"
|
||||
ammonia = "3"
|
||||
async-std = "1.5.0"
|
||||
mailparse = "0.12.0"
|
||||
notmuch = "0.6.0"
|
||||
serde = "1.0.106"
|
||||
toml = "0.5.0"
|
325
src/main.rs
Normal file
325
src/main.rs
Normal file
|
@ -0,0 +1,325 @@
|
|||
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 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}/{query}", web::get().to(autocomplete))
|
||||
.route("/search/{query}", 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(); // 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 message_ids = thread.messages().map(|msg| msg.id().to_string()).collect();
|
||||
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,
|
||||
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 body = ammonia::Builder::new()
|
||||
.attribute_filter(|tag, attr, val| match (tag, attr) {
|
||||
("img", "src") => None,
|
||||
_ => Some(val.into()),
|
||||
})
|
||||
.clean(&body)
|
||||
.to_string();
|
||||
let 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(),
|
||||
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();
|
||||
println!("mid:{}", message_id);
|
||||
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().json(Message::from(&message))
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/html;charset=utf-8")
|
||||
.body(Message::from(&message).body)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
199
web/index.html
Normal file
199
web/index.html
Normal file
|
@ -0,0 +1,199 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="https://unpkg.com/buefy/dist/buefy.min.css">
|
||||
<style>
|
||||
.maillist .tags {
|
||||
display: inline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<section>
|
||||
<div id="app" class="container">
|
||||
<div class="row">
|
||||
<b-tabs v-model="activeTab">
|
||||
<b-tab-item label="List">
|
||||
|
||||
<b-taginput
|
||||
icon="magnify"
|
||||
placeholder="Search..."
|
||||
expanded
|
||||
|
||||
v-model="mailSearch"
|
||||
|
||||
autocomplete
|
||||
allow-new
|
||||
:data="mailSearchAutocomplete"
|
||||
@typing="getFilteredMailAddrs"
|
||||
|
||||
@input="loadMails"
|
||||
>
|
||||
</b-taginput>
|
||||
<b-table :data="mailTable"
|
||||
:loading="mailLoading"
|
||||
narrowed
|
||||
:row-class="() => 'maillist'"
|
||||
|
||||
paginated
|
||||
backend-pagination
|
||||
:total="mailTotal"
|
||||
:per-page="mailLimit"
|
||||
@page-change="mailPageChange"
|
||||
|
||||
@select="mailSelect"
|
||||
>
|
||||
|
||||
<template slot-scope="props">
|
||||
<b-table-column field="date" label="Datum">
|
||||
{{ props.row.date}}
|
||||
</b-table-column>
|
||||
<b-table-column field="subject" label="Subject">
|
||||
<b-taglist attached>
|
||||
<b-tag v-for="tag in props.row.tags" type="is-info">
|
||||
{{ tag }}
|
||||
</b-tag>
|
||||
<b-tag type="is-success">{{ props.row.count }}</b-tag>
|
||||
</b-taglist>
|
||||
{{ props.row.subject}}
|
||||
</b-table-column>
|
||||
</template>
|
||||
</b-table>
|
||||
|
||||
</b-tab-item>
|
||||
<b-tab-item v-for="(thread, thread_id) in mailsOpen"
|
||||
:key="thread_id"
|
||||
>
|
||||
<template slot="header">
|
||||
{{ thread.subject | truncate }}
|
||||
<b-tag rounded>{{ thread.count }}</b-tag>
|
||||
<button type="button"
|
||||
class="delete"
|
||||
@click="closeActiveTab(thread_id)"
|
||||
></button>
|
||||
</template>
|
||||
<div class="box" v-for="message_id in thread.message_ids"
|
||||
key="message_id">
|
||||
<iframe style="width: 100%"
|
||||
:src="`/message/${message_id}`"
|
||||
onload="javascript:(function(o){o.style.height=o.contentWindow.document.body.scrollHeight+'px';}(this));"
|
||||
></iframe>
|
||||
</div>
|
||||
</b-tab-item>
|
||||
</b-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue"></script>
|
||||
<script src="https://unpkg.com/buefy/dist/buefy.min.js"></script>
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data: {
|
||||
mailTable: [],
|
||||
mailSearch: ['tag:inbox'],
|
||||
mailLoading: false,
|
||||
mailStart: 0,
|
||||
mailLimit: 30,
|
||||
mailTotal: 0,
|
||||
mailRowContent: {},
|
||||
mailAddrs: [],
|
||||
mailTags: [],
|
||||
mailBody: '',
|
||||
mailSearchAutocomplete: [],
|
||||
searchTimeout: null,
|
||||
|
||||
activeTab: 0,
|
||||
mailsOpen: {},
|
||||
},
|
||||
filters: {
|
||||
truncate(value) {
|
||||
if (value.length > 11) {
|
||||
value = value.substring(0, 10) + '…';
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
loadMails() {
|
||||
this.mailLoading = true;
|
||||
search = this.mailSearch.join(' ');
|
||||
axios.get(`/search/${search}/${this.mailStart}/${this.mailLimit}`)
|
||||
.then(({data}) => {
|
||||
this.mailTable = data.threads;
|
||||
this.mailTotal = data.count;
|
||||
this.mailLoading = false;
|
||||
})
|
||||
.catch(({request}) => {
|
||||
this.mailTable = [];
|
||||
this.mailTotal = 0;
|
||||
this.mailLoading = false;
|
||||
})
|
||||
},
|
||||
|
||||
getFilteredMailAddrs(text) {
|
||||
kind = text.split(':', 1);
|
||||
text = text.substr(text.indexOf(':') + 1);
|
||||
if (text.length == 0) { return; }
|
||||
axios.get(`/autocomplete/${kind}/${text}`)
|
||||
.then(({data}) => {
|
||||
this.mailSearchAutocomplete = data.results.filter((option) => {
|
||||
return option.indexOf(text) >= 0;
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
mailPageChange(page) {
|
||||
this.mailStart = (page - 1) * this.mailLimit;
|
||||
this.loadMails();
|
||||
},
|
||||
|
||||
doMailSearch(search) {
|
||||
this.mailSearch = search;
|
||||
this.loadMails();
|
||||
},
|
||||
|
||||
debounceSearch(search) {
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.doMailSearch(search);
|
||||
}, 100);
|
||||
},
|
||||
|
||||
mailSelect(row) {
|
||||
axios.get(`/thread/${row.thread_id}`)
|
||||
.then(({data}) => {
|
||||
console.log('hello');
|
||||
console.log(data);
|
||||
this.$set(this.mailsOpen, data.thread_id, data);
|
||||
this.$nextTick(() => {
|
||||
pos = Object.keys(this.mailsOpen).indexOf(data.thread_id);
|
||||
this.activeTab = pos + 1;
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
closeActiveTab(thread_id) {
|
||||
delete this.mailsOpen[thread_id];
|
||||
this.mailsOpen = Object.assign({}, this.mailsOpen);
|
||||
this.activeTab--;
|
||||
}
|
||||
|
||||
},
|
||||
mounted() {
|
||||
this.loadMails();
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
Loading…
Add table
Add a link
Reference in a new issue