Skip to main content

hypermail/
file_utils.rs

1use crate::config::Config;
2use crate::message::EmailInfo;
3use chrono::{Local, TimeZone, Utc};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Apply configured permissions to a path (Unix only).
8#[cfg(unix)]
9pub fn apply_permissions(path: &Path, mode: i32) {
10    use std::os::unix::fs::PermissionsExt;
11    let perms = std::fs::Permissions::from_mode(mode as u32);
12    if let Err(e) = std::fs::set_permissions(path, perms) {
13        log::debug!("Failed to set permissions on {:?}: {}", path, e);
14    }
15}
16
17/// Apply configured permissions to a path (no-op on non-Unix platforms).
18#[cfg(not(unix))]
19pub fn apply_permissions(_path: &Path, _mode: i32) {
20    // Permissions are Unix-only; no-op on other platforms
21}
22
23/// Generates a unique name for an email message.
24///
25/// Uses either sequential numbering (default) or content-based hashing
26/// when `config.nonsequential` is enabled.
27///
28/// # Arguments
29///
30/// * `email` - The email to generate a name for
31/// * `config` - Configuration determining naming scheme
32///
33/// # Returns
34///
35/// - Sequential: `"0001"`, `"0042"`, etc. (4 digits, zero-padded)
36/// - Hashed: `"a3f2c891be4f3210"` (16 hex digits from FNV32 hash)
37///
38/// # Security
39///
40/// The hashed mode uses FNV-1a to prevent predictable filenames, which
41/// could be used to guess Message-IDs. The hash includes both message ID
42/// and timestamp for uniqueness.
43pub fn message_name(email: &EmailInfo, config: &Config) -> String {
44    if config.nonsequential {
45        if let Some(ref msgid) = email.msgid {
46            let hash = fnv32(msgid.as_bytes(), email.from_date);
47            return format!("{:08x}{:08x}", hash, email.from_date as u32);
48        }
49    }
50    format!("{:04}", email.msgnum)
51}
52
53/// FNV-1a 32-bit hash function.
54///
55/// Computes a hash using the FNV-1a algorithm with an optional seed.
56/// This is used for generating content-based filenames that don't reveal
57/// the original Message-ID.
58///
59/// # Security Note
60///
61/// Uses `wrapping_mul` intentionally as required by the FNV algorithm.
62/// Integer overflow is part of the hash specification and produces
63/// correct hash distribution.
64///
65/// # Arguments
66///
67/// * `buf` - Input bytes to hash
68/// * `seed` - Optional seed value (typically timestamp)
69///
70/// # Returns
71///
72/// 32-bit hash value as u32
73fn fnv32(buf: &[u8], seed: i64) -> u32 {
74    const FNV1_32_INIT: u32 = 0x811c9dc5;
75    const FNV_32_PRIME: u32 = 0x01000193;
76    let mut hash = FNV1_32_INIT;
77    for &b in buf {
78        hash ^= b as u32;
79        hash = hash.wrapping_mul(FNV_32_PRIME);
80    }
81    if seed != 0 {
82        for b in seed.to_le_bytes() {
83            hash ^= b as u32;
84            hash = hash.wrapping_mul(FNV_32_PRIME);
85        }
86    }
87    hash
88}
89
90/// Returns the full filename for a message (name + HTML suffix).
91pub fn message_filename(email: &EmailInfo, config: &Config) -> String {
92    format!("{}.{}", message_name(email, config), config.htmlsuffix)
93}
94
95/// Returns the full filesystem path for a message's HTML file.
96pub fn message_path(email: &EmailInfo, config: &Config) -> PathBuf {
97    let dir = config.dir.as_deref().unwrap_or(".");
98    let sub = msg_subdir(email, config);
99    let base = match sub {
100        Some(ref s) => PathBuf::from(dir).join(&s.subdir),
101        None => PathBuf::from(dir),
102    };
103    base.join(message_filename(email, config))
104}
105
106/// Returns the relative URL string for a message, including subdirectory if applicable.
107pub fn message_url_str(email: &EmailInfo, config: &Config) -> String {
108    let sub = msg_subdir(email, config);
109    let filename = message_filename(email, config);
110    match sub {
111        Some(ref s) => {
112            let subdir = s.subdir.trim_end_matches('/');
113            if subdir.is_empty() {
114                filename
115            } else {
116                format!("{}/{}", subdir, filename)
117            }
118        },
119        None => filename,
120    }
121}
122
123/// Returns the path to the message index file.
124pub fn messageindex_name(config: &Config) -> PathBuf {
125    PathBuf::from(config.dir.as_deref().unwrap_or(".")).join("msgindex")
126}
127
128/// Scans the output directory and returns the highest sequential message number found.
129pub fn find_max_msgnum(config: &Config) -> i32 {
130    let dir = config.dir.as_deref().unwrap_or(".");
131    let suffix = &config.htmlsuffix;
132    let mut max = -1;
133    if let Ok(entries) = fs::read_dir(dir) {
134        for entry in entries.flatten() {
135            let path = entry.path();
136            if let Some(ext) = path.extension() {
137                if ext == suffix.as_str() {
138                    if let Some(stem) = path.file_stem() {
139                        if let Some(s) = stem.to_str() {
140                            if let Ok(n) = s.parse::<i32>() {
141                                if n > max {
142                                    max = n;
143                                }
144                            }
145                        }
146                    }
147                }
148            }
149        }
150    }
151    if max < 0 {
152        -1
153    } else {
154        max
155    }
156}
157
158/// Expands date format placeholders (`%y`, `%m`, `%d`, etc.) in a path template.
159pub fn dirpath(frmptr: &str) -> String {
160    let now = chrono::Local::now();
161    let mut result = String::new();
162    let mut chars = frmptr.chars();
163    while let Some(c) = chars.next() {
164        if c == '%' {
165            match chars.next() {
166                Some('d') => result.push_str(&now.format("%d").to_string()),
167                Some('D') => result.push_str(&now.format("%a").to_string()),
168                Some('j') => result.push_str(&now.format("%j").to_string()),
169                Some('m') => result.push_str(&now.format("%m").to_string()),
170                Some('M') => result.push_str(&now.format("%b").to_string()),
171                Some('y') => result.push_str(&now.format("%Y").to_string()),
172                Some('%') => result.push('%'),
173                Some(c) => {
174                    result.push('%');
175                    result.push(c);
176                },
177                None => result.push('%'),
178            }
179        } else {
180            result.push(c);
181        }
182    }
183    result
184}
185
186/// Information about the subdirectory for a message when using folder-based layouts.
187pub struct EmailSubdirInfo {
188    pub subdir: String,
189    pub full_path: String,
190    pub rel_path_to_top: String,
191    pub description: Option<String>,
192}
193
194/// Determines the subdirectory for a message based on folder configuration.
195pub fn msg_subdir(email: &EmailInfo, config: &Config) -> Option<EmailSubdirInfo> {
196    if config.msgsperfolder > 0 {
197        let subdir_no = email.msgnum / config.msgsperfolder;
198        let sub = format!("{}/", subdir_no);
199        let base = config.dir.as_deref().unwrap_or(".");
200        let full = PathBuf::from(base).join(&sub).to_string_lossy().to_string();
201        let rel = if subdir_no == 0 {
202            "./".to_string()
203        } else {
204            let mut r = String::new();
205            let depth = sub.matches('/').count();
206            for _ in 0..depth {
207                r.push_str("../");
208            }
209            r
210        };
211        return Some(EmailSubdirInfo {
212            subdir: sub,
213            full_path: full,
214            rel_path_to_top: rel,
215            description: None,
216        });
217    }
218    if let Some(ref fbd) = config.folder_by_date {
219        if email.date > 0 {
220            let ts = Utc.timestamp_opt(email.date, 0).unwrap();
221            let sub = if config.gmtime {
222                ts.format(fbd).to_string()
223            } else {
224                ts.with_timezone(&Local).format(fbd).to_string()
225            };
226            // Security: reject path traversal attempts via format string
227            if sub.contains("..") || std::path::Path::new(&sub).is_absolute() {
228                log::warn!("folder_by_date produced suspicious path '{}', using flat layout", sub);
229                return None;
230            }
231            let sub = if !sub.ends_with('/') {
232                format!("{}/", sub)
233            } else {
234                sub
235            };
236            let base = config.dir.as_deref().unwrap_or(".");
237            let full = PathBuf::from(base).join(&sub).to_string_lossy().to_string();
238            let depth = sub.matches('/').count();
239            let rel = if depth == 0 {
240                "./".to_string()
241            } else {
242                let mut r = String::new();
243                for _ in 0..depth {
244                    r.push_str("../");
245                }
246                r
247            };
248            return Some(EmailSubdirInfo {
249                subdir: sub,
250                full_path: full,
251                rel_path_to_top: rel,
252                description: None,
253            });
254        }
255    }
256    None
257}
258
259/// Creates a symlink pointing to the latest folder (if configured).
260pub fn symlink_latest(config: &Config) -> std::io::Result<()> {
261    if let Some(ref latest) = config.latest_folder {
262        let dir = config.dir.as_deref().unwrap_or(".");
263        let link_path = PathBuf::from(dir).join(latest);
264        let _ = fs::remove_file(&link_path);
265        let target = latest_folder_path(config);
266        #[cfg(unix)]
267        std::os::unix::fs::symlink(&target, &link_path)?;
268        #[cfg(windows)]
269        std::os::windows::fs::symlink_dir(&target, &link_path)?;
270    }
271    Ok(())
272}
273
274fn latest_folder_path(config: &Config) -> String {
275    config.dir.as_deref().unwrap_or(".").to_string()
276}
277
278/// Creates a directory and all parent directories if they don't exist.
279pub fn checkdir(path: &str) -> std::io::Result<()> {
280    let p = Path::new(path);
281    if !p.exists() {
282        fs::create_dir_all(p)?;
283    }
284    Ok(())
285}
286
287/// Returns true if the archive directory contains no non-hidden files.
288pub fn is_empty_archive(config: &Config) -> bool {
289    let dir = config.dir.as_deref().unwrap_or(".");
290    if let Ok(entries) = fs::read_dir(dir) {
291        for entry in entries.flatten() {
292            if let Some(name) = entry.file_name().to_str() {
293                if !name.starts_with('.') {
294                    return false;
295                }
296            }
297        }
298    }
299    true
300}
301
302/// Writes the message index file mapping message numbers to filenames.
303pub fn write_messageindex(
304    store: &crate::structs::EmailStore,
305    config: &Config,
306) -> std::io::Result<()> {
307    let path = messageindex_name(config);
308    let mut content = String::new();
309    content.push_str(&format!("{:04} {:04}\n", 0, store.max_msgnum.max(0)));
310    for email in &store.emails {
311        let name = message_name(email, config);
312        content.push_str(&format!("{:04} {}\n", email.msgnum, name));
313    }
314    fs::write(&path, &content)
315}
316
317/// Reads the message index file, returning a table of message number to filename mappings.
318pub fn read_messageindex(config: &Config) -> std::io::Result<Vec<Option<String>>> {
319    let path = messageindex_name(config);
320    let content = fs::read_to_string(&path)?;
321    let mut lines = content.lines();
322    let mut table: Vec<Option<String>> = Vec::new();
323    if let Some(first) = lines.next() {
324        let parts: Vec<&str> = first.split_whitespace().collect();
325        let max_num: i32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
326        table.resize((max_num + 1) as usize, None);
327        for line in lines {
328            let parts: Vec<&str> = line.split_whitespace().collect();
329            if parts.len() >= 2 {
330                if let Ok(num) = parts[0].parse::<i32>() {
331                    if (num as usize) < table.len() {
332                        table[num as usize] = Some(parts[1].to_string());
333                    }
334                }
335            }
336        }
337    }
338    Ok(table)
339}
340
341/// Loads email metadata from existing HTML archive files for incremental updates.
342pub fn load_old_headers_from_html(store: &mut crate::structs::EmailStore, config: &Config) -> i32 {
343    let dir = config.dir.as_deref().unwrap_or(".");
344    let suffix = &config.htmlsuffix;
345    let mut count = 0;
346
347    let mut files = Vec::new();
348    collect_html_files(Path::new(dir), suffix, &mut files);
349    // Also scan subdirectories if using folders
350    if config.msgsperfolder > 0 || config.folder_by_date.is_some() {
351        if let Ok(entries) = fs::read_dir(dir) {
352            for entry in entries.flatten() {
353                let path = entry.path();
354                if path.is_dir() {
355                    collect_html_files(&path, suffix, &mut files);
356                }
357            }
358        }
359    }
360
361    for path in files {
362        let msgnum = if config.nonsequential {
363            if let Ok(content) = fs::read_to_string(&path) {
364                extract_msgnum_from_html(&content, &config.fragment_prefix).unwrap_or(0)
365            } else {
366                0
367            }
368        } else {
369            path.file_stem()
370                .and_then(|s| s.to_str())
371                .and_then(|s| s.parse::<i32>().ok())
372                .unwrap_or(0)
373        };
374        if msgnum == 0 {
375            continue;
376        }
377        if let Ok(content) = fs::read_to_string(&path) {
378            if let Some(email) = parse_old_html_comments(&content, msgnum) {
379                let idx = store.add_email(email);
380                store.insert_into_date_list(idx);
381                store.insert_into_subject_list(idx);
382                store.insert_into_author_list(idx);
383                count += 1;
384            }
385        }
386    }
387    count
388}
389
390fn collect_html_files(dir: &Path, suffix: &str, files: &mut Vec<PathBuf>) {
391    if let Ok(entries) = fs::read_dir(dir) {
392        for entry in entries.flatten() {
393            let path = entry.path();
394            if path.is_file() {
395                if let Some(ext) = path.extension() {
396                    if ext.to_string_lossy() == suffix {
397                        files.push(path);
398                    }
399                }
400            }
401        }
402    }
403}
404
405fn extract_msgnum_from_html(html: &str, fragment_prefix: &str) -> Option<i32> {
406    // Anchor format: <a name="PREFIXmsgnum"> (no separator between prefix and msgnum)
407    let needle_str = format!("<a name=\"{}\"", fragment_prefix);
408    let needle = needle_str.trim_end_matches('"');
409    if let Some(start) = html.find(needle) {
410        let after_prefix = start + needle.len();
411        if let Some(end) = html[after_prefix..].find('"') {
412            let msgnum_str = &html[after_prefix..after_prefix + end];
413            return msgnum_str.parse::<i32>().ok();
414        }
415    }
416    None
417}
418
419fn parse_old_html_comments(html: &str, msgnum: i32) -> Option<EmailInfo> {
420    let mut name = None;
421    let mut email_addr = None;
422    let mut subject = None;
423    let mut msgid = None;
424    let mut inreplyto = None;
425    let mut date_str = None;
426    let mut from_date_str = None;
427    let mut charset = None;
428    let mut is_deleted = 0;
429    let mut found_body = false;
430
431    for line in html.lines() {
432        let line = line.trim();
433        if let Some(val) = extract_comment(line, "received") {
434            from_date_str = Some(val.to_string());
435        } else if let Some(val) = extract_comment(line, "sent") {
436            date_str = Some(val.to_string());
437        } else if let Some(val) = extract_comment(line, "name") {
438            name = Some(val.to_string());
439        } else if let Some(val) = extract_comment(line, "email") {
440            email_addr = Some(val.to_string());
441        } else if let Some(val) = extract_comment(line, "subject") {
442            subject = Some(val.to_string());
443        } else if let Some(val) = extract_comment(line, "id") {
444            msgid = Some(val.to_string());
445        } else if let Some(val) = extract_comment(line, "charset") {
446            charset = Some(val.to_string());
447        } else if let Some(val) = extract_comment(line, "inreplyto") {
448            inreplyto = Some(val.to_string());
449        } else if let Some(val) = extract_comment(line, "isdeleted") {
450            is_deleted = val.parse().unwrap_or(0);
451        } else if let Some(val) = extract_comment(line, "body") {
452            if val == "start" {
453                found_body = true;
454            }
455        }
456    }
457
458    if !found_body && msgid.is_none() {
459        return None;
460    }
461
462    Some(EmailInfo {
463        msgnum,
464        name,
465        email_addr,
466        from_date_str,
467        date_str,
468        subject,
469        msgid,
470        inreplyto,
471        charset,
472        is_deleted,
473        ..Default::default()
474    })
475}
476
477fn extract_comment<'a>(line: &'a str, key: &str) -> Option<&'a str> {
478    let pattern = format!("<!-- {}=\"", key);
479    if let Some(start) = line.find(&pattern) {
480        let val_start = start + pattern.len();
481        if let Some(end) = line[val_start..].find('"') {
482            return Some(&line[val_start..val_start + end]);
483        }
484    }
485    None
486}
487
488/// Returns true if two Message-IDs match (trimmed comparison).
489pub fn matches_existing(msgid: &str, existing_msgid: &str) -> bool {
490    msgid.trim() == existing_msgid.trim()
491}
492
493/// Returns the path to the lock file for this archive.
494pub fn lock_file_name(config: &Config) -> PathBuf {
495    PathBuf::from(config.dir.as_deref().unwrap_or(".")).join(".hm_lock")
496}
497
498/// Acquires an exclusive non-blocking lock on the archive directory.
499pub fn try_lock(config: &Config) -> std::io::Result<fs::File> {
500    let path = lock_file_name(config);
501    let file = fs::OpenOptions::new().write(true).create(true).truncate(true).open(&path)?;
502
503    #[cfg(unix)]
504    {
505        use std::os::unix::io::AsRawFd;
506
507        let fd = file.as_raw_fd();
508
509        // SAFETY: This is a safe FFI call to the POSIX flock() system call.
510        // - `fd` is a valid file descriptor obtained via AsRawFd()
511        // - flock() is a standard POSIX function that cannot cause UB with valid fd
512        // - LOCK_EX | LOCK_NB requests exclusive non-blocking lock (safe flags)
513        // - Return value is checked for errors
514        // - The file is owned by this function and fd remains valid
515        let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
516        if ret != 0 {
517            return Err(std::io::Error::last_os_error());
518        }
519    }
520
521    // Write PID on all platforms (best-effort advisory on non-Unix)
522    use std::io::Write;
523    let _ = write!(&file, "{}", std::process::id());
524
525    Ok(file)
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use crate::config::Config;
532    use crate::message::EmailInfo;
533
534    fn make_email(msgnum: i32, msgid: &str) -> EmailInfo {
535        EmailInfo { msgnum, msgid: Some(msgid.to_string()), date: 1000000, ..Default::default() }
536    }
537
538    #[test]
539    fn test_message_name_sequential() {
540        let config = Config::default();
541        let email = make_email(42, "<a@b>");
542        assert_eq!(message_name(&email, &config), "0042");
543    }
544
545    #[test]
546    fn test_message_name_nonsequential() {
547        let mut config = Config::default();
548        config.nonsequential = true;
549        let email = make_email(42, "<hello@world.com>");
550        let name = message_name(&email, &config);
551        assert_eq!(name.len(), 16);
552        assert!(name.chars().all(|c| c.is_ascii_hexdigit()));
553    }
554
555    #[test]
556    fn test_message_filename() {
557        let config = Config::default();
558        let email = make_email(7, "<a@b>");
559        assert_eq!(message_filename(&email, &config), "0007.html");
560    }
561
562    #[test]
563    fn test_fnv32_consistency() {
564        let h1 = fnv32(b"test", 0);
565        let h2 = fnv32(b"test", 0);
566        assert_eq!(h1, h2);
567    }
568
569    #[test]
570    fn test_fnv32_different() {
571        let h1 = fnv32(b"abc", 0);
572        let h2 = fnv32(b"xyz", 0);
573        assert_ne!(h1, h2);
574    }
575
576    #[test]
577    fn test_extract_comment() {
578        let line = "<!-- name=\"Alice\" -->";
579        assert_eq!(extract_comment(line, "name"), Some("Alice"));
580        assert_eq!(extract_comment(line, "subject"), None);
581    }
582
583    #[test]
584    fn test_matches_existing() {
585        assert!(matches_existing("<a@b>", "<a@b>"));
586        assert!(!matches_existing("<a@b>", "<c@d>"));
587    }
588
589    #[test]
590    fn test_dirpath() {
591        let result = dirpath("/archives/%y/%m");
592        assert!(result.starts_with("/archives/20"));
593    }
594
595    #[test]
596    fn test_message_url_str_sequential() {
597        let config = Config::default();
598        let email = make_email(42, "<a@b>");
599        assert_eq!(message_url_str(&email, &config), "0042.html");
600    }
601
602    #[test]
603    fn test_message_url_str_nonsequential() {
604        let mut config = Config::default();
605        config.nonsequential = true;
606        let email = make_email(42, "<hello@world.com>");
607        let url = message_url_str(&email, &config);
608        assert_eq!(url.len(), 21); // 16 hex + ".html"
609        assert!(url.ends_with(".html"));
610    }
611
612    #[test]
613    fn test_message_url_str_with_subdir() {
614        let mut config = Config::default();
615        config.msgsperfolder = 100;
616        let email = make_email(142, "<a@b>");
617        let url = message_url_str(&email, &config);
618        assert_eq!(url, "1/0142.html");
619    }
620
621    #[test]
622    fn test_extract_msgnum_from_html_basic() {
623        let html = r#"<html><body><a name="msg42"></a></body></html>"#;
624        let msgnum = extract_msgnum_from_html(html, "msg");
625        assert_eq!(msgnum, Some(42));
626    }
627
628    #[test]
629    fn test_extract_msgnum_from_html_no_match() {
630        let html = r#"<html><body>no anchor here</body></html>"#;
631        let msgnum = extract_msgnum_from_html(html, "msg");
632        assert_eq!(msgnum, None);
633    }
634
635    #[test]
636    fn test_extract_msgnum_from_html_invalid_prefix() {
637        let html = r#"<html><body><a name="HM_42"></a></body></html>"#;
638        let msgnum = extract_msgnum_from_html(html, "XYZ");
639        assert_eq!(msgnum, None);
640    }
641
642    #[test]
643    fn test_extract_msgnum_from_html_large_msgnum() {
644        let html = r#"<html><body><a name="msg9999999"></a></body></html>"#;
645        let msgnum = extract_msgnum_from_html(html, "msg");
646        assert_eq!(msgnum, Some(9999999));
647    }
648
649    #[test]
650    fn test_checkdir_creates_directory() {
651        let tmp = tempfile::tempdir().unwrap();
652        let new_dir = tmp.path().join("new_subdir").to_string_lossy().to_string();
653        assert!(!std::path::Path::new(&new_dir).exists());
654        checkdir(&new_dir).unwrap();
655        assert!(std::path::Path::new(&new_dir).exists());
656    }
657
658    #[test]
659    fn test_checkdir_existing_dir_is_ok() {
660        let tmp = tempfile::tempdir().unwrap();
661        let result = checkdir(tmp.path().to_str().unwrap());
662        assert!(result.is_ok());
663    }
664
665    #[test]
666    fn test_is_empty_archive_empty_dir() {
667        let tmp = tempfile::tempdir().unwrap();
668        let mut config = Config::default();
669        config.dir = Some(tmp.path().to_str().unwrap().to_string());
670        assert!(is_empty_archive(&config));
671    }
672
673    #[test]
674    fn test_is_empty_archive_with_file() {
675        let tmp = tempfile::tempdir().unwrap();
676        std::fs::write(tmp.path().join("index.html"), "content").unwrap();
677        let mut config = Config::default();
678        config.dir = Some(tmp.path().to_str().unwrap().to_string());
679        assert!(!is_empty_archive(&config));
680    }
681
682    #[test]
683    fn test_is_empty_archive_hidden_file_ignored() {
684        let tmp = tempfile::tempdir().unwrap();
685        std::fs::write(tmp.path().join(".hidden"), "content").unwrap();
686        let mut config = Config::default();
687        config.dir = Some(tmp.path().to_str().unwrap().to_string());
688        assert!(is_empty_archive(&config));
689    }
690
691    #[test]
692    fn test_write_and_read_messageindex_roundtrip() {
693        let tmp = tempfile::tempdir().unwrap();
694        let mut config = Config::default();
695        config.dir = Some(tmp.path().to_str().unwrap().to_string());
696
697        let mut store = crate::structs::EmailStore::new();
698        store.add_email(make_email(0, "<a@b>"));
699        store.add_email(make_email(1, "<b@b>"));
700        store.add_email(make_email(2, "<c@b>"));
701
702        write_messageindex(&store, &config).unwrap();
703        let table = read_messageindex(&config).unwrap();
704
705        assert_eq!(table[0].as_deref(), Some("0000"));
706        assert_eq!(table[1].as_deref(), Some("0001"));
707        assert_eq!(table[2].as_deref(), Some("0002"));
708    }
709
710    #[test]
711    fn test_msg_subdir_msgsperfolder() {
712        let mut config = Config::default();
713        config.dir = Some("/tmp".to_string());
714        config.msgsperfolder = 100;
715
716        let email = make_email(250, "<a@b>");
717        let sub = msg_subdir(&email, &config);
718        assert!(sub.is_some());
719        let info = sub.unwrap();
720        assert_eq!(info.subdir, "2/");
721    }
722
723    #[test]
724    fn test_msg_subdir_none_by_default() {
725        let config = Config::default();
726        let email = make_email(42, "<a@b>");
727        assert!(msg_subdir(&email, &config).is_none());
728    }
729
730    #[test]
731    fn test_message_path_sequential() {
732        let mut config = Config::default();
733        config.dir = Some("/tmp".to_string());
734        let email = make_email(7, "<a@b>");
735        let path = message_path(&email, &config);
736        assert!(path.to_string_lossy().ends_with("0007.html"));
737    }
738}