Skip to main content

rmut_core/
maildir.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5
6/// Maildir flags stored in the filename after `:2,` (see
7/// <https://cr.yp.to/proto/maildir.html>).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub struct Flags {
10    pub seen: bool,
11    pub answered: bool,
12    pub flagged: bool,
13    pub deleted: bool,
14    pub draft: bool,
15}
16
17impl Flags {
18    pub fn from_filename(name: &str) -> Self {
19        let mut flags = Flags::default();
20        if let Some((_, info)) = name.rsplit_once(":2,") {
21            for c in info.chars() {
22                match c {
23                    'S' => flags.seen = true,
24                    'R' => flags.answered = true,
25                    'F' => flags.flagged = true,
26                    'T' => flags.deleted = true,
27                    'D' => flags.draft = true,
28                    _ => {}
29                }
30            }
31        }
32        flags
33    }
34
35    /// The `:2,...` info suffix encoding these flags, letters in the
36    /// ASCII order the spec requires.
37    pub fn to_info(self) -> String {
38        let mut info = String::from(":2,");
39        if self.draft {
40            info.push('D');
41        }
42        if self.flagged {
43            info.push('F');
44        }
45        if self.answered {
46            info.push('R');
47        }
48        if self.seen {
49            info.push('S');
50        }
51        if self.deleted {
52            info.push('T');
53        }
54        info
55    }
56
57    /// Status column as mutt renders it in the index.
58    pub fn status_char(&self, is_new: bool) -> char {
59        if self.deleted {
60            'D'
61        } else if is_new {
62            'N'
63        } else if self.answered {
64            'r'
65        } else if !self.seen {
66            'O'
67        } else {
68            ' '
69        }
70    }
71}
72
73#[derive(Debug, Clone)]
74pub struct MailFile {
75    pub path: PathBuf,
76    pub is_new: bool,
77    pub flags: Flags,
78    pub size: u64,
79}
80
81/// Scan a maildir's `new/` and `cur/` subdirectories.
82pub fn scan(dir: &Path) -> Result<Vec<MailFile>> {
83    let cur = dir.join("cur");
84    let new = dir.join("new");
85    if !cur.is_dir() || !new.is_dir() {
86        bail!("{} is not a maildir (missing cur/ or new/)", dir.display());
87    }
88    let mut out = Vec::new();
89    for (sub, is_new) in [(cur, false), (new, true)] {
90        let entries = sub
91            .read_dir()
92            .with_context(|| format!("reading {}", sub.display()))?;
93        for entry in entries {
94            let entry = entry?;
95            // Through symlinks (the notmuch view is one per hit);
96            // broken links and directories fall out here, and so does
97            // a message another client moved since read_dir saw it.
98            let meta = match std::fs::metadata(entry.path()) {
99                Ok(meta) if meta.is_file() => meta,
100                Ok(_) => continue,
101                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
102                Err(e) => {
103                    return Err(e).with_context(|| format!("reading {}", entry.path().display()));
104                }
105            };
106            let name = entry.file_name();
107            let name = name.to_string_lossy();
108            if name.starts_with('.') {
109                continue;
110            }
111            out.push(MailFile {
112                path: entry.path(),
113                is_new,
114                flags: Flags::from_filename(&name),
115                size: size_from_name(&name).unwrap_or(meta.len()),
116            });
117        }
118    }
119    Ok(out)
120}
121
122/// Filename without the `:2,` info suffix.
123fn base_name(name: &str) -> &str {
124    name.rsplit_once(":2,").map_or(name, |(base, _)| base)
125}
126
127/// The `,S=<bytes>` message size some tools (and our IMAP cache) put in
128/// the base name, authoritative when present, since a cached file may
129/// hold only the headers.
130fn size_from_name(name: &str) -> Option<u64> {
131    let rest = base_name(name).rsplit_once(",S=")?.1;
132    let end = rest
133        .find(|c: char| !c.is_ascii_digit())
134        .unwrap_or(rest.len());
135    rest[..end].parse().ok()
136}
137
138/// Write the file's current flags to disk by renaming it into `cur/`
139/// with the matching info suffix. Returns the new path.
140pub fn store_flags(file: &MailFile) -> Result<PathBuf> {
141    let name = file
142        .path
143        .file_name()
144        .context("mail file has no filename")?
145        .to_string_lossy()
146        .into_owned();
147    let maildir = file
148        .path
149        .parent()
150        .and_then(Path::parent)
151        .context("mail file is not inside a maildir")?;
152    let target = maildir
153        .join("cur")
154        .join(format!("{}{}", base_name(&name), file.flags.to_info()));
155    if target != file.path {
156        fs::rename(&file.path, &target)
157            .with_context(|| format!("renaming to {}", target.display()))?;
158    }
159    Ok(target)
160}
161
162/// Give a message new content under the name it has. The bytes go
163/// to the maildir's tmp/ first and are renamed over the message, so a
164/// crash leaves the old message or the new one, never a cut-off one
165/// (a plain write truncates first). A symlink, as in the notmuch
166/// view, is followed to the message it stands for.
167pub fn replace_content(path: &Path, bytes: &[u8]) -> Result<()> {
168    use std::io::Write as _;
169    use std::os::unix::fs::OpenOptionsExt as _;
170    use std::sync::atomic::{AtomicUsize, Ordering};
171    static COUNTER: AtomicUsize = AtomicUsize::new(0);
172    let real = fs::canonicalize(path).with_context(|| format!("reading {}", path.display()))?;
173    let parent = real.parent().context("a message path has a parent")?;
174    // tmp/ beside cur/ and new/, on the same filesystem, as maildir
175    // has it; the message's own directory when there is none.
176    let dir = parent
177        .parent()
178        .map(|d| d.join("tmp"))
179        .filter(|t| t.is_dir())
180        .unwrap_or_else(|| parent.to_path_buf());
181    let tmp = dir.join(format!(
182        ".rmut-rewrite-{}-{}",
183        std::process::id(),
184        COUNTER.fetch_add(1, Ordering::Relaxed)
185    ));
186    let perms = fs::metadata(&real)?.permissions();
187    let written = (|| -> std::io::Result<()> {
188        let mut file = fs::OpenOptions::new()
189            .write(true)
190            .create_new(true)
191            .mode(0o600)
192            .open(&tmp)?;
193        file.write_all(bytes)?;
194        file.set_permissions(perms)?;
195        file.sync_all()?;
196        fs::rename(&tmp, &real)
197    })();
198    if let Err(err) = written {
199        let _ = fs::remove_file(&tmp);
200        return Err(err).with_context(|| format!("rewriting {}", real.display()));
201    }
202    Ok(())
203}
204
205pub fn remove(file: &MailFile) -> Result<()> {
206    fs::remove_file(&file.path).with_context(|| format!("removing {}", file.path.display()))
207}
208
209pub fn hostname() -> String {
210    fs::read_to_string("/proc/sys/kernel/hostname")
211        .map(|s| s.trim().to_string())
212        .ok()
213        .filter(|s| !s.is_empty())
214        .unwrap_or_else(|| "localhost".into())
215}
216
217/// Create an empty maildir (cur/new/tmp).
218pub fn create(dir: &Path) -> Result<()> {
219    for sub in ["cur", "new", "tmp"] {
220        fs::create_dir_all(dir.join(sub))
221            .with_context(|| format!("creating {}", dir.join(sub).display()))?;
222    }
223    Ok(())
224}
225
226/// Deliver a message into a maildir the spec way: write to tmp/, then
227/// rename into cur/ with the given flags. Returns the delivered path.
228pub fn deliver(dir: &Path, content: &[u8], flags: Flags) -> Result<PathBuf> {
229    use std::sync::atomic::{AtomicUsize, Ordering};
230    static COUNTER: AtomicUsize = AtomicUsize::new(0);
231    if !dir.join("cur").is_dir() || !dir.join("tmp").is_dir() {
232        bail!("{} is not a maildir", dir.display());
233    }
234    let epoch = std::time::SystemTime::now()
235        .duration_since(std::time::UNIX_EPOCH)
236        .unwrap_or_default()
237        .as_secs();
238    let name = format!(
239        "{epoch}.{}_{}.{}",
240        std::process::id(),
241        COUNTER.fetch_add(1, Ordering::Relaxed),
242        hostname(),
243    );
244    let tmp = dir.join("tmp").join(&name);
245    fs::write(&tmp, content).with_context(|| format!("writing {}", tmp.display()))?;
246    let target = dir.join("cur").join(format!("{name}{}", flags.to_info()));
247    fs::rename(&tmp, &target).with_context(|| format!("renaming to {}", target.display()))?;
248    Ok(target)
249}
250
251/// Messages waiting in `new/`: the cheap new-mail count for the
252/// folder browser and the cross-mailbox poll. 0 for non-maildirs.
253pub fn new_count(dir: &Path) -> usize {
254    dir.join("new")
255        .read_dir()
256        .map(|entries| {
257            entries
258                .filter_map(|e| e.ok())
259                .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
260                .count()
261        })
262        .unwrap_or(0)
263}
264
265/// Find a nearby maildir whose name (ignoring a leading dot) matches one
266/// of `names` case-insensitively, e.g. Sent/.Sent or Drafts.
267pub fn find_special(dir: &Path, names: &[&str]) -> Option<PathBuf> {
268    discover(dir).into_iter().find(|p| {
269        p.file_name().is_some_and(|n| {
270            let n = n.to_string_lossy();
271            let n = n.trim_start_matches('.');
272            names.iter().any(|w| n.eq_ignore_ascii_case(w))
273        })
274    })
275}
276
277/// Maildirs to offer in the folder browser: subdirectories of `dir`
278/// (Maildir++ style children) and of its parent (sibling mailboxes).
279pub fn discover(dir: &Path) -> Vec<PathBuf> {
280    fn push_children(parent: &Path, out: &mut Vec<PathBuf>) {
281        let Ok(entries) = parent.read_dir() else {
282            return;
283        };
284        for entry in entries.flatten() {
285            let p = entry.path();
286            if p.is_dir() && p.join("cur").is_dir() && p.join("new").is_dir() {
287                out.push(p);
288            }
289        }
290    }
291    let mut out = Vec::new();
292    push_children(dir, &mut out);
293    if let Some(parent) = dir.parent() {
294        push_children(parent, &mut out);
295    }
296    out.sort();
297    out.dedup();
298    out
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn make_maildir(root: &Path) {
306        for sub in ["cur", "new", "tmp"] {
307            fs::create_dir_all(root.join(sub)).unwrap();
308        }
309    }
310
311    #[test]
312    fn flags_from_filename() {
313        let f = Flags::from_filename("1234.abc.host:2,FS");
314        assert!(f.seen && f.flagged);
315        assert!(!f.deleted && !f.answered && !f.draft);
316        assert_eq!(Flags::from_filename("1234.abc.host"), Flags::default());
317    }
318
319    #[test]
320    fn flags_roundtrip_info() {
321        let f = Flags {
322            seen: true,
323            flagged: true,
324            deleted: true,
325            ..Default::default()
326        };
327        assert_eq!(f.to_info(), ":2,FST");
328        assert_eq!(Flags::from_filename(&format!("x{}", f.to_info())), f);
329    }
330
331    #[test]
332    fn size_comes_from_name_when_present() {
333        assert_eq!(size_from_name("12.rmut,S=345:2,S"), Some(345));
334        assert_eq!(size_from_name("12.rmut,S=345"), Some(345));
335        assert_eq!(size_from_name("1234.abc.host:2,S"), None);
336        let tmp = tempfile::tempdir().unwrap();
337        make_maildir(tmp.path());
338        fs::write(tmp.path().join("cur/9.rmut,S=777:2,S"), "tiny").unwrap();
339        let files = scan(tmp.path()).unwrap();
340        assert_eq!(files[0].size, 777);
341    }
342
343    #[test]
344    fn replace_content_swaps_whole_files_and_follows_links() {
345        use std::os::unix::fs::PermissionsExt as _;
346        let tmp = tempfile::tempdir().unwrap();
347        make_maildir(tmp.path());
348        let msg = tmp.path().join("cur/1.host:2,S");
349        fs::write(&msg, "Subject: old\n\nx\n").unwrap();
350        fs::set_permissions(&msg, fs::Permissions::from_mode(0o640)).unwrap();
351        replace_content(&msg, b"Subject: new\n\nx\n").unwrap();
352        assert_eq!(fs::read_to_string(&msg).unwrap(), "Subject: new\n\nx\n");
353        let mode = fs::metadata(&msg).unwrap().permissions().mode();
354        assert_eq!(mode & 0o777, 0o640, "the message keeps its mode");
355        assert_eq!(fs::read_dir(tmp.path().join("tmp")).unwrap().count(), 0);
356        // Through a link (the notmuch view): the message changes, the
357        // link stays a link.
358        let view = tempfile::tempdir().unwrap();
359        make_maildir(view.path());
360        let link = view.path().join("cur/0001.1.host:2,S");
361        std::os::unix::fs::symlink(&msg, &link).unwrap();
362        replace_content(&link, b"Subject: newer\n\nx\n").unwrap();
363        assert!(
364            fs::symlink_metadata(&link)
365                .unwrap()
366                .file_type()
367                .is_symlink()
368        );
369        assert_eq!(fs::read_to_string(&msg).unwrap(), "Subject: newer\n\nx\n");
370    }
371
372    #[test]
373    fn scan_skips_what_is_gone_instead_of_failing() {
374        // A dangling link reads like a message another client moved
375        // away between read_dir and the stat: skipped, not an error.
376        let tmp = tempfile::tempdir().unwrap();
377        make_maildir(tmp.path());
378        fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: kept\n\nx\n").unwrap();
379        std::os::unix::fs::symlink(tmp.path().join("gone"), tmp.path().join("cur/2.host:2,S"))
380            .unwrap();
381        let files = scan(tmp.path()).unwrap();
382        assert_eq!(files.len(), 1);
383        assert_eq!(files[0].size, 17);
384    }
385
386    #[test]
387    fn scan_rejects_non_maildir() {
388        let tmp = tempfile::tempdir().unwrap();
389        assert!(scan(tmp.path()).is_err());
390    }
391
392    #[test]
393    fn scan_finds_new_and_cur() {
394        let tmp = tempfile::tempdir().unwrap();
395        make_maildir(tmp.path());
396        fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: a\n\nx\n").unwrap();
397        fs::write(tmp.path().join("new/2.host"), "Subject: b\n\ny\n").unwrap();
398        let mut files = scan(tmp.path()).unwrap();
399        files.sort_by(|a, b| a.path.cmp(&b.path));
400        assert_eq!(files.len(), 2);
401        assert!(!files[0].is_new && files[0].flags.seen);
402        assert!(files[1].is_new);
403        assert!(files[0].size > 0);
404    }
405
406    #[test]
407    fn store_flags_moves_new_to_cur_with_info() {
408        let tmp = tempfile::tempdir().unwrap();
409        make_maildir(tmp.path());
410        let src = tmp.path().join("new/99.host");
411        fs::write(&src, "Subject: a\n\nx\n").unwrap();
412        let file = MailFile {
413            path: src.clone(),
414            is_new: true,
415            flags: Flags {
416                seen: true,
417                ..Default::default()
418            },
419            size: 14,
420        };
421        let new_path = store_flags(&file).unwrap();
422        assert_eq!(new_path, tmp.path().join("cur/99.host:2,S"));
423        assert!(!src.exists());
424        assert!(new_path.exists());
425    }
426
427    #[test]
428    fn store_flags_rewrites_existing_info() {
429        let tmp = tempfile::tempdir().unwrap();
430        make_maildir(tmp.path());
431        let src = tmp.path().join("cur/7.host:2,S");
432        fs::write(&src, "x").unwrap();
433        let mut file = MailFile {
434            path: src.clone(),
435            is_new: false,
436            flags: Flags::from_filename("7.host:2,S"),
437            size: 1,
438        };
439        file.flags.flagged = true;
440        let new_path = store_flags(&file).unwrap();
441        assert_eq!(new_path, tmp.path().join("cur/7.host:2,FS"));
442        assert!(!src.exists());
443    }
444
445    #[test]
446    fn discover_finds_children_and_siblings() {
447        let tmp = tempfile::tempdir().unwrap();
448        let inbox = tmp.path().join("inbox");
449        let sent = tmp.path().join("sent");
450        let sub = inbox.join(".archive");
451        for d in [&inbox, &sent, &sub] {
452            make_maildir(d);
453        }
454        fs::create_dir(tmp.path().join("not-a-maildir")).unwrap();
455        let found = discover(&inbox);
456        assert!(found.contains(&inbox));
457        assert!(found.contains(&sent));
458        assert!(found.contains(&sub));
459        assert!(!found.iter().any(|p| p.ends_with("not-a-maildir")));
460    }
461}