crabmail

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

commit 8463b9147b8fb9ea7f44216622c4ada259085708
parent cf5d1171c127fafb15121a0e0dec01fd269b8d06
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Sat,  5 Mar 2022 14:39:36 -0800

WIP rewrite

Diffstat:
MCargo.lock | 48+++++++-----------------------------------------
MCargo.toml | 16+++-------------
Msrc/arg.rs | 82++++++++++++++++++++++++++++++++++++++++----------------------------------------
Asrc/jwzthreading.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/lib.rs | 0
Msrc/maildir.rs | 46+---------------------------------------------
Msrc/main.rs | 1474+++++++++++++++++++++++++++++++++++++++----------------------------------------
Asrc/models.rs | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/templates/gmi.rs | 7+++++++
Asrc/templates/html.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/templates/mod.rs | 4++++
Asrc/templates/util.rs | 20++++++++++++++++++++
Asrc/templates/xml.rs | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/threading.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Msrc/utils.rs | 126+++++++------------------------------------------------------------------------
15 files changed, 1392 insertions(+), 1033 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -9,37 +9,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84450d0b4a8bd1ba4144ce8ce718fbc5d071358b1e5384bace6536b3d1f2d5b3" [[package]] -name = "base64" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" - -[[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] -name = "charset" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e9079d1a12a2cc2bffb5db039c43661836ead4082120d5844f02555aca2d46" -dependencies = [ - "base64", - "encoding_rs", -] - -[[package]] name = "crabmail" version = "0.1.0" dependencies = [ "anyhow", - "horrorshow", "linkify", "mail-parser", - "mailparse", "nanohtml2text", + "nanotemplate", "once_cell", "urlencoding", ] @@ -54,12 +37,6 @@ dependencies = [ ] [[package]] -name = "horrorshow" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371fb981840150b1a54f7cb117bf6699f7466a1d4861daac33bc6fe2b5abea0" - -[[package]] name = "linkify" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -79,17 +56,6 @@ dependencies = [ ] [[package]] -name = "mailparse" -version = "0.13.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d70ae0840b192a2f7d1dc46e75f38720a7e3c52dfdc968ba3202fa270668dc67" -dependencies = [ - "base64", - "charset", - "quoted_printable", -] - -[[package]] name = "memchr" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -102,6 +68,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb649921a71ed2f2c70d7a3426f97912f31570400756867d7f3cede9653f4b81" [[package]] +name = "nanotemplate" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcde141f0a9acabd38860369eeb0d69f1756d19c5948672c211e82e0519edd61" + +[[package]] name = "once_cell" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -126,12 +98,6 @@ dependencies = [ ] [[package]] -name = "quoted_printable" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fee2dce59f7a43418e3382c766554c614e06a552d53a8f07ef499ea4b332c0f" - -[[package]] name = "serde" version = "1.0.136" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -7,25 +7,15 @@ edition = "2018" [features] default = [] -# Justifying all my dependencies -# cargo tree | wc -l => 15 -# Diminishing returns to cut this further [dependencies] -# I am lazy anyhow = "1.0.52" - -# TODO replace with nanotemplate -# https://git.sr.ht/~jpastuszek/nanotemplate -horrorshow = "0.8.4" - -# Largest dependency, required for email parsing -mailparse = "0.13.7" -mail-parser = "*" +nanotemplate = "*" +mail-parser = "*" # TODO no-default # Small, effective dependencies, little benefit to vendoring linkify = "0.8.0" urlencoding = "2.1.0" -nanohtml2text = "0.1.2" +nanohtml2text = "0.1.2" # TODO rem # Should be in stdlib once_cell = "1.9.0" diff --git a/src/arg.rs b/src/arg.rs @@ -9,31 +9,21 @@ // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, // NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE // OF THIS SOFTWARE. - -// Extremely minimalist command line interface, inspired by -// [sbase](https://git.suckless.org/sbase/)'s -// [arg.h](https://git.suckless.org/sbase/file/arg.h.html) -// -// I believe this has the same behavior, which is: -// * flags can be grouped (-abc) -// * missing arg -> print usage, exit -// * invalid flag -> print usage, exit // -// This is, of course, aggressively minimalist, perhaps even too much so. -// -// Copy/paste this code and you have a CLI! No library needed! use std::env; +use std::ffi::OsString; +use std::os::unix::ffi::OsStrExt; use std::path::PathBuf; use std::process::exit; +use std::str::FromStr; fn usage() -> ! { let name = env::args().next().unwrap(); eprintln!( "usage: {} [-rR] [-c CONFIG] [-d OUT_DIR] maildir FLAGS: --r use relative timestamps --R include raw emails [ALPHA] +-g include gemini output ARGS: -c config file (crabmail.conf) @@ -45,50 +35,60 @@ ARGS: #[derive(Default)] pub struct Args { - pub maildir: String, pub config: PathBuf, pub out_dir: PathBuf, - pub flags: String, + pub positional: Vec<OsString>, + pub a: i32, // placeholder + pub include_gemini: bool, } impl Args { - pub fn from_env() -> Self { - // Modify as neede - let mut out = Args { + pub fn default() -> Self { + Args { out_dir: "site".into(), config: "crabmail.conf".into(), ..Default::default() - }; - - // TODO figure out args_os - let mut args = env::args().skip(1); - - let mut maildir = None; - // Doesn't support non-UTF-8 paths TODO: solution? - // See https://github.com/RazrFalcon/pico-args/issues/2 - let parsenext = - |a: Option<String>| a.and_then(|a| a.parse().ok()).unwrap_or_else(|| usage()); + } + } + pub fn from_env() -> Self { + let mut out = Self::default(); + let mut args = env::args_os().skip(1); while let Some(arg) = args.next() { - let mut chars = arg.chars(); - // Positional args - if chars.next() != Some('-') { - maildir = Some(arg); + let s = arg.to_string_lossy(); + let mut ch_iter = s.chars(); + if ch_iter.next() != Some('-') { + out.positional.push(arg); continue; } - chars.for_each(|m| match m { - 'c' => out.config = parsenext(args.next()), - 'd' => out.out_dir = parsenext(args.next()), - 'r' | 'R' => out.flags.push(m), + ch_iter.for_each(|m| match m { + // Edit these lines // + 'c' => out.config = parse_os_arg(args.next()), + 'd' => out.out_dir = parse_os_arg(args.next()), + 'g' => out.include_gemini = true, + // Stop editing // _ => { usage(); } }) } - out.maildir = match maildir { - Some(m) => m.into(), - None => usage(), - }; + // other validation + if out.positional.len() < 1 { + usage() + } out } } + +fn parse_arg<T: FromStr>(a: Option<OsString>) -> T { + a.and_then(|a| a.into_string().ok()) + .and_then(|a| a.parse().ok()) + .unwrap_or_else(|| usage()) +} + +fn parse_os_arg<T: From<OsString>>(a: Option<OsString>) -> T { + match a { + Some(s) => T::from(s), + None => usage(), + } +} diff --git a/src/jwzthreading.rs b/src/jwzthreading.rs @@ -0,0 +1,138 @@ +// jwz threading https://www.jwz.org/doc/threading.html +// +// +// implementing this in Rust is a nightmare and makes me feel bad about myself so I am probably +// going to do something simpler + +use anyhow::{Context, Result}; +use mail_parser::parsers::fields::thread::thread_name; +use mail_parser::Message; +use std::cell::RefCell; +use std::collections::HashMap; +use std::fmt::Display; +use std::rc::{Rc, Weak}; + +#[derive(Default, Clone)] +struct JwzContainer { + message: Option<JwzMessage>, + parent: Option<MessageId>, + children: Vec<MessageId>, + next: Option<MessageId>, +} + +impl JwzContainer {} + +#[derive(Default, Clone)] +pub struct JwzMessage { + id: String, + subject: String, + references: Vec<String>, +} + +impl JwzMessage { + // TODO move out of here + pub fn parse(msg: Message) -> Result<Self> { + let id = msg + .get_message_id() + .context("Missing message ID")? + .to_owned(); + let subject = msg.get_subject().context("Missing subject")?.to_owned(); + let references = vec![]; + Ok(JwzMessage { + id, + subject, + references, + }) + } +} + +type MessageId = String; + +#[derive(Default, Clone)] +pub struct List { + id_table: HashMap<MessageId, JwzContainer>, + subject_table: HashMap<String, MessageId>, +} + +impl List { + pub fn new() -> Self { + List::default() + } + + // Todo enumerate errors or something + pub fn add_email(&mut self, jwz_msg: JwzMessage) { + let msg_id = jwz_msg.id.clone(); + let references = jwz_msg.references.clone(); + // 1A + if self + .id_table + .get(&msg_id) + .and_then(|c| Some(c.message.is_none())) + == Some(true) + { + let cont = self.id_table.get_mut(&msg_id).unwrap(); + cont.message = Some(jwz_msg) + } else { + let new_container = JwzContainer { + message: Some(jwz_msg), + ..Default::default() + }; + self.id_table.insert(msg_id.clone(), new_container); + } + // 1B + for pair in references.windows(2) { + // TODO check loop + let parent = self.container_from_id(&pair[0]); + if !parent.children.contains(&pair[1]) { + parent.children.push(pair[1].to_owned()); + } + let child = self.container_from_id(&pair[1]); + child.parent = Some(pair[0].to_owned()); + } + + // 1C + if references.len() > 0 { + let container = self.container_from_id(&msg_id); + container.parent = Some(references[references.len() - 1].clone()); + } + + // 2-4 + let root: Vec<&JwzContainer> = self + .id_table + .iter() + .filter_map(|(k, v)| { + if v.parent.is_none() { + return Some(v); + } + return None; + }) + .filter(|c| c.children.len() > 0) + // TODO Filter and promote if no message (4B) + .collect(); + + // 5 + for item in root { + // TODO If there is no message in the Container... + // ^^^ WHY WOULD THIS HAPPEN JWZ?? + if let Some(i) = &item.message { + let threadn = thread_name(&i.subject); + if threadn == "" { + continue; + } + } + } + } + + fn container_from_id(&mut self, msg_id: &str) -> &mut JwzContainer { + match self.id_table.get(msg_id) { + Some(c) => self.id_table.get_mut(msg_id).unwrap(), + None => { + self.id_table + .insert(msg_id.to_string(), JwzContainer::default()); + self.id_table.get_mut(msg_id).unwrap() + } + } + } + + pub fn finalize(&mut self) {} +} diff --git a/src/lib.rs b/src/lib.rs diff --git a/src/maildir.rs b/src/maildir.rs @@ -11,7 +11,7 @@ // OF THIS SOFTWARE. // Vendoring https://github.com/staktrace/maildir -// Could cut down a bit more +// TODO cleanup use std::error; use std::fmt; use std::fs; @@ -19,12 +19,9 @@ use std::io::prelude::*; use std::ops::Deref; use std::path::PathBuf; -use mailparse::*; - #[derive(Debug)] pub enum MailEntryError { IOError(std::io::Error), - ParseError(MailParseError), DateError(&'static str), } @@ -32,7 +29,6 @@ impl fmt::Display for MailEntryError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { MailEntryError::IOError(ref err) => write!(f, "IO error: {}", err), - MailEntryError::ParseError(ref err) => write!(f, "Parse error: {}", err), MailEntryError::DateError(ref msg) => write!(f, "Date error: {}", msg), } } @@ -42,7 +38,6 @@ impl error::Error for MailEntryError { fn source(&self) -> Option<&(dyn error::Error + 'static)> { match *self { MailEntryError::IOError(ref err) => Some(err), - MailEntryError::ParseError(ref err) => Some(err), MailEntryError::DateError(_) => None, } } @@ -54,12 +49,6 @@ impl From<std::io::Error> for MailEntryError { } } -impl From<MailParseError> for MailEntryError { - fn from(err: MailParseError) -> MailEntryError { - MailEntryError::ParseError(err) - } -} - impl From<&'static str> for MailEntryError { fn from(err: &'static str) -> MailEntryError { MailEntryError::DateError(err) @@ -92,41 +81,9 @@ pub struct MailEntry { id: String, flags: String, path: PathBuf, - data: MailData, } impl MailEntry { - fn read_data(&mut self) -> std::io::Result<()> { - if self.data.is_none() { - #[cfg(feature = "mmap")] - { - let f = fs::File::open(&self.path)?; - let mmap = unsafe { memmap::MmapOptions::new().map(&f)? }; - self.data = MailData::File(mmap); - } - - #[cfg(not(feature = "mmap"))] - { - let mut f = fs::File::open(&self.path)?; - let mut d = Vec::<u8>::new(); - f.read_to_end(&mut d)?; - self.data = MailData::Bytes(d); - } - } - Ok(()) - } - - pub fn parsed(&mut self) -> Result<ParsedMail, MailEntryError> { - self.read_data()?; - match self.data { - MailData::None => panic!("read_data should have returned an Err!"), - #[cfg(not(feature = "mmap"))] - MailData::Bytes(ref b) => parse_mail(b).map_err(MailEntryError::ParseError), - #[cfg(feature = "mmap")] - MailData::File(ref m) => parse_mail(m).map_err(MailEntryError::ParseError), - } - } - pub fn path(&self) -> &PathBuf { &self.path } @@ -202,7 +159,6 @@ impl Iterator for MailEntries { id: String::from(id.unwrap()), flags: String::from(flags.unwrap()), path: entry.path(), - data: MailData::None, })) }); return match result { diff --git a/src/main.rs b/src/main.rs @@ -1,794 +1,780 @@ // this code is not good // i am not very good at rust // that is ok though - +#[forbid(unsafe_code)] use anyhow::{Context, Result}; -use horrorshow::helper::doctype; -use horrorshow::owned_html; -use horrorshow::prelude::*; -use horrorshow::Template; use maildir::Maildir; -use std::io::BufWriter; -use std::path::Path; +use std::path::PathBuf; use std::str; -#[macro_use] -extern crate horrorshow; - -use mailparse::*; -use std::collections::{HashMap, HashSet}; -use std::fs::File; +use models::*; use std::io::prelude::*; -use urlencoding; use config::{Config, INSTANCE}; -use utils::xml_safe; mod arg; mod config; mod maildir; +mod models; +mod templates; +mod threading; mod time; mod utils; -// Not a "raw email" struct, but an email object that can be represented by -// crabmail. -#[derive(Debug, Clone)] -struct Email { - id: String, - from: SingleInfo, - subject: String, - in_reply_to: Option<String>, - date: u64, // unix epoch. received date (if present) - date_string: String, - body: String, - mime: String, -} +const ATOM_ENTRY_LIMIT: i32 = 100; +const PAGE_SIZE: i32 = 100; -#[derive(Debug, Clone)] -struct MailThread<'a> { - messages: Vec<&'a Email>, // sorted - hash: String, - last_reply: u64, - list_name: String, -} +// TODO -impl<'a> MailThread<'a> {} - -fn layout(page_title: impl Render, content: impl Render) -> impl Render { - // owned_html _moves_ the arguments into the template. Useful for returning - // owned (movable) templates. - owned_html! { - : doctype::HTML; - html { - head { - title : &page_title; - : Raw("<meta http-equiv='Permissions-Policy' content='interest-cohort=()'/> - <link rel='stylesheet' type='text/css' href='style.css' /> - <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0,user-scalable=0' /> - <link rel='icon' href='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📧</text></svg>'>"); - meta(name="description", content=&page_title); - } - body { - main { - :&content - } - hr; - div(class="footer") { - : Raw("Archive generated with <a href='https://crabmail.flounder.online/'>crabmail</a> at "); - : &Config::global().now; - } - } - } +impl Lists<'_> { + fn add(&mut self, list: threading::ThreadIdx) { + // let newlist = List { threads: vec![] }; + for thread in list.threads { + // let ids = thread.iter().map(|m| m.id).collect(); + // newlist.threads.push(Thread::from_id_list(ids)); } -} - -struct ThreadList<'a> { - threads: Vec<MailThread<'a>>, - name: String, - email: String, - description: String, - title: String, - url: String, // URL? -} - -// Get short name from an address name like "alex wennerberg <alex@asdfasdfafd>" -fn short_name(s: &SingleInfo) -> &str { - match &s.display_name { - Some(dn) => dn, - None => &s.addr, + // TODO sort threads + // self.lists.push(newlist); } -} -impl<'a> ThreadList<'a> { - fn new(threads: Vec<MailThread<'a>>, list_name: &str) -> Self { - let config = Config::global(); - let d = config.default_subsection(&list_name); - let subsection_config = config - .subsections - .iter() - .find(|s| s.name == list_name) - .unwrap_or(&d); - - ThreadList { - threads, - name: list_name.to_owned(), // TODO handle ownership - email: subsection_config.email.to_owned(), - title: subsection_config.title.to_owned(), - description: subsection_config.description.to_owned(), - url: format!("{}/{}", Config::global().base_url, &list_name), + fn write_lists(&self) { + // write index - ez + for list in &self.lists { + list.write_all_files() } } - fn write_atom_feed(&self) -> Result<()> { - // not sure how well this feed works... it just tracks thread updates. - let mut entries_str: String = String::new(); - let mut entries = vec![]; - for thread in &self.threads { - for message in &thread.messages { - let tmpl = thread.build_msg_atom(message); - entries.push((message.date, tmpl)); - } - } - // uggo - entries.sort_by_key(|a| a.0); - entries.reverse(); - for entry in &entries { - entries_str.push_str(&entry.1); - } - let l = &entries.len(); - let last_updated = match l { - 0 => 0, - _ => entries[0].0, - }; - let atom = format!( - r#"<?xml version="1.0" encoding="utf-8"?> -<feed xmlns="http://www.w3.org/2005/Atom"> -<title>{feed_title}</title> -<link href="{feed_link}"/> -<updated>{last_updated}</updated> -<author> - <name>{author_name}</name> - <email>{author_email}</email> -</author> -<id>{feed_id}</id> -{entry_list} -</feed>"#, - feed_title = xml_safe(&self.name), - feed_link = xml_safe(&self.url), - last_updated = time::secs_to_date(last_updated).rfc3339(), - author_name = &self.email, - author_email = &self.email, - feed_id = &self.url, - entry_list = entries_str, - ); - let path = Config::global().out_dir.join(&self.name).join("atom.xml"); - let mut file = File::create(&path)?; - file.write(atom.as_bytes())?; - Ok(()) - } - pub fn write_to_file(&self) -> Result<()> { - let timestring = match Config::global().relative_times { - false => |t| time::secs_to_date(t).ymd(), - true => |t| time::timeago(t), - }; - let tmp = html! { - h1(class="page-title") { - : &self.title; - : Raw(" "); - a(href="atom.xml") { - img(alt="Atom feed", src=utils::RSS_SVG); - } - } - : Raw(&self.description); - - @if self.description.len() > 1 { - br; - } - a(href=format!("mailto:{}", &self.email)) { - : &self.email - } - hr; - @ for thread in &self.threads { - div(class="message-sum") { - a(class="bigger", href=format!("threads/{}.html", &thread.hash)) { - : &thread.messages[0].subject - } - : format!(" ({})", thread.messages.len() -1) ; - br; - span { - : short_name(&thread.messages[0].from) - } - - span(class="light") { - : format!(" {created} | updated {last}", created=timestring(thread.messages[0].date), last=timestring(thread.last_reply())) - } br; - - } - } - }; - - let file = File::create(&Config::global().out_dir.join(&self.name).join("index.html"))?; - let mut br = BufWriter::new(file); - layout(self.name.clone(), tmp).write_to_io(&mut br)?; - Ok(()) - } } +use std::fs::{read, write}; -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 build_msg_atom(&self, message: &Email) -> String { - let tmpl = format!( - r#"<entry> -<title>{title}</title> -<link href="{item_link}"/> -<id>{entry_id}</id> -<updated>{updated_at}</updated> -<author> -<name>{author_name}</name> -<email>{author_email}</email> -</author> -<content type="text/plain"> -{content} -</content> -</entry> -"#, - title = xml_safe(&message.subject), - item_link = xml_safe(&message.url(&self)), - entry_id = xml_safe(&message.url(&self)), - updated_at = time::secs_to_date(message.date).rfc3339(), - author_name = xml_safe(short_name(&message.from)), - author_email = xml_safe(&message.from.addr), - content = xml_safe(&message.body), - ); - tmpl - } - - fn write_atom_feed(&self) -> Result<()> { - let mut entries: String = String::new(); - for message in &self.messages { - let tmpl = self.build_msg_atom(message); - entries.push_str(&tmpl); - } - let root = self.messages[0]; - let atom = format!( - r#"<?xml version="1.0" encoding="utf-8"?> -<feed xmlns="http://www.w3.org/2005/Atom"> -<title>{feed_title}</title> -<link rel="self" href="{feed_link}"/> -<updated>{last_updated}</updated> -<author> - <name>{author_name}</name> - <email>{author_email}</email> -</author> -<id>{feed_id}</id> -{entry_list} -</feed>"#, - feed_title = xml_safe(&root.subject), - feed_link = self.url(), - last_updated = time::secs_to_date(self.last_reply()).rfc3339(), - author_name = xml_safe(short_name(&root.from)), - author_email = xml_safe(&root.from.addr), - feed_id = self.url(), - entry_list = entries, - ); - let thread_dir = Config::global() - .out_dir - .join(&self.list_name) - .join("threads"); - let mut file = File::create(&thread_dir.join(format!("{}.xml", &self.hash)))?; - file.write(atom.as_bytes())?; - Ok(()) - } - - 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"; - } +// TODO: use checksum / cache. bool whether it writes +fn write_if_unchanged(path: &PathBuf, data: &[u8]) -> bool { + if let Ok(d) = read(path) { + if &d == data { + return false; } - } - 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("/", ";") + } else { + write(path, data).unwrap() } + return true; } -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 write_parsed_mail(parsed_mail: &ParsedMail, f: &mut std::fs::File) -> Result<()> { - for header in parsed_mail.get_headers() { - // binary search? - if EXPORT_HEADERS.contains(&header.get_key().as_str()) { - f.write_all(header.get_key_raw())?; - f.write_all(b": ")?; - f.write_all(header.get_value_raw())?; - f.write_all(b"\r\n")?; - } - } - f.write_all(b"\r\n")?; - f.write_all(&parsed_mail.get_body_raw()?)?; - Ok(()) +// / is disallowed in paths. ; is disallowed in message IDs +fn pathescape_msg_id(s: &str) -> PathBuf { + PathBuf::from(s.replace("/", ";")) } -fn local_parse_email(parsed_mail: &ParsedMail) -> Result<Email> { - let mut body: String = "[Message has no body]".to_owned(); - let mut mime: String = "".to_owned(); - let nobody = "[No body found]"; - // nested lookup - let mut queue = vec![parsed_mail]; - let mut text_body = None; - let mut html_body = None; - while queue.len() > 0 { - let top = queue.pop().unwrap(); - for sub in &top.subparts { - queue.push(sub); - } - let content_disposition = top.get_content_disposition(); - if content_disposition.disposition == mailparse::DispositionType::Attachment { - // attachment handler - } else { - if top.ctype.mimetype == "text/plain" { - let b = top.get_body().unwrap_or(nobody.to_owned()); - if parsed_mail.ctype.params.get("format") == Some(&"flowed".to_owned()) { - text_body = Some(utils::unformat_flowed(&b)); - } else { - text_body = Some(b); - } - } - if top.ctype.mimetype == "text/html" { - html_body = Some(nanohtml2text::html2text( - &top.get_body().unwrap_or(nobody.to_owned()), - )); - } - } - } - if let Some(b) = text_body { - body = b; - mime = "text/plain".to_owned(); - } else if let Some(b) = html_body { - body = b; - mime = "text/html".to_owned(); - } - let headers = &parsed_mail.headers; - let id = headers - .get_first_value("message-id") - .and_then(|m| { - msgidparse(&m).ok().and_then(|i| match i.len() { - 0 => None, - _ => Some(i[0].clone()), - }) - }) - .context("No valid message ID")?; - // Assume 1 in-reply-to header. a reasonable assumption - let in_reply_to = headers.get_first_value("in-reply-to").and_then(|m| { - msgidparse(&m).ok().and_then(|i| match i.len() { - 0 => None, - _ => Some(i[0].clone()), - }) - }); - let subject = headers - .get_first_value("subject") - .unwrap_or("(no subject)".to_owned()); - // TODO move upstream, add key/value parsing - // https://datatracker.ietf.org/doc/html/rfc2822.html#section-3.6.7 - // https://github.com/staktrace/mailparse/issues/99 - let received = headers.get_first_value("received"); - let date_string = match received { - Some(r) => { - let s: Vec<&str> = r.split(";").collect(); - s[s.len() - 1].to_owned() - } - None => headers.get_first_value("date").context("No date header")?, - }; - - let date = dateparse(&date_string)? as u64; - let from = addrparse_header(headers.get_first_header("from").context("No from header")?)? - .extract_single_info() - .context("Could not parse from header")?; - - return Ok(Email { - id, - in_reply_to, - from, - subject, - date, - date_string, - body, - mime, - }); +enum Formats { + XML, + HTML, + GMI, } -// if [arg] has cur,new,tmp -> that is the index -// else, do each subfolder - -fn write_index(lists: Vec<String>) -> Result<()> { - let description = &Config::global().description; - let tmp = html! { - h1(class="page-title") { - : format!("Mail Archives"); - } - : Raw(&description); - - @if description.len() > 1 { - br; - } - hr; - @for list in &lists { - a(href=list, class="bigger bold") { - :list; +fn write_format() {} +impl List<'_> { + // TODO move to main + // fn from_maildir() -> Self { // TODO figure out init + // where to live + // List { threads: vec![] } + fn write_all_files(&self) { + let index = self.out_dir.join("index.html"); + // TODO write index (paginated) gmi + // write index (paginated) html + // write xml feed + // Delete threads that aren't in my list (xml, gmi, html) + for thread in &self.threads { + let basepath = self + .out_dir + .join("threads") + .join(&pathescape_msg_id(&thread.messages[0].id)); + // TODO cleanup, abstract + write_if_unchanged( + &basepath.with_extension("html"), + thread.to_html().as_bytes(), + ); + write_if_unchanged(&basepath.with_extension("xml"), thread.to_xml().as_bytes()); + write_if_unchanged(&basepath.with_extension("gmi"), thread.to_gmi().as_bytes()); + // Delete nonexistent messages (cache?) + // for file in thread + // write raw file } - br; } - }; - let file = File::create(&Config::global().out_dir.join("index.html"))?; - let mut br = BufWriter::new(file); - layout("Mail Archives".to_string(), tmp).write_to_io(&mut br)?; - Ok(()) -} - -fn newmain() -> Result<()> { - let args = arg::Args::from_env(); - let mut config = Config::from_file(&args.config)?; - INSTANCE.set(config).unwrap(); - // if is file, interpret as mbox, else interpret as maildir etc - // mailgen = mbox/mdir generator - for maildir in std::fs::read_dir(&args.maildir).unwrap() {} - Ok(()) } fn main() -> Result<()> { let args = arg::Args::from_env(); - + let maildir = &args.positional[0]; let mut config = Config::from_file(&args.config)?; - config.out_dir = args.out_dir; - config.relative_times = args.flags.contains('r'); - config.include_raw = args.flags.contains('R'); INSTANCE.set(config).unwrap(); - // let is_subfolder = std::fs::read_dir(&args.maildir) - // .unwrap() - // .any(|a| a.unwrap().file_name().to_str().unwrap() == "cur"); - - let css = include_bytes!("style.css"); - let mut names = vec![]; - let mut message_count = 0; - for maildir in std::fs::read_dir(&args.maildir).unwrap() { - let maildir = maildir?; - let file_name = maildir.file_name(); - let config = Config::global(); - let out_dir = config.out_dir.join(&file_name); - std::fs::create_dir(&out_dir).ok(); - let dirreader = Maildir::from(maildir.path().to_str().unwrap()); - let list_name = file_name.into_string().unwrap(); - // filter out maildir internal junk - if list_name.as_bytes()[0] == b'.' || ["cur", "new", "tmp"].contains(&list_name.as_str()) { + let mut lists = Lists { + lists: vec![], + out_dir: args.out_dir, + }; + for maildir in std::fs::read_dir(maildir)?.filter_map(|m| m.ok()) { + let dir_name = maildir.file_name().into_string().unwrap(); // TODO no unwrap + if dir_name.as_bytes()[0] == b'.' || ["cur", "new", "tmp"].contains(&dir_name.as_str()) { continue; } - let path = out_dir.join("messages"); - std::fs::remove_dir_all(&path).ok(); - names.push(list_name.clone()); - // new world WIP - // let mut threader = threading::Arena::default(); - // Loads whole file into memory for threading - // for item in maildir.list_cur().chain(maildir.list_new()) { - // threader.add_message(item?); - // } - // threader.finalize(); - // return Ok(()); - - let mut thread_index: HashMap<String, Vec<String>> = HashMap::new(); - - let mut email_index: HashMap<String, Email> = HashMap::new(); - for entry in dirreader.list_cur().chain(dirreader.list_new()) { - let mut tmp = entry.unwrap(); - let buffer = tmp.parsed()?; - // persist raw messages - let email = match local_parse_email(&buffer) { - Ok(e) => e, - Err(e) => { - eprintln!("Error parsing {:?} -- {:?}", tmp.path(), e); - continue; - } - }; - message_count += 1; - // write raw emails - if Config::global().include_raw { - // inefficient here -- no diff - std::fs::create_dir(&path).ok(); - let mut file = File::create(out_dir.join("messages").join(email.hash()))?; - write_parsed_mail(&buffer, &mut file)?; - } - // TODO fix borrow checker - if let Some(reply) = email.in_reply_to.clone() { - match thread_index.get(&reply) { - Some(_) => { - 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); - } - - // Add index by subject lines - // atrocious - let mut todo = vec![]; // im bad at borrow checker - for (_, em) in &email_index { - if em.in_reply_to.is_none() - && (em.subject.starts_with("Re: ") || em.subject.starts_with("RE: ")) - { - // TODO O(n^2) - for (_, em2) in &email_index { - if em2.subject == em.subject[4..] { - match thread_index.get(&em2.id) { - Some(_) => { - let d = thread_index.get_mut(&em2.id).unwrap(); - d.push(em.id.clone()); - } - None => { - thread_index.insert(em2.id.clone(), vec![em.id.clone()]); - } - } - todo.push((em.id.clone(), em2.id.clone())); - break; - } - } - } + let mut list = threading::ThreadIdx::new(); + let dirreader = Maildir::from(maildir.path()); + for f in dirreader + .list_cur() + .chain(dirreader.list_new()) + .filter_map(|e| e.ok()) + { + let data = std::fs::read(f.path())?; + // TODO move these 2 lines to dirreader + let msg = mail_parser::Message::parse(&data).context("Missing mail bytes")?; + list.add_email(&msg, f.path().to_path_buf()); } - for (id, reply) in todo { - let em = email_index.get_mut(&id).unwrap(); - em.in_reply_to = Some(reply) - } - - let mut thread_roots: Vec<Email> = email_index - .iter() - .filter_map(|(_, v)| { - if v.in_reply_to.is_none() { - // or can't find root based on Re: subject - return Some(v.clone()); - } - return None; - }) - .collect(); - let thread_dir = out_dir.join("threads"); - std::fs::create_dir_all(&thread_dir).ok(); - let mut threads = vec![]; - let mut curr_threads = get_current_threads(&thread_dir); - - for root in &mut thread_roots { - let mut thread_ids = vec![]; - 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 thread = MailThread { - messages: messages, - hash: root.hash(), - last_reply: 0, // TODO - list_name: list_name.clone(), - }; - - thread.last_reply = thread.last_reply(); - - thread.write_to_file()?; - thread.write_atom_feed()?; - curr_threads.remove(&thread.hash); - threads.push(thread); - } - - for leftover in curr_threads { - let file_to_remove = thread_dir.join(format!("{}.html", leftover)); - std::fs::remove_file(&file_to_remove).ok(); - let file_to_remove = thread_dir.join(format!("{}.xml", leftover)); - std::fs::remove_file(&file_to_remove).ok(); - } - - // Remove any threads left over - - threads.sort_by_key(|a| a.last_reply); - threads.reverse(); - let list = ThreadList::new(threads, &list_name); - list.write_to_file()?; - list.write_atom_feed()?; - // kinda clunky - let mut css_root = File::create(out_dir.join("style.css"))?; - css_root.write(css)?; - let mut css_sub = File::create(out_dir.join("threads").join("style.css"))?; - css_sub.write(css)?; + list.finalize(); + lists.add(list); } - let mut css_root = File::create(Config::global().out_dir.join("style.css"))?; - css_root.write(css)?; - write_index(names)?; - eprintln!("Processed {} emails", message_count); + + lists.write_lists(); Ok(()) } -// Use the sha3 hash of the ID. It is what it is. -// lots of unwrapping here -fn get_current_threads(thread_dir: &Path) -> HashSet<String> { - std::fs::read_dir(thread_dir) - .unwrap() - .map(|x| { - x.unwrap() - .path() - .file_stem() - .unwrap() - .to_str() - .unwrap() - .to_owned() - }) - .filter(|x| !(x == "style")) - .collect() -} +// // Not a "raw email" struct, but an email object that can be represented by +// // crabmail. +// #[derive(Debug, Clone)] +// struct Email { +// id: String, +// from: SingleInfo, +// subject: String, +// in_reply_to: Option<String>, +// date: u64, // unix epoch. received date (if present) +// date_string: String, +// body: String, +// mime: String, +// } + +// #[derive(Debug, Clone)] +// struct MailThread<'a> { +// messages: Vec<&'a Email>, // sorted +// hash: String, +// last_reply: u64, +// list_name: String, +// } + +// impl<'a> MailThread<'a> {} + +// fn layout(page_title: impl Render, content: impl Render) -> impl Render { +// // owned_html _moves_ the arguments into the template. Useful for returning +// // owned (movable) templates. +// owned_html! { +// : doctype::HTML; +// html { +// head { +// title : &page_title; +// : Raw("<meta http-equiv='Permissions-Policy' content='interest-cohort=()'/> +// <link rel='stylesheet' type='text/css' href='style.css' /> +// <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0,user-scalable=0' /> +// <link rel='icon' href='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📧</text></svg>'>"); +// meta(name="description", content=&page_title); +// } +// body { +// main { +// :&content +// } +// hr; +// div(class="footer") { +// : Raw("Archive generated with <a href='https://crabmail.flounder.online/'>crabmail</a> at "); +// : &Config::global().now; +// } +// } +// } +// } +// } + +// struct ThreadList<'a> { +// threads: Vec<MailThread<'a>>, +// name: String, +// email: String, +// description: String, +// title: String, +// url: String, // URL? +// } + +// // Get short name from an address name like "alex wennerberg <alex@asdfasdfafd>" +// fn short_name(s: &SingleInfo) -> &str { +// match &s.display_name { +// Some(dn) => dn, +// None => &s.addr, +// } +// } + +// impl<'a> ThreadList<'a> { +// fn new(threads: Vec<MailThread<'a>>, list_name: &str) -> Self { +// let config = Config::global(); +// let d = config.default_subsection(&list_name); +// let subsection_config = config +// .subsections +// .iter() +// .find(|s| s.name == list_name) +// .unwrap_or(&d); + +// ThreadList { +// threads, +// name: list_name.to_owned(), // TODO handle ownership +// email: subsection_config.email.to_owned(), +// title: subsection_config.title.to_owned(), +// description: subsection_config.description.to_owned(), +// url: format!("{}/{}", Config::global().base_url, &list_name), +// } +// } + +// pub fn write_to_file(&self) -> Result<()> { +// let timestring = match Config::global().relative_times { +// false => |t| time::secs_to_date(t).ymd(), +// true => |t| time::timeago(t), +// }; +// let tmp = html! { +// h1(class="page-title") { +// : &self.title; +// : Raw(" "); +// a(href="atom.xml") { +// // img(alt="Atom feed", src=utils::RSS_SVG); +// } +// } +// : Raw(&self.description); + +// @if self.description.len() > 1 { +// br; +// } +// a(href=format!("mailto:{}", &self.email)) { +// : &self.email +// } +// hr; +// @ for thread in &self.threads { +// div(class="message-sum") { +// a(class="bigger", href=format!("threads/{}.html", &thread.hash)) { +// : &thread.messages[0].subject +// } +// : format!(" ({})", thread.messages.len() -1) ; +// br; +// span { +// : short_name(&thread.messages[0].from) +// } + +// span(class="light") { +// : format!(" {created} | updated {last}", created=timestring(thread.messages[0].date), last=timestring(thread.last_reply())) +// } br; + +// } +// } +// }; + +// let file = File::create(&Config::global().out_dir.join(&self.name).join("index.html"))?; +// let mut br = BufWriter::new(file); +// layout(self.name.clone(), tmp).write_to_io(&mut br)?; +// Ok(()) +// } +// } + +// 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("/", ";") +// } +// } + +// 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 write_parsed_mail(parsed_mail: &ParsedMail, f: &mut std::fs::File) -> Result<()> { +// for header in parsed_mail.get_headers() { +// // binary search? +// if EXPORT_HEADERS.contains(&header.get_key().as_str()) { +// f.write_all(header.get_key_raw())?; +// f.write_all(b": ")?; +// f.write_all(header.get_value_raw())?; +// f.write_all(b"\r\n")?; +// } +// } +// f.write_all(b"\r\n")?; +// f.write_all(&parsed_mail.get_body_raw()?)?; +// Ok(()) +// } + +// fn local_parse_email(parsed_mail: &ParsedMail) -> Result<Email> { +// let mut body: String = "[Message has no body]".to_owned(); +// let mut mime: String = "".to_owned(); +// let nobody = "[No body found]"; +// // nested lookup +// let mut queue = vec![parsed_mail]; +// let mut text_body = None; +// let mut html_body = None; +// while queue.len() > 0 { +// let top = queue.pop().unwrap(); +// for sub in &top.subparts { +// queue.push(sub); +// } +// let content_disposition = top.get_content_disposition(); +// if content_disposition.disposition == mailparse::DispositionType::Attachment { +// // attachment handler +// } else { +// if top.ctype.mimetype == "text/plain" { +// let b = top.get_body().unwrap_or(nobody.to_owned()); +// if parsed_mail.ctype.params.get("format") == Some(&"flowed".to_owned()) { +// text_body = Some(b); +// // text_body = Some(utils::unformat_flowed(&b)); +// } else { +// text_body = Some(b); +// } +// } +// if top.ctype.mimetype == "text/html" { +// html_body = Some(nanohtml2text::html2text( +// &top.get_body().unwrap_or(nobody.to_owned()), +// )); +// } +// } +// } +// if let Some(b) = text_body { +// body = b; +// mime = "text/plain".to_owned(); +// } else if let Some(b) = html_body { +// body = b; +// mime = "text/html".to_owned(); +// } +// let headers = &parsed_mail.headers; +// let id = headers +// .get_first_value("message-id") +// .and_then(|m| { +// msgidparse(&m).ok().and_then(|i| match i.len() { +// 0 => None, +// _ => Some(i[0].clone()), +// }) +// }) +// .context("No valid message ID")?; +// // Assume 1 in-reply-to header. a reasonable assumption +// let in_reply_to = headers.get_first_value("in-reply-to").and_then(|m| { +// msgidparse(&m).ok().and_then(|i| match i.len() { +// 0 => None, +// _ => Some(i[0].clone()), +// }) +// }); +// let subject = headers +// .get_first_value("subject") +// .unwrap_or("(no subject)".to_owned()); +// // TODO move upstream, add key/value parsing +// // https://datatracker.ietf.org/doc/html/rfc2822.html#section-3.6.7 +// // https://github.com/staktrace/mailparse/issues/99 +// let received = headers.get_first_value("received"); +// let date_string = match received { +// Some(r) => { +// let s: Vec<&str> = r.split(";").collect(); +// s[s.len() - 1].to_owned() +// } +// None => headers.get_first_value("date").context("No date header")?, +// }; + +// let date = dateparse(&date_string)? as u64; +// let from = addrparse_header(headers.get_first_header("from").context("No from header")?)? +// .extract_single_info() +// .context("Could not parse from header")?; + +// return Ok(Email { +// id, +// in_reply_to, +// from, +// subject, +// date, +// date_string, +// body, +// mime, +// }); +// } + +// // if [arg] has cur,new,tmp -> that is the index +// // else, do each subfolder + +// fn write_index(lists: Vec<String>) -> Result<()> { +// let description = &Config::global().description; +// let tmp = html! { +// h1(class="page-title") { +// : format!("Mail Archives"); +// } +// : Raw(&description); + +// @if description.len() > 1 { +// br; +// } +// hr; +// @for list in &lists { +// a(href=list, class="bigger bold") { +// :list; +// } +// br; +// } +// }; +// let file = File::create(&Config::global().out_dir.join("index.html"))?; +// let mut br = BufWriter::new(file); +// layout("Mail Archives".to_string(), tmp).write_to_io(&mut br)?; +// Ok(()) +// } + +// // fn oldmain() -> Result<()> { +// // let args = arg::Args::from_env(); + +// // let mut config = Config::from_file(&args.config)?; +// // config.out_dir = args.out_dir; +// // config.relative_times = args.flags.contains('r'); +// // config.include_raw = args.flags.contains('R'); +// // INSTANCE.set(config).unwrap(); + +// // // let is_subfolder = std::fs::read_dir(&args.maildir) +// // // .unwrap() +// // // .any(|a| a.unwrap().file_name().to_str().unwrap() == "cur"); + +// // let css = include_bytes!("style.css"); +// // let mut names = vec![]; +// // let mut message_count = 0; +// // for maildir in std::fs::read_dir(&args.maildir).unwrap() { +// // let maildir = maildir?; +// // let file_name = maildir.file_name(); +// // let config = Config::global(); +// // let out_dir = config.out_dir.join(&file_name); +// // std::fs::create_dir(&out_dir).ok(); +// // let dirreader = Maildir::from(maildir.path().to_str().unwrap()); +// // let list_name = file_name.into_string().unwrap(); +// // // filter out maildir internal junk +// // if list_name.as_bytes()[0] == b'.' || ["cur", "new", "tmp"].contains(&list_name.as_str()) { +// // continue; +// // } + +// // let path = out_dir.join("messages"); +// // std::fs::remove_dir_all(&path).ok(); +// // names.push(list_name.clone()); +// // // new world WIP +// // // let mut threader = threading::Arena::default(); +// // // Loads whole file into memory for threading +// // // for item in maildir.list_cur().chain(maildir.list_new()) { +// // // threader.add_message(item?); +// // // } +// // // threader.finalize(); +// // // return Ok(()); + +// // let mut thread_index: HashMap<String, Vec<String>> = HashMap::new(); + +// // let mut email_index: HashMap<String, Email> = HashMap::new(); +// // for entry in dirreader.list_cur().chain(dirreader.list_new()) { +// // let mut tmp = entry.unwrap(); +// // let buffer = tmp.parsed()?; +// // // persist raw messages +// // let email = match local_parse_email(&buffer) { +// // Ok(e) => e, +// // Err(e) => { +// // eprintln!("Error parsing {:?} -- {:?}", tmp.path(), e); +// // continue; +// // } +// // }; +// // message_count += 1; +// // // write raw emails +// // if Config::global().include_raw { +// // // inefficient here -- no diff +// // std::fs::create_dir(&path).ok(); +// // let mut file = File::create(out_dir.join("messages").join(email.hash()))?; +// // write_parsed_mail(&buffer, &mut file)?; +// // } +// // // TODO fix borrow checker +// // if let Some(reply) = email.in_reply_to.clone() { +// // match thread_index.get(&reply) { +// // Some(_) => { +// // 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); +// // } + +// // // Add index by subject lines +// // // atrocious +// // let mut todo = vec![]; // im bad at borrow checker +// // for (_, em) in &email_index { +// // if em.in_reply_to.is_none() +// // && (em.subject.starts_with("Re: ") || em.subject.starts_with("RE: ")) +// // { +// // // TODO O(n^2) +// // for (_, em2) in &email_index { +// // if em2.subject == em.subject[4..] { +// // match thread_index.get(&em2.id) { +// // Some(_) => { +// // let d = thread_index.get_mut(&em2.id).unwrap(); +// // d.push(em.id.clone()); +// // } +// // None => { +// // thread_index.insert(em2.id.clone(), vec![em.id.clone()]); +// // } +// // } +// // todo.push((em.id.clone(), em2.id.clone())); +// // break; +// // } +// // } +// // } +// // } +// // for (id, reply) in todo { +// // let em = email_index.get_mut(&id).unwrap(); +// // em.in_reply_to = Some(reply) +// // } + +// // let mut thread_roots: Vec<Email> = email_index +// // .iter() +// // .filter_map(|(_, v)| { +// // if v.in_reply_to.is_none() { +// // // or can't find root based on Re: subject +// // return Some(v.clone()); +// // } +// // return None; +// // }) +// // .collect(); +// // let thread_dir = out_dir.join("threads"); +// // std::fs::create_dir_all(&thread_dir).ok(); +// // let mut threads = vec![]; +// // let mut curr_threads = get_current_threads(&thread_dir); + +// // for root in &mut thread_roots { +// // let mut thread_ids = vec![]; +// // 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 thread = MailThread { +// // messages: messages, +// // hash: root.hash(), +// // last_reply: 0, // TODO +// // list_name: list_name.clone(), +// // }; + +// // thread.last_reply = thread.last_reply(); + +// // thread.write_to_file()?; +// // thread.write_atom_feed()?; +// // curr_threads.remove(&thread.hash); +// // threads.push(thread); +// // } + +// // for leftover in curr_threads { +// // let file_to_remove = thread_dir.join(format!("{}.html", leftover)); +// // std::fs::remove_file(&file_to_remove).ok(); +// // let file_to_remove = thread_dir.join(format!("{}.xml", leftover)); +// // std::fs::remove_file(&file_to_remove).ok(); +// // } + +// // // Remove any threads left over + +// // threads.sort_by_key(|a| a.last_reply); +// // threads.reverse(); +// // let list = ThreadList::new(threads, &list_name); +// // list.write_to_file()?; +// // list.write_atom_feed()?; +// // // kinda clunky +// // let mut css_root = File::create(out_dir.join("style.css"))?; +// // css_root.write(css)?; +// // let mut css_sub = File::create(out_dir.join("threads").join("style.css"))?; +// // css_sub.write(css)?; +// // } +// // let mut css_root = File::create(Config::global().out_dir.join("style.css"))?; +// // css_root.write(css)?; +// // write_index(names)?; +// // eprintln!("Processed {} emails", message_count); +// // Ok(()) +// // } + +// // Use the sha3 hash of the ID. It is what it is. +// // lots of unwrapping here +// fn get_current_threads(thread_dir: &Path) -> HashSet<String> { +// std::fs::read_dir(thread_dir) +// .unwrap() +// .map(|x| { +// x.unwrap() +// .path() +// .file_stem() +// .unwrap() +// .to_str() +// .unwrap() +// .to_owned() +// }) +// .filter(|x| !(x == "style")) +// .collect() +// } diff --git a/src/models.rs b/src/models.rs @@ -0,0 +1,107 @@ +use crate::config::{Config, Subsection}; +use mail_parser::{Addr, HeaderValue, Message}; +use std::borrow::Cow; +use std::path::PathBuf; + +// messages are path-cleaned in this context (/ replaced) +// list_path = "/{list_name}/index.html" +// xml = "/{list_name}/atom.xml" +// thread_path = "/{list_name}/{thread_id}.html +// thread_xml = "/{list_name}/{thread_id}.xml +// raw_email = "/{list_name}/messages/{message_id}.eml +// paginate index somehow (TBD) + +pub struct Lists<'a> { + pub lists: Vec<List<'a>>, + pub out_dir: PathBuf, +} + +pub struct List<'a> { + pub threads: Vec<Thread<'a>>, + pub config: Subsection, // path + pub out_dir: PathBuf, +} + +pub struct Thread<'a> { + pub messages: Vec<StrMessage<'a>>, +} + +impl Thread<'_> { + // fn new() -> Self { + // Thread {messagse: } + // } +} + +// TODO rename +// simplified, stringified-email for templating +pub struct StrMessage<'a> { + pub id: Cow<'a, str>, + pub subject: Cow<'a, str>, + pub from: MailAddress, + pub date: Cow<'a, str>, // TODO better dates + pub body: Cow<'a, str>, + pub in_reply_to: Option<Cow<'a, str>>, + // url: Cow<'a, str>, + // download_path: PathBuf, // TODO +} + +impl StrMessage<'_> { + // Raw file path +} + +// i suck at Cow and strings +pub struct MailAddress { + name: String, + address: String, +} +impl MailAddress { + fn from_addr(addr: &Addr) -> Self { + // todo wtf + let address = addr + .address + .clone() + .unwrap_or(Cow::Borrowed("invalid-email")) + .to_string(); + MailAddress { + name: addr + .name + .clone() + .unwrap_or(Cow::Owned(address.clone())) + .to_string(), + address: address.to_string(), + } + } +} + +// TODO rename +impl<'a> Thread<'a> { + fn new_message(msg: &'a Message<'a>) -> StrMessage<'a> { + let id = msg.get_message_id().unwrap_or(""); + let subject = msg.get_subject().unwrap_or("(No Subject)"); + let invalid_email = Addr::new(None, "invalid-email"); + let from = match msg.get_from() { + HeaderValue::Address(fr) => fr, + _ => &invalid_email, + }; + let from = MailAddress::from_addr(from); + let date = msg.get_date().unwrap().to_iso8601(); + let in_reply_to = msg + .get_in_reply_to() + .as_text_ref() + .and_then(|a| Some(Cow::Borrowed(a))); + + // TODO linkify body + // TODO unformat-flowed + let body = msg + .get_text_body(0) + .unwrap_or(Cow::Borrowed("[No message body]")); + StrMessage { + id: Cow::Borrowed(id), + subject: Cow::Borrowed(subject), + from: from, + date: Cow::Owned(date), + body: body, + in_reply_to: in_reply_to, + } + } +} diff --git a/src/templates/gmi.rs b/src/templates/gmi.rs @@ -0,0 +1,7 @@ +use crate::models::*; + +impl Thread<'_> { + pub fn to_gmi(&self) -> String { + String::new() + } +} diff --git a/src/templates/html.rs b/src/templates/html.rs @@ -0,0 +1,165 @@ +use super::util::xml_escape; +use crate::models::*; +use linkify::{LinkFinder, LinkKind}; +use nanotemplate::template; +use std::borrow::Cow; + +const layout: &str = r#"<!DOCTYPE html> +<html> +<head> +<title>{title}</title> +<meta http-equiv='Permissions-Policy' content='interest-cohort=()'/> +<link rel='stylesheet' type='text/css' href='style.css' /> +<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0,user-scalable=0' /> +<link rel='icon' href='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📧</text></svg>'></head> +<meta name="description" content="{title}"/> +<body> +{core} +</body> +</html> +"#; + +impl Lists<'_> { + pub fn to_html(&self) -> String { + template(r#""#, &[("title", "tbd")]).unwrap() + } +} + +impl List<'_> { + pub fn to_html(&self) -> String { + template( + r#" + <h1 class="page-title"> + {title} + <a href="atom.xml"> { + <img alt="Atom feed" src={rss_svg} /> + </a> + </h1> + "#, + &[("title", self.config.title.as_str()), ("rss_svg", RSS_SVG)], + ) + .unwrap() + } +} + +impl Thread<'_> { + pub fn to_html(&self) -> String { + template(r#""#, &[("title", "tbd")]).unwrap() + } +} + +impl<'a> StrMessage<'a> { + pub fn to_html(&self) -> String { + // TODO test thoroughly + template( + r#"<div id="{id}", class="message"> + <div class="message-meta"> + <span class="bold"> + {subject} + </span> + <a href="mailto:{from}" class="bold">{from}</a> + <span class="light">{date} + <a class="permalink" href=#{id}>🔗</a> + </div> + </div> + etc + "#, + &[("id", "asdf")], + ) + .unwrap() + } +} + +// gpl licensed from wikipedia https://commons.wikimedia.org/wiki/File:Generic_Feed-icon.svg +pub const RSS_SVG: &'static str = r#" +data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" + id="RSSicon" + viewBox="0 0 8 8" width="16" height="16"> + <title>RSS feed icon</title> + <style type="text/css"> + .button {stroke: none; fill: orange;} + .symbol {stroke: none; fill: white;} + </style> + <rect class="button" width="8" height="8" rx="1.5" /> + <circle class="symbol" cx="2" cy="6" r="1" /> + <path class="symbol" d="m 1,4 a 3,3 0 0 1 3,3 h 1 a 4,4 0 0 0 -4,-4 z" /> + <path class="symbol" d="m 1,2 a 5,5 0 0 1 5,5 h 1 a 6,6 0 0 0 -6,-6 z" /> +</svg>"#; + +// partly stolen from +// https://github.com/robinst/linkify/blob/demo/src/lib.rs#L5 +// Dual licensed under MIT and Apache +pub fn email_body(body: &str) -> String { + let mut bytes = Vec::new(); + let mut in_reply: bool = false; + for line in body.lines() { + if line.starts_with(">") || (line.starts_with("On ") && line.ends_with("wrote:")) { + if !in_reply { + in_reply = true; + bytes.extend_from_slice(b"<span class='light'>"); + } + } else if in_reply { + bytes.extend_from_slice(b"</span>"); + in_reply = false + } + + let finder = LinkFinder::new(); + for span in finder.spans(line) { + match span.kind() { + Some(LinkKind::Url) => { + bytes.extend_from_slice(b"<a href=\""); + xml_escape(span.as_str(), &mut bytes); + bytes.extend_from_slice(b"\">"); + xml_escape(span.as_str(), &mut bytes); + bytes.extend_from_slice(b"</a>"); + } + Some(LinkKind::Email) => { + bytes.extend_from_slice(b"<a href=\"mailto:"); + xml_escape(span.as_str(), &mut bytes); + bytes.extend_from_slice(b"\">"); + xml_escape(span.as_str(), &mut bytes); + bytes.extend_from_slice(b"</a>"); + } + _ => { + xml_escape(span.as_str(), &mut bytes); + } + } + } + bytes.extend(b"\n"); + } + if in_reply { + bytes.extend_from_slice(b"</span>"); + } + // TODO err conversion + String::from_utf8(bytes).unwrap() +} + +// TODO MOVE! +// stolen from https://github.com/deltachat/deltachat-core-rust/blob/master/src/format_flowed.rs +// undoes format=flowed +pub fn unformat_flowed(text: &str) -> String { + let mut result = String::new(); + let mut skip_newline = true; + + for line in text.split('\n') { + // Revert space-stuffing + let line = line.strip_prefix(' ').unwrap_or(line); + + if !skip_newline { + result.push('\n'); + } + + if let Some(line) = line.strip_suffix(' ') { + // Flowed line + result += line; + result.push(' '); + skip_newline = true; + } else { + // Fixed line + result += line; + skip_newline = false; + } + } + result +} diff --git a/src/templates/mod.rs b/src/templates/mod.rs @@ -0,0 +1,4 @@ +pub mod gmi; +pub mod html; +pub mod util; +pub mod xml; diff --git a/src/templates/util.rs b/src/templates/util.rs @@ -0,0 +1,20 @@ +// less efficient, easier api +pub fn xml_safe(text: &str) -> String { + // note we escape more than we need to + let mut dest = Vec::new(); + xml_escape(text, &mut dest); + std::str::from_utf8(&dest).unwrap().to_owned() +} + +pub fn xml_escape(text: &str, dest: &mut Vec<u8>) { + for c in text.bytes() { + match c { + b'&' => dest.extend_from_slice(b"&amp;"), + b'<' => dest.extend_from_slice(b"&lt;"), + b'>' => dest.extend_from_slice(b"&gt;"), + b'"' => dest.extend_from_slice(b"&quot;"), + b'\'' => dest.extend_from_slice(b"&#39;"), + _ => dest.push(c), + } + } +} diff --git a/src/templates/xml.rs b/src/templates/xml.rs @@ -0,0 +1,98 @@ +use super::util::xml_escape; +use crate::models::*; +use crate::templates::util::xml_safe; +use anyhow::{Context, Result}; +use nanotemplate::template; + +// impl List { +// fn to_xml(&self) { +// template( +// r#"<?xml version="1.0" encoding="utf-8"?> +// <feed xmlns="http://www.w3.org/2005/Atom"> +// <title>{feed_title}</title> +// <link href="{feed_link}"/> +// <updated>{last_updated}</updated> +// <author> +// <name>{author_name}</name> +// <email>{author_email}</email> +// </author> +// <id>{feed_id}</id> +// {entry_list} +// </feed>"#, +// &[], +// // feed_title = &self.name, +// // feed_link = &self.url, +// // last_updated = time::secs_to_date(last_updated).rfc3339(), +// // author_name = &self.email, +// // author_email = &self.email, +// // feed_id = &self.url, +// // entry_list = entries_str, +// ) +// } +// } + +impl Thread<'_> { + pub fn to_xml(&self) -> String { + String::new() + } +} +// return "" +// for message in &self.messages { +// let tmpl = self.build_msg_atom(message); +// entries.push_str(&tmpl); +// } +// let root = self.messages[0]; +// let atom = format!( +// r#"<?xml version="1.0" encoding="utf-8"?> +// <feed xmlns="http://www.w3.org/2005/Atom"> +// <title>{feed_title}</title> +// <link rel="self" href="{feed_link}"/> +// <updated>{last_updated}</updated> +// <author> +// <name>{author_name}</name> +// <email>{author_email}</email> +// </author> +// <id>{feed_id}</id> +// {entry_list} +// </feed>"#, +// feed_title = xml_safe(&root.subject), +// feed_link = self.url(), +// last_updated = time::secs_to_date(self.last_reply()).rfc3339(), +// author_name = xml_safe(short_name(&root.from)), +// author_email = xml_safe(&root.from.addr), +// feed_id = self.url(), +// entry_list = entries, +// ); +// } +// } + +// impl<'a> StrMessage<'a> { +// fn to_xml(&self) -> String { +// template( +// r#"<entry> +// <title>{title}</title> +// <link href="{item_link}"/> +// <id>{entry_id}</id> +// <updated>{updated_at}</updated> +// <author> +// <name>{author_name}</name> +// <email>{author_email}</email> +// </author> +// <content type="text/plain"> +// {content} +// </content> +// </entry> +// "#, +// &[ +// ("title", self.subject.as_ref()), +// // ("item_link", "TBD"), -> this introduces filesystem dependency +// // ("entry_id", self.id), +// ("updated_at", "TBD"), +// // ("author_name", self.from.name), +// // ("author_email", self.from.address), +// // ("content", self.body), +// ], +// ) +// .unwrap() +// } +// } diff --git a/src/threading.rs b/src/threading.rs @@ -1,46 +1,72 @@ -// Take an iterator of emails and build a thread -// jmap threading algorithm -// For new implementations, it is -// suggested that two messages belong in the same Thread if both of the -// following conditions apply: +// Simple threading algorithm based on https://datatracker.ietf.org/doc/html/rfc8621 +// A thread is a collection of messages sorted by date. +// Assumes msg can be found on disk at `path` -- could be made more abstract -// 1. An identical message id [RFC5322] appears in both messages in any -// of the Message-Id, In-Reply-To, and References header fields. -// 2. After stripping automatically added prefixes such as "Fwd:", -// "Re:", "[List-Tag]", etc., and ignoring white space, the subjects -// are the same. This avoids the situation where a person replies -// to an old message as a convenient way of finding the right -// recipient to send to but changes the subject and starts a new -// conversation. +use mail_parser::parsers::fields::thread::thread_name; +use mail_parser::{DateTime, Message}; +use std::collections::HashMap; +use std::path::PathBuf; -// If messages are delivered out of order for some reason, a user may -// have two Emails in the same Thread but without headers that associate -// them with each other. The arrival of a third Email may provide the -// missing references to join them all together into a single Thread. -// Since the "threadId" of an Email is immutable, if the server wishes -// to merge the Threads, it MUST handle this by deleting and reinserting -// (with a new Email id) the Emails that change "threadId". +pub type MessageId = String; -// A *Thread* object has the following properties: - -// o id: "Id" (immutable; server-set) +pub struct Msg { + pub id: MessageId, + pub path: PathBuf, +} +impl Msg {} -// The id of the Thread. +#[derive(Default)] +pub struct ThreadIdx { + pub threads: Vec<Vec<Msg>>, + id_index: HashMap<MessageId, usize>, + subject_index: HashMap<String, usize>, +} -// o emailIds: "Id[]" (server-set) +impl ThreadIdx { + pub fn new() -> Self { + ThreadIdx::default() + } -// The ids of the Emails in the Thread, sorted by the "receivedAt" -// date of the Email, oldest first. If two Emails have an identical -// date, the sort is server dependent but MUST be stable (sorting by -// id is recommended). + // Todo enumerate errors or something + // TODO should be format agnostic (use internal representation of email) + pub fn add_email(&mut self, msg: &Message, path: PathBuf) { + let msg_id = msg.get_message_id().unwrap(); // TODO unwrap + // TODO handle duplicate id case + let received = msg + .get_received() + .as_datetime_ref() + .or_else(|| msg.get_date()) + .unwrap(); // TODO fix unwrap + let in_reply_to = msg.get_in_reply_to().as_text_ref(); + let last_reference = msg.get_in_reply_to().as_text_ref(); + let thread_name = thread_name(msg.get_subject().unwrap_or("(No Subject)")); -use mail_parser::Message; + let msg = Msg { + id: msg_id.to_owned(), + path, + }; + let reference = in_reply_to.or_else(|| last_reference); -fn Index { -} + let idx = match reference { + Some(id) => self.id_index.get(id), + None => self.subject_index.get(thread_name), + }; + let id = match idx { + Some(i) => { + self.threads[*i].push(msg); + *i + } + None => { + self.threads.push(vec![msg]); + self.threads.len() - 1 + } + }; + self.id_index.insert(msg_id.to_string(), id); + self.subject_index.insert(thread_name.to_string(), id); + } -impl Index { - fn build(emails: impl Iterator<Item = Message>) -> Self { + pub fn finalize(&mut self) { + // TODO sort thread list by last reply date } } diff --git a/src/utils.rs b/src/utils.rs @@ -1,119 +1,15 @@ -use linkify::{LinkFinder, LinkKind}; +use std::fs::{read, write}; +use std::io::prelude::*; +use std::path::PathBuf; -// gpl licensed from wikipedia https://commons.wikimedia.org/wiki/File:Generic_Feed-icon.svg -pub const RSS_SVG: &str = r#" -data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?> -<svg xmlns="http://www.w3.org/2000/svg" - id="RSSicon" - viewBox="0 0 8 8" width="16" height="16"> - <title>RSS feed icon</title> - <style type="text/css"> - .button {stroke: none; fill: orange;} - .symbol {stroke: none; fill: white;} - </style> - <rect class="button" width="8" height="8" rx="1.5" /> - <circle class="symbol" cx="2" cy="6" r="1" /> - <path class="symbol" d="m 1,4 a 3,3 0 0 1 3,3 h 1 a 4,4 0 0 0 -4,-4 z" /> - <path class="symbol" d="m 1,2 a 5,5 0 0 1 5,5 h 1 a 6,6 0 0 0 -6,-6 z" /> -</svg>"#; - -// partly stolen from -// https://github.com/robinst/linkify/blob/demo/src/lib.rs#L5 -// Dual licensed under MIT and Apache -pub fn email_body(body: &str) -> String { - let mut bytes = Vec::new(); - let mut in_reply: bool = false; - for line in body.lines() { - if line.starts_with("[View original message") { - // see main.rs - continue; - } - if line.starts_with(">") || (line.starts_with("On ") && line.ends_with("wrote:")) { - if !in_reply { - in_reply = true; - bytes.extend_from_slice(b"<span class='light'>"); - } - } else if in_reply { - bytes.extend_from_slice(b"</span>"); - in_reply = false - } - - let finder = LinkFinder::new(); - for span in finder.spans(line) { - match span.kind() { - Some(LinkKind::Url) => { - bytes.extend_from_slice(b"<a href=\""); - xml_escape(span.as_str(), &mut bytes); - bytes.extend_from_slice(b"\">"); - xml_escape(span.as_str(), &mut bytes); - bytes.extend_from_slice(b"</a>"); - } - Some(LinkKind::Email) => { - bytes.extend_from_slice(b"<a href=\"mailto:"); - xml_escape(span.as_str(), &mut bytes); - bytes.extend_from_slice(b"\">"); - xml_escape(span.as_str(), &mut bytes); - bytes.extend_from_slice(b"</a>"); - } - _ => { - xml_escape(span.as_str(), &mut bytes); - } - } - } - bytes.extend(b"\n"); - } - if in_reply { - bytes.extend_from_slice(b"</span>"); - } - // TODO err conversion - String::from_utf8(bytes).unwrap() -} - -// stolen from https://github.com/deltachat/deltachat-core-rust/blob/master/src/format_flowed.rs -// undoes format=flowed -pub fn unformat_flowed(text: &str) -> String { - let mut result = String::new(); - let mut skip_newline = true; - - for line in text.split('\n') { - // Revert space-stuffing - let line = line.strip_prefix(' ').unwrap_or(line); - - if !skip_newline { - result.push('\n'); - } - - if let Some(line) = line.strip_suffix(' ') { - // Flowed line - result += line; - result.push(' '); - skip_newline = true; - } else { - // Fixed line - result += line; - skip_newline = false; - } - } - result -} - -// less efficient, easier api -pub fn xml_safe(text: &str) -> String { - // note we escape more than we need to - let mut dest = Vec::new(); - xml_escape(text, &mut dest); - std::str::from_utf8(&dest).unwrap().to_owned() -} - -fn xml_escape(text: &str, dest: &mut Vec<u8>) { - for c in text.bytes() { - match c { - b'&' => dest.extend_from_slice(b"&amp;"), - b'<' => dest.extend_from_slice(b"&lt;"), - b'>' => dest.extend_from_slice(b"&gt;"), - b'"' => dest.extend_from_slice(b"&quot;"), - b'\'' => dest.extend_from_slice(b"&#39;"), - _ => dest.push(c), +// TODO: use checksum / cache. bool whether it writes +fn write_if_unchanged(path: PathBuf, data: &[u8]) -> bool { + if let Ok(d) = read(&path) { + if &d == data { + return false; } + } else { + write(&path, data).unwrap() } + return true; }