crabmail

Static HTML email archive viewer in Rust
git clone git://git.alexwennerberg.com/crabmail
Log | Files | Refs | README | LICENSE

commit 0c8f32c80a76880b9947cf9b88e2f97f083b07c4
parent ba4a11828f87aa0790b87b99542833bc8076a524
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Sun, 12 Dec 2021 15:12:42 -0800

get threading kinda working

Diffstat:
Mcrabmail/Cargo.lock | 7+++++++
Mcrabmail/Cargo.toml | 1+
Mcrabmail/src/main.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrabmail/templates/thread.html | 2+-
Mcrabmail/templates/threadlist.html | 2+-
5 files changed, 97 insertions(+), 11 deletions(-)

diff --git a/crabmail/Cargo.lock b/crabmail/Cargo.lock @@ -146,6 +146,7 @@ dependencies = [ "mailparse", "mbox-reader", "pico-args", + "urlencoding", ] [[package]] @@ -673,6 +674,12 @@ dependencies = [ ] [[package]] +name = "urlencoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821" + +[[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/crabmail/Cargo.toml b/crabmail/Cargo.toml @@ -12,6 +12,7 @@ html = ["ammonia"] [dependencies] mailparse = "0.13" +urlencoding = "2.1.0" mbox-reader = "0.2.0" #unamaintained, should remove dep pico-args = "0.4.1" askama = "0.10" diff --git a/crabmail/src/main.rs b/crabmail/src/main.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::fmt; use std::fs::{File, OpenOptions}; use std::io::prelude::*; +use urlencoding::encode; mod filters; mod utils; @@ -21,13 +22,14 @@ Usage: crabmail // Not a "raw email" struct, but an email object that can be represented by // crabmail. +#[derive(Debug, Clone)] struct Email { // TODO allocs id: String, from: String, subject: String, in_reply_to: Option<String>, - date: i64, // unix epoch + date: i64, // unix epoch. received date body: String, // raw_email: String, } @@ -40,6 +42,7 @@ fn local_parse_email(data: &[u8]) -> Result<Email> { .context("No message ID")?; if id.contains("..") { // dont hack me + // id goes into filename. TODO more verification return Err(anyhow!("bad message ID")); } // Assume 1 in-reply-to header. a reasonable assumption @@ -47,7 +50,11 @@ fn local_parse_email(data: &[u8]) -> Result<Email> { let subject = headers .get_first_value("subject") .unwrap_or("(no subject)".to_owned()); - let date = dateparse(&headers.get_first_value("date").context("No date header")?)?; + let date = dateparse( + &headers + .get_first_value("received") + .context("No date header")?, + )?; let from = headers.get_first_value("from").context("No from header")?; let body = "lorem ipsum".to_owned(); return Ok(Email { @@ -76,14 +83,84 @@ fn main() -> Result<()> { let mbox = MboxFile::from_file(&in_mbox)?; - let mut mail_index: HashMap<String, Email> = HashMap::new(); - let mut reply_index: HashMap<String, String> = HashMap::new(); + let mut thread_index: HashMap<String, Vec<String>> = HashMap::new(); + let mut email_index: HashMap<String, Email> = HashMap::new(); for entry in mbox.iter() { let buffer = entry.message().unwrap(); - // unwrap or warn let email = local_parse_email(buffer)?; + // TODO fix borrow checker + if let Some(reply) = email.in_reply_to.clone() { + match thread_index.get(&reply) { + Some(e) => { + let d = thread_index.get_mut(&reply).unwrap(); + d.push(email.id.clone()); + } + None => { + thread_index.insert(reply, vec![email.id.clone()]); + } + } + } + email_index.insert(email.id.clone(), email); } + + let mut thread_roots: Vec<&Email> = email_index + .iter() + .filter_map(|(k, v)| { + if v.in_reply_to.is_none() { + return Some(v); + } + return None; + }) + .collect(); + thread_roots.sort_by_key(|a| a.date); + thread_roots.reverse(); + std::fs::create_dir(&out_dir).ok(); + let thread_dir = &out_dir.join("threads"); + std::fs::create_dir(thread_dir).ok(); + for root in thread_roots.iter() { + let mut thread_ids = vec![root.id.clone()]; + let mut current: Vec<String> = vec![root.id.clone()]; + while current.len() > 0 { + let top = current.pop().unwrap().clone(); + thread_ids.push(top.clone()); + if let Some(ids) = thread_index.get(&top.clone()) { + for item in ids { + current.push(item.to_string()); + } + } + } + + let mut messages: Vec<&Email> = thread_ids + .iter() + .map(|id| email_index.get(id).unwrap()) + .collect(); + + messages.sort_by_key(|a| a.date); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(thread_dir.join(format!("{}", root.date)))?; + file.write(Thread { root, messages }.render()?.as_bytes()) + .ok(); + } + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(out_dir.join("index.html"))?; + file.write( + ThreadList { + messages: thread_roots, + } + .render()? + .as_bytes(), + ) + .ok(); + Ok(()) } @@ -93,13 +170,14 @@ fn parse_path(s: &std::ffi::OsStr) -> Result<std::path::PathBuf, &'static str> { #[derive(Template)] #[template(path = "thread.html")] -struct Thread { - messages: Vec<Email>, +struct Thread<'a> { + messages: Vec<&'a Email>, + root: &'a Email, } #[derive(Template)] #[template(path = "threadlist.html")] -struct ThreadList { +struct ThreadList<'a> { // message root - messages: Vec<Email>, + messages: Vec<&'a Email>, } diff --git a/crabmail/templates/thread.html b/crabmail/templates/thread.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block content %} - <div class="page-title"><h1>Some thread</h1></div> +<div class="page-title"><h1>{{root.subject}}</h1></div> <div> <div class="message"> {% for message in messages %} diff --git a/crabmail/templates/threadlist.html b/crabmail/templates/threadlist.html @@ -6,7 +6,7 @@ <table> {% for message in messages %} <tr> - <td><a href="threads/{{message.id}}">{{message.subject}}</a></td> + <td><a href="threads/{{message.date}}">{{message.subject}}</a></td> <td> {{message.from}}</td> <td>{{message.date}}</td> {% endfor %}