crabmail

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

commit bfd3bc610f0f12566b8cb88fd6fdb34bb6b2774d
parent e17d62cf8c2c507166cf231ec2490bd19957cb14
Author: alex wennerberg <alex@alexwennerberg.com>
Date:   Thu, 16 Dec 2021 00:10:16 -0800

Begin horrorshow rewrite

build is broken / buggy at this point

Diffstat:
MCargo.lock | 184+++----------------------------------------------------------------------------
MCargo.toml | 2+-
Dsrc/filters.rs | 105-------------------------------------------------------------------------------
Msrc/main.rs | 144++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Msrc/style.css | 2+-
Asrc/utils.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtemplates/base.html | 24------------------------
Dtemplates/thread.html | 25-------------------------
8 files changed, 208 insertions(+), 374 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -24,64 +24,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" [[package]] -name = "arrayvec" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" - -[[package]] -name = "askama" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d298738b6e47e1034e560e5afe63aa488fea34e25ec11b855a76f0d7b8e73134" -dependencies = [ - "askama_derive", - "askama_escape", - "askama_shared", -] - -[[package]] -name = "askama_derive" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2925c4c290382f9d2fa3d1c1b6a63fa1427099721ecca4749b154cc9c25522" -dependencies = [ - "askama_shared", - "proc-macro2", - "syn", -] - -[[package]] -name = "askama_escape" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90c108c1a94380c89d2215d0ac54ce09796823cca0fd91b299cfff3b33e346fb" - -[[package]] -name = "askama_shared" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2582b77e0f3c506ec4838a25fa8a5f97b9bed72bb6d3d272ea1c031d8bd373bc" -dependencies = [ - "askama_escape", - "humansize", - "nom", - "num-traits", - "percent-encoding", - "proc-macro2", - "quote", - "serde", - "syn", - "toml", -] - -[[package]] -name = "autocfg" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" - -[[package]] name = "base64" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -97,24 +39,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" [[package]] -name = "bitflags" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" - -[[package]] -name = "bitvec" -version = "0.19.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] name = "block-buffer" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -151,7 +75,7 @@ version = "0.1.0" dependencies = [ "ammonia", "anyhow", - "askama", + "horrorshow", "linkify", "mailparse", "mbox-reader", @@ -202,12 +126,6 @@ dependencies = [ ] [[package]] -name = "funty" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" - -[[package]] name = "futf" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -239,6 +157,12 @@ dependencies = [ ] [[package]] +name = "horrorshow" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8371fb981840150b1a54f7cb117bf6699f7466a1d4861daac33bc6fe2b5abea0" + +[[package]] name = "html5ever" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -253,12 +177,6 @@ dependencies = [ ] [[package]] -name = "humansize" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02296996cb8796d7c6e3bc2d9211b7802812d36999a51bb754123ead7d37d026" - -[[package]] name = "idna" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -282,19 +200,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] -name = "lexical-core" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" -dependencies = [ - "arrayvec", - "bitflags", - "cfg-if", - "ryu", - "static_assertions", -] - -[[package]] name = "libc" version = "0.2.98" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -405,28 +310,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" [[package]] -name = "nom" -version = "6.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c5c51b9083a3c620fa67a2a635d1ce7d95b897e957d6b28ff9a5da960a103a6" -dependencies = [ - "bitvec", - "funty", - "lexical-core", - "memchr", - "version_check", -] - -[[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.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -528,12 +411,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1238256b09923649ec89b08104c4dfe9f6cb2fea734a5db5384e44916d59e9c5" [[package]] -name = "radium" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" - -[[package]] name = "rand" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -585,30 +462,10 @@ dependencies = [ ] [[package]] -name = "ryu" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" - -[[package]] name = "serde" version = "1.0.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "sha3" @@ -627,12 +484,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "729a25c17d72b06c68cb47955d44fda88ad2d3e7d77e025663fdd69b93dd71a1" [[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] name = "string_cache" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -669,12 +520,6 @@ dependencies = [ ] [[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] name = "tendril" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -712,15 +557,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] -name = "toml" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" -dependencies = [ - "serde", -] - -[[package]] name = "typenum" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -815,12 +651,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "wyz" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" - -[[package]] name = "xml5ever" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -13,7 +13,7 @@ html = ["ammonia"] [dependencies] ammonia = {version = "3", optional = true} anyhow = "1.0" -askama = {version = "0.10", default_feature = false} +horrorshow = "0.8.4" linkify = "0.8.0" mailparse = "0.13" mbox-reader = "0.2.0" #unamaintained, should remove dep diff --git a/src/filters.rs b/src/filters.rs @@ -1,105 +0,0 @@ -use linkify::{LinkFinder, LinkKind}; -use std::time::{SystemTime, UNIX_EPOCH}; - -pub fn time_ago(amount: &u64) -> askama::Result<String> { - Ok(timeago(*amount)) -} - -const SOLAR_YEAR_SECS: u64 = 31556926; -// TODO filter body: -// add <span> for lines starting with > to make them grey -// parse hyperlinks for you - -// stolen from -// https://github.com/robinst/linkify/blob/demo/src/lib.rs#L5 - -pub fn email_body(body: &str) -> askama::Result<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='reply-text'>"); - } - } else if in_reply { - bytes.extend_from_slice(b"</span>"); - in_reply = false - } - - let mut finder = LinkFinder::new(); - for span in finder.spans(line) { - match span.kind() { - Some(LinkKind::Url) => { - bytes.extend_from_slice(b"<a href=\""); - escape(span.as_str(), &mut bytes); - bytes.extend_from_slice(b"\">"); - escape(span.as_str(), &mut bytes); - bytes.extend_from_slice(b"</a>"); - } - Some(LinkKind::Email) => { - bytes.extend_from_slice(b"<a href=\"mailto:"); - escape(span.as_str(), &mut bytes); - bytes.extend_from_slice(b"\">"); - escape(span.as_str(), &mut bytes); - bytes.extend_from_slice(b"</a>"); - } - _ => { - escape(span.as_str(), &mut bytes); - } - } - } - bytes.extend(b"\n"); - } - if in_reply { - bytes.extend_from_slice(b"</span>"); - } - // TODO err conversion - Ok(String::from_utf8(bytes).expect("not utf8")) -} - -fn 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), - } - } -} - -fn timeago(unixtime: u64) -> String { - let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - if unixtime > current_time { - return "in the future".to_owned(); - } - let diff = current_time - unixtime; - let amount: u64; - let metric: &str; - if diff < 60 { - amount = diff; - metric = "second"; - } else if diff < 60 * 60 { - amount = diff / 60; - metric = "minute"; - } else if diff < 60 * 60 * 24 { - amount = diff / (60 * 60); - metric = "hour"; - } else if diff < SOLAR_YEAR_SECS * 2 { - amount = diff / (60 * 60 * 24); - metric = "day"; - } else { - amount = diff / SOLAR_YEAR_SECS * 2; - metric = "year"; - } - match amount { - 1 => format!("{} {} ago", amount, metric), - _ => format!("{} {}s ago", amount, metric), - } -} diff --git a/src/main.rs b/src/main.rs @@ -1,5 +1,15 @@ use anyhow::{anyhow, Context, Result}; -use askama::Template; +use horrorshow::helper::doctype; +use horrorshow::owned_html; +use horrorshow::prelude::*; +use horrorshow::Template; +use std::io; +use std::io::BufWriter; +use std::path::Path; + +#[macro_use] +extern crate horrorshow; + use mailparse::*; use mbox_reader::MboxFile; use sha3::{ @@ -13,7 +23,7 @@ use urlencoding; use config::{Config, INSTANCE}; mod config; -mod filters; +mod utils; // TODO be more clear about the expected input types // maildi @@ -39,6 +49,52 @@ struct MailThread<'a> { last_reply: u64, } +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://git.alexwennerberg.com/crabmail/'>crabmail</a>") + } + } + } + } +} + +struct ThreadList<'a> { + threads: Vec<MailThread<'a>>, +} + +impl<'a> ThreadList<'a> { + pub fn write_to_file(&self) -> Result<()> { + let tmp = html! { + h1(class="page-title"): &Config::global().list_name; + a(href=format!("mailto:{}", &Config::global().list_email)) { + : &Config::global().list_email + } + hr; + @ for thread in &self.threads { + } + }; + Ok(()) + } +} + impl<'a> MailThread<'a> { pub fn last_reply(&self) -> u64 { return self.messages[self.messages.len() - 1].date; @@ -47,6 +103,49 @@ impl<'a> MailThread<'a> { fn build_atom_feed() -> String { String::new() } + + fn write_to_file(&self, out_dir: &Path) -> Result<()> { + let root = self.messages[0]; + let tmp = html! { + h1(class="page-title"): &root.subject; + div { + @ for message in &self.messages { + hr; + div(id=&message.id, class="message") { + a(href=format!("mailto:{}", &message.from.addr), class="addr") { + : &message.from.to_string(); + } + } + span(class="timeago") { + : utils::timeago(message.date) + } + a(title="permalink", href=format!("#{}", &message.id)) { + : "🔗" + } + @ if message.in_reply_to.is_some() { // TODO figure out match + a(title="replies-to", href=format!("#{}", message.in_reply_to.clone().unwrap())){ + : "Re:" + } + } + div(class="email-body") { + : Raw(utils::email_body(&message.body)) + } + div(class="right"){ + a (href=message.mailto()) { + :"✉️ reply" + } + } + } + } + }; + let thread_dir = out_dir.join("threads"); + std::fs::create_dir(&thread_dir).ok(); + + let mut 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 { @@ -219,9 +318,6 @@ fn main() -> Result<()> { }) .collect(); std::fs::create_dir(&out_dir).ok(); - let thread_dir = &out_dir.join("threads"); - std::fs::create_dir(thread_dir).ok(); - let mut threads = vec![]; for root in &mut thread_roots { let mut thread_ids = vec![]; @@ -251,37 +347,18 @@ fn main() -> Result<()> { thread.last_reply = thread.last_reply(); - let mut file = File::create(thread_dir.join(format!("{}.html", thread.hash)))?; - file.write( - Thread { - thread: &thread, - config: Config::global(), - } - .render()? - .as_bytes(), - ) - .ok(); - + thread.write_to_file(&out_dir); threads.push(thread); } threads.sort_by_key(|a| a.last_reply); threads.reverse(); let mut file = File::create(out_dir.join("index.html"))?; - file.write( - ThreadList { - threads: threads, - config: Config::global(), - } - .render()? - .as_bytes(), - ) - .ok(); // kinda clunky let css = include_bytes!("style.css"); let mut css_root = File::create(out_dir.join("style.css"))?; css_root.write(css); - let mut css_sub = File::create(thread_dir.join("style.css"))?; + let mut css_sub = File::create(out_dir.join("threads").join("style.css"))?; css_sub.write(css); Ok(()) } @@ -293,18 +370,3 @@ fn remove_missing() {} fn parse_path(s: &std::ffi::OsStr) -> Result<std::path::PathBuf, &'static str> { Ok(s.into()) } - -#[derive(Template)] -#[template(path = "thread.html")] -struct Thread<'a> { - thread: &'a MailThread<'a>, - config: &'a Config, -} - -#[derive(Template)] -#[template(path = "threadlist.html")] -struct ThreadList<'a> { - // message root - threads: Vec<MailThread<'a>>, - config: &'a Config, // Not ideal repetition -} diff --git a/src/style.css b/src/style.css @@ -59,7 +59,7 @@ table { border-spacing: 0.5em 0.1em; } font-size: .9rem; } -hr.thin { +hr { border: 0; height: 0; color: lightgrey; diff --git a/src/utils.rs b/src/utils.rs @@ -0,0 +1,96 @@ +use linkify::{LinkFinder, LinkKind}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SOLAR_YEAR_SECS: u64 = 31556926; +// partly stolen from +// https://github.com/robinst/linkify/blob/demo/src/lib.rs#L5 + +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='reply-text'>"); + } + } else if in_reply { + bytes.extend_from_slice(b"</span>"); + in_reply = false + } + + let mut finder = LinkFinder::new(); + for span in finder.spans(line) { + match span.kind() { + Some(LinkKind::Url) => { + bytes.extend_from_slice(b"<a href=\""); + escape(span.as_str(), &mut bytes); + bytes.extend_from_slice(b"\">"); + escape(span.as_str(), &mut bytes); + bytes.extend_from_slice(b"</a>"); + } + Some(LinkKind::Email) => { + bytes.extend_from_slice(b"<a href=\"mailto:"); + escape(span.as_str(), &mut bytes); + bytes.extend_from_slice(b"\">"); + escape(span.as_str(), &mut bytes); + bytes.extend_from_slice(b"</a>"); + } + _ => { + 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() +} + +fn 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), + } + } +} +pub fn timeago(unixtime: u64) -> String { + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + if unixtime > current_time { + return "in the future".to_owned(); + } + let diff = current_time - unixtime; + let amount: u64; + let metric: &str; + if diff < 60 { + amount = diff; + metric = "second"; + } else if diff < 60 * 60 { + amount = diff / 60; + metric = "minute"; + } else if diff < 60 * 60 * 24 { + amount = diff / (60 * 60); + metric = "hour"; + } else if diff < SOLAR_YEAR_SECS * 2 { + amount = diff / (60 * 60 * 24); + metric = "day"; + } else { + amount = diff / SOLAR_YEAR_SECS * 2; + metric = "year"; + } + match amount { + 1 => format!("{} {} ago", amount, metric), + _ => format!("{} {}s ago", amount, metric), + } +} diff --git a/templates/base.html b/templates/base.html @@ -1,24 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8"> - <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="{{config.list_name}}"> - <title>{{config.list_name}}</title> - {% block head %}{% endblock %} - </head> - <body> - <main> - <div id="content"> - {% block content %}{% endblock %} - </div> - <hr class="thin"> - <div class="footer"> - Archive generated with <a href="https://git.alexwennerberg.com/crabmail/">crabmail</a> - </div> - </main> - </body> -</html> diff --git a/templates/thread.html b/templates/thread.html @@ -1,25 +0,0 @@ -{% extends "base.html" %} - -{% block content %} - {% let root = thread.messages[0] %} -<div class="page-title"><h1>{{root.subject}}</h1></div> - <div> - {% for message in thread.messages %} - <hr class="thin"> - <div id="{{message.id}}" class="message"> - <b> - <a href="mailto:{{message.from.addr}}" class="addr">{{message.from}}</a></b> - <span class="timeago">{{message.date | time_ago}}</span> - <a title="permalink" href="#{{message.id}}">🔗</a> - {% match message.in_reply_to %}{% when Some with (replies_to) %} - <a title="replies-to" href="#{{replies_to}}">Re:</a>{%when none %}{% endmatch %} - <div class="email-body"> - {{message.body | email_body | safe }} - </div> - <div class="right"> - <a href="{{message.mailto()}}">✉️ reply</a> - </div> - {% endfor %} - </div> - </div> -{% endblock %}