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 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 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 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 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 "{} <<a href=\"mailto:{}\">{}</a>>",
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
280pub 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 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 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 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 if body.line.starts_with("<div") || body.line.starts_with("<hr") {
403 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 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 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
466pub fn get_message_filename(email: &EmailInfo, config: &Config) -> String {
468 format!("{}.{}", message_name(email, config), config.htmlsuffix)
469}
470
471pub 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"); 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 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"); assert_eq!(format_subject_text("JotD...", &config), "JotD..."); 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); }
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("<script>"));
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 assert!(!result.contains("0042.html"));
631 assert!(result.contains(".html"));
632 }
633}