1use crate::config::Config;
2use crate::message::EmailInfo;
3use chrono::{Local, TimeZone, Utc};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[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#[cfg(not(unix))]
19pub fn apply_permissions(_path: &Path, _mode: i32) {
20 }
22
23pub 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
53fn 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
90pub fn message_filename(email: &EmailInfo, config: &Config) -> String {
92 format!("{}.{}", message_name(email, config), config.htmlsuffix)
93}
94
95pub 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
106pub 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
123pub fn messageindex_name(config: &Config) -> PathBuf {
125 PathBuf::from(config.dir.as_deref().unwrap_or(".")).join("msgindex")
126}
127
128pub 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
158pub 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
186pub 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
194pub 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 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
259pub 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
278pub 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
287pub 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
302pub 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
317pub 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
341pub 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 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 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
488pub fn matches_existing(msgid: &str, existing_msgid: &str) -> bool {
490 msgid.trim() == existing_msgid.trim()
491}
492
493pub fn lock_file_name(config: &Config) -> PathBuf {
495 PathBuf::from(config.dir.as_deref().unwrap_or(".")).join(".hm_lock")
496}
497
498pub 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 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 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); 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}