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