1use std::collections::HashMap;
10use std::fs;
11use std::hash::{Hash, Hasher};
12use std::io::{Read, Seek, Write};
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16use anyhow::{Context, Result, bail, ensure};
17
18use crate::maildir::{self, Flags};
19use crate::remote::sanitize;
20
21pub struct Mbox {
22 pub path: PathBuf,
23 pub cache: PathBuf,
24 snapshot: (Option<SystemTime>, u64),
27}
28
29struct Raw {
31 from_line: String,
33 bytes: Vec<u8>,
35}
36
37impl Raw {
38 fn id(&self) -> String {
39 let mut hasher = std::collections::hash_map::DefaultHasher::new();
40 self.bytes.hash(&mut hasher);
41 format!("{:016x}", hasher.finish())
42 }
43}
44
45pub fn cache_dir(path: &Path) -> PathBuf {
48 let abs = path
49 .canonicalize()
50 .unwrap_or_else(|_| path.to_path_buf())
51 .display()
52 .to_string();
53 crate::remote::cache_base()
54 .join("mbox")
55 .join(sanitize(&abs))
56}
57
58pub fn id_of(path: &Path) -> Option<String> {
60 let name = path.file_name()?.to_str()?;
61 let base = name.split(":2,").next()?;
62 let base = base.split(",S=").next()?;
63 base.strip_suffix(".mbox").map(str::to_string)
64}
65
66pub fn looks_like_mbox(path: &Path) -> bool {
69 let Ok(meta) = fs::metadata(path) else {
70 return false;
71 };
72 if !meta.is_file() {
73 return false;
74 }
75 if meta.len() == 0 {
76 return true;
77 }
78 let mut buf = [0u8; 5];
79 fs::File::open(path)
80 .and_then(|mut f| f.read_exact(&mut buf))
81 .is_ok_and(|()| &buf == b"From ")
82}
83
84fn stat(path: &Path) -> Result<(Option<SystemTime>, u64)> {
85 let meta = fs::metadata(path).with_context(|| format!("reading {}", path.display()))?;
86 Ok((meta.modified().ok(), meta.len()))
87}
88
89fn lock(file: &fs::File) -> Result<()> {
92 use std::os::fd::AsRawFd;
93 for _ in 0..25 {
94 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
95 return Ok(());
96 }
97 std::thread::sleep(std::time::Duration::from_millis(200));
98 }
99 bail!("the mbox is locked by another program")
100}
101
102fn split(data: &[u8]) -> Vec<Raw> {
107 let mut out: Vec<Raw> = Vec::new();
108 let mut prev_blank = true;
109 for line in data.split_inclusive(|&b| b == b'\n') {
110 let text = line.strip_suffix(b"\n").unwrap_or(line);
111 if prev_blank && text.starts_with(b"From ") {
112 if let Some(last) = out.last_mut()
115 && last.bytes.ends_with(b"\n\n")
116 {
117 last.bytes.pop();
118 }
119 out.push(Raw {
120 from_line: String::from_utf8_lossy(text).into_owned(),
121 bytes: Vec::new(),
122 });
123 prev_blank = false;
124 continue;
125 }
126 prev_blank = text.is_empty();
127 if let Some(msg) = out.last_mut() {
128 let trimmed = trim_quoting(text);
130 if trimmed.starts_with(b"From ") && text != trimmed {
131 msg.bytes.extend_from_slice(&text[1..]);
132 } else {
133 msg.bytes.extend_from_slice(text);
134 }
135 msg.bytes.push(b'\n');
136 }
137 }
138 out
139}
140
141fn trim_quoting(line: &[u8]) -> &[u8] {
142 let mut rest = line;
143 while let Some(r) = rest.strip_prefix(b">") {
144 rest = r;
145 }
146 rest
147}
148
149fn status_flags(bytes: &[u8]) -> (Flags, bool) {
152 let head_end = bytes
153 .windows(2)
154 .position(|w| w == b"\n\n")
155 .unwrap_or(bytes.len());
156 let head = String::from_utf8_lossy(&bytes[..head_end]);
157 let mut chars = String::new();
158 for line in head.lines() {
159 if let Some((key, value)) = line.split_once(':')
160 && matches!(
161 key.trim().to_ascii_lowercase().as_str(),
162 "status" | "x-status"
163 )
164 {
165 chars += value.trim();
166 }
167 }
168 let flags = Flags {
169 seen: chars.contains('R'),
170 answered: chars.contains('A'),
171 flagged: chars.contains('F'),
172 deleted: false,
173 draft: false,
174 };
175 (flags, chars.contains('O'))
176}
177
178fn set_status(bytes: &[u8], flags: Flags, old: bool) -> Vec<u8> {
181 let head_end = bytes
182 .windows(2)
183 .position(|w| w == b"\n\n")
184 .map(|i| i + 1)
185 .unwrap_or(bytes.len());
186 let (head, body) = bytes.split_at(head_end);
187 let mut out = Vec::with_capacity(bytes.len() + 32);
188 for line in head.split_inclusive(|&b| b == b'\n') {
189 let lower = line.to_ascii_lowercase();
190 if lower.starts_with(b"status:") || lower.starts_with(b"x-status:") {
191 continue;
192 }
193 out.extend_from_slice(line);
194 }
195 let mut status = String::new();
196 if flags.seen {
197 status.push('R');
198 }
199 if old || flags.seen {
200 status.push('O');
201 }
202 if !status.is_empty() {
203 out.extend_from_slice(format!("Status: {status}\n").as_bytes());
204 }
205 let mut xstatus = String::new();
206 if flags.answered {
207 xstatus.push('A');
208 }
209 if flags.flagged {
210 xstatus.push('F');
211 }
212 if !xstatus.is_empty() {
213 out.extend_from_slice(format!("X-Status: {xstatus}\n").as_bytes());
214 }
215 out.extend_from_slice(body);
216 out
217}
218
219impl Mbox {
220 pub fn open(path: &Path) -> Result<Mbox> {
225 Mbox::open_at(path, cache_dir(path))
226 }
227
228 fn open_at(path: &Path, cache: PathBuf) -> Result<Mbox> {
229 ensure!(
230 looks_like_mbox(path),
231 "{} does not look like an mbox file",
232 path.display()
233 );
234 let mut mbox = Mbox {
235 path: path.to_path_buf(),
236 cache,
237 snapshot: (None, 0),
238 };
239 mbox.mirror()?;
240 Ok(mbox)
241 }
242
243 fn mirror(&mut self) -> Result<()> {
244 let file = fs::File::open(&self.path)
245 .with_context(|| format!("opening {}", self.path.display()))?;
246 use std::os::fd::AsRawFd;
248 let _ = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) };
249 let mut data = Vec::new();
250 (&file).read_to_end(&mut data)?;
251 drop(file);
252 maildir::create(&self.cache)?;
253 let mut cached: HashMap<String, PathBuf> = HashMap::new();
254 for f in maildir::scan(&self.cache)? {
255 if let Some(id) = id_of(&f.path) {
256 cached.insert(id, f.path.clone());
257 }
258 }
259 for raw in split(&data) {
260 let id = raw.id();
261 if cached.remove(&id).is_some() {
262 continue; }
264 let (flags, old) = status_flags(&raw.bytes);
265 let plain_new = !old && flags == Flags::default();
267 let name = format!("{id}.mbox,S={}", raw.bytes.len());
268 let target = if plain_new {
269 self.cache.join("new").join(name)
270 } else {
271 self.cache.join("cur").join(name + &flags.to_info())
272 };
273 fs::write(&target, &raw.bytes)
274 .with_context(|| format!("writing {}", target.display()))?;
275 }
276 for (_, gone) in cached {
277 let _ = fs::remove_file(gone);
278 }
279 self.snapshot = stat(&self.path)?;
280 Ok(())
281 }
282
283 pub fn refresh(&mut self) -> Result<()> {
285 if stat(&self.path)? != self.snapshot {
286 self.mirror()?;
287 }
288 Ok(())
289 }
290
291 pub fn write_back(&mut self, state: &HashMap<String, Option<(Flags, bool)>>) -> Result<()> {
297 let backup = self.cache.join(".backup");
301 ensure!(
302 !backup.exists(),
303 "an earlier sync of {} did not finish; the mbox as it was is saved in {}: \
304 check the mbox (restore that copy over it if messages are missing), \
305 then delete the copy to sync again",
306 self.path.display(),
307 backup.display()
308 );
309 let mut file = fs::OpenOptions::new()
310 .read(true)
311 .write(true)
312 .open(&self.path)
313 .with_context(|| format!("opening {} for writing", self.path.display()))?;
314 lock(&file)?;
315 ensure!(
316 stat(&self.path)? == self.snapshot,
317 "{} changed on disk; check for new mail (G), then sync again",
318 self.path.display()
319 );
320 let mut data = Vec::new();
321 file.read_to_end(&mut data)?;
322 let mut copy =
327 fs::File::create(&backup).with_context(|| format!("writing {}", backup.display()))?;
328 copy.write_all(&data)
329 .and_then(|()| copy.sync_all())
330 .with_context(|| format!("writing {}", backup.display()))?;
331 let mut out = Vec::with_capacity(data.len());
332 for raw in split(&data) {
333 let (flags, old) = match state.get(&raw.id()) {
334 Some(None) => continue, Some(Some((flags, is_new))) => (*flags, !*is_new),
336 None => status_flags(&raw.bytes),
337 };
338 let bytes = set_status(&raw.bytes, flags, old);
339 out.extend_from_slice(raw.from_line.as_bytes());
340 out.push(b'\n');
341 for line in bytes.split_inclusive(|&b| b == b'\n') {
342 if trim_quoting(line.strip_suffix(b"\n").unwrap_or(line)).starts_with(b"From ") {
344 out.push(b'>');
345 }
346 out.extend_from_slice(line);
347 }
348 if !out.ends_with(b"\n") {
349 out.push(b'\n');
350 }
351 out.push(b'\n'); }
353 file.rewind()?;
354 file.write_all(&out)?;
355 file.set_len(out.len() as u64)?;
356 file.sync_all()?;
357 drop(file); let _ = fs::remove_file(&backup);
359 self.snapshot = stat(&self.path)?;
360 Ok(())
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 const TWO: &str = "From jane@example.com Thu Jul 9 10:00:00 2026\n\
369 From: jane@example.com\nSubject: first\n\
370 Status: RO\n\nhello\n>From me, quoted\n\n\
371 From petr@example.com Thu Jul 9 11:00:00 2026\n\
372 From: petr@example.com\nSubject: second\n\nfresh mail\n";
373
374 #[test]
375 fn split_finds_messages_and_unescapes() {
376 let msgs = split(TWO.as_bytes());
377 assert_eq!(msgs.len(), 2);
378 assert!(msgs[0].from_line.starts_with("From jane@example.com"));
379 let first = String::from_utf8_lossy(&msgs[0].bytes);
380 assert!(first.contains("Subject: first"));
381 assert!(first.contains("\nFrom me, quoted\n"), "{first}");
382 assert!(!first.ends_with("\n\n"), "separator kept out: {first:?}");
383 let second = String::from_utf8_lossy(&msgs[1].bytes);
384 assert!(second.contains("fresh mail"));
385 let tricky = "From a@x Thu Jul 9 10:00:00 2026\n\nbody\nFrom here on\n";
387 assert_eq!(split(tricky.as_bytes()).len(), 1);
388 assert!(split(b"").is_empty());
389 }
390
391 #[test]
392 fn status_flags_read_and_written() {
393 let msgs = split(TWO.as_bytes());
394 let (flags, old) = status_flags(&msgs[0].bytes);
395 assert!(flags.seen && old && !flags.flagged);
396 let (flags, old) = status_flags(&msgs[1].bytes);
397 assert!(!flags.seen && !old);
398 let updated = set_status(
399 &msgs[1].bytes,
400 Flags {
401 seen: true,
402 flagged: true,
403 ..Default::default()
404 },
405 true,
406 );
407 let text = String::from_utf8_lossy(&updated);
408 assert!(text.contains("Status: RO\n"), "{text}");
409 assert!(text.contains("X-Status: F\n"), "{text}");
410 assert!(text.ends_with("fresh mail\n"));
411 let cleared = set_status(&msgs[0].bytes, Flags::default(), false);
413 let text = String::from_utf8_lossy(&cleared);
414 assert!(!text.contains("Status:"), "{text}");
415 }
416
417 #[test]
418 fn mirror_and_remirror_keep_cache_flags() {
419 let tmp = tempfile::tempdir().unwrap();
420 let spool = tmp.path().join("spool");
421 fs::write(&spool, TWO).unwrap();
422 let mut mbox = Mbox::open_at(&spool, tmp.path().join("cache")).unwrap();
423 let files = maildir::scan(&mbox.cache).unwrap();
424 assert_eq!(files.len(), 2);
425 assert_eq!(files.iter().filter(|f| f.is_new).count(), 1);
427 let read = files.iter().find(|f| !f.is_new).unwrap();
429 let mut flagged = read.clone();
430 flagged.flags.flagged = true;
431 let new_path = maildir::store_flags(&flagged).unwrap();
432 let mut data = fs::read(&spool).unwrap();
435 data.extend_from_slice(
436 b"\nFrom ci@example.com Thu Jul 9 12:00:00 2026\nSubject: third\n\njob done\n",
437 );
438 fs::write(&spool, &data).unwrap();
439 mbox.refresh().unwrap();
440 let files = maildir::scan(&mbox.cache).unwrap();
441 assert_eq!(files.len(), 3);
442 let kept = files.iter().find(|f| f.path == new_path).unwrap();
443 assert!(kept.flags.flagged, "cache flag lost on re-mirror");
444 }
445
446 #[test]
447 fn write_back_keeps_the_copy_an_unfinished_sync_left() {
448 let tmp = tempfile::tempdir().unwrap();
449 let spool = tmp.path().join("spool");
450 fs::write(&spool, TWO).unwrap();
451 let mut mbox = Mbox::open_at(&spool, tmp.path().join("cache")).unwrap();
452 let backup = mbox.cache.join(".backup");
454 fs::write(&backup, TWO).unwrap();
455 fs::write(&spool, &TWO[..TWO.len() / 2]).unwrap();
456 mbox.refresh().unwrap();
457 let err = mbox.write_back(&HashMap::new()).unwrap_err().to_string();
458 assert!(err.contains("did not finish"), "{err}");
459 assert!(err.contains(&backup.display().to_string()), "{err}");
460 assert_eq!(fs::read_to_string(&backup).unwrap(), TWO);
461 fs::remove_file(&backup).unwrap();
463 mbox.write_back(&HashMap::new()).unwrap();
464 }
465
466 #[test]
467 fn write_back_purges_and_rewrites_status() {
468 let tmp = tempfile::tempdir().unwrap();
469 let spool = tmp.path().join("spool");
470 fs::write(&spool, TWO).unwrap();
471 let mut mbox = Mbox::open_at(&spool, tmp.path().join("cache")).unwrap();
472 let msgs = split(TWO.as_bytes());
473 let mut state = HashMap::new();
474 state.insert(msgs[0].id(), None); state.insert(
476 msgs[1].id(),
477 Some((
478 Flags {
479 seen: true,
480 answered: true,
481 ..Default::default()
482 },
483 false,
484 )),
485 );
486 mbox.write_back(&state).unwrap();
487 assert!(!mbox.cache.join(".backup").exists());
489 let text = fs::read_to_string(&spool).unwrap();
490 assert!(!text.contains("Subject: first"), "{text}");
491 assert!(text.contains("Subject: second"));
492 assert!(text.contains("Status: RO\n"));
493 assert!(text.contains("X-Status: A\n"));
494 assert!(text.starts_with("From petr@example.com"));
495 assert_eq!(split(text.as_bytes()).len(), 1);
497 fs::write(&spool, TWO).unwrap();
499 let mut mbox = Mbox::open_at(&spool, tmp.path().join("cache")).unwrap();
500 mbox.write_back(&HashMap::new()).unwrap();
501 let msgs = split(&fs::read(&spool).unwrap());
502 assert_eq!(msgs.len(), 2);
503 assert!(
504 String::from_utf8_lossy(&msgs[0].bytes).contains("\nFrom me, quoted\n"),
505 "quoting did not roundtrip"
506 );
507 }
508
509 #[test]
510 fn write_back_refuses_a_changed_spool() {
511 let tmp = tempfile::tempdir().unwrap();
512 let spool = tmp.path().join("spool");
513 fs::write(&spool, TWO).unwrap();
514 let mut mbox = Mbox::open_at(&spool, tmp.path().join("cache")).unwrap();
515 let mut data = fs::read(&spool).unwrap();
516 data.extend_from_slice(b"From x@y Thu Jul 9 13:00:00 2026\n\nsurprise\n");
517 fs::write(&spool, &data).unwrap();
518 let err = mbox.write_back(&HashMap::new()).unwrap_err();
519 assert!(err.to_string().contains("changed on disk"), "{err}");
520 assert!(fs::read_to_string(&spool).unwrap().contains("surprise"));
522 }
523
524 #[test]
525 fn looks_like_mbox_checks_the_first_bytes() {
526 let tmp = tempfile::tempdir().unwrap();
527 let good = tmp.path().join("good");
528 fs::write(&good, TWO).unwrap();
529 assert!(looks_like_mbox(&good));
530 let empty = tmp.path().join("empty");
531 fs::write(&empty, "").unwrap();
532 assert!(looks_like_mbox(&empty));
533 let bad = tmp.path().join("bad");
534 fs::write(&bad, "not a spool\n").unwrap();
535 assert!(!looks_like_mbox(&bad));
536 assert!(!looks_like_mbox(&tmp.path().join("missing")));
537 }
538}