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
162pub fn remove(file: &MailFile) -> Result<()> {
163    fs::remove_file(&file.path).with_context(|| format!("removing {}", file.path.display()))
164}
165
166pub fn hostname() -> String {
167    fs::read_to_string("/proc/sys/kernel/hostname")
168        .map(|s| s.trim().to_string())
169        .ok()
170        .filter(|s| !s.is_empty())
171        .unwrap_or_else(|| "localhost".into())
172}
173
174/// Create an empty maildir (cur/new/tmp).
175pub fn create(dir: &Path) -> Result<()> {
176    for sub in ["cur", "new", "tmp"] {
177        fs::create_dir_all(dir.join(sub))
178            .with_context(|| format!("creating {}", dir.join(sub).display()))?;
179    }
180    Ok(())
181}
182
183/// Deliver a message into a maildir the spec way: write to tmp/, then
184/// rename into cur/ with the given flags. Returns the delivered path.
185pub fn deliver(dir: &Path, content: &[u8], flags: Flags) -> Result<PathBuf> {
186    use std::sync::atomic::{AtomicUsize, Ordering};
187    static COUNTER: AtomicUsize = AtomicUsize::new(0);
188    if !dir.join("cur").is_dir() || !dir.join("tmp").is_dir() {
189        bail!("{} is not a maildir", dir.display());
190    }
191    let epoch = std::time::SystemTime::now()
192        .duration_since(std::time::UNIX_EPOCH)
193        .unwrap_or_default()
194        .as_secs();
195    let name = format!(
196        "{epoch}.{}_{}.{}",
197        std::process::id(),
198        COUNTER.fetch_add(1, Ordering::Relaxed),
199        hostname(),
200    );
201    let tmp = dir.join("tmp").join(&name);
202    fs::write(&tmp, content).with_context(|| format!("writing {}", tmp.display()))?;
203    let target = dir.join("cur").join(format!("{name}{}", flags.to_info()));
204    fs::rename(&tmp, &target).with_context(|| format!("renaming to {}", target.display()))?;
205    Ok(target)
206}
207
208/// Messages waiting in `new/`: the cheap new-mail count for the
209/// folder browser and the cross-mailbox poll. 0 for non-maildirs.
210pub fn new_count(dir: &Path) -> usize {
211    dir.join("new")
212        .read_dir()
213        .map(|entries| {
214            entries
215                .filter_map(|e| e.ok())
216                .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
217                .count()
218        })
219        .unwrap_or(0)
220}
221
222/// Find a nearby maildir whose name (ignoring a leading dot) matches one
223/// of `names` case-insensitively, e.g. Sent/.Sent or Drafts.
224pub fn find_special(dir: &Path, names: &[&str]) -> Option<PathBuf> {
225    discover(dir).into_iter().find(|p| {
226        p.file_name().is_some_and(|n| {
227            let n = n.to_string_lossy();
228            let n = n.trim_start_matches('.');
229            names.iter().any(|w| n.eq_ignore_ascii_case(w))
230        })
231    })
232}
233
234/// Maildirs to offer in the folder browser: subdirectories of `dir`
235/// (Maildir++ style children) and of its parent (sibling mailboxes).
236pub fn discover(dir: &Path) -> Vec<PathBuf> {
237    fn push_children(parent: &Path, out: &mut Vec<PathBuf>) {
238        let Ok(entries) = parent.read_dir() else {
239            return;
240        };
241        for entry in entries.flatten() {
242            let p = entry.path();
243            if p.is_dir() && p.join("cur").is_dir() && p.join("new").is_dir() {
244                out.push(p);
245            }
246        }
247    }
248    let mut out = Vec::new();
249    push_children(dir, &mut out);
250    if let Some(parent) = dir.parent() {
251        push_children(parent, &mut out);
252    }
253    out.sort();
254    out.dedup();
255    out
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn make_maildir(root: &Path) {
263        for sub in ["cur", "new", "tmp"] {
264            fs::create_dir_all(root.join(sub)).unwrap();
265        }
266    }
267
268    #[test]
269    fn flags_from_filename() {
270        let f = Flags::from_filename("1234.abc.host:2,FS");
271        assert!(f.seen && f.flagged);
272        assert!(!f.deleted && !f.answered && !f.draft);
273        assert_eq!(Flags::from_filename("1234.abc.host"), Flags::default());
274    }
275
276    #[test]
277    fn flags_roundtrip_info() {
278        let f = Flags {
279            seen: true,
280            flagged: true,
281            deleted: true,
282            ..Default::default()
283        };
284        assert_eq!(f.to_info(), ":2,FST");
285        assert_eq!(Flags::from_filename(&format!("x{}", f.to_info())), f);
286    }
287
288    #[test]
289    fn size_comes_from_name_when_present() {
290        assert_eq!(size_from_name("12.rmut,S=345:2,S"), Some(345));
291        assert_eq!(size_from_name("12.rmut,S=345"), Some(345));
292        assert_eq!(size_from_name("1234.abc.host:2,S"), None);
293        let tmp = tempfile::tempdir().unwrap();
294        make_maildir(tmp.path());
295        fs::write(tmp.path().join("cur/9.rmut,S=777:2,S"), "tiny").unwrap();
296        let files = scan(tmp.path()).unwrap();
297        assert_eq!(files[0].size, 777);
298    }
299
300    #[test]
301    fn scan_skips_what_is_gone_instead_of_failing() {
302        // A dangling link reads like a message another client moved
303        // away between read_dir and the stat: skipped, not an error.
304        let tmp = tempfile::tempdir().unwrap();
305        make_maildir(tmp.path());
306        fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: kept\n\nx\n").unwrap();
307        std::os::unix::fs::symlink(tmp.path().join("gone"), tmp.path().join("cur/2.host:2,S"))
308            .unwrap();
309        let files = scan(tmp.path()).unwrap();
310        assert_eq!(files.len(), 1);
311        assert_eq!(files[0].size, 17);
312    }
313
314    #[test]
315    fn scan_rejects_non_maildir() {
316        let tmp = tempfile::tempdir().unwrap();
317        assert!(scan(tmp.path()).is_err());
318    }
319
320    #[test]
321    fn scan_finds_new_and_cur() {
322        let tmp = tempfile::tempdir().unwrap();
323        make_maildir(tmp.path());
324        fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: a\n\nx\n").unwrap();
325        fs::write(tmp.path().join("new/2.host"), "Subject: b\n\ny\n").unwrap();
326        let mut files = scan(tmp.path()).unwrap();
327        files.sort_by(|a, b| a.path.cmp(&b.path));
328        assert_eq!(files.len(), 2);
329        assert!(!files[0].is_new && files[0].flags.seen);
330        assert!(files[1].is_new);
331        assert!(files[0].size > 0);
332    }
333
334    #[test]
335    fn store_flags_moves_new_to_cur_with_info() {
336        let tmp = tempfile::tempdir().unwrap();
337        make_maildir(tmp.path());
338        let src = tmp.path().join("new/99.host");
339        fs::write(&src, "Subject: a\n\nx\n").unwrap();
340        let file = MailFile {
341            path: src.clone(),
342            is_new: true,
343            flags: Flags {
344                seen: true,
345                ..Default::default()
346            },
347            size: 14,
348        };
349        let new_path = store_flags(&file).unwrap();
350        assert_eq!(new_path, tmp.path().join("cur/99.host:2,S"));
351        assert!(!src.exists());
352        assert!(new_path.exists());
353    }
354
355    #[test]
356    fn store_flags_rewrites_existing_info() {
357        let tmp = tempfile::tempdir().unwrap();
358        make_maildir(tmp.path());
359        let src = tmp.path().join("cur/7.host:2,S");
360        fs::write(&src, "x").unwrap();
361        let mut file = MailFile {
362            path: src.clone(),
363            is_new: false,
364            flags: Flags::from_filename("7.host:2,S"),
365            size: 1,
366        };
367        file.flags.flagged = true;
368        let new_path = store_flags(&file).unwrap();
369        assert_eq!(new_path, tmp.path().join("cur/7.host:2,FS"));
370        assert!(!src.exists());
371    }
372
373    #[test]
374    fn discover_finds_children_and_siblings() {
375        let tmp = tempfile::tempdir().unwrap();
376        let inbox = tmp.path().join("inbox");
377        let sent = tmp.path().join("sent");
378        let sub = inbox.join(".archive");
379        for d in [&inbox, &sent, &sub] {
380            make_maildir(d);
381        }
382        fs::create_dir(tmp.path().join("not-a-maildir")).unwrap();
383        let found = discover(&inbox);
384        assert!(found.contains(&inbox));
385        assert!(found.contains(&sent));
386        assert!(found.contains(&sub));
387        assert!(!found.iter().any(|p| p.ends_with("not-a-maildir")));
388    }
389}