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 i18n = I18n::new(&config.language);
71        let raw_subject = email.subject.as_deref().unwrap_or(i18n.get("no subject"));
72        let decoded_subject = format_subject_for_index(raw_subject, config);
73        let decoded_name = decode_mime_words(email.name.as_deref().unwrap_or(""));
74        let data = PrintfileData {
75            label: config.label.as_deref().unwrap_or(""),
76            subject: &decoded_subject,
77            dir: config.dir.as_deref().unwrap_or("."),
78            name: Some(decoded_name.as_str()),
79            email: email.email_addr.as_deref(),
80            msgid: email.msgid.as_deref(),
81            charset: email.charset.as_deref(),
82            date: if date_str.is_empty() {
83                None
84            } else {
85                Some(date_str.as_str())
86            },
87            display_date: if display_date_str.is_empty() {
88                None
89            } else {
90                Some(display_date_str.as_str())
91            },
92            filename: Some(filename.as_str()),
93            archives: config.archives.as_deref(),
94            about: config.about.as_deref(),
95            mailto: config.mailto.as_deref(),
96            language: &config.language,
97            rel_path_to_top: "",
98        };
99
100        let header_html = substitute_printfile(&header_tpl, &data);
101        let footer_html = substitute_printfile(&footer_tpl, &data);
102        let generator = if config.showgenerator {
103            let i18n = I18n::new(&config.language);
104            let gen_text = crate::txt2html::escape_html(i18n.get("Generated by"));
105            format!(
106                "\n<p class=\"hm-generator\">{} <a href=\"https://hypermail-rs.github.io\">hypermail-rs</a></p>\n",
107                gen_text
108            )
109        } else {
110            String::new()
111        };
112        Ok(format!("{}{}{}{}", header_html, article_html, footer_html, generator))
113    } else {
114        // Internal default templates: use %COOKIE_NAME% substitution.
115        let i18n = I18n::new(&config.language);
116        let subject_raw = email.subject.as_deref().unwrap_or(i18n.get("no subject"));
117        let subject = &format_subject_for_index(subject_raw, config);
118        let mut cookies = get_header_cookies(config, subject);
119        set_cookie(&mut cookies, "ARTICLE", article_html);
120
121        if email.is_deleted != 0 && email.is_deleted & 2 != 0 {
122            set_cookie(&mut cookies, "DELETED_NOTE", i18n.get("Expired message"));
123            set_cookie(&mut cookies, "DELETED_HTML", "");
124        } else if email.is_deleted != 0 {
125            set_cookie(&mut cookies, "DELETED_NOTE", i18n.get("Deleted message"));
126            set_cookie(&mut cookies, "DELETED_HTML", "");
127        }
128
129        let header_template = load_template_or_default(None, default_header_template());
130        let footer_template = load_template_or_default(None, default_footer_template());
131        let article_template = default_article_template();
132
133        let header_html = substitute_cookies(&header_template, &cookies);
134        let article_content = substitute_cookies(article_template, &cookies);
135        let mut nav_cookies = cookies.clone();
136        set_cookie(&mut nav_cookies, "NAVIGATION", "");
137        let footer_html = substitute_cookies(&footer_template, &nav_cookies);
138        Ok(format!("{}{}{}", header_html, article_content, footer_html))
139    }
140}
141
142fn print_deleted_article(
143    email: &EmailInfo,
144    _store: &EmailStore,
145    config: &Config,
146) -> Result<String> {
147    let note = if email.is_deleted & 2 != 0 {
148        let i18n = I18n::new(&config.language);
149        i18n.get("Expired message").to_string()
150    } else {
151        let i18n = I18n::new(&config.language);
152        i18n.get("Deleted message").to_string()
153    };
154    let mut article_html = String::new();
155    article_html.push_str(&format!("<p class=\"hm-deleted\">{}</p>\n", note));
156
157    render_message_page(email, config, &article_html)
158}
159
160fn generate_article_email(
161    email: &EmailInfo,
162    store: &EmailStore,
163    config: &Config,
164) -> Result<String> {
165    let i18n = I18n::new(&config.language);
166    let mut html = String::new();
167
168    html.push_str(&format!(
169        "<article id=\"{}{}\">\n",
170        escape_html(&config.fragment_prefix),
171        email.msgnum
172    ));
173
174    if config.showheaders {
175        html.push_str("<table class=\"hm-msg-header\" aria-label=\"Message headers\">\n<tbody>\n");
176
177        html.push_str(&format!(
178            "<tr><th class=\"hm-hdrlabel\" scope=\"row\">{}</th><td class=\"hm-hdrdata\">{}</td></tr>\n",
179            escape_html(i18n.get("Author")),
180            format_author(email, config)
181        ));
182
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("Date")),
186            format_date(email, config)
187        ));
188
189        let subject = email.subject.as_deref().unwrap_or(i18n.get("no subject"));
190        html.push_str(&format!(
191            "<tr><th class=\"hm-hdrlabel\" scope=\"row\">{}</th><td class=\"hm-hdrdata\">{}</td></tr>\n",
192            escape_html(i18n.get("Subject")),
193            format_subject_text(subject, config)
194        ));
195
196        // Message-ID is kept in HTML comments but not displayed in the visible header table
197        // This reduces clutter while preserving the ID for debugging/threading purposes
198
199        html.push_str("</tbody>\n</table>\n");
200    }
201
202    if config.showreplies {
203        if let Some(reply_html) = format_reply_links(email, store, config) {
204            html.push_str(&reply_html);
205        }
206    }
207
208    html.push_str("<hr class=\"hm-msg-sep\">\n");
209
210    // Only show [Deleted]/[Expired] note if we're not showing the body content
211    // If delete_level >= DELETE_LEAVES_TEXT, we show the body, so no need for the note
212    if email.is_deleted != 0
213        && config.delete_level >= DELETE_LEAVES_STUBS
214        && config.delete_level < DELETE_LEAVES_TEXT
215    {
216        let note = if email.is_deleted & 2 != 0 {
217            i18n.get("[Expired]")
218        } else {
219            i18n.get("[Deleted]")
220        };
221        html.push_str(&format!("<p class=\"hm-deleted\">{}</p>\n", note));
222        if email.bodylist.bodies.is_empty() {
223            html.push_str("</article>\n");
224            return Ok(html);
225        }
226    }
227
228    let body_html = format_body(&email.bodylist, config);
229    html.push_str(&body_html);
230    html.push_str("</article>\n");
231
232    Ok(html)
233}
234
235fn format_author(email: &EmailInfo, config: &Config) -> String {
236    let display_name = decode_mime_words(email.name.as_deref().unwrap_or(""));
237    let display_addr = email.email_addr.as_deref().unwrap_or("unknown");
238
239    let obfuscated_addr = if config.email_address_obfuscation {
240        obfuscate_email_address(display_addr)
241    } else {
242        escape_html(display_addr)
243    };
244
245    let final_addr = spamify(
246        &obfuscated_addr,
247        &config.antispam_at,
248        config.antispamdomain.as_deref(),
249        config.spamprotect,
250        config.spamprotect_id,
251    );
252
253    let escaped_name = escape_html(&display_name);
254
255    let mailto = if let Some(ref hmail) = config.hmail {
256        hmail.replace("$TO", display_addr)
257    } else {
258        format!("mailto:{}", display_addr)
259    };
260
261    if display_name.is_empty() {
262        format!("<a href=\"{}\">{}</a>", escape_html(&mailto), final_addr)
263    } else {
264        format!(
265            "{} &lt;<a href=\"{}\">{}</a>&gt;",
266            escaped_name,
267            escape_html(&mailto),
268            final_addr
269        )
270    }
271}
272
273fn format_date(email: &EmailInfo, config: &Config) -> String {
274    let timestamp = email.date;
275    let date_str = get_date_str(
276        timestamp,
277        config.dateformat.as_deref(),
278        config.gmtime,
279        config.eurodate,
280        config.isodate,
281        &config.language,
282    );
283    let iso_str = secs_to_iso(timestamp);
284    format!("<time datetime=\"{}\">{}</time>", escape_html(&iso_str), escape_html(&date_str))
285}
286
287/// Decode and strip the list tag from a subject, for use in both message and index pages.
288pub fn format_subject_for_index(subject: &str, config: &Config) -> String {
289    let decoded = decode_mime_words(subject);
290    if let Some(ref strip) = config.stripsubject {
291        let trimmed = decoded.strip_prefix(strip.as_str()).map(|s| s.trim());
292        match trimmed {
293            Some(s) if !s.is_empty() => s.to_string(),
294            _ => decoded,
295        }
296    } else {
297        decoded
298    }
299}
300
301fn format_subject_text(subject: &str, config: &Config) -> String {
302    escape_html(&format_subject_for_index(subject, config))
303}
304
305fn format_reply_links(email: &EmailInfo, store: &EmailStore, config: &Config) -> Option<String> {
306    let i18n = I18n::new(&config.language);
307    if store.replylist.is_empty() {
308        return None;
309    }
310
311    let mut reply_indices: Vec<usize> = Vec::new();
312    for reply in &store.replylist {
313        if reply.from_msgnum == email.msgnum {
314            if let Some(idx) = store.find_by_msgnum(reply.msgnum) {
315                reply_indices.push(idx);
316            }
317        }
318    }
319
320    if reply_indices.is_empty() {
321        return None;
322    }
323
324    let mut html = String::from("<ul class=\"hm-reply-list\">\n");
325    for &idx in &reply_indices {
326        let rep = &store.emails[idx];
327        let filename = crate::file_utils::message_url_str(rep, config);
328        let subject = rep.subject.as_deref().unwrap_or(i18n.get("no subject"));
329        let author = decode_mime_words(rep.name.as_deref().unwrap_or(i18n.get("unknown author")));
330        html.push_str(&format!(
331            "  <li><a href=\"{}\">{} by {}</a></li>\n",
332            escape_html(&filename),
333            format_subject_text(subject, config),
334            escape_html(&author)
335        ));
336    }
337    html.push_str("</ul>\n");
338    Some(html)
339}
340
341/// True if `line` is markup produced by our converters (trusted class names only).
342/// Rejects arbitrary `<div onclick=…>` / `<hr onload=…>` that would otherwise
343/// pass through because they start with `<div` / `<hr`.
344fn is_trusted_body_markup(line: &str) -> bool {
345    let s = line.trim_start();
346    if s.starts_with("<hr class=\"hm-sig\">") || s.starts_with("<hr class=\"hm-sig\"") {
347        return true;
348    }
349    if !s.starts_with("<div class=\"") {
350        return false;
351    }
352    // Known classes emitted by txt2html / conv_showhtml
353    const TRUSTED: &[&str] = &[
354        "<div class=\"hm-blank\"",
355        "<div class=\"hm-pg\"",
356        "<div class=\"hm-sig-text\"",
357        "<div class=\"hm-quote-1\"",
358        "<div class=\"hm-quote-2\"",
359        "<div class=\"hm-quote-3\"",
360        "<div class=\"hm-quote-4\"",
361        "<div class=\"hm-quote-5\"",
362        "<div class=\"hm-quote-6\"",
363        "<div class=\"hm-quote-7\"",
364        "<div class=\"hm-quote-8\"",
365        "<div class=\"hm-quote-9\"",
366    ];
367    TRUSTED.iter().any(|p| s.starts_with(p))
368}
369
370fn format_body(body_chain: &crate::message::BodyChain, config: &Config) -> String {
371    let i18n = I18n::new(&config.language);
372    let mut html = String::new();
373    let mut in_attachment = false;
374
375    // Coalescing state: consecutive lines with the same opening tag (e.g. all
376    // `<div class="hm-pg">…</div>`) are merged into ONE block, joined by `\n`.
377    // With `white-space: pre-wrap` in CSS, this renders identically to N
378    // separate divs but eliminates the block-level vertical gap between every
379    // single line — the body reads as one flowing paragraph instead of a
380    // double-spaced list.
381    let mut run_open: Option<String> = None;
382    let mut run_inner = String::new();
383
384    let flush_run = |html: &mut String, run_open: &mut Option<String>, run_inner: &mut String| {
385        if let Some(open) = run_open.take() {
386            html.push_str(&open);
387            html.push_str(run_inner);
388            html.push_str("</div>\n");
389            run_inner.clear();
390        }
391    };
392
393    // Returns the opening `<div …>` tag of a coalesce-eligible line, plus the
394    // inner content. A line is eligible iff it begins with `<div ` and ends
395    // with `</div>\n` (or `</div>`). Anything else (e.g. `<hr …>`,
396    // `<div …><img …></div>` we still merge if same class) breaks the run.
397    fn split_div_line(s: &str) -> Option<(&str, &str)> {
398        let s = s.strip_suffix('\n').unwrap_or(s);
399        let s = s.strip_suffix("</div>")?;
400        if !s.starts_with("<div ") {
401            return None;
402        }
403        let close = s.find('>')?;
404        let open = &s[..=close];
405        let inner = &s[close + 1..];
406        Some((open, inner))
407    }
408
409    for body in &body_chain.bodies {
410        if body.header {
411            continue;
412        }
413
414        if body.attached {
415            flush_run(&mut html, &mut run_open, &mut run_inner);
416            if !in_attachment {
417                html.push_str(&format!(
418                    "<details class=\"hm-attachment\"><summary>{}</summary>\n",
419                    i18n.get("Attachment")
420                ));
421                in_attachment = true;
422            }
423            // Strip the [Attachment: name] wrapper and show the filename cleanly.
424            let filename = body
425                .line
426                .strip_prefix("[Attachment: ")
427                .and_then(|s| s.strip_suffix(']'))
428                .unwrap_or(&body.line);
429            html.push_str(&format!("<p>{}</p>\n", escape_html(filename)));
430        } else {
431            if in_attachment {
432                html.push_str("</details>\n");
433                in_attachment = false;
434            }
435            // Only pass through converter-emitted markup (hm-* classes). showhtml>=2
436            // may leave raw HTML; that still goes through the allowlist or is escaped.
437            if is_trusted_body_markup(&body.line) {
438                // Blank-line marker: produce a paragraph gap *within* the
439                // current run. Combined with the trailing `\n` already on the
440                // previous line, this gives `pre-wrap` a real empty line.
441                if body.line.starts_with("<div class=\"hm-blank\"") {
442                    if run_open.is_some() {
443                        run_inner.push('\n');
444                    }
445                    continue;
446                }
447                if let Some((open, inner)) = split_div_line(&body.line) {
448                    match &run_open {
449                        Some(cur_open) if cur_open == open => {
450                            // Same class as current run — append with newline.
451                            run_inner.push('\n');
452                            run_inner.push_str(inner);
453                        },
454                        _ => {
455                            flush_run(&mut html, &mut run_open, &mut run_inner);
456                            run_open = Some(open.to_string());
457                            run_inner.push_str(inner);
458                        },
459                    }
460                } else {
461                    // Non-coalescable trusted line (e.g. <hr class="hm-sig">).
462                    flush_run(&mut html, &mut run_open, &mut run_inner);
463                    html.push_str(&body.line);
464                }
465            } else {
466                flush_run(&mut html, &mut run_open, &mut run_inner);
467                html.push_str(&escape_html(&body.line));
468            }
469        }
470    }
471
472    flush_run(&mut html, &mut run_open, &mut run_inner);
473
474    if in_attachment {
475        html.push_str("</details>\n");
476    }
477
478    html
479}
480
481#[cfg(test)]
482fn generate_navigation(email: &EmailInfo, config: &Config) -> Result<String> {
483    let i18n = I18n::new(&config.language);
484    let mut nav = String::from("<nav class=\"hm-nav\" aria-label=\"Message navigation\">\n");
485
486    let filename = crate::file_utils::message_url_str(email, config);
487    nav.push_str(&format!("  <a href=\"{}\">{}</a>\n", filename, i18n.get("Article")));
488
489    if config.show_msg_links > 0 {
490        nav.push_str(&format!(
491            "  <a href=\"index.{}\">{}</a>\n",
492            config.htmlsuffix,
493            i18n.get("Index")
494        ));
495    }
496
497    nav.push_str("</nav>\n");
498    Ok(nav)
499}
500
501/// Returns the HTML filename for a given email message.
502pub fn get_message_filename(email: &EmailInfo, config: &Config) -> String {
503    format!("{}.{}", message_name(email, config), config.htmlsuffix)
504}
505
506/// Returns the full filesystem path for a message's HTML file.
507pub fn get_message_path(email: &EmailInfo, config: &Config) -> PathBuf {
508    utils_message_path(email, config)
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::config::Config;
515    use crate::message::{Body, BodyChain, EmailInfo};
516
517    fn make_test_email() -> EmailInfo {
518        EmailInfo {
519            msgnum: 42,
520            date: 1615824000,
521            from_date_str: Some("Mon, 15 Mar 2021 12:00:00 +0000".to_string()),
522            date_str: Some("Mon, 15 Mar 2021 12:00:00 +0000".to_string()),
523            name: Some("Alice".to_string()),
524            email_addr: Some("alice@example.com".to_string()),
525            subject: Some("Test Message".to_string()),
526            msgid: Some("<abc123@example.com>".to_string()),
527            charset: Some("utf-8".to_string()),
528            inreplyto: Some("<parent@example.com>".to_string()),
529            bodylist: BodyChain {
530                bodies: vec![Body {
531                    line: "Hello World".to_string(),
532                    html: false,
533                    header: false,
534                    parsed_header: false,
535                    attached: false,
536                    demimed: false,
537                    msgnum: 0,
538                }],
539            },
540            ..Default::default()
541        }
542    }
543
544    #[test]
545    fn test_format_author() {
546        let email = make_test_email();
547        let config = Config::default();
548        let result = format_author(&email, &config);
549        assert!(result.contains("Alice"));
550        assert!(result.contains("alice@example.com"));
551    }
552
553    #[test]
554    fn test_format_subject() {
555        let config = Config::default();
556        let result = format_subject_text("Test Message", &config);
557        assert_eq!(result, "Test Message");
558    }
559
560    #[test]
561    fn test_format_subject_stripsubject() {
562        let mut config = Config::default();
563        config.stripsubject = Some("[mylist] ".to_string());
564
565        assert_eq!(format_subject_text("[mylist] Hello", &config), "Hello");
566        assert_eq!(format_subject_text("[mylist]  Hello", &config), "Hello"); // double space trimmed
567        assert_eq!(format_subject_text("No prefix here", &config), "No prefix here");
568    }
569
570    #[test]
571    fn test_format_subject_stripsubject_no_trailing_space() {
572        // stripsubject without trailing space (e.g. "JotD..."):
573        // "JotD... The joke" → strip "JotD..." → " The joke" → trim → "The joke"
574        let mut config = Config::default();
575        config.stripsubject = Some("JotD...".to_string());
576
577        assert_eq!(format_subject_text("JotD... The joke", &config), "The joke");
578        assert_eq!(format_subject_text("JotD...  Spaces  ", &config), "Spaces"); // trim both ends
579        assert_eq!(format_subject_text("JotD...", &config), "JotD..."); // empty after strip → keep original
580        assert_eq!(format_subject_text("Other subject", &config), "Other subject");
581    }
582
583    #[test]
584    fn test_get_message_filename() {
585        let mut config = Config::default();
586        let mut email = make_test_email();
587        assert_eq!(get_message_filename(&email, &config), "0042.html");
588        config.nonsequential = true;
589        email.from_date = 1615824000;
590        let name = get_message_filename(&email, &config);
591        assert_eq!(name.len(), 21); // 16 hex + ".html"
592    }
593
594    #[test]
595    fn test_format_date() {
596        let email = make_test_email();
597        let config = Config::default();
598        let result = format_date(&email, &config);
599        assert!(!result.is_empty());
600    }
601
602    #[test]
603    fn test_deleted_article_stub() {
604        let mut email = make_test_email();
605        email.is_deleted = 1;
606        let config = Config::default();
607        let result = print_article(&email, &crate::structs::EmailStore::new(), &config).unwrap();
608        assert!(result.contains("Deleted") || result.contains("deleted"));
609    }
610
611    #[test]
612    fn test_format_body_escapes_raw_text() {
613        let mut chain = BodyChain { bodies: Vec::new() };
614        chain.bodies.push(Body {
615            line: "<script>alert('xss')</script>".to_string(),
616            html: false,
617            header: false,
618            parsed_header: false,
619            attached: false,
620            demimed: false,
621            msgnum: 1,
622        });
623        let config = Config::default();
624        let result = format_body(&chain, &config);
625        assert!(result.contains("&lt;script&gt;"));
626        assert!(!result.contains("<script>"));
627    }
628
629    #[test]
630    fn test_format_body_passes_div_through() {
631        let mut chain = BodyChain { bodies: Vec::new() };
632        chain.bodies.push(Body {
633            line: "<div class=\"hm-pg\">safe</div>\n".to_string(),
634            html: false,
635            header: false,
636            parsed_header: false,
637            attached: false,
638            demimed: false,
639            msgnum: 1,
640        });
641        let config = Config::default();
642        let result = format_body(&chain, &config);
643        assert!(result.contains("<div class=\"hm-pg\">safe</div>"));
644    }
645
646    #[test]
647    fn test_message_id_spam_protected() {
648        let email = make_test_email();
649        let mut config = Config::default();
650        config.spamprotect_id = true;
651        config.antispam_at = " at ".to_string();
652        let store = crate::structs::EmailStore::new();
653        let result = print_article(&email, &store, &config).unwrap();
654        assert!(result.contains(" at "));
655        assert!(!result.contains("abc123@example.com"));
656    }
657
658    #[test]
659    fn test_nonsequential_navigation_link() {
660        let email = make_test_email();
661        let mut config = Config::default();
662        config.nonsequential = true;
663        let result = generate_navigation(&email, &config).unwrap();
664        // Link should be hex hash based, not msgnum
665        assert!(!result.contains("0042.html"));
666        assert!(result.contains(".html"));
667    }
668
669    #[test]
670    fn test_format_author_no_double_mailto() {
671        let email = make_test_email();
672        let config = Config::default();
673        let result = format_author(&email, &config);
674        // href should contain exactly one mailto: prefix, not mailto:mailto:
675        assert!(result.contains("href=\"mailto:"), "should have mailto: link");
676        assert!(!result.contains("mailto:mailto:"), "should NOT have double mailto: prefix");
677    }
678
679    #[test]
680    fn test_format_author_with_hmail_no_double_mailto() {
681        let email = make_test_email();
682        let mut config = Config::default();
683        config.hmail = Some("mailto:$TO?subject=test".to_string());
684        let result = format_author(&email, &config);
685        assert!(
686            !result.contains("mailto:mailto:"),
687            "hmail substitution should NOT produce double mailto:"
688        );
689    }
690
691    #[test]
692    fn test_article_uses_id_not_a_name() {
693        let email = make_test_email();
694        let store = crate::structs::EmailStore::new();
695        let config = Config::default();
696        let result = print_article(&email, &store, &config).unwrap();
697        // Should use <article id="..."> not <a name="...">
698        assert!(result.contains("<article id=\""), "should use <article id=>");
699        assert!(!result.contains("<a name=\""), "should NOT use deprecated <a name=>");
700    }
701
702    #[test]
703    fn test_subject_xss_escaped_in_title() {
704        let mut email = make_test_email();
705        email.subject = Some("</title><script>alert(1)</script>".to_string());
706        let store = crate::structs::EmailStore::new();
707        let config = Config::default();
708        let result = print_article(&email, &store, &config).unwrap();
709        assert!(!result.contains("<script>alert(1)</script>"), "raw script in subject");
710        assert!(result.contains("&lt;script&gt;") || result.contains("&lt;/title&gt;"));
711    }
712
713    #[test]
714    fn test_inline_image_xss_in_body_not_emitted() {
715        let mut email = make_test_email();
716        email.bodylist = BodyChain {
717            bodies: vec![Body {
718                line: r#"[INLINE_IMAGE:image/png:x" onerror="alert(1)]"#.to_string(),
719                html: false,
720                header: false,
721                parsed_header: false,
722                attached: false,
723                demimed: false,
724                msgnum: 42,
725            }],
726        };
727        let store = crate::structs::EmailStore::new();
728        let config = Config::default();
729        // process like main does
730        let mut store_email = email.clone();
731        crate::txt2html::conv_showhtml(&mut store_email.bodylist, &config);
732        let result = print_article(&store_email, &store, &config).unwrap();
733        assert!(
734            !result.contains("<img"),
735            "malicious INLINE_IMAGE must not become img: {}",
736            result
737        );
738        assert!(
739            !result.contains(r#"onerror=""#),
740            "must not create onerror attribute: {}",
741            result
742        );
743    }
744}