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
18pub fn print_article(email: &EmailInfo, store: &EmailStore, config: &Config) -> Result<String> {
20 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
36fn render_message_page(email: &EmailInfo, config: &Config, article_html: &str) -> Result<String> {
39 if config.mhtmlheader.is_some() || config.mhtmlfooter.is_some() {
40 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 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 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 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 "{} <<a href=\"{}\">{}</a>>",
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
287pub 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
341fn 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 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 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 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 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 if is_trusted_body_markup(&body.line) {
438 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 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 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
501pub fn get_message_filename(email: &EmailInfo, config: &Config) -> String {
503 format!("{}.{}", message_name(email, config), config.htmlsuffix)
504}
505
506pub 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"); 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 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"); assert_eq!(format_subject_text("JotD...", &config), "JotD..."); 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); }
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("<script>"));
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 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 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 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("<script>") || result.contains("</title>"));
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 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}