crabmail

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

commit 55821627e75f2e9fa07f848d7aa6d733ff8c7bb6
parent ef39f649715f93bfeaf13e69038ee095598d1b31
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Sat, 19 Mar 2022 17:32:16 -0700

eml export

Diffstat:
MCargo.lock | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 2++
MTODO | 11+++++++++++
Msrc/main.rs | 158+++----------------------------------------------------------------------------
Msrc/models.rs | 79++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Asrc/util.rs | 13+++++++++++++
6 files changed, 233 insertions(+), 187 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -9,11 +9,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27" [[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + +[[package]] name = "crabmail" version = "0.1.0" dependencies = [ "anyhow", "linkify", + "mail-builder", "mail-parser", "nanotemplate", "once_cell", @@ -21,6 +47,33 @@ dependencies = [ ] [[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "getrandom" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "libc" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efaa7b300f3b5fe8eb6bf21ce3895e1751d9665086af2d64b42f19701015ff4f" + +[[package]] name = "linkify" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -30,6 +83,17 @@ dependencies = [ ] [[package]] +name = "mail-builder" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a0b1038ef981145cfec88536cc5b29aa6ab0a8131d42cc5d7d2738455d720e" +dependencies = [ + "chrono", + "gethostname", + "rand", +] + +[[package]] name = "mail-parser" version = "0.4.5" source = "git+https://github.com/alexwennerberg/mail-parser#75604396cb2168ada0e37b705ab68b51e14332b5" @@ -47,13 +111,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcde141f0a9acabd38860369eeb0d69f1756d19c5948672c211e82e0519edd61" [[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] name = "once_cell" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" [[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" +dependencies = [ + "getrandom", +] + +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + +[[package]] name = "urlencoding" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821" + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/Cargo.toml b/Cargo.toml @@ -12,6 +12,8 @@ anyhow = "1.0.52" nanotemplate = "*" # using my fork just to upstream patches faster mail-parser = {git = "https://github.com/alexwennerberg/mail-parser", default-features=false} +# TODO cleanup dependencies +mail-builder = "*" # Small, effective dependencies, little benefit to vendoring linkify = "0.8.0" diff --git a/TODO b/TODO @@ -1,6 +1,17 @@ TODO ==== +truncate in-reply-to string +use get_thread_name? +get export working -> use mail-builder (fork it and/or cut deps) +Replace "Download" with "export eml" +get reply link working +allow mbox/single folder input +atom feeds working +gemini pages +paginate list home +create list home page + dkim Duplicate ID verification: warn on duplicate ID, use first received-date. This diff --git a/src/main.rs b/src/main.rs @@ -23,8 +23,6 @@ mod time; const ATOM_ENTRY_LIMIT: i32 = 100; const PAGE_SIZE: i32 = 100; -// TODO - use std::ffi::{OsStr, OsString}; use std::path::Path; @@ -111,7 +109,9 @@ impl List { fn write_threads(&mut self) { // files written = HashSet let thread_dir = self.out_dir.join("threads"); + let message_dir = self.out_dir.join("messages"); std::fs::create_dir_all(&thread_dir).unwrap(); + std::fs::create_dir_all(&message_dir).unwrap(); for thread_ids in &self.thread_idx.threads { // Load thread let thread = Thread::new(thread_ids); @@ -128,8 +128,10 @@ impl List { reply_count: (thread.messages.len() - 1) as u64, last_reply: thread_ids[thread_ids.len() - 1].time, }); - // for file in thread - // write raw file + for msg in thread.messages { + let base_path = message_dir.join(&msg.pathescape_msg_id()); + write_if_unchanged(&&append_ext("eml", &base_path), &msg.export_eml()); + } } self.thread_topics.sort_by_key(|t| t.last_reply); self.thread_topics.reverse(); @@ -187,154 +189,6 @@ fn main() -> Result<()> { // // // -// -// impl<'a> MailThread<'a> { -// pub fn last_reply(&self) -> u64 { -// return self.messages[self.messages.len() - 1].date; -// } - -// fn url(&self) -> String { -// format!( -// "{}/{}/threads/{}.html", -// Config::global().base_url, -// self.list_name, -// self.hash -// ) -// } - -// fn write_to_file(&self) -> Result<()> { -// let root = self.messages[0]; -// let tmp = html! { -// h1(class="page-title") { -// : &root.subject; -// : Raw(" "); -// a(href=format!("./{}.xml", self.hash)) { -// // img(alt="Atom feed", src=utils::RSS_SVG); -// } -// } -// div { -// a(href="../") { -// : "Back"; -// } -// : " "; -// a(href="#bottom") { -// : "Latest"; -// } -// hr; -// } div { -// @ for message in self.messages.iter() { -// div(id=&message.id, class="message") { -// div(class="message-meta") { -// span(class="bold") { -// : &message.subject -// } - -// br; -// a(href=format!("mailto:{}", &message.from.addr), class="bold") { -// : &message.from.to_string(); -// } -// br; -// span(class="light") { -// : &message.date_string -// } -// a(title="permalink", href=format!("#{}", &message.id)) { -// } -// @ if &message.mime == "text/html" { -// span(class="light italic") { -// : " (converted from html)"; -// } -// } -// br; -// a (class="bold", href=message.mailto(&self)) { -// :"✉️ Reply" -// } -// @ if Config::global().include_raw { -// : " ["; -// a(href=format!("../messages/{}", message.id)) { -// : "Download" ; -// } -// : "]"; -// } -// @ if message.in_reply_to.is_some() { -// : " "; -// a(title="replies-to", href=format!("#{}", message.in_reply_to.clone().unwrap())){ -// : "Parent"; -// } -// } -// } -// br; -// @ if message.subject.starts_with("[PATCH") || message.subject.starts_with("[PULL") { -// div(class="email-body monospace") { -// // : Raw(utils::email_body(&message.body)) -// } -// } else { -// div(class="email-body") { -// // : Raw(utils::email_body(&message.body)) -// } -// } br; -// } -// } -// a(id="bottom"); -// } -// }; -// let thread_dir = Config::global() -// .out_dir -// .join(&self.list_name) -// .join("threads"); -// std::fs::create_dir(&thread_dir).ok(); - -// let file = File::create(&thread_dir.join(format!("{}.html", &self.hash)))?; -// let mut br = BufWriter::new(file); -// layout(root.subject.as_str(), tmp).write_to_io(&mut br)?; -// Ok(()) -// } -// } - -// impl Email { -// // mailto:... populated with everything you need -// // TODO add these to constructors -// pub fn url(&self, thread: &MailThread) -> String { -// format!("{}#{}", thread.url(), self.id) -// } -// pub fn mailto(&self, thread: &MailThread) -> String { -// let config = Config::global(); -// let d = config.default_subsection(&thread.list_name); -// let subsection_config = config -// .subsections -// .iter() -// .find(|s| s.name == thread.list_name) -// .unwrap_or(&d); - -// let mut url = format!("mailto:{}?", subsection_config.email); - -// let from = self.from.to_string(); -// // make sure k is already urlencoded -// let mut pushencode = |k: &str, v| { -// url.push_str(&format!("{}={}&", k, urlencoding::encode(v))); -// }; -// let fixed_id = format!("<{}>", &self.id); -// pushencode("cc", &from); -// pushencode("in-reply-to", &fixed_id); -// let list_url = format!("{}/{}", &Config::global().base_url, &thread.list_name); -// pushencode("list-archive", &list_url); -// pushencode("subject", &format!("Re: {}", thread.messages[0].subject)); -// // quoted body -// url.push_str("body="); -// // This is ugly and I dont like it. May deprecate it -// if Config::global().reply_add_link { -// url.push_str(&format!( -// "[View original message: {}]%0A%0A", -// &urlencoding::encode(&thread.url()) -// )); -// } -// for line in self.body.lines() { -// url.push_str("%3E%20"); -// url.push_str(&urlencoding::encode(&line)); -// url.push_str("%0A"); -// } -// url.into() -// } - // // TODO rename // pub fn hash(&self) -> String { // self.id.replace("/", ";") diff --git a/src/models.rs b/src/models.rs @@ -1,7 +1,8 @@ use crate::config::{Config, Subsection}; use crate::threading::{Msg, ThreadIdx}; use crate::time::Date; -use mail_parser::{Addr, HeaderValue, Message, MessagePart}; +use mail_builder::MessageBuilder; +use mail_parser::{Addr, HeaderName, HeaderValue, Message, MessagePart}; use mail_parser::{MimeHeaders, RfcHeader}; use std::borrow::Cow; use std::path::PathBuf; @@ -100,16 +101,6 @@ pub struct StrMessage { // download_path: PathBuf, // TODO } -impl StrMessage { - pub fn pathescape_msg_id(&self) -> PathBuf { - PathBuf::from(self.id.replace("/", ";")) - } - - pub fn mailto(&self) -> String { - "tbd".to_string() - } -} - // i suck at Cow and strings #[derive(Debug, Clone)] pub struct MailAddress { @@ -129,6 +120,48 @@ impl MailAddress { // TODO rename impl StrMessage { + pub fn pathescape_msg_id(&self) -> PathBuf { + PathBuf::from(self.id.replace("/", ";")) + } + // wonky + pub fn export_eml(&self) -> Vec<u8> { + let mut message = MessageBuilder::new(); + let from = self.from.name.clone().unwrap_or(String::new()); + message.from((from.as_str(), self.from.address.as_str())); + message.to("jane@doe.com"); + // cc + // list-archive + // in-reply-to + message.subject(&self.subject); + message.text_body(&self.body); + let mut output = Vec::new(); + message.write_to(&mut output).unwrap(); + output + } + // pub fn mailto(&self, email: &str, list_name: &str, thread_subject: &str) -> String { + // let mut url = format!("mailto:{}?", email); + + // let from = self.from.address; + // // make sure k is already urlencoded + // let mut pushencode = |k: &str, v| { + // url.push_str(&format!("{}={}&", k, urlencoding::encode(v))); + // }; + // let fixed_id = format!("<{}>", &self.id); + // pushencode("cc", &from); + // pushencode("in-reply-to", &fixed_id); + // let list_url = format!("{}/{}", &Config::global().base_url, list_name); + // pushencode("list-archive", &list_url); + // pushencode("subject", &format!("Re: {}", thread_subject)); + // // quoted body + // url.push_str("body="); + // for line in self.body.lines() { + // url.push_str("%3E%20"); + // url.push_str(&urlencoding::encode(&line)); + // url.push_str("%0A"); + // } + // url.into() + // } + pub fn new(msg: &Message) -> StrMessage { let id = msg.get_message_id().unwrap_or(""); let subject = msg.get_subject().unwrap_or("(No Subject)"); @@ -177,27 +210,3 @@ impl StrMessage { } } } - -// Export the email, not as it originally is, but a "clean" version of it -// Maybe based off of https://git.causal.agency/bubger/tree/export.c -// const EXPORT_HEADERS: &[&str] = &[ -// "Date", -// "Subject", -// "From", -// "Sender", -// "Reply-To", -// "To", -// "Cc", -// "Bcc", -// "Message-Id", -// "In-Reply-To", -// "References", -// "MIME-Version", -// "Content-Type", -// "Content-Disposition", -// "Content-Transfer-Encoding", -// ]; - -fn raw_export(msg: &Message) -> String { - String::new() -} diff --git a/src/util.rs b/src/util.rs @@ -0,0 +1,13 @@ +// Truncate a string, adding ellpises if it's too long +fn truncate_ellipsis(s: &str, n: usize) -> String { + let out = String::with_capacity(n); + if s.len() <= n { + return s[..n].to_owned(); + } + let res = match s.char_indices().nth(n - 3) { + None => s, + Some((idx, _)) => &s[..idx], + }; + out.push_str(res); + res +}