1use std::collections::{BTreeMap, HashSet};
2use std::path::PathBuf;
3
4use crate::config::Config;
5use crate::date::get_date_str;
6use crate::error::Result;
7use crate::file_utils::msg_subdir;
8use crate::headers::decode_mime_words;
9use crate::html::format_subject_for_index;
10use crate::i18n::I18n;
11use crate::message::{EmailInfo, IndexType};
12use crate::structs::EmailStore;
13use crate::templates::{
14 default_footer_template, default_header_template, get_header_cookies, set_cookie,
15 substitute_cookies, substitute_printfile, PrintfileData,
16};
17use crate::txt2html::escape_html;
18use chrono::TimeZone;
19
20pub fn print_date_index(store: &EmailStore, config: &Config) -> Result<String> {
22 let i18n = I18n::new(&config.language);
23 let title = config.label.as_deref().unwrap_or(i18n.get("Date Index"));
24 let mut cookies = get_header_cookies(config, title);
25
26 let indices = store.traverse_date_list();
27 let list = if config.reverse {
28 let rev: Vec<usize> = indices.iter().rev().cloned().collect();
29 render_flat_index(&rev, store, config, IndexType::Date)
30 } else {
31 render_flat_index(&indices, store, config, IndexType::Date)
32 };
33 let top = render_archive_stats_top(store, config, IndexType::Date, &i18n);
34 let bottom = render_archive_stats_bottom(store, config, IndexType::Date, &i18n);
35 set_cookie(&mut cookies, "ARTICLE", &format!("{}{}{}", top, list, bottom));
36
37 render_index_page(config, &cookies)
38}
39
40pub fn print_subject_index(store: &EmailStore, config: &Config) -> Result<String> {
42 let i18n = I18n::new(&config.language);
43 let title = config.label.as_deref().unwrap_or(i18n.get("Subject Index"));
44 let mut cookies = get_header_cookies(config, title);
45
46 let indices = store.traverse_subject_list();
47 let list = if config.reverse {
48 let rev: Vec<usize> = indices.iter().rev().cloned().collect();
49 render_flat_index(&rev, store, config, IndexType::Subject)
50 } else {
51 render_flat_index(&indices, store, config, IndexType::Subject)
52 };
53 let top = render_archive_stats_top(store, config, IndexType::Subject, &i18n);
54 let bottom = render_archive_stats_bottom(store, config, IndexType::Subject, &i18n);
55 set_cookie(&mut cookies, "ARTICLE", &format!("{}{}{}", top, list, bottom));
56
57 render_index_page(config, &cookies)
58}
59
60pub fn print_author_index(store: &EmailStore, config: &Config) -> Result<String> {
62 let i18n = I18n::new(&config.language);
63 let title = config.label.as_deref().unwrap_or(i18n.get("Author Index"));
64 let mut cookies = get_header_cookies(config, title);
65
66 let indices = store.traverse_author_list();
67 let list = if config.reverse {
68 let rev: Vec<usize> = indices.iter().rev().cloned().collect();
69 render_flat_index(&rev, store, config, IndexType::Author)
70 } else {
71 render_flat_index(&indices, store, config, IndexType::Author)
72 };
73 let top = render_archive_stats_top(store, config, IndexType::Author, &i18n);
74 let bottom = render_archive_stats_bottom(store, config, IndexType::Author, &i18n);
75 set_cookie(&mut cookies, "ARTICLE", &format!("{}{}{}", top, list, bottom));
76
77 render_index_page(config, &cookies)
78}
79
80pub fn print_thread_index(store: &EmailStore, config: &Config) -> Result<String> {
82 let i18n = I18n::new(&config.language);
83 let title = config.label.as_deref().unwrap_or(i18n.get("Thread Index"));
84 let mut cookies = get_header_cookies(config, title);
85
86 let list = render_thread_index(store, config);
87 let top = render_archive_stats_top(store, config, IndexType::Thread, &i18n);
88 let bottom = render_archive_stats_bottom(store, config, IndexType::Thread, &i18n);
89 set_cookie(&mut cookies, "ARTICLE", &format!("{}{}{}", top, list, bottom));
90
91 render_index_page(config, &cookies)
92}
93
94fn archive_date_range(store: &EmailStore) -> (i64, i64, usize) {
96 let count = store.emails.len();
97 if count == 0 {
98 return (0, 0, 0);
99 }
100 let first = store.emails.iter().map(|e| e.date).min().unwrap_or(0);
101 let last = store.emails.iter().map(|e| e.date).max().unwrap_or(0);
102 (first, last, count)
103}
104
105fn index_href(index_type: IndexType, config: &Config) -> String {
112 let sfx = &config.htmlsuffix;
113 let is_default = match index_type {
114 IndexType::Date => config.defaultindex == "date",
115 IndexType::Subject => config.defaultindex == "subject",
116 IndexType::Author => config.defaultindex == "author",
117 IndexType::Thread => config.defaultindex == "thread",
118 IndexType::Attachment => config.defaultindex == "attachment",
119 _ => false,
120 };
121 if is_default {
122 return format!("index.{}", sfx);
123 }
124 match index_type {
125 IndexType::Date => format!("date.{}", sfx),
126 IndexType::Subject => format!("subject.{}", sfx),
127 IndexType::Author => format!("author.{}", sfx),
128 IndexType::Thread => format!("thread.{}", sfx),
129 IndexType::Attachment => format!("attachment.{}", sfx),
130 _ => format!("index.{}", sfx),
131 }
132}
133
134fn render_nav_links(
137 store: &EmailStore,
138 config: &Config,
139 current: IndexType,
140 i18n: &I18n,
141) -> String {
142 let mk_link = |t: IndexType, label: &str| -> String {
143 if current == t {
144 format!("[ {} ]", label)
145 } else {
146 format!("[ <a href=\"{}\">{}</a> ]", index_href(t, config), label)
147 }
148 };
149 let author = mk_link(IndexType::Author, i18n.get("Author Index"));
150 let date = mk_link(IndexType::Date, i18n.get("Date Index"));
151 let subject = mk_link(IndexType::Subject, i18n.get("Subject Index"));
152 let thread = mk_link(IndexType::Thread, i18n.get("Thread Index"));
153
154 let has_attachments = store.emails.iter().any(|e| e.bodylist.bodies.iter().any(|b| b.attached));
155 if config.attachmentsindex && has_attachments {
156 let label = i18n.get("Attachment").trim_end_matches(':');
157 let attach = mk_link(IndexType::Attachment, label);
158 format!("{} {} {} {} {}", author, date, subject, thread, attach)
159 } else {
160 format!("{} {} {} {}", author, date, subject, thread)
161 }
162}
163
164fn render_archive_stats_top(
165 store: &EmailStore,
166 config: &Config,
167 current: IndexType,
168 i18n: &I18n,
169) -> String {
170 let (first, last, count) = archive_date_range(store);
171 if count == 0 {
172 return String::new();
173 }
174
175 let first_str = get_date_str(
176 first,
177 config.dateformat.as_deref(),
178 config.gmtime,
179 config.eurodate,
180 config.isodate,
181 &config.language,
182 );
183 let last_str = get_date_str(
184 last,
185 config.dateformat.as_deref(),
186 config.gmtime,
187 config.eurodate,
188 config.isodate,
189 &config.language,
190 );
191
192 let mut nav = format!("{} {} ", count, i18n.get("messages sorted by:"));
193 nav.push_str(&render_nav_links(store, config, current, i18n));
194
195 let mut html = format!(
196 "<div class=\"hm-archive-info\">\n\
197 <p>{}</p>\n\
198 <p>{} <em>{}</em><br>{} <em>{}</em></p>\n",
199 nav,
200 i18n.get("Starting:"),
201 escape_html(&first_str),
202 i18n.get("Ending:"),
203 escape_html(&last_str),
204 );
205
206 if let Some(ref about) = config.about {
207 html.push_str(&format!(
208 "<p><a href=\"{}\">{}</a></p>\n",
209 escape_html(about),
210 i18n.get("About this archive")
211 ));
212 }
213
214 html.push_str("</div>\n");
215 html
216}
217
218fn render_archive_stats_bottom(
224 store: &EmailStore,
225 config: &Config,
226 current: IndexType,
227 i18n: &I18n,
228) -> String {
229 let (_, last, count) = archive_date_range(store);
230 if count == 0 {
231 return String::new();
232 }
233
234 let last_str = get_date_str(
235 last,
236 config.dateformat.as_deref(),
237 config.gmtime,
238 config.eurodate,
239 config.isodate,
240 &config.language,
241 );
242
243 let now = std::time::SystemTime::now()
244 .duration_since(std::time::UNIX_EPOCH)
245 .map(|d| d.as_secs() as i64)
246 .unwrap_or(0);
247 let now_str = get_date_str(
248 now,
249 config.dateformat.as_deref(),
250 config.gmtime,
251 config.eurodate,
252 config.isodate,
253 &config.language,
254 );
255
256 let mut nav = format!("{} {} ", count, i18n.get("messages sorted by:"));
257 nav.push_str(&render_nav_links(store, config, current, i18n));
258
259 let mut html = format!(
260 "<div class=\"hm-archive-info\">\n\
261 <p>{} <em>{}</em></p>\n\
262 <p>{} <em>{}</em></p>\n\
263 <p>{}</p>\n",
264 i18n.get("Last message date:"),
265 escape_html(&last_str),
266 i18n.get("Archived on:"),
267 escape_html(&now_str),
268 nav,
269 );
270
271 if let Some(ref about) = config.about {
272 html.push_str(&format!(
273 "<p><a href=\"{}\">{}</a></p>\n",
274 escape_html(about),
275 i18n.get("About this archive")
276 ));
277 }
278
279 html.push_str("</div>\n");
280 html
281}
282
283fn render_flat_index(
284 indices: &[usize],
285 store: &EmailStore,
286 config: &Config,
287 index_type: IndexType,
288) -> String {
289 let i18n = I18n::new(&config.language);
290 let mut html = String::new();
291
292 if config.indextable {
293 html.push_str("<table class=\"hm-index\">\n<tbody>\n");
294 for &idx in indices {
295 let email = &store.emails[idx];
296 html.push_str(&format!(
297 "<tr>{}</tr>\n",
298 render_index_row(email, config, index_type, &i18n)
299 ));
300 }
301 html.push_str("</tbody>\n</table>\n");
302 } else {
303 html.push_str("<ul class=\"hm-index\">\n");
304 for &idx in indices {
305 let email = &store.emails[idx];
306 html.push_str(&format!(
307 " <li>{}</li>\n",
308 render_index_row(email, config, index_type, &i18n)
309 ));
310 }
311 html.push_str("</ul>\n");
312 }
313
314 html
315}
316
317fn render_index_row(
318 email: &EmailInfo,
319 config: &Config,
320 index_type: IndexType,
321 i18n: &I18n,
322) -> String {
323 let decoded_author = {
324 let raw = email
325 .name
326 .as_deref()
327 .or(email.email_addr.as_deref())
328 .unwrap_or(i18n.get("unknown author"));
329 decode_mime_words(raw)
330 };
331 match index_type {
332 IndexType::Date => {
333 let date_str = get_date_str(
334 email.date,
335 config.dateformat.as_deref(),
336 config.gmtime,
337 config.eurodate,
338 config.isodate,
339 &config.language,
340 );
341 let subject = email.subject.as_deref().unwrap_or(i18n.get("no subject"));
342 let decoded_subject = format_subject_for_index(subject, config);
343 let filename = crate::file_utils::message_url_str(email, config);
344 format!(
345 "<a href=\"{}\"><strong>{}</strong></a> <span id=\"{}\"><em>{} <small>({})</small></em></span>",
346 filename,
347 escape_html(&decoded_subject),
348 email.msgnum,
349 escape_html(&decoded_author),
350 date_str,
351 )
352 },
353 IndexType::Subject => {
354 let subject = email.subject.as_deref().unwrap_or(i18n.get("no subject"));
355 let decoded = format_subject_for_index(subject, config);
356 let filename = crate::file_utils::message_url_str(email, config);
357 let date_str = get_date_str(
358 email.date,
359 config.dateformat.as_deref(),
360 config.gmtime,
361 config.eurodate,
362 config.isodate,
363 &config.language,
364 );
365 format!(
366 "<a href=\"{}\"><strong>{}</strong></a> <span id=\"{}\"><em>{} <small>({})</small></em></span>",
367 filename,
368 escape_html(&decoded),
369 email.msgnum,
370 escape_html(&decoded_author),
371 date_str,
372 )
373 },
374 IndexType::Author => {
375 let subject = email.subject.as_deref().unwrap_or(i18n.get("no subject"));
376 let decoded = format_subject_for_index(subject, config);
377 let filename = crate::file_utils::message_url_str(email, config);
378 let date_str = get_date_str(
379 email.date,
380 config.dateformat.as_deref(),
381 config.gmtime,
382 config.eurodate,
383 config.isodate,
384 &config.language,
385 );
386 format!(
387 "<em>{}</em> <a href=\"{}\"><strong>{}</strong></a> <span id=\"{}\"><small>({})</small></span>",
388 escape_html(&decoded_author),
389 filename,
390 escape_html(&decoded),
391 email.msgnum,
392 date_str,
393 )
394 },
395 IndexType::Thread => String::new(),
396 IndexType::Attachment | IndexType::Folders | IndexType::NoIndex => String::new(),
397 }
398}
399
400fn render_thread_index(store: &EmailStore, config: &Config) -> String {
401 let i18n = I18n::new(&config.language);
402 let mut html = String::new();
403 let mut printed: HashSet<i32> = HashSet::new();
404
405 let indices = store.traverse_date_list();
410 let ordered: Box<dyn Iterator<Item = &usize>> = if config.reverse {
411 Box::new(indices.iter().rev())
412 } else {
413 Box::new(indices.iter())
414 };
415
416 for &idx in ordered {
417 let email = &store.emails[idx];
418 if printed.contains(&email.msgnum) {
419 continue;
420 }
421 let has_parent =
423 store.replylist.iter().any(|r| r.msgnum == email.msgnum && r.from_msgnum >= 0);
424 if !has_parent {
425 render_thread_tree(email, store, config, &mut html, &mut printed, 0, &i18n);
426 }
427 }
428
429 for &idx in &indices {
431 let email = &store.emails[idx];
432 if !printed.contains(&email.msgnum) {
433 render_thread_tree(email, store, config, &mut html, &mut printed, 0, &i18n);
434 }
435 }
436
437 if html.is_empty() {
438 return format!("<p>{}</p>\n", i18n.get("No messages found."));
439 }
440
441 format!("<ul class=\"hm-index\">\n{}</ul>\n", html)
442}
443
444fn render_thread_tree(
445 email: &EmailInfo,
446 store: &EmailStore,
447 config: &Config,
448 html: &mut String,
449 printed: &mut HashSet<i32>,
450 depth: i32,
451 i18n: &I18n,
452) {
453 if printed.contains(&email.msgnum) {
454 return;
455 }
456 printed.insert(email.msgnum);
457
458 let max_depth = if config.thrdlevels > 0 && config.thrdlevels < 100 {
459 config.thrdlevels
460 } else {
461 50
462 };
463
464 if depth > max_depth {
465 return;
466 }
467
468 let subject = format_subject_for_index(
469 email.subject.as_deref().unwrap_or(i18n.get("no subject")),
470 config,
471 );
472 let author = decode_mime_words(
473 email
474 .name
475 .as_deref()
476 .or(email.email_addr.as_deref())
477 .unwrap_or(i18n.get("unknown author")),
478 );
479 let filename = crate::file_utils::message_url_str(email, config);
480
481 let date_fmt = config
484 .indexdateformat
485 .as_deref()
486 .filter(|s| !s.is_empty())
487 .or(config.dateformat.as_deref());
488 let date_str = get_date_str(
489 email.date,
490 date_fmt,
491 config.gmtime,
492 config.eurodate,
493 config.isodate,
494 &config.language,
495 );
496
497 let entry = format!(
499 "<a href=\"{}\"><strong>{}</strong></a> <span id=\"{}\"><em>{} <small>({})</small></em></span>",
500 filename,
501 escape_html(&subject),
502 email.msgnum,
503 escape_html(&author),
504 date_str,
505 );
506
507 if depth == 0 {
508 html.push_str(&format!(" <li>{}", entry));
509 } else {
510 html.push_str(&format!("<li>{}", entry));
511 }
512
513 let replies: Vec<_> = store
515 .replylist
516 .iter()
517 .filter(|r| r.from_msgnum == email.msgnum)
518 .filter_map(|r| store.find_by_msgnum(r.msgnum).map(|idx| &store.emails[idx]))
519 .collect();
520
521 if !replies.is_empty() {
522 html.push_str("\n<ul class=\"hm-thread-children\">\n");
523 for reply_email in replies {
524 render_thread_tree(reply_email, store, config, html, printed, depth + 1, i18n);
525 }
526 html.push_str("</ul>\n");
527 }
528
529 html.push_str("</li>\n");
530}
531
532fn render_index_page(
533 config: &Config,
534 cookies: &std::collections::HashMap<String, String>,
535) -> Result<String> {
536 use crate::templates::default_article_template;
537
538 if config.ihtmlheader.is_some() || config.ihtmlfooter.is_some() {
539 let header_tpl = config
542 .ihtmlheader
543 .as_deref()
544 .and_then(|p| std::fs::read_to_string(p).ok())
545 .unwrap_or_default();
546 let footer_tpl = config
547 .ihtmlfooter
548 .as_deref()
549 .and_then(|p| std::fs::read_to_string(p).ok())
550 .unwrap_or_default();
551
552 let title = cookies.get("TITLE").map(|s| s.as_str()).unwrap_or("");
553 let data = PrintfileData {
554 label: config.label.as_deref().unwrap_or(""),
555 subject: title,
556 dir: config.dir.as_deref().unwrap_or("."),
557 name: None,
558 email: None,
559 msgid: None,
560 charset: None,
561 date: None,
562 display_date: None,
563 filename: None,
564 archives: config.archives.as_deref(),
565 about: config.about.as_deref(),
566 mailto: config.mailto.as_deref(),
567 language: &config.language,
568 rel_path_to_top: "",
569 };
570
571 let article_body = cookies.get("ARTICLE").map(|s| s.as_str()).unwrap_or("");
572 let header_html = substitute_printfile(&header_tpl, &data);
573 let footer_html = substitute_printfile(&footer_tpl, &data);
574 let generator = if config.showgenerator {
575 "\n<p class=\"hm-generator\">Generated by <a href=\"https://hypermail-rs.github.io\">hypermail-rs</a></p>\n"
576 } else {
577 ""
578 };
579 Ok(format!("{}{}{}{}", header_html, article_body, footer_html, generator))
580 } else {
581 let header_template = load_template_or_default(None, default_header_template());
583 let footer_template = load_template_or_default(None, default_footer_template());
584 let article_template =
585 load_template_or_default(config.ihtmlhead.as_deref(), default_article_template());
586
587 let header_html = substitute_cookies(&header_template, cookies);
588 let article_content = substitute_cookies(&article_template, cookies);
589 let mut nav_cookies = cookies.clone();
590 set_cookie(&mut nav_cookies, "NAVIGATION", "");
591 let footer_html = substitute_cookies(&footer_template, &nav_cookies);
592
593 Ok(format!("{}{}{}", header_html, article_content, footer_html))
594 }
595}
596
597fn load_template_or_default(path: Option<&str>, default: &str) -> String {
598 path.and_then(|p| std::fs::read_to_string(p).ok())
599 .unwrap_or_else(|| default.to_string())
600}
601
602pub fn print_attachment_index(store: &EmailStore, config: &Config) -> Result<String> {
605 let i18n = I18n::new(&config.language);
606 let label = i18n.get("Attachment").trim_end_matches(':');
607 let title = format!("{} — {}", config.label.as_deref().unwrap_or("Archive"), label);
608 let mut cookies = get_header_cookies(config, &title);
609
610 let indices_with_attachments: Vec<usize> = store
612 .traverse_date_list()
613 .into_iter()
614 .filter(|&idx| store.emails[idx].bodylist.bodies.iter().any(|b| b.attached))
615 .collect();
616
617 let list = if config.indextable {
618 let mut html = String::from("<table class=\"hm-index\">\n<tbody>\n");
619 for idx in &indices_with_attachments {
620 let email = &store.emails[*idx];
621 html.push_str(&format!(
622 "<tr>{}</tr>\n",
623 render_index_row(email, config, IndexType::Date, &i18n)
624 ));
625 }
626 html.push_str("</tbody>\n</table>\n");
627 html
628 } else {
629 let mut html = String::from("<ul class=\"hm-index\">\n");
630 for idx in &indices_with_attachments {
631 let email = &store.emails[*idx];
632 html.push_str(&format!(
633 " <li>{}</li>\n",
634 render_index_row(email, config, IndexType::Date, &i18n)
635 ));
636 }
637 html.push_str("</ul>\n");
638 html
639 };
640
641 let top = render_archive_stats_top(store, config, IndexType::Attachment, &i18n);
642 let bottom = render_archive_stats_bottom(store, config, IndexType::Attachment, &i18n);
643 set_cookie(&mut cookies, "ARTICLE", &format!("{}{}{}", top, list, bottom));
644 render_index_page(config, &cookies)
645}
646
647pub fn print_folders_index(store: &EmailStore, config: &Config) -> Result<String> {
651 let i18n = I18n::new(&config.language);
652 let title =
653 format!("{} — {}", config.label.as_deref().unwrap_or("Archive"), i18n.get("Folders"));
654 let mut cookies = get_header_cookies(config, &title);
655
656 let mut folder_map: BTreeMap<String, Vec<usize>> = BTreeMap::new();
658 for (idx, email) in store.emails.iter().enumerate() {
659 let subdir = msg_subdir(email, config)
660 .map(|s| s.subdir.trim_end_matches('/').to_string())
661 .unwrap_or_default();
662 folder_map.entry(subdir).or_default().push(idx);
663 }
664
665 let suffix = &config.htmlsuffix;
666 let mut body = String::from("<ul class=\"hm-folders\">\n");
667
668 let ordered: Vec<_> = if config.reverse_folders {
669 folder_map.iter().rev().collect()
670 } else {
671 folder_map.iter().collect()
672 };
673
674 for (folder, indices) in &ordered {
675 let count = indices.len();
676 let min_date = indices.iter().map(|&i| store.emails[i].date).min().unwrap_or(0);
677 let max_date = indices.iter().map(|&i| store.emails[i].date).max().unwrap_or(0);
678 let min_str = get_date_str(
679 min_date,
680 config.dateformat.as_deref(),
681 config.gmtime,
682 config.eurodate,
683 config.isodate,
684 &config.language,
685 );
686 let max_str = get_date_str(
687 max_date,
688 config.dateformat.as_deref(),
689 config.gmtime,
690 config.eurodate,
691 config.isodate,
692 &config.language,
693 );
694 let label = if folder.is_empty() {
695 "(root)".to_string()
696 } else {
697 folder.to_string()
698 };
699 let index_href = if folder.is_empty() {
700 format!("index.{}", suffix)
701 } else {
702 format!("{}/index.{}", folder, suffix)
703 };
704 body.push_str(&format!(
705 " <li><a href=\"{}\">{}</a> — {} messages ({} – {})</li>\n",
706 escape_html(&index_href),
707 escape_html(&label),
708 count,
709 escape_html(&min_str),
710 escape_html(&max_str),
711 ));
712 }
713 body.push_str("</ul>\n");
714
715 let top = render_archive_stats_top(store, config, IndexType::Folders, &i18n);
716 set_cookie(&mut cookies, "ARTICLE", &format!("{}{}", top, body));
717 render_index_page(config, &cookies)
718}
719
720pub fn print_folder_index_set(
725 store: &EmailStore,
726 config: &Config,
727) -> Result<Vec<(String, String)>> {
728 let mut folder_map: BTreeMap<String, Vec<usize>> = BTreeMap::new();
729 for (idx, email) in store.emails.iter().enumerate() {
730 let subdir = msg_subdir(email, config)
731 .map(|s| s.subdir.trim_end_matches('/').to_string())
732 .unwrap_or_default();
733 folder_map.entry(subdir).or_default().push(idx);
734 }
735
736 let mut results: Vec<(String, String)> = Vec::new();
737 let suffix = &config.htmlsuffix;
738
739 for (folder, indices) in &folder_map {
740 let mut sub_store = EmailStore::new();
742 for &idx in indices {
743 let e = store.emails[idx].clone();
744 let new_idx = sub_store.add_email(e);
745 sub_store.insert_into_date_list(new_idx);
746 sub_store.insert_into_subject_list(new_idx);
747 sub_store.insert_into_author_list(new_idx);
748 }
749 let msgnums: std::collections::HashSet<i32> =
751 sub_store.emails.iter().map(|e| e.msgnum).collect();
752 for r in &store.replylist {
753 if msgnums.contains(&r.from_msgnum) && msgnums.contains(&r.msgnum) {
754 sub_store.replylist.push(r.clone());
755 }
756 }
757
758 let prefix = if folder.is_empty() {
759 String::new()
760 } else {
761 format!("{}/", folder)
762 };
763
764 let date_html = print_date_index(&sub_store, config)?;
766 results.push((format!("{}index.{}", prefix, suffix), date_html));
767
768 let subj_html = print_subject_index(&sub_store, config)?;
769 results.push((format!("{}subject.{}", prefix, suffix), subj_html));
770
771 let auth_html = print_author_index(&sub_store, config)?;
772 results.push((format!("{}author.{}", prefix, suffix), auth_html));
773
774 let thread_html = print_thread_index(&sub_store, config)?;
775 results.push((format!("{}thread.{}", prefix, suffix), thread_html));
776 }
777
778 Ok(results)
779}
780
781pub fn get_index_filename(config: &Config) -> String {
783 format!("index.{}", config.htmlsuffix)
784}
785
786pub fn get_index_path(config: &Config) -> PathBuf {
788 let dir = config.dir.as_deref().unwrap_or(".");
789 PathBuf::from(dir).join(get_index_filename(config))
790}
791
792pub fn print_monthly_index(store: &EmailStore, config: &Config) -> Result<Vec<(String, String)>> {
794 use std::collections::BTreeMap;
795 let mut month_map: BTreeMap<String, Vec<usize>> = BTreeMap::new();
796
797 for idx in store.traverse_date_list() {
798 let email = &store.emails[idx];
799 let key = if config.gmtime {
800 let ts = chrono::Utc.timestamp_opt(email.date, 0).unwrap();
801 ts.format("%Y-%m").to_string()
802 } else {
803 let ts = chrono::Local.timestamp_opt(email.date, 0).unwrap();
804 ts.format("%Y-%m").to_string()
805 };
806 month_map.entry(key).or_default().push(idx);
807 }
808
809 let mut results = Vec::new();
810 for (month, indices) in &month_map {
811 let title = format!("{} - {}", config.label.as_deref().unwrap_or("Archive"), month);
812 let mut cookies = get_header_cookies(config, &title);
813 let body = if config.reverse {
814 let rev: Vec<usize> = indices.iter().rev().cloned().collect();
815 render_flat_index(&rev, store, config, IndexType::Date)
816 } else {
817 render_flat_index(indices, store, config, IndexType::Date)
818 };
819 set_cookie(&mut cookies, "ARTICLE", &body);
820 let html = render_index_page(config, &cookies)?;
821 let filename = format!("{}.{}", month, config.htmlsuffix);
822 results.push((filename, html));
823 }
824 Ok(results)
825}
826
827pub fn print_yearly_index(store: &EmailStore, config: &Config) -> Result<Vec<(String, String)>> {
829 use std::collections::BTreeMap;
830 let mut year_map: BTreeMap<String, Vec<usize>> = BTreeMap::new();
831
832 for idx in store.traverse_date_list() {
833 let email = &store.emails[idx];
834 let key = if config.gmtime {
835 let ts = chrono::Utc.timestamp_opt(email.date, 0).unwrap();
836 ts.format("%Y").to_string()
837 } else {
838 let ts = chrono::Local.timestamp_opt(email.date, 0).unwrap();
839 ts.format("%Y").to_string()
840 };
841 year_map.entry(key).or_default().push(idx);
842 }
843
844 let mut results = Vec::new();
845 for (year, indices) in &year_map {
846 let title = format!("{} - {}", config.label.as_deref().unwrap_or("Archive"), year);
847 let mut cookies = get_header_cookies(config, &title);
848 let body = if config.reverse {
849 let rev: Vec<usize> = indices.iter().rev().cloned().collect();
850 render_flat_index(&rev, store, config, IndexType::Date)
851 } else {
852 render_flat_index(indices, store, config, IndexType::Date)
853 };
854 set_cookie(&mut cookies, "ARTICLE", &body);
855 let html = render_index_page(config, &cookies)?;
856 let filename = format!("year-{}.{}", year, config.htmlsuffix);
857 results.push((filename, html));
858 }
859 Ok(results)
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865 use crate::config::Config;
866 use crate::message::EmailInfo;
867
868 fn make_store() -> EmailStore {
869 let mut store = EmailStore::new();
870 let e1 = EmailInfo {
871 msgnum: 1,
872 name: Some("Alice".to_string()),
873 email_addr: Some("alice@example.com".to_string()),
874 subject: Some("Hello".to_string()),
875 date: 1000,
876 ..Default::default()
877 };
878 let e2 = EmailInfo {
879 msgnum: 2,
880 name: Some("Bob".to_string()),
881 email_addr: Some("bob@example.com".to_string()),
882 subject: Some("Re: Hello".to_string()),
883 date: 2000,
884 ..Default::default()
885 };
886 store.add_email(e1);
887 store.add_email(e2);
888 store.insert_into_date_list(0);
889 store.insert_into_date_list(1);
890 store.insert_into_subject_list(0);
891 store.insert_into_subject_list(1);
892 store.insert_into_author_list(0);
893 store.insert_into_author_list(1);
894 store
895 }
896
897 #[test]
898 fn test_date_index() {
899 let store = make_store();
900 let config = Config::default();
901 let html = print_date_index(&store, &config).unwrap();
902 assert!(html.contains("Alice"));
903 assert!(html.contains("Hello"));
904 assert!(html.contains("<strong>Hello</strong>"), "subject should be wrapped in <strong>");
906 assert!(html.contains("<em>Alice"), "author should be inside <em>");
907 assert!(html.contains("<small>"), "date should be inside <small>");
908 assert!(html.contains("<span id="), "each row should have a named anchor");
909 assert!(!html.contains("> - <"), "old dash-separated format should not appear");
910 }
911
912 #[test]
913 fn test_subject_index() {
914 let store = make_store();
915 let config = Config::default();
916 let html = print_subject_index(&store, &config).unwrap();
917 assert!(html.contains("Alice"));
918 assert!(html.contains("Hello"));
919 }
920
921 #[test]
922 fn test_author_index() {
923 let store = make_store();
924 let config = Config::default();
925 let html = print_author_index(&store, &config).unwrap();
926 assert!(html.contains("Alice"));
927 assert!(html.contains("Bob"));
928 }
929
930 #[test]
931 fn test_get_index_filename() {
932 let config = Config::default();
933 assert_eq!(get_index_filename(&config), "index.html");
934 }
935
936 #[test]
937 fn test_yearly_index_filename_prefixed() {
938 let store = make_store();
939 let config = Config::default();
940 let results = print_yearly_index(&store, &config).unwrap();
941 for (filename, _) in &results {
942 assert!(
943 filename.starts_with("year-"),
944 "yearly filename should be prefixed: {}",
945 filename
946 );
947 assert!(
948 filename.ends_with(".html"),
949 "yearly filename should end with .html: {}",
950 filename
951 );
952 }
953 }
954
955 #[test]
956 fn test_yearly_index_no_collision_with_msgnum() {
957 let store = make_store();
958 let config = Config::default();
959 let results = print_yearly_index(&store, &config).unwrap();
960 for (filename, _) in &results {
963 assert!(
964 !filename.chars().all(|c| c == '.' || c.is_ascii_digit()),
965 "filename should not be just digits + suffix: {}",
966 filename
967 );
968 }
969 }
970
971 #[test]
972 fn test_archive_stats_top_contains_starting_ending() {
973 let store = make_store();
974 let config = Config::default();
975 let i18n = I18n::new("en");
976 let html = render_archive_stats_top(&store, &config, IndexType::Date, &i18n);
977 assert!(html.contains("Starting:"), "should have Starting label");
978 assert!(html.contains("Ending:"), "should have Ending label");
979 assert!(html.contains("2 messages sorted by:"), "should show message count");
980 }
981
982 #[test]
983 fn test_archive_stats_bottom_contains_last_and_archived() {
984 let store = make_store();
985 let config = Config::default();
986 let i18n = I18n::new("en");
987 let html = render_archive_stats_bottom(&store, &config, IndexType::Date, &i18n);
988 assert!(html.contains("Last message date:"), "should have last message date label");
989 assert!(html.contains("Archived on:"), "should have archived-on label");
990 assert!(html.contains("2 messages sorted by:"), "should show message count");
991 }
992
993 #[test]
994 fn test_archive_stats_contains_about_link() {
995 let store = make_store();
996 let mut config = Config::default();
997 config.about = Some("https://example.com/about".to_string());
998 let i18n = I18n::new("en");
999 let top = render_archive_stats_top(&store, &config, IndexType::Date, &i18n);
1000 assert!(top.contains("https://example.com/about"), "should include about link");
1001 assert!(top.contains("About this archive"), "should include about label");
1002 }
1003
1004 #[test]
1005 fn test_archive_stats_no_about_when_not_configured() {
1006 let store = make_store();
1007 let config = Config::default();
1008 let i18n = I18n::new("en");
1009 let top = render_archive_stats_top(&store, &config, IndexType::Date, &i18n);
1010 assert!(
1011 !top.contains("About this archive"),
1012 "should not include about when unconfigured"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_index_uses_email_addr_when_no_name() {
1018 let mut store = EmailStore::new();
1019 let e = EmailInfo {
1020 msgnum: 1,
1021 name: None,
1022 email_addr: Some("noreply@example.com".to_string()),
1023 subject: Some("Test".to_string()),
1024 date: 1000,
1025 ..Default::default()
1026 };
1027 store.add_email(e);
1028 store.insert_into_date_list(0);
1029 store.insert_into_subject_list(0);
1030 store.insert_into_author_list(0);
1031 let config = Config::default();
1032 let html = print_date_index(&store, &config).unwrap();
1033 assert!(
1034 html.contains("noreply@example.com"),
1035 "should show email address when name is absent"
1036 );
1037 assert!(
1038 !html.contains("Unknown"),
1039 "should not show 'Unknown' fallback when email is available"
1040 );
1041 }
1042
1043 #[test]
1044 fn test_index_date_sorted_index_has_stats_blocks() {
1045 let store = make_store();
1046 let config = Config::default();
1047 let html = print_date_index(&store, &config).unwrap();
1048 assert!(html.contains("Starting:"), "date index should contain Starting:");
1050 assert!(
1051 html.contains("Last message date:"),
1052 "date index should contain Last message date:"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_no_subject_shows_locale_fallback() {
1058 let mut store = EmailStore::new();
1059 let e = EmailInfo {
1060 msgnum: 1,
1061 name: Some("Alice".to_string()),
1062 email_addr: Some("alice@example.com".to_string()),
1063 subject: None,
1064 date: 1000,
1065 ..Default::default()
1066 };
1067 store.add_email(e);
1068 store.insert_into_date_list(0);
1069 store.insert_into_subject_list(0);
1070 store.insert_into_author_list(0);
1071 let config = Config::default();
1072 let html = print_date_index(&store, &config).unwrap();
1073 assert!(html.contains("(no subject)"), "None subject should render as '(no subject)'");
1074 }
1075
1076 fn make_store_with_attachment() -> EmailStore {
1081 use crate::message::{Body, BodyChain};
1082 let mut store = EmailStore::new();
1083 let e1 = EmailInfo {
1084 msgnum: 1,
1085 name: Some("Alice".to_string()),
1086 subject: Some("Has attachment".to_string()),
1087 date: 1000,
1088 bodylist: BodyChain {
1089 bodies: vec![Body {
1090 line: "file.pdf".to_string(),
1091 html: false,
1092 header: false,
1093 parsed_header: false,
1094 attached: true,
1095 demimed: false,
1096 msgnum: 1,
1097 }],
1098 },
1099 ..Default::default()
1100 };
1101 let e2 = EmailInfo {
1102 msgnum: 2,
1103 name: Some("Bob".to_string()),
1104 subject: Some("No attachment".to_string()),
1105 date: 2000,
1106 ..Default::default()
1107 };
1108 store.add_email(e1);
1109 store.add_email(e2);
1110 store.insert_into_date_list(0);
1111 store.insert_into_date_list(1);
1112 store.insert_into_subject_list(0);
1113 store.insert_into_subject_list(1);
1114 store.insert_into_author_list(0);
1115 store.insert_into_author_list(1);
1116 store
1117 }
1118
1119 #[test]
1120 fn test_attachment_index_includes_message_with_attachment() {
1121 let store = make_store_with_attachment();
1122 let config = Config::default();
1123 let html = print_attachment_index(&store, &config).unwrap();
1124 assert!(html.contains("Has attachment"), "should list message with attachment");
1125 }
1126
1127 #[test]
1128 fn test_attachment_index_excludes_plain_message() {
1129 let store = make_store_with_attachment();
1130 let config = Config::default();
1131 let html = print_attachment_index(&store, &config).unwrap();
1132 assert!(!html.contains("No attachment"), "should NOT list message without attachment");
1133 }
1134
1135 #[test]
1136 fn test_attachment_index_empty_when_no_attachments() {
1137 let store = make_store(); let config = Config::default();
1139 let html = print_attachment_index(&store, &config).unwrap();
1140 assert!(html.contains("<ul class=\"hm-index\">"), "should render the list container");
1142 assert!(!html.contains("<li>"), "empty attachment index should have no list items");
1143 }
1144
1145 #[test]
1146 fn test_attachment_index_indextable_mode() {
1147 let store = make_store_with_attachment();
1148 let mut config = Config::default();
1149 config.indextable = true;
1150 let html = print_attachment_index(&store, &config).unwrap();
1151 assert!(html.contains("<table"), "indextable mode should use <table>");
1152 assert!(html.contains("Has attachment"));
1153 }
1154
1155 fn make_foldered_store() -> (EmailStore, Config) {
1160 let mut store = EmailStore::new();
1161 let e1 = EmailInfo {
1163 msgnum: 1,
1164 name: Some("Alice".to_string()),
1165 subject: Some("Jan message".to_string()),
1166 date: 1706745600, ..Default::default()
1168 };
1169 let e2 = EmailInfo {
1170 msgnum: 2,
1171 name: Some("Bob".to_string()),
1172 subject: Some("Mar message".to_string()),
1173 date: 1709251200, ..Default::default()
1175 };
1176 store.add_email(e1);
1177 store.add_email(e2);
1178 store.insert_into_date_list(0);
1179 store.insert_into_date_list(1);
1180 store.insert_into_subject_list(0);
1181 store.insert_into_subject_list(1);
1182 store.insert_into_author_list(0);
1183 store.insert_into_author_list(1);
1184 let mut config = Config::default();
1185 config.folder_by_date = Some("%Y-%m".to_string());
1186 config.gmtime = true; (store, config)
1188 }
1189
1190 #[test]
1191 fn test_folders_index_contains_folder_links() {
1192 let (store, config) = make_foldered_store();
1193 let html = print_folders_index(&store, &config).unwrap();
1194 assert!(html.contains("2024-"), "should list year-month folder names");
1195 }
1196
1197 #[test]
1198 fn test_folders_index_shows_message_count() {
1199 let (store, config) = make_foldered_store();
1200 let html = print_folders_index(&store, &config).unwrap();
1201 assert!(html.contains("1 messages"), "each folder should show its message count");
1202 }
1203
1204 #[test]
1205 fn test_folders_index_links_to_subfolder_index() {
1206 let (store, config) = make_foldered_store();
1207 let html = print_folders_index(&store, &config).unwrap();
1208 assert!(html.contains("/index.html"), "folder link should point to subfolder index.html");
1209 }
1210
1211 #[test]
1216 fn test_folder_index_set_returns_four_pages_per_folder() {
1217 let (store, config) = make_foldered_store();
1218 let pages = print_folder_index_set(&store, &config).unwrap();
1219 assert_eq!(pages.len(), 8, "should generate 4 index pages per folder");
1221 }
1222
1223 #[test]
1224 fn test_folder_index_set_path_contains_folder_name() {
1225 let (store, config) = make_foldered_store();
1226 let pages = print_folder_index_set(&store, &config).unwrap();
1227 let paths: Vec<&str> = pages.iter().map(|(p, _)| p.as_str()).collect();
1228 assert!(
1229 paths.iter().any(|p| p.contains("2024-") && p.contains("index.html")),
1230 "paths should include folder-prefixed index.html; got: {:?}",
1231 paths
1232 );
1233 }
1234
1235 #[test]
1236 fn test_folder_index_set_each_contains_only_folder_messages() {
1237 let (store, config) = make_foldered_store();
1238 let pages = print_folder_index_set(&store, &config).unwrap();
1239 let folder_indexes: Vec<_> =
1241 pages.iter().filter(|(p, _)| p.ends_with("index.html")).collect();
1242 for (path, html) in &folder_indexes {
1243 let has_jan = html.contains("Jan message");
1245 let has_mar = html.contains("Mar message");
1246 assert!(
1247 !(has_jan && has_mar),
1248 "folder index {} should not contain messages from both folders",
1249 path
1250 );
1251 }
1252 }
1253
1254 #[test]
1259 fn test_index_href_date_is_index_when_defaultindex_date() {
1260 let config = Config::default(); assert_eq!(index_href(IndexType::Date, &config), "index.html");
1262 }
1263
1264 #[test]
1265 fn test_index_href_date_is_date_html_when_defaultindex_subject() {
1266 let mut config = Config::default();
1267 config.defaultindex = "subject".to_string();
1268 assert_eq!(index_href(IndexType::Date, &config), "date.html");
1269 }
1270
1271 #[test]
1272 fn test_index_href_subject_is_index_when_defaultindex_subject() {
1273 let mut config = Config::default();
1274 config.defaultindex = "subject".to_string();
1275 assert_eq!(index_href(IndexType::Subject, &config), "index.html");
1276 }
1277
1278 #[test]
1279 fn test_index_href_author_is_index_when_defaultindex_author() {
1280 let mut config = Config::default();
1281 config.defaultindex = "author".to_string();
1282 assert_eq!(index_href(IndexType::Author, &config), "index.html");
1283 }
1284
1285 #[test]
1286 fn test_index_href_thread_is_index_when_defaultindex_thread() {
1287 let mut config = Config::default();
1288 config.defaultindex = "thread".to_string();
1289 assert_eq!(index_href(IndexType::Thread, &config), "index.html");
1290 }
1291
1292 #[test]
1293 fn test_index_href_non_default_types_use_named_files() {
1294 let config = Config::default(); assert_eq!(index_href(IndexType::Subject, &config), "subject.html");
1296 assert_eq!(index_href(IndexType::Author, &config), "author.html");
1297 assert_eq!(index_href(IndexType::Thread, &config), "thread.html");
1298 assert_eq!(index_href(IndexType::Attachment, &config), "attachment.html");
1299 }
1300
1301 #[test]
1302 fn test_nav_links_current_page_shown_as_plain_text() {
1303 let store = make_store();
1304 let config = Config::default();
1305 let i18n = I18n::new("en");
1306 let nav = render_nav_links(&store, &config, IndexType::Date, &i18n);
1307 assert!(
1309 !nav.contains("<a href=\"index.html\">Date Index</a>"),
1310 "current page should not be a link"
1311 );
1312 assert!(nav.contains("[ Date Index ]"), "current page should appear as plain text");
1313 }
1314
1315 #[test]
1316 fn test_nav_links_other_pages_are_links() {
1317 let store = make_store();
1318 let config = Config::default(); let i18n = I18n::new("en");
1320 let nav = render_nav_links(&store, &config, IndexType::Date, &i18n);
1321 assert!(nav.contains("href=\"subject.html\""), "subject link should be present");
1323 assert!(nav.contains("href=\"author.html\""), "author link should be present");
1324 assert!(nav.contains("href=\"thread.html\""), "thread link should be present");
1325 }
1326
1327 #[test]
1328 fn test_nav_links_subject_defaultindex_uses_index_html() {
1329 let store = make_store();
1330 let mut config = Config::default();
1331 config.defaultindex = "subject".to_string();
1332 let i18n = I18n::new("en");
1333 let nav = render_nav_links(&store, &config, IndexType::Author, &i18n);
1335 assert!(
1337 nav.contains("href=\"index.html\""),
1338 "subject defaultindex should link to index.html"
1339 );
1340 assert!(
1342 nav.contains("href=\"date.html\""),
1343 "date link should be date.html when it is not defaultindex"
1344 );
1345 }
1346
1347 #[test]
1348 fn test_nav_links_attachment_hidden_when_no_attachments() {
1349 let store = make_store(); let mut config = Config::default();
1351 config.attachmentsindex = true;
1352 let i18n = I18n::new("en");
1353 let nav = render_nav_links(&store, &config, IndexType::Date, &i18n);
1354 assert!(
1355 !nav.contains("attachment"),
1356 "attachment nav link should be absent when no attachments exist"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_nav_links_attachment_shown_when_attachments_exist() {
1362 let store = make_store_with_attachment();
1363 let mut config = Config::default();
1364 config.attachmentsindex = true;
1365 let i18n = I18n::new("en");
1366 let nav = render_nav_links(&store, &config, IndexType::Date, &i18n);
1367 assert!(
1368 nav.contains("attachment.html"),
1369 "attachment nav link should be present when attachments exist"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_nav_links_attachment_hidden_when_attachmentsindex_off() {
1375 let store = make_store_with_attachment();
1376 let mut config = Config::default();
1377 config.attachmentsindex = false;
1378 let i18n = I18n::new("en");
1379 let nav = render_nav_links(&store, &config, IndexType::Date, &i18n);
1380 assert!(
1381 !nav.contains("attachment"),
1382 "attachment nav link should be absent when attachmentsindex=false"
1383 );
1384 }
1385
1386 #[test]
1391 fn test_subject_index_re_replies_sort_with_originals() {
1392 let mut store = EmailStore::new();
1393
1394 let mut alpha = EmailInfo {
1395 msgnum: 1,
1396 name: Some("Alice".to_string()),
1397 subject: Some("Alpha".to_string()),
1398 date: 1000,
1399 ..Default::default()
1400 };
1401 alpha.unre_subject = Some("alpha".to_string());
1402
1403 let mut re_alpha = EmailInfo {
1404 msgnum: 2,
1405 name: Some("Bob".to_string()),
1406 subject: Some("Re: Alpha".to_string()),
1407 date: 2000,
1408 ..Default::default()
1409 };
1410 re_alpha.unre_subject = Some("alpha".to_string());
1411
1412 let mut zebra = EmailInfo {
1413 msgnum: 3,
1414 name: Some("Carol".to_string()),
1415 subject: Some("Zebra".to_string()),
1416 date: 3000,
1417 ..Default::default()
1418 };
1419 zebra.unre_subject = Some("zebra".to_string());
1420
1421 store.add_email(alpha);
1422 store.add_email(re_alpha);
1423 store.add_email(zebra);
1424 store.insert_into_subject_list(0);
1425 store.insert_into_subject_list(1);
1426 store.insert_into_subject_list(2);
1427
1428 let config = Config::default();
1429 let html = print_subject_index(&store, &config).unwrap();
1430
1431 let alpha_pos = html.find("Alpha").unwrap();
1433 let zebra_pos = html.find("Zebra").unwrap();
1434 assert!(zebra_pos > alpha_pos, "Zebra should sort after Alpha/Re:Alpha");
1435 }
1436
1437 #[test]
1442 fn test_author_index_no_name_sorted_by_email() {
1443 let mut store = EmailStore::new();
1444
1445 let e_zoe = EmailInfo {
1446 msgnum: 1,
1447 name: Some("Zoe".to_string()),
1448 email_addr: Some("zoe@example.com".to_string()),
1449 subject: Some("From Zoe".to_string()),
1450 date: 1000,
1451 ..Default::default()
1452 };
1453 let e_no_name = EmailInfo {
1454 msgnum: 2,
1455 name: None,
1456 email_addr: Some("amy@example.com".to_string()),
1457 subject: Some("From Amy".to_string()),
1458 date: 2000,
1459 ..Default::default()
1460 };
1461 store.add_email(e_zoe);
1462 store.add_email(e_no_name);
1463 store.insert_into_author_list(0);
1464 store.insert_into_author_list(1);
1465
1466 let config = Config::default();
1467 let html = print_author_index(&store, &config).unwrap();
1468
1469 let amy_pos = html.find("From Amy").unwrap();
1471 let zoe_pos = html.find("From Zoe").unwrap();
1472 assert!(amy_pos < zoe_pos, "amy@ should sort before Zoe");
1473 }
1474}