Skip to main content

rmut_core/
mbox.rs

1//! mbox support (system spools like /var/mail/$USER): the file is
2//! mirrored into a cache maildir so the whole index/pager stack works
3//! on it unchanged, and `$` sync writes the changes back, rewriting
4//! Status:/X-Status: headers and dropping purged messages, with the
5//! file flock()ed and rewritten in place (mboxrd `>From` quoting).
6//! Messages are keyed by a content hash, so flags survive re-mirrors
7//! when the spool grows.
8
9use 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    /// (mtime, len) of the file at mirror time; a mismatch at
25    /// write-back time means someone else changed the spool.
26    snapshot: (Option<SystemTime>, u64),
27}
28
29/// One message split out of the file.
30struct Raw {
31    /// The `From sender date` separator line, kept for the rewrite.
32    from_line: String,
33    /// Unescaped message bytes (headers + body).
34    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
45/// Where an mbox's cache maildir lives:
46/// `$XDG_CACHE_HOME/rmut/mbox/<percent-encoded absolute path>`.
47pub 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
58/// Message id encoded in a cache filename (`<hash>.mbox[,S=n][:2,f]`).
59pub 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
66/// True when the file looks like an mbox: empty, or starting with a
67/// `From ` separator line.
68pub 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
89/// flock the file, briefly retrying (delivery holds locks for
90/// moments, not minutes).
91fn 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
102/// Split mbox bytes into messages: a `From ` line at the start of the
103/// file or after a blank line separates; one level of `>From` quoting
104/// is removed (mboxrd). The blank separator line stays out of the
105/// message.
106fn 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            // Drop the separator blank line collected into the
113            // previous message.
114            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            // Unescape one quoting level: ">From " → "From ".
129            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
149/// Seen/old/flagged/answered from the Status: and X-Status: headers;
150/// `old` is mbox's "not new" (Status: O).
151fn 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
178/// The message with its Status:/X-Status: headers replaced to encode
179/// `flags` and old-ness.
180fn 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    /// Read the file and bring the cache maildir up to date: messages
221    /// already mirrored keep whatever flags the cache has, new ones
222    /// land in new/ or cur/ per their Status: header, ones gone from
223    /// the file leave the cache.
224    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        // Best-effort shared lock against a delivery mid-read.
247        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; // known: the cache's flags are newer
263            }
264            let (flags, old) = status_flags(&raw.bytes);
265            // new/ carries no flag info: anything marked goes to cur/.
266            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    /// Re-mirror when the file changed on disk (the new-mail poll).
284    pub fn refresh(&mut self) -> Result<()> {
285        if stat(&self.path)? != self.snapshot {
286            self.mirror()?;
287        }
288        Ok(())
289    }
290
291    /// Rewrite the mbox to the wanted end state, keyed by message id:
292    /// None drops the message (purge), Some((flags, is_new)) rewrites
293    /// its Status:/X-Status:; ids not in the map keep their current
294    /// state. Runs under an exclusive flock and refuses when the file
295    /// changed since the last mirror (refresh first, then sync again).
296    pub fn write_back(&mut self, state: &HashMap<String, Option<(Flags, bool)>>) -> Result<()> {
297        // A backup still here means a rewrite never finished: the mbox
298        // may be cut short and that copy the only whole one, so it is
299        // not overwritten with whatever the file holds now.
300        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        // The rewrite happens in place (no temp files in /var/mail):
323        // keep a copy in the cache until it lands, in case of a crash.
324        // Synced before the rewrite starts, or a power cut could take
325        // the copy along with the file.
326        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, // purged
335                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                // mboxrd quoting: any From-ish line gains one '>'.
343                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'); // the blank separator line
352        }
353        file.rewind()?;
354        file.write_all(&out)?;
355        file.set_len(out.len() as u64)?;
356        file.sync_all()?;
357        drop(file); // releases the flock
358        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        // A "From " mid-paragraph (no blank line before) is body text.
386        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        // Replacing drops the old Status line instead of stacking.
412        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        // Read message in cur/, fresh one in new/.
426        assert_eq!(files.iter().filter(|f| f.is_new).count(), 1);
427        // Flag the read one in the cache, like the app would.
428        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        // Append a third message to the spool; refresh mirrors it and
433        // keeps the flag.
434        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        // A crash mid-rewrite: the spool cut short, the copy whole.
453        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        // Once it is dealt with and removed, syncing works again.
462        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); // purge the first
475        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        // The crash backup is cleaned up after a successful rewrite.
488        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        // The rewritten file still parses to the one message.
496        assert_eq!(split(text.as_bytes()).len(), 1);
497        // Quoting roundtrip: a From-line in a body survives a rewrite.
498        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        // The surprise message is still there.
521        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}