crabmail

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

html.rs (11718B) - raw


      1 use super::util::xml_escape;
      2 use super::util::xml_safe as x;
      3 use crate::models::*;
      4 use crate::templates::PAGE_SIZE;
      5 use crate::time::Date;
      6 use crate::util::*;
      7 use linkify::{LinkFinder, LinkKind};
      8 use nanotemplate::template;
      9 
     10 const HEADER: &str = r#"<!DOCTYPE html>
     11 <html>
     12 <head>
     13 <title>{title}</title>
     14 <meta http-equiv='Permissions-Policy' content='interest-cohort=()'/>
     15 <link rel='stylesheet' type='text/css' href='{css_path}' />
     16 <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0,user-scalable=0' />
     17 <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>'>
     18 <meta name="description" content="{title}"/>
     19 </head>
     20 <body>
     21 "#;
     22 
     23 const FOOTER: &str = r#"
     24 Archive generated with  <a href='https://crabmail.flounder.online/'>crabmail</a>
     25 </body>
     26 </html>
     27 "#;
     28 
     29 impl Lists {
     30     pub fn to_html(&self) -> String {
     31         let mut lists = String::new();
     32         for list in &self.lists {
     33             lists.push_str(&format!(
     34                 "<a href='./{0}'><h2>{0}</h2></a>\n",
     35                 &x(&list.config.name)
     36             ));
     37         }
     38         let body = r#"<h1 class="page-title">{title}</h1>
     39             <hr>
     40             {lists}
     41             <hr>"#;
     42         template(
     43             &format!("{}{}{}", HEADER, body, FOOTER),
     44             &[
     45                 ("title", "Mail Archives"),
     46                 ("css_path", "style.css"),
     47                 ("lists", &lists),
     48             ],
     49         )
     50         .unwrap()
     51     }
     52 }
     53 
     54 // current 0-indexed
     55 fn build_page_idx(current: usize, total: usize) -> String {
     56     if total == 1 {
     57         return "".to_string();
     58     }
     59     let mut page_idx = "<b>Pages</b>: ".to_string();
     60     for n in 0..total {
     61         let path = match n {
     62             0 => "index.html".to_string(),
     63             n => format!("index-{}.html", n + 1),
     64         };
     65         if current == n {
     66             page_idx.push_str("<b>");
     67             page_idx.push_str(&(n + 1).to_string());
     68             page_idx.push_str("</b> ");
     69         } else {
     70             page_idx.push_str(&format!("<a href='{}'>{}</a> ", path, n + 1));
     71         }
     72     }
     73     page_idx.push_str("<hr>");
     74     return page_idx;
     75 }
     76 
     77 impl List {
     78     pub fn to_html(&self) -> Vec<String> {
     79         // TODO paginate
     80         let page_count = self.thread_topics.len() / PAGE_SIZE + 1;
     81         // hacky
     82         self.thread_topics
     83             .chunks(PAGE_SIZE)
     84             .enumerate()
     85             .map(|(n, thread_topics)| {
     86                 let mut threads = String::new();
     87                 for thread in thread_topics {
     88                     threads.push_str(
     89                         &template(
     90                             r#"
     91             <div class='message-sum'>
     92             <a class="bigger" href="threads/{path_id}.html">{subject}</a>
     93             <br>
     94             <div class="monospace">{preview}</div>
     95             <b>{from}</b> | {replies} replies | {date}<hr>
     96             "#,
     97                             &[
     98                                 (
     99                                     "path_id",
    100                                     &x(thread.message.pathescape_msg_id().to_str().unwrap()),
    101                                 ),
    102                                 ("subject", &x(&thread.message.subject)),
    103                                 ("replies", &thread.reply_count.to_string()),
    104                                 ("date", &Date::from(thread.last_reply).ymd()),
    105                                 (
    106                                     "from",
    107                                     &x(&thread. // awkawrd
    108                                 message.from
    109                                 .name
    110                                 .clone()
    111                                 .unwrap_or(thread.message.from.address.clone())
    112                                 .clone()),
    113                                 ),
    114                                 ("preview", &x(&thread.message.preview)),
    115                             ],
    116                         )
    117                         .unwrap(),
    118                     );
    119                 }
    120                 // TODO use summary??
    121                 let page = template(
    122                     &format!(
    123                         "{}{}{}",
    124                         HEADER,
    125                         r#"
    126         <h1 class="page-title">
    127         {title} <a href="atom.xml"><img alt="Atom feed" src='{rss_svg}' /></a>
    128         </h1>
    129         {description}<br>
    130         <a href="mailto:{list_email}">{list_email}</a>
    131         <hr>
    132         {threads}
    133         {page_idx}
    134                  "#,
    135                         FOOTER
    136                     ),
    137                     &[
    138                         ("header", HEADER),
    139                         ("description", &self.config.description),
    140                         ("title", self.config.title.as_str()),
    141                         ("css_path", "../style.css"),
    142                         ("threads", &threads),
    143                         ("page_idx", &build_page_idx(n, page_count)),
    144                         ("list_email", &self.config.email),
    145                         ("rss_svg", RSS_SVG),
    146                         ("footer", FOOTER),
    147                     ],
    148                 )
    149                 .unwrap();
    150                 page
    151             })
    152             .collect()
    153     }
    154 }
    155 
    156 impl MailAddress {
    157     fn to_html(&self) -> String {
    158         let mut out = String::new();
    159         if let Some(n) = &self.name {
    160             out.push('"');
    161             out.push_str(&x(n));
    162             out.push_str("\" ");
    163         }
    164         out.push_str(&format!(
    165             "<<a href='mailto:{0}'>{0}</a>>",
    166             &x(&self.address)
    167         ));
    168         out
    169     }
    170 }
    171 impl Thread {
    172     pub fn to_html(&self) -> String {
    173         let root = &self.messages[0];
    174         let body = r#"
    175         <h1 class="page-title">{title} 
    176         <a href="{path_id}.xml"><img alt="Atom Feed" src='{rss_svg}'></a>
    177         </h1>
    178         <div>
    179         <a href="../">Back</a>
    180         <hr>
    181         <div>
    182          "#;
    183         let mut out = template(
    184             &format!("{}{}", HEADER, body),
    185             // TODO html escape
    186             &[
    187                 ("title", x(&root.subject).as_ref()),
    188                 ("css_path", "../../style.css"),
    189                 ("rss_svg", RSS_SVG),
    190                 ("path_id", &x(root.pathescape_msg_id().to_str().unwrap())),
    191             ],
    192         )
    193         .unwrap();
    194         for msg in &self.messages {
    195             // TODO converted from html
    196             // fix from header parsing
    197             // TODO in reply to
    198             let in_reply_to = if let Some(irt) = &msg.in_reply_to {
    199                 format!(
    200                     "In-Reply-To: <a href='#{0}'>{1}</a><br>\n",
    201                     x(irt),
    202                     x(&truncate_ellipsis(irt, 40))
    203                 )
    204             } else {
    205                 String::new()
    206             };
    207             let mut extra_headers =
    208                 format!("Message-Id: <a href='#{0}'>{0}</a><br>\n", &x(&msg.id));
    209 
    210             extra_headers.push_str("To: \n");
    211             extra_headers.push_str(
    212                 &msg.to
    213                     .iter()
    214                     .map(|x| x.to_html())
    215                     .collect::<Vec<String>>()
    216                     .join(","),
    217             );
    218             // todo no copy pasta
    219             extra_headers.push_str("<br>\n");
    220             if msg.cc.len() > 0 {
    221                 extra_headers.push_str("Cc: ");
    222                 extra_headers.push_str(
    223                     &msg.cc
    224                         .iter()
    225                         .map(|x| x.to_html())
    226                         .collect::<Vec<String>>()
    227                         .join(","),
    228                 );
    229                 extra_headers.push_str("<br>\n");
    230             }
    231 
    232             let ms = r#"<div id="{msg_id}" class="message">
    233             <div class="message-meta">
    234             <span class="bold">
    235                 {subject}
    236             </span>
    237             <br>
    238             From: {from}
    239             <br>
    240             Date: <span>{date}</span>
    241             <br>
    242             {in_reply_to}
    243             <details>
    244             <summary>More</summary>
    245             {extra_headers}
    246             </details>
    247             <a class="bold" href="{mailto}">Reply</a>
    248             [<a href="../messages/{msg_path}.mbox">Export</a>]
    249             </div>
    250             <div class="email-body">
    251              {body}
    252             </div>
    253             </div>
    254             "#;
    255             out.push_str(
    256                 &template(
    257                     ms,
    258                     &[
    259                         ("msg_id", &x(&msg.id)),
    260                         ("msg_path", &x(msg.pathescape_msg_id().to_str().unwrap())),
    261                         ("subject", &x(&msg.subject)),
    262                         ("mailto", &x(&msg.mailto)),
    263                         ("from", &msg.from.to_html()),
    264                         ("date", &x(&msg.date)),
    265                         ("in_reply_to", &in_reply_to),
    266                         ("extra_headers", &extra_headers),
    267                         ("body", &email_body(&msg.body, msg.flowed)),
    268                     ],
    269                 )
    270                 .unwrap(),
    271             );
    272         }
    273         out.push_str("</div><hr></body></html>");
    274         // body
    275         out
    276     }
    277 }
    278 
    279 // gpl licensed from wikipedia https://commons.wikimedia.org/wiki/File:Generic_Feed-icon.svg
    280 pub const RSS_SVG: &'static str = r#"
    281 data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?>
    282 <svg xmlns="http://www.w3.org/2000/svg"
    283      id="RSSicon"
    284      viewBox="0 0 8 8" width="16" height="16">
    285   <title>RSS feed icon</title>
    286   <style type="text/css">
    287     .button {stroke: none; fill: orange;}
    288     .symbol {stroke: none; fill: white;}
    289   </style>
    290   <rect   class="button" width="8" height="8" rx="1.5" />
    291   <circle class="symbol" cx="2" cy="6" r="1" />
    292   <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" />
    293   <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" />
    294 </svg>"#;
    295 
    296 // partly stolen from
    297 // https://github.com/robinst/linkify/blob/demo/src/lib.rs#L5
    298 // Dual licensed under MIT and Apache
    299 pub fn email_body(body: &str, flowed: bool) -> String {
    300     let mut bytes = Vec::new();
    301     let mut body = body;
    302     let tmp;
    303     if flowed {
    304         tmp = unformat_flowed(body);
    305         body = &tmp;
    306     }
    307     let mut in_reply: bool = false;
    308     for line in body.lines() {
    309         if line.starts_with(">") || (line.starts_with("On ") && line.ends_with("wrote:")) {
    310             if !in_reply {
    311                 in_reply = true;
    312                 bytes.extend_from_slice(b"<span class='light'>");
    313             }
    314         } else if in_reply {
    315             bytes.extend_from_slice(b"</span>");
    316             in_reply = false
    317         }
    318 
    319         let finder = LinkFinder::new();
    320         for span in finder.spans(line) {
    321             match span.kind() {
    322                 Some(LinkKind::Url) => {
    323                     bytes.extend_from_slice(b"<a href=\"");
    324                     xml_escape(span.as_str(), &mut bytes);
    325                     bytes.extend_from_slice(b"\">");
    326                     xml_escape(span.as_str(), &mut bytes);
    327                     bytes.extend_from_slice(b"</a>");
    328                 }
    329                 Some(LinkKind::Email) => {
    330                     bytes.extend_from_slice(b"<a href=\"mailto:");
    331                     xml_escape(span.as_str(), &mut bytes);
    332                     bytes.extend_from_slice(b"\">");
    333                     xml_escape(span.as_str(), &mut bytes);
    334                     bytes.extend_from_slice(b"</a>");
    335                 }
    336                 _ => {
    337                     xml_escape(span.as_str(), &mut bytes);
    338                 }
    339             }
    340         }
    341         bytes.extend(b"\n");
    342     }
    343     if in_reply {
    344         bytes.extend_from_slice(b"</span>");
    345     }
    346     // TODO err conversion
    347     String::from_utf8(bytes).unwrap()
    348 }