Skip to main content

hypermail/
index.rs

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
20/// Generates the date-sorted index page HTML.
21pub 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
40/// Generates the subject-sorted index page HTML.
41pub 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
60/// Generates the author-sorted index page HTML.
61pub 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
80/// Generates the threaded discussion index page HTML.
81pub 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
94/// Returns (first_date, last_date, count) across all emails in the store.
95fn 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
105/// Renders the top summary block shown above the message list:
106///   N messages sorted by: [author][date][subject][attachment]
107///   Starting: <date>  Ending: <date>
108///   About this archive  (if config.about is set)
109/// Returns the href for a given index type, using `index.{suffix}` for whichever
110/// type is the configured `defaultindex` and the canonical named file otherwise.
111fn 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
134/// Builds the nav link string: `[ Author ] [ Date ] [ Subject ] [ Thread ] [ Attachment ]`
135/// The current page type is shown as plain text; others are hyperlinks using `index_href`.
136fn 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
224/// Renders the bottom summary block shown below the message list:
225///   Last message date: <date>
226///   Archived on: <now>
227///   N messages sorted by: [author][date][subject][attachment]
228///   About this archive  (if config.about is set)
229fn 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    // Walk ALL messages in date order (matching original hypermail threadlist behaviour).
412    // For each message not yet printed, start a thread tree.  This ensures that
413    // standalone messages (no parent, no replies) appear as single top-level items,
414    // and that reply trees are only rendered once — rooted at their earliest ancestor.
415    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        // Only start a tree from root messages (no parent in the archive).
428        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    // Catch any messages that were somehow missed (shouldn't normally happen).
436    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    // Date string: use indexdateformat if set (non-empty), else dateformat — matching
488    // original hypermail's getindexdatestr().
489    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    // Format matches flat index style: bold subject, em author, small date
504    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    // Find all direct replies to this message.
520    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        // External templates: use printfile-style %x substitution.
546        // The ARTICLE body content sits between the header and footer.
547        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        // Internal default templates: use %COOKIE_NAME% substitution.
593        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
613/// Generates `attachment.html` — an index of messages that have at least one
614/// attached MIME part.  Mirrors the original C hypermail `writeattachments()`.
615pub 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    // Collect only messages that have at least one attached body part.
622    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
658/// Generates `folders.html` — a top-level directory listing used when
659/// `folder_by_date` or `msgsperfolder` is configured.
660/// Mirrors the C hypermail `write_toplevel_indices()` folders page.
661pub 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    // Group email indices by their subdirectory name (preserving insertion order).
668    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
731/// Generates per-folder index pages (date / subject / author / thread) for each
732/// subdirectory created by `folder_by_date` or `msgsperfolder`.
733///
734/// Returns `(relative_path, html)` pairs — the caller writes them to disk.
735pub 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        // Build a mini-store containing only this folder's messages.
752        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        // Copy reply relationships that are entirely within this folder.
761        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        // date  → index.html  (the "default" for the folder)
776        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
792/// Returns the default index filename (e.g., `"index.html"`).
793pub fn get_index_filename(config: &Config) -> String {
794    format!("index.{}", config.htmlsuffix)
795}
796
797/// Returns the full filesystem path for the main index file.
798pub 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
803/// Generates per-month index pages, returning `(filename, html)` pairs.
804pub 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
842/// Generates per-year index pages, returning `(filename, html)` pairs.
843pub 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        // Subject must be in a <strong> inside the link; author in <em> with date in <small>
924        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        // Yearly index for "1970" should be "year-1970.html", not "1970.html"
980        // which could collide with msgnum 1970's message file
981        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        // Both top stats and bottom stats should appear
1068        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    // -----------------------------------------------------------------------
1096    // print_attachment_index
1097    // -----------------------------------------------------------------------
1098
1099    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(); // make_store() emails have no attached bodies
1157        let config = Config::default();
1158        let html = print_attachment_index(&store, &config).unwrap();
1159        // The message list should be an empty <ul> with no <li> items.
1160        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    // -----------------------------------------------------------------------
1175    // print_folders_index
1176    // -----------------------------------------------------------------------
1177
1178    fn make_foldered_store() -> (EmailStore, Config) {
1179        let mut store = EmailStore::new();
1180        // Two messages with different dates → different folders under %Y-%m
1181        let e1 = EmailInfo {
1182            msgnum: 1,
1183            name: Some("Alice".to_string()),
1184            subject: Some("Jan message".to_string()),
1185            date: 1706745600, // 2024-02-01 00:00:00 UTC
1186            ..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, // 2024-03-01 00:00:00 UTC
1193            ..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; // deterministic folder names in any TZ
1206        (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    // -----------------------------------------------------------------------
1231    // print_folder_index_set
1232    // -----------------------------------------------------------------------
1233
1234    #[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        // 2 folders × 4 pages (index, subject, author, thread) = 8
1239        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        // Each folder's date index should contain only its own message, not both.
1259        let folder_indexes: Vec<_> =
1260            pages.iter().filter(|(p, _)| p.ends_with("index.html")).collect();
1261        for (path, html) in &folder_indexes {
1262            // Both messages should NOT appear in the same subfolder index.
1263            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    // -----------------------------------------------------------------------
1274    // index_href / render_nav_links — nav link routing
1275    // -----------------------------------------------------------------------
1276
1277    #[test]
1278    fn test_index_href_date_is_index_when_defaultindex_date() {
1279        let config = Config::default(); // defaultindex = "date"
1280        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(); // defaultindex = "date"
1314        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        // Current page (Date) must not be a link
1327        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(); // defaultindex=date
1338        let i18n = I18n::new("en");
1339        let nav = render_nav_links(&store, &config, IndexType::Date, &i18n);
1340        // Non-current pages should be links
1341        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        // View from the Author page so both Date and Subject links are rendered as <a>.
1353        let nav = render_nav_links(&store, &config, IndexType::Author, &i18n);
1354        // Subject is the defaultindex so its link should be index.html
1355        assert!(
1356            nav.contains("href=\"index.html\""),
1357            "subject defaultindex should link to index.html"
1358        );
1359        // Date is not defaultindex so its link should be date.html
1360        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(); // no attached bodies
1369        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    // -----------------------------------------------------------------------
1406    // Subject index sort order
1407    // -----------------------------------------------------------------------
1408
1409    #[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        // "Zebra" should appear after both "Alpha" entries
1451        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    // -----------------------------------------------------------------------
1457    // Author index sort order
1458    // -----------------------------------------------------------------------
1459
1460    #[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        // "amy@example.com" sorts before "Zoe" → Amy's message should appear first
1489        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}