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