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.
97            if !entry.path().is_file() {
98                continue;
99            }
100            let name = entry.file_name();
101            let name = name.to_string_lossy();
102            if name.starts_with('.') {
103                continue;
104            }
105            out.push(MailFile {
106                path: entry.path(),
107                is_new,
108                flags: Flags::from_filename(&name),
109                size: size_from_name(&name).unwrap_or(std::fs::metadata(entry.path())?.len()),
110            });
111        }
112    }
113    Ok(out)
114}
115
116/// Filename without the `:2,` info suffix.
117fn base_name(name: &str) -> &str {
118    name.rsplit_once(":2,").map_or(name, |(base, _)| base)
119}
120
121/// The `,S=<bytes>` message size some tools (and our IMAP cache) put in
122/// the base name, authoritative when present, since a cached file may
123/// hold only the headers.
124fn size_from_name(name: &str) -> Option<u64> {
125    let rest = base_name(name).rsplit_once(",S=")?.1;
126    let end = rest
127        .find(|c: char| !c.is_ascii_digit())
128        .unwrap_or(rest.len());
129    rest[..end].parse().ok()
130}
131
132/// Write the file's current flags to disk by renaming it into `cur/`
133/// with the matching info suffix. Returns the new path.
134pub fn store_flags(file: &MailFile) -> Result<PathBuf> {
135    let name = file
136        .path
137        .file_name()
138        .context("mail file has no filename")?
139        .to_string_lossy()
140        .into_owned();
141    let maildir = file
142        .path
143        .parent()
144        .and_then(Path::parent)
145        .context("mail file is not inside a maildir")?;
146    let target = maildir
147        .join("cur")
148        .join(format!("{}{}", base_name(&name), file.flags.to_info()));
149    if target != file.path {
150        fs::rename(&file.path, &target)
151            .with_context(|| format!("renaming to {}", target.display()))?;
152    }
153    Ok(target)
154}
155
156pub fn remove(file: &MailFile) -> Result<()> {
157    fs::remove_file(&file.path).with_context(|| format!("removing {}", file.path.display()))
158}
159
160pub fn hostname() -> String {
161    fs::read_to_string("/proc/sys/kernel/hostname")
162        .map(|s| s.trim().to_string())
163        .ok()
164        .filter(|s| !s.is_empty())
165        .unwrap_or_else(|| "localhost".into())
166}
167
168/// Create an empty maildir (cur/new/tmp).
169pub fn create(dir: &Path) -> Result<()> {
170    for sub in ["cur", "new", "tmp"] {
171        fs::create_dir_all(dir.join(sub))
172            .with_context(|| format!("creating {}", dir.join(sub).display()))?;
173    }
174    Ok(())
175}
176
177/// Deliver a message into a maildir the spec way: write to tmp/, then
178/// rename into cur/ with the given flags. Returns the delivered path.
179pub fn deliver(dir: &Path, content: &[u8], flags: Flags) -> Result<PathBuf> {
180    use std::sync::atomic::{AtomicUsize, Ordering};
181    static COUNTER: AtomicUsize = AtomicUsize::new(0);
182    if !dir.join("cur").is_dir() || !dir.join("tmp").is_dir() {
183        bail!("{} is not a maildir", dir.display());
184    }
185    let epoch = std::time::SystemTime::now()
186        .duration_since(std::time::UNIX_EPOCH)
187        .unwrap_or_default()
188        .as_secs();
189    let name = format!(
190        "{epoch}.{}_{}.{}",
191        std::process::id(),
192        COUNTER.fetch_add(1, Ordering::Relaxed),
193        hostname(),
194    );
195    let tmp = dir.join("tmp").join(&name);
196    fs::write(&tmp, content).with_context(|| format!("writing {}", tmp.display()))?;
197    let target = dir.join("cur").join(format!("{name}{}", flags.to_info()));
198    fs::rename(&tmp, &target).with_context(|| format!("renaming to {}", target.display()))?;
199    Ok(target)
200}
201
202/// Messages waiting in `new/`: the cheap new-mail count for the
203/// folder browser and the cross-mailbox poll. 0 for non-maildirs.
204pub fn new_count(dir: &Path) -> usize {
205    dir.join("new")
206        .read_dir()
207        .map(|entries| {
208            entries
209                .filter_map(|e| e.ok())
210                .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
211                .count()
212        })
213        .unwrap_or(0)
214}
215
216/// Find a nearby maildir whose name (ignoring a leading dot) matches one
217/// of `names` case-insensitively, e.g. Sent/.Sent or Drafts.
218pub fn find_special(dir: &Path, names: &[&str]) -> Option<PathBuf> {
219    discover(dir).into_iter().find(|p| {
220        p.file_name().is_some_and(|n| {
221            let n = n.to_string_lossy();
222            let n = n.trim_start_matches('.');
223            names.iter().any(|w| n.eq_ignore_ascii_case(w))
224        })
225    })
226}
227
228/// Maildirs to offer in the folder browser: subdirectories of `dir`
229/// (Maildir++ style children) and of its parent (sibling mailboxes).
230pub fn discover(dir: &Path) -> Vec<PathBuf> {
231    fn push_children(parent: &Path, out: &mut Vec<PathBuf>) {
232        let Ok(entries) = parent.read_dir() else {
233            return;
234        };
235        for entry in entries.flatten() {
236            let p = entry.path();
237            if p.is_dir() && p.join("cur").is_dir() && p.join("new").is_dir() {
238                out.push(p);
239            }
240        }
241    }
242    let mut out = Vec::new();
243    push_children(dir, &mut out);
244    if let Some(parent) = dir.parent() {
245        push_children(parent, &mut out);
246    }
247    out.sort();
248    out.dedup();
249    out
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn make_maildir(root: &Path) {
257        for sub in ["cur", "new", "tmp"] {
258            fs::create_dir_all(root.join(sub)).unwrap();
259        }
260    }
261
262    #[test]
263    fn flags_from_filename() {
264        let f = Flags::from_filename("1234.abc.host:2,FS");
265        assert!(f.seen && f.flagged);
266        assert!(!f.deleted && !f.answered && !f.draft);
267        assert_eq!(Flags::from_filename("1234.abc.host"), Flags::default());
268    }
269
270    #[test]
271    fn flags_roundtrip_info() {
272        let f = Flags {
273            seen: true,
274            flagged: true,
275            deleted: true,
276            ..Default::default()
277        };
278        assert_eq!(f.to_info(), ":2,FST");
279        assert_eq!(Flags::from_filename(&format!("x{}", f.to_info())), f);
280    }
281
282    #[test]
283    fn size_comes_from_name_when_present() {
284        assert_eq!(size_from_name("12.rmut,S=345:2,S"), Some(345));
285        assert_eq!(size_from_name("12.rmut,S=345"), Some(345));
286        assert_eq!(size_from_name("1234.abc.host:2,S"), None);
287        let tmp = tempfile::tempdir().unwrap();
288        make_maildir(tmp.path());
289        fs::write(tmp.path().join("cur/9.rmut,S=777:2,S"), "tiny").unwrap();
290        let files = scan(tmp.path()).unwrap();
291        assert_eq!(files[0].size, 777);
292    }
293
294    #[test]
295    fn scan_rejects_non_maildir() {
296        let tmp = tempfile::tempdir().unwrap();
297        assert!(scan(tmp.path()).is_err());
298    }
299
300    #[test]
301    fn scan_finds_new_and_cur() {
302        let tmp = tempfile::tempdir().unwrap();
303        make_maildir(tmp.path());
304        fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: a\n\nx\n").unwrap();
305        fs::write(tmp.path().join("new/2.host"), "Subject: b\n\ny\n").unwrap();
306        let mut files = scan(tmp.path()).unwrap();
307        files.sort_by(|a, b| a.path.cmp(&b.path));
308        assert_eq!(files.len(), 2);
309        assert!(!files[0].is_new && files[0].flags.seen);
310        assert!(files[1].is_new);
311        assert!(files[0].size > 0);
312    }
313
314    #[test]
315    fn store_flags_moves_new_to_cur_with_info() {
316        let tmp = tempfile::tempdir().unwrap();
317        make_maildir(tmp.path());
318        let src = tmp.path().join("new/99.host");
319        fs::write(&src, "Subject: a\n\nx\n").unwrap();
320        let file = MailFile {
321            path: src.clone(),
322            is_new: true,
323            flags: Flags {
324                seen: true,
325                ..Default::default()
326            },
327            size: 14,
328        };
329        let new_path = store_flags(&file).unwrap();
330        assert_eq!(new_path, tmp.path().join("cur/99.host:2,S"));
331        assert!(!src.exists());
332        assert!(new_path.exists());
333    }
334
335    #[test]
336    fn store_flags_rewrites_existing_info() {
337        let tmp = tempfile::tempdir().unwrap();
338        make_maildir(tmp.path());
339        let src = tmp.path().join("cur/7.host:2,S");
340        fs::write(&src, "x").unwrap();
341        let mut file = MailFile {
342            path: src.clone(),
343            is_new: false,
344            flags: Flags::from_filename("7.host:2,S"),
345            size: 1,
346        };
347        file.flags.flagged = true;
348        let new_path = store_flags(&file).unwrap();
349        assert_eq!(new_path, tmp.path().join("cur/7.host:2,FS"));
350        assert!(!src.exists());
351    }
352
353    #[test]
354    fn discover_finds_children_and_siblings() {
355        let tmp = tempfile::tempdir().unwrap();
356        let inbox = tmp.path().join("inbox");
357        let sent = tmp.path().join("sent");
358        let sub = inbox.join(".archive");
359        for d in [&inbox, &sent, &sub] {
360            make_maildir(d);
361        }
362        fs::create_dir(tmp.path().join("not-a-maildir")).unwrap();
363        let found = discover(&inbox);
364        assert!(found.contains(&inbox));
365        assert!(found.contains(&sent));
366        assert!(found.contains(&sub));
367        assert!(!found.iter().any(|p| p.ends_with("not-a-maildir")));
368    }
369}