Skip to main content

hypermail/
html.rs

1use std::path::PathBuf;
2
3use crate::config::{Config, DELETE_LEAVES_STUBS, DELETE_LEAVES_TEXT};
4use crate::date::{get_date_str, secs_to_iso};
5use crate::error::Result;
6use crate::file_utils::{message_name, message_path as utils_message_path, message_url_str};
7use crate::headers::decode_mime_words;
8use crate::i18n::I18n;
9use crate::message::EmailInfo;
10use crate::string_utils::{obfuscate_email_address, spamify};
11use crate::structs::EmailStore;
12use crate::templates::{
13    default_article_template, default_footer_template, default_header_template, get_header_cookies,
14    set_cookie, substitute_cookies, substitute_printfile, PrintfileData,
15};
16use crate::txt2html::escape_html;
17
18/// Renders a complete HTML page for a single email message.
19pub fn print_article(email: &EmailInfo, store: &EmailStore, config: &Config) -> Result<String> {
20    // If delete_level < DELETE_LEAVES_TEXT, show "Deleted message" stub
21    // If delete_level >= DELETE_LEAVES_TEXT, show the actual message content
22    if email.is_deleted != 0 && config.delete_level < DELETE_LEAVES_TEXT {
23        return print_deleted_article(email, store, config);
24    }
25
26    let article_html = generate_article_email(email, store, config)?;
27
28    render_message_page(email, config, &article_html)
29}
30
31fn load_template_or_default(path: Option<&str>, default: &str) -> String {
32    path.and_then(|p| std::fs::read_to_string(p).ok())
33        .unwrap_or_else(|| default.to_string())
34}
35
36/// Render a complete message HTML page, using external templates (mhtmlheader/mhtmlfooter)
37/// with printfile-style %x substitution when configured, or the default internal templates.
38fn render_message_page(email: &EmailInfo, config: &Config, article_html: &str) -> Result<String> {
39    if config.mhtmlheader.is_some() || config.mhtmlfooter.is_some() {
40        // External templates: use original hypermail printfile-style %x substitution.
41        let header_tpl = config
42            .mhtmlheader
43            .as_deref()
44            .and_then(|p| std::fs::read_to_string(p).ok())
45            .unwrap_or_default();
46        let footer_tpl = config
47            .mhtmlfooter
48            .as_deref()
49            .and_then(|p| std::fs::read_to_string(p).ok())
50            .unwrap_or_default();
51
52        let date_str = if email.date > 0 {
53            secs_to_iso(email.date)
54        } else {
55            String::new()
56        };
57        let display_date_str = if email.date > 0 {
58            get_date_str(
59                email.date,
60                config.dateformat.as_deref(),
61                config.gmtime,
62                config.eurodate,
63                config.isodate,
64                &config.language,
65            )
66        } else {
67            String::new()
68        };
69        let filename = message_url_str(email, config);
70        let decoded_subject = decode_mime_words(email.subject.as_deref().unwrap_or(""));
71        let decoded_name = decode_mime_words(email.name.as_deref().unwrap_or(""));
72        let data = PrintfileData {
73            label: config.label.as_deref().unwrap_or(""),
74            subject: &decoded_subject,
75            dir: config.dir.as_deref().unwrap_or("."),
76            name: Some(decoded_name.as_str()),
77            email: email.email_addr.as_deref(),
78            msgid: email.msgid.as_deref(),
79            charset: email.charset.as_deref(),
80            date: if date_str.is_empty() {
81                None
82            } else {
83                Some(date_str.as_str())
84            },
85            display_date: if display_date_str.is_empty() {
86                None
87            } else {
88                Some(display_date_str.as_str())
89            },
90            filename: Some(filename.as_str()),
91            archives: config.archives.as_deref(),
92            about: config.about.as_deref(),
93            mailto: config.mailto.as_deref(),
94            language: &config.language,
95            rel_path_to_top: "",
96        };
97
98        let header_html = substitute_printfile(&header_tpl, &data);
99        let footer_html = substitute_printfile(&footer_tpl, &data);
100        let generator = if config.showgenerator {
101            "\n<p class=\"hm-generator\">Generated by <a href=\"https://hypermail-rs.github.io\">hypermail-rs</a></p>\n"
102        } else {
103            ""
104        };
105        Ok(format!("{}{}{}{}", header_html, article_html, footer_html, generator))
106    } else {
107        // Internal default templates: use %COOKIE_NAME% substitution.
108        let i18n = I18n::new(&config.language);
109        let subject_raw = email.subject.as_deref().unwrap_or(i18n.get("no subject"));
110        let subject = &format_subject_for_index(subject_raw, config);
111        let mut cookies = get_header_cookies(config, subject);
112        set_cookie(&mut cookies, "ARTICLE", article_html);
113
114        if email.is_deleted != 0 && email.is_deleted & 2 != 0 {
115            set_cookie(&mut cookies, "DELETED_NOTE", i18n.get("Expired message"));
116            set_cookie(&mut cookies, "DELETED_HTML", "");
117        } else if email.is_deleted != 0 {
118            set_cookie(&mut cookies, "DELETED_NOTE", i18n.get("Deleted message"));
119            set_cookie(&mut cookies, "DELETED_HTML", "");
120        }
121
122        let header_template = load_template_or_default(None, default_header_template());
123        let footer_template = load_template_or_default(None, default_footer_template());
124        let article_template = default_article_template();
125
126        let header_html = substitute_cookies(&header_template, &cookies);
127        let article_content = substitute_cookies(article_template, &cookies);
128        let mut nav_cookies = cookies.clone();
129        set_cookie(&mut nav_cookies, "NAVIGATION", "");
130        let footer_html = substitute_cookies(&footer_template, &nav_cookies);
131        Ok(format!("{}{}{}", header_html, article_content, footer_html))
132    }
133}
134
135fn print_deleted_article(
136    email: &EmailInfo,
137    _store: &EmailStore,
138    config: &Config,
139) -> Result<String> {
140    let note = if email.is_deleted & 2 != 0 {
141        let i18n = I18n::new(&config.language);
142        i18n.get("Expired message").to_string()
143    } else {
144        let i18n = I18n::new(&config.language);
145        i18n.get("Deleted message").to_string()
146    };
147    let mut article_html = String::new();
148    article_html.push_str(&format!("<p class=\"hm-deleted\">{}</p>\n", note));
149
150    render_message_page(email, config, &article_html)
151}
152
153fn generate_article_email(
154    email: &EmailInfo,
155    store: &EmailStore,
156    config: &Config,
157) -> Result<String> {
158    let i18n = I18n::new(&config.language);
159    let mut html = String::new();
160
161    html.push_str(&format!(
162        "<article>\n<a name=\"{}{}\"></a>\n",
163        escape_html(&config.fragment_prefix),
164        email.msgnum
165    ));
166
167    if config.showheaders {
168        html.push_str("<table class=\"hm-msg-header\" aria-label=\"Message headers\">\n<tbody>\n");
169
170        html.push_str(&format!(
171            "<tr><th class=\"hm-hdrlabel\" scope=\"row\">{}</th><td class=\"hm-hdrdata\">{}</td></tr>\n",
172            escape_html(i18n.get("Author:")),
173            format_author(email, config)
174        ));
175
176        html.push_str(&format!(
177            "<tr><th class=\"hm-hdrlabel\" scope=\"row\">{}</th><td class=\"hm-hdrdata\">{}</td></tr>\n",
178            escape_html(i18n.get("Date")),
179            format_date(email, config)
180        ));
181
182        let subject = email.subject.as_deref().unwrap_or(i18n.get("no subject"));
183        html.push_str(&format!(
184            "<tr><th class=\"hm-hdrlabel\" scope=\"row\">{}</th><td class=\"hm-hdrdata\">{}</td></tr>\n",
185            escape_html(i18n.get("Subject")),
186            format_subject_text(subject, config)
187        ));
188
189        // Message-ID is kept in HTML comments but not displayed in the visible header table
190        // This reduces clutter while preserving the ID for debugging/threading purposes
191
192        html.push_str("</tbody>\n</table>\n");
193    }
194
195    if config.showreplies {
196        if let Some(reply_html) = format_reply_links(email, store, config) {
197            html.push_str(&reply_html);
198        }
199    }
200
201    html.push_str("<hr class=\"hm-msg-sep\">\n");
202
203    // Only show [Deleted]/[Expired] note if we're not showing the body content
204    // If delete_level >= DELETE_LEAVES_TEXT, we show the body, so no need for the note
205    if email.is_deleted != 0
206        && config.delete_level >= DELETE_LEAVES_STUBS
207        && config.delete_level < DELETE_LEAVES_TEXT
208    {
209        let note = if email.is_deleted & 2 != 0 {
210            i18n.get("[Expired]")
211        } else {
212            i18n.get("[Deleted]")
213        };
214        html.push_str(&format!("<p class=\"hm-deleted\">{}</p>\n", note));
215        if email.bodylist.bodies.is_empty() {
216            html.push_str("</article>\n");
217            return Ok(html);
218        }
219    }
220
221    let body_html = format_body(&email.bodylist, config);
222    html.push_str(&body_html);
223    html.push_str("</article>\n");
224
225    Ok(html)
226}
227
228fn format_author(email: &EmailInfo, config: &Config) -> String {
229    let display_name = decode_mime_words(email.name.as_deref().unwrap_or(""));
230    let display_addr = email.email_addr.as_deref().unwrap_or("unknown");
231
232    let obfuscated_addr = if config.email_address_obfuscation {
233        obfuscate_email_address(display_addr)
234    } else {
235        escape_html(display_addr)
236    };
237
238    let final_addr = spamify(
239        &obfuscated_addr,
240        &config.antispam_at,
241        config.antispamdomain.as_deref(),
242        config.spamprotect,
243        config.spamprotect_id,
244    );
245
246    let escaped_name = escape_html(&display_name);
247
248    let mailto = if let Some(ref hmail) = config.hmail {
249        hmail.replace("$TO", display_addr)
250    } else {
251        format!("mailto:{}", display_addr)
252    };
253
254    if display_name.is_empty() {
255        format!("<a href=\"mailto:{}\">{}</a>", escape_html(&mailto), final_addr)
256    } else {
257        format!(
258            "{} &lt;<a href=\"mailto:{}\">{}</a>&gt;",
259            escaped_name,
260            escape_html(&mailto),
261            final_addr
262        )
263    }
264}
265
266fn format_date(email: &EmailInfo, config: &Config) -> String {
267    let timestamp = email.date;
268    let date_str = get_date_str(
269        timestamp,
270        config.dateformat.as_deref(),
271        config.gmtime,
272        config.eurodate,
273        config.isodate,
274        &config.language,
275    );
276    let iso_str = secs_to_iso(timestamp);
277    format!("<time datetime=\"{}\">{}</time>", iso_str, date_str)
278}
279
280/// Decode and strip the list tag from a subject, for use in both message and index pages.
281pub fn format_subject_for_index(subject: &str, config: &Config) -> String {
282    let decoded = decode_mime_words(subject);
283    if let Some(ref strip) = config.stripsubject {
284        let trimmed = decoded.strip_prefix(strip.as_str()).map(|s| s.trim());
285        match trimmed {
286            Some(s) if !s.is_empty() => s.to_string(),
287            _ => decoded,
288        }
289    } else {
290        decoded
291    }
292}
293
294fn format_subject_text(subject: &str, config: &Config) -> String {
295    escape_html(&format_subject_for_index(subject, config))
296}
297
298fn format_reply_links(email: &EmailInfo, store: &EmailStore, config: &Config) -> Option<String> {
299    let i18n = I18n::new(&config.language);
300    if store.replylist.is_empty() {
301        return None;
302    }
303
304    let mut reply_indices: Vec<usize> = Vec::new();
305    for reply in &store.replylist {
306        if reply.from_msgnum == email.msgnum {
307            if let Some(idx) = store.find_by_msgnum(reply.msgnum) {
308                reply_indices.push(idx);
309            }
310        }
311    }
312
313    if reply_indices.is_empty() {
314        return None;
315    }
316
317    let mut html = String::from("<ul class=\"hm-reply-list\">\n");
318    for &idx in &reply_indices {
319        let rep = &store.emails[idx];
320        let filename = crate::file_utils::message_url_str(rep, config);
321        let subject = rep.subject.as_deref().unwrap_or(i18n.get("no subject"));
322        let author = decode_mime_words(rep.name.as_deref().unwrap_or(i18n.get("unknown author")));
323        html.push_str(&format!(
324            "  <li><a href=\"{}\">{} by {}</a></li>\n",
325            filename,
326            format_subject_text(subject, config),
327            escape_html(&author)
328        ));
329    }
330    html.push_str("</ul>\n");
331    Some(html)
332}
333
334fn format_body(body_chain: &crate::message::BodyChain, config: &Config) -> String {
335    let i18n = I18n::new(&config.language);
336    let mut html = String::new();
337    let mut in_attachment = false;
338
339    // Coalescing state: consecutive lines with the same opening tag (e.g. all
340    // `<div class="hm-pg">…</div>`) are merged into ONE block, joined by `\n`.
341    // With `white-space: pre-wrap` in CSS, this renders identically to N
342    // separate divs but eliminates the block-level vertical gap between every
343    // single line — the body reads as one flowing paragraph instead of a
344    // double-spaced list.
345    let mut run_open: Option<String> = None;
346    let mut run_inner = String::new();
347
348    let flush_run = |html: &mut String, run_open: &mut Option<String>, run_inner: &mut String| {
349        if let Some(open) = run_open.take() {
350            html.push_str(&open);
351            html.push_str(run_inner);
352            html.push_str("</div>\n");
353            run_inner.clear();
354        }
355    };
356
357    // Returns the opening `<div …>` tag of a coalesce-eligible line, plus the
358    // inner content. A line is eligible iff it begins with `<div ` and ends
359    // with `</div>\n` (or `</div>`). Anything else (e.g. `<hr …>`,
360    // `<div …><img …></div>` we still merge if same class) breaks the run.
361    fn split_div_line(s: &str) -> Option<(&str, &str)> {
362        let s = s.strip_suffix('\n').unwrap_or(s);
363        let s = s.strip_suffix("</div>")?;
364        if !s.starts_with("<div ") {
365            return None;
366        }
367        let close = s.find('>')?;
368        let open = &s[..=close];
369        let inner = &s[close + 1..];
370        Some((open, inner))
371    }
372
373    for body in &body_chain.bodies {
374        if body.header {
375            continue;
376        }
377
378        if body.attached {
379            flush_run(&mut html, &mut run_open, &mut run_inner);
380            if !in_attachment {
381                html.push_str(&format!(
382                    "<details class=\"hm-attachment\"><summary>{}</summary>\n",
383                    i18n.get("Attachment")
384                ));
385                in_attachment = true;
386            }
387            // Strip the [Attachment: name] wrapper and show the filename cleanly.
388            let filename = body
389                .line
390                .strip_prefix("[Attachment: ")
391                .and_then(|s| s.strip_suffix(']'))
392                .unwrap_or(&body.line);
393            html.push_str(&format!("<p>{}</p>\n", escape_html(filename)));
394        } else {
395            if in_attachment {
396                html.push_str("</details>\n");
397                in_attachment = false;
398            }
399            // SAFETY: body.line was already HTML-escaped by txt2html_line() which wraps
400            // all user content in <div> or <hr> tags. This check confirms that processing
401            // occurred. Lines not matching these patterns are escaped as defense-in-depth.
402            if body.line.starts_with("<div") || body.line.starts_with("<hr") {
403                // Blank-line marker: produce a paragraph gap *within* the
404                // current run. Combined with the trailing `\n` already on the
405                // previous line, this gives `pre-wrap` a real empty line.
406                if body.line.starts_with("<div class=\"hm-blank\"") {
407                    if run_open.is_some() {
408                        run_inner.push('\n');
409                    }
410                    continue;
411                }
412                if let Some((open, inner)) = split_div_line(&body.line) {
413                    match &run_open {
414                        Some(cur_open) if cur_open == open => {
415                            // Same class as current run — append with newline.
416                            run_inner.push('\n');
417                            run_inner.push_str(inner);
418                        },
419                        _ => {
420                            flush_run(&mut html, &mut run_open, &mut run_inner);
421                            run_open = Some(open.to_string());
422                            run_inner.push_str(inner);
423                        },
424                    }
425                } else {
426                    // Non-coalescable line (e.g. <hr class="hm-sig">).
427                    flush_run(&mut html, &mut run_open, &mut run_inner);
428                    html.push_str(&body.line);
429                }
430            } else {
431                flush_run(&mut html, &mut run_open, &mut run_inner);
432                html.push_str(&escape_html(&body.line));
433            }
434        }
435    }
436
437    flush_run(&mut html, &mut run_open, &mut run_inner);
438
439    if in_attachment {
440        html.push_str("</details>\n");
441    }
442
443    html
444}
445
446#[cfg(test)]
447fn generate_navigation(email: &EmailInfo, config: &Config) -> Result<String> {
448    let i18n = I18n::new(&config.language);
449    let mut nav = String::from("<nav class=\"hm-nav\" aria-label=\"Message navigation\">\n");
450
451    let filename = crate::file_utils::message_url_str(email, config);
452    nav.push_str(&format!("  <a href=\"{}\">{}</a>\n", filename, i18n.get("Article")));
453
454    if config.show_msg_links > 0 {
455        nav.push_str(&format!(
456            "  <a href=\"index.{}\">{}</a>\n",
457            config.htmlsuffix,
458            i18n.get("Index")
459        ));
460    }
461
462    nav.push_str("</nav>\n");
463    Ok(nav)
464}
465
466/// Returns the HTML filename for a given email message.
467pub fn get_message_filename(email: &EmailInfo, config: &Config) -> String {
468    format!("{}.{}", message_name(email, config), config.htmlsuffix)
469}
470
471/// Returns the full filesystem path for a message's HTML file.
472pub fn get_message_path(email: &EmailInfo, config: &Config) -> PathBuf {
473    utils_message_path(email, config)
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::config::Config;
480    use crate::message::{Body, BodyChain, EmailInfo};
481
482    fn make_test_email() -> EmailInfo {
483        EmailInfo {
484            msgnum: 42,
485            date: 1615824000,
486            from_date_str: Some("Mon, 15 Mar 2021 12:00:00 +0000".to_string()),
487            date_str: Some("Mon, 15 Mar 2021 12:00:00 +0000".to_string()),
488            name: Some("Alice".to_string()),
489            email_addr: Some("alice@example.com".to_string()),
490            subject: Some("Test Message".to_string()),
491            msgid: Some("<abc123@example.com>".to_string()),
492            charset: Some("utf-8".to_string()),
493            inreplyto: Some("<parent@example.com>".to_string()),
494            bodylist: BodyChain {
495                bodies: vec![Body {
496                    line: "Hello World".to_string(),
497                    html: false,
498                    header: false,
499                    parsed_header: false,
500                    attached: false,
501                    demimed: false,
502                    msgnum: 0,
503                }],
504            },
505            ..Default::default()
506        }
507    }
508
509    #[test]
510    fn test_format_author() {
511        let email = make_test_email();
512        let config = Config::default();
513        let result = format_author(&email, &config);
514        assert!(result.contains("Alice"));
515        assert!(result.contains("alice@example.com"));
516    }
517
518    #[test]
519    fn test_format_subject() {
520        let config = Config::default();
521        let result = format_subject_text("Test Message", &config);
522        assert_eq!(result, "Test Message");
523    }
524
525    #[test]
526    fn test_format_subject_stripsubject() {
527        let mut config = Config::default();
528        config.stripsubject = Some("[mylist] ".to_string());
529
530        assert_eq!(format_subject_text("[mylist] Hello", &config), "Hello");
531        assert_eq!(format_subject_text("[mylist]  Hello", &config), "Hello"); // double space trimmed
532        assert_eq!(format_subject_text("No prefix here", &config), "No prefix here");
533    }
534
535    #[test]
536    fn test_format_subject_stripsubject_no_trailing_space() {
537        // stripsubject without trailing space (e.g. "JotD..."):
538        // "JotD... The joke" → strip "JotD..." → " The joke" → trim → "The joke"
539        let mut config = Config::default();
540        config.stripsubject = Some("JotD...".to_string());
541
542        assert_eq!(format_subject_text("JotD... The joke", &config), "The joke");
543        assert_eq!(format_subject_text("JotD...  Spaces  ", &config), "Spaces"); // trim both ends
544        assert_eq!(format_subject_text("JotD...", &config), "JotD..."); // empty after strip → keep original
545        assert_eq!(format_subject_text("Other subject", &config), "Other subject");
546    }
547
548    #[test]
549    fn test_get_message_filename() {
550        let mut config = Config::default();
551        let mut email = make_test_email();
552        assert_eq!(get_message_filename(&email, &config), "0042.html");
553        config.nonsequential = true;
554        email.from_date = 1615824000;
555        let name = get_message_filename(&email, &config);
556        assert_eq!(name.len(), 21); // 16 hex + ".html"
557    }
558
559    #[test]
560    fn test_format_date() {
561        let email = make_test_email();
562        let config = Config::default();
563        let result = format_date(&email, &config);
564        assert!(!result.is_empty());
565    }
566
567    #[test]
568    fn test_deleted_article_stub() {
569        let mut email = make_test_email();
570        email.is_deleted = 1;
571        let config = Config::default();
572        let result = print_article(&email, &crate::structs::EmailStore::new(), &config).unwrap();
573        assert!(result.contains("Deleted") || result.contains("deleted"));
574    }
575
576    #[test]
577    fn test_format_body_escapes_raw_text() {
578        let mut chain = BodyChain { bodies: Vec::new() };
579        chain.bodies.push(Body {
580            line: "<script>alert('xss')</script>".to_string(),
581            html: false,
582            header: false,
583            parsed_header: false,
584            attached: false,
585            demimed: false,
586            msgnum: 1,
587        });
588        let config = Config::default();
589        let result = format_body(&chain, &config);
590        assert!(result.contains("&lt;script&gt;"));
591        assert!(!result.contains("<script>"));
592    }
593
594    #[test]
595    fn test_format_body_passes_div_through() {
596        let mut chain = BodyChain { bodies: Vec::new() };
597        chain.bodies.push(Body {
598            line: "<div class=\"hm-pg\">safe</div>\n".to_string(),
599            html: false,
600            header: false,
601            parsed_header: false,
602            attached: false,
603            demimed: false,
604            msgnum: 1,
605        });
606        let config = Config::default();
607        let result = format_body(&chain, &config);
608        assert!(result.contains("<div class=\"hm-pg\">safe</div>"));
609    }
610
611    #[test]
612    fn test_message_id_spam_protected() {
613        let email = make_test_email();
614        let mut config = Config::default();
615        config.spamprotect_id = true;
616        config.antispam_at = " at ".to_string();
617        let store = crate::structs::EmailStore::new();
618        let result = print_article(&email, &store, &config).unwrap();
619        assert!(result.contains(" at "));
620        assert!(!result.contains("abc123@example.com"));
621    }
622
623    #[test]
624    fn test_nonsequential_navigation_link() {
625        let email = make_test_email();
626        let mut config = Config::default();
627        config.nonsequential = true;
628        let result = generate_navigation(&email, &config).unwrap();
629        // Link should be hex hash based, not msgnum
630        assert!(!result.contains("0042.html"));
631        assert!(result.contains(".html"));
632    }
633}