Skip to main content

sioul_core/
maildir.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Messages on disk: a Maildir (`new/` and `cur/`), or a plain folder of `.eml` files.
5//!
6//! Sioul writes what it fetches as a Maildir (<https://cr.yp.to/proto/maildir.html>),
7//! so notmuch, mutt or any other reader can open the same folders. Each file
8//! name carries where the message sits on the server, `U<uidvalidity>-<uid>`,
9//! so the Porch knows what you have seen without opening the files.
10//!
11//! After the flags, two IMAP keywords (RFC 9051 §2.3.2) are kept as Dovecot
12//! keeps them: a lowercase letter each, named in the folder's
13//! `dovecot-keywords` file. They say what you, or a mail client, said of a
14//! message: junk (`$Junk`), or not junk (`$NotJunk`).
15
16use crate::card::{Card, ImapOrigin};
17
18/// `$Junk`'s letter.
19pub const JUNK: char = 'a';
20/// `$NotJunk`'s letter.
21pub const NOT_JUNK: char = 'b';
22/// The keywords kept, by letter, as the folder's `dovecot-keywords` file numbers them (a is 0, b is 1).
23pub const KEYWORDS: [(char, &str); 2] = [(JUNK, "$Junk"), (NOT_JUNK, "$NotJunk")];
24
25/// The letter of an IMAP keyword Sioul keeps; none for the others. Case
26/// does not matter: `$junk` is `$Junk` to IMAP servers.
27pub fn keyword_letter(keyword: &str) -> Option<char> {
28    KEYWORDS.iter().find(|(_, name)| name.eq_ignore_ascii_case(keyword)).map(|(letter, _)| *letter)
29}
30
31/// What a stored message's file name says of it, junk or not.
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub struct Keywords {
34    /// `$Junk`.
35    pub junk: bool,
36    /// `$NotJunk`.
37    pub not_junk: bool,
38}
39
40/// The keywords in a stored message's file name.
41pub fn keywords_of(path: &Path) -> Keywords {
42    let flags = flags_of(path);
43    Keywords { junk: flags.contains(JUNK), not_junk: flags.contains(NOT_JUNK) }
44}
45
46/// The folder's `dovecot-keywords` file, which names the letters, written
47/// once, when a letter is first kept there: Dovecot reads the same folder alike.
48fn name_keywords(folder: &Path, flags: &str) {
49    let file = folder.join("dovecot-keywords");
50    if KEYWORDS.iter().any(|(letter, _)| flags.contains(*letter)) && !file.exists() {
51        let names: String = KEYWORDS.iter().enumerate().map(|(n, (_, name))| format!("{n} {name}\n")).collect();
52        let _ = std::fs::write(file, names);
53    }
54}
55
56/// What separates a message's unique name from its flags: ":" by the Maildir
57/// convention, "!" on Windows, where ":" cannot be in a file name (mbsync does
58/// the same). Both are read on every system.
59pub const INFO: char = if cfg!(windows) { '!' } else { ':' };
60
61/// "1759400000.U17-4521.sioul:2,FS" → "1759400000.U17-4521.sioul".
62pub fn unique_part(name: &str) -> &str {
63    name.split([':', '!']).next().unwrap_or(name)
64}
65
66/// The flags after ":2," or "!2,".
67fn flags_part(name: &str) -> Option<&str> {
68    name.split_once(":2,").or_else(|| name.split_once("!2,")).map(|(_, flags)| flags)
69}
70use std::io::Write;
71use std::path::{Path, PathBuf};
72use std::time::{Duration, SystemTime, UNIX_EPOCH};
73
74/// Every message of a Maildir or folder, oldest first.
75pub fn read_messages(dir: &Path) -> Vec<Card> {
76    read_messages_where(dir, |_| true)
77}
78
79/// The messages whose server origin passes `keep`, oldest first; the others are not even read.
80pub fn read_messages_where(dir: &Path, keep: impl Fn(Option<ImapOrigin>) -> bool) -> Vec<Card> {
81    let maildir = [dir.join("new"), dir.join("cur")];
82    let folders: Vec<PathBuf> = if maildir.iter().any(|d| d.is_dir()) {
83        maildir.into_iter().filter(|d| d.is_dir()).collect()
84    } else {
85        vec![dir.to_path_buf()]
86    };
87    let mut cards: Vec<Card> = folders.iter().flat_map(|f| read_folder(f, &keep)).collect();
88    cards.sort_by_key(|c| c.date.unwrap_or(0));
89    cards
90}
91
92fn read_folder(folder: &Path, keep: &impl Fn(Option<ImapOrigin>) -> bool) -> Vec<Card> {
93    let Ok(entries) = std::fs::read_dir(folder) else { return Vec::new() };
94    entries
95        .filter_map(Result::ok)
96        .map(|e| e.path())
97        .filter(|p| p.is_file() && !is_hidden(p) && keep(origin_of(p)))
98        .filter_map(|p| read_one(&p))
99        .collect()
100}
101
102/// One message file as a card. While a `CardsKept` lives in this thread, a
103/// message already read in it is not parsed again: the file's name less its
104/// flags, its size and its time say it is the same (a message's content never
105/// changes; its flags move it between `new/` and `cur/`).
106pub fn read_one(path: &Path) -> Option<Card> {
107    let key = kept_key(path);
108    if let Some(mut card) = key.as_ref().and_then(|key| KEPT.with(|kept| kept.borrow().as_ref().and_then(|kept| kept.get(key).cloned()))) {
109        card.path = Some(path.to_path_buf());
110        card.origin = origin_of(path);
111        return Some(card);
112    }
113    let raw = std::fs::read(path).ok()?;
114    #[cfg(test)]
115    PARSED.with(|n| n.set(n.get() + 1));
116    let mut card = Card::from_bytes(&raw)?;
117    card.path = Some(path.to_path_buf());
118    card.origin = origin_of(path);
119    if let Some(key) = key {
120        KEPT.with(|kept| {
121            if let Some(kept) = kept.borrow_mut().as_mut() {
122                kept.insert(key, card.clone());
123            }
124        });
125    }
126    Some(card)
127}
128
129type KeptCards = std::collections::HashMap<(String, u64, u64), Card>;
130
131thread_local! {
132    /// The cards read in this thread while a `CardsKept` lives.
133    static KEPT: std::cell::RefCell<Option<KeptCards>> = const { std::cell::RefCell::new(None) };
134}
135
136#[cfg(test)]
137thread_local! {
138    /// Message files parsed in this test's thread.
139    static PARSED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
140}
141
142/// A message file as `CardsKept` knows it: its name less its flags, its size, its time (nanoseconds).
143fn kept_key(path: &Path) -> Option<(String, u64, u64)> {
144    if KEPT.with(|kept| kept.borrow().is_none()) {
145        return None;
146    }
147    let meta = std::fs::metadata(path).ok()?;
148    let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?.as_nanos() as u64;
149    Some((unique_part(path.file_name()?.to_str()?).to_string(), meta.len(), modified))
150}
151
152/// While it lives, each message file this thread reads is parsed once
153/// (`read_one`): one look at the mail (the Porch, then the money lines it
154/// holds, each going through every message kept) parses each message once,
155/// not once each. Nothing is kept once it ends: a card holds a few kilobytes,
156/// and every message kept would stay in memory between looks. One begun
157/// inside another changes nothing.
158pub struct CardsKept(bool);
159
160impl CardsKept {
161    pub fn begin() -> CardsKept {
162        let outer = KEPT.with(|kept| {
163            let mut kept = kept.borrow_mut();
164            let outer = kept.is_some();
165            if !outer {
166                *kept = Some(KeptCards::new());
167            }
168            outer
169        });
170        CardsKept(outer)
171    }
172}
173
174impl Drop for CardsKept {
175    fn drop(&mut self) {
176        if !self.0 {
177            KEPT.with(|kept| *kept.borrow_mut() = None);
178        }
179    }
180}
181
182fn is_hidden(path: &Path) -> bool {
183    path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.'))
184}
185
186/// `1759400000.U1700000000-4521.sioul:2,S` → validity 1700000000, UID 4521.
187pub fn origin_of(path: &Path) -> Option<ImapOrigin> {
188    let name = path.file_name()?.to_str()?;
189    let unique = unique_part(name).split('.').find(|part| part.starts_with('U'))?;
190    let (validity, uid) = unique[1..].split_once('-')?;
191    Some(ImapOrigin { validity: validity.parse().ok()?, uid: uid.parse().ok()? })
192}
193
194/// A message fetched from the server, ready to be written.
195pub struct Fetched<'a> {
196    pub origin: ImapOrigin,
197    /// The server's IMAP flags, as Maildir letters (`S` seen, `R` replied, `F` flagged…).
198    pub flags: String,
199    /// When the server received it (INTERNALDATE), as Unix seconds.
200    pub received: Option<i64>,
201    pub raw: &'a [u8],
202}
203
204/// Writes one message into a Maildir: first in `tmp/`, then moved, so a reader
205/// never sees half a message. Mail without any flag goes to `new/`; mail with
206/// some (read, flagged, a keyword kept) to `cur/` with its letters, as the
207/// Maildir convention says, so that none is lost until the next sync.
208pub fn store(root: &Path, message: &Fetched) -> std::io::Result<PathBuf> {
209    for sub in ["tmp", "new", "cur"] {
210        std::fs::create_dir_all(root.join(sub))?;
211    }
212    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
213    let unique = format!("{now}.U{}-{}.sioul", message.origin.validity, message.origin.uid);
214    let temporary = root.join("tmp").join(&unique);
215    let mut options = std::fs::OpenOptions::new();
216    options.write(true).create(true).truncate(true);
217    // Mail is yours alone, as mail servers keep it: not readable by the other
218    // accounts of a shared computer, whatever the umask.
219    #[cfg(unix)]
220    {
221        use std::os::unix::fs::OpenOptionsExt;
222        options.mode(0o600);
223    }
224    let mut file = options.open(&temporary)?;
225    file.write_all(message.raw)?;
226    file.sync_all()?;
227    // The file's date is the server's arrival date, which tools sorting by date expect.
228    if let Some(seconds) = message.received.and_then(|s| u64::try_from(s).ok()) {
229        file.set_modified(UNIX_EPOCH + Duration::from_secs(seconds))?;
230    }
231    drop(file);
232    let target = if message.flags.is_empty() { root.join("new").join(unique) } else { root.join("cur").join(format!("{unique}{INFO}2,{}", sorted(&message.flags))) };
233    name_keywords(root, &message.flags);
234    std::fs::rename(&temporary, &target)?;
235    Ok(target)
236}
237
238/// The Maildir flags of a stored message, from its name, with the keywords'
239/// letters: "FS", "Sb"; none in `new/`.
240pub fn flags_of(path: &Path) -> String {
241    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
242    flags_part(name).map_or_else(String::new, str::to_string)
243}
244
245/// Gives a stored message other flags: it is renamed in `cur/`, where every
246/// message that has been seen or flagged lives. Returns where it is now.
247pub fn set_flags(path: &Path, flags: &str) -> std::io::Result<PathBuf> {
248    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
249    let unique = unique_part(name);
250    let folder = path.parent().and_then(Path::parent).ok_or_else(|| std::io::Error::other("not in a Maildir"))?;
251    let target = folder.join("cur").join(format!("{unique}{INFO}2,{}", sorted(flags)));
252    if target != path {
253        std::fs::create_dir_all(folder.join("cur"))?;
254        name_keywords(folder, flags);
255        std::fs::rename(path, &target)?;
256    }
257    Ok(target)
258}
259
260/// Where a stored message is now: its flags rename it, and a first reading
261/// moves it from `new/` to `cur/`. The part of the name before ":2," never changes.
262pub fn locate(path: &Path) -> Option<PathBuf> {
263    if path.is_file() {
264        return Some(path.to_path_buf());
265    }
266    let name = path.file_name()?.to_str()?;
267    let unique = unique_part(name);
268    let folder = path.parent()?.parent()?;
269    ["cur", "new"].iter().find_map(|sub| {
270        std::fs::read_dir(folder.join(sub))
271            .ok()?
272            .filter_map(Result::ok)
273            .map(|e| e.path())
274            .find(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| unique_part(n) == unique))
275    })
276}
277
278/// Whether a Maildir holds a message not read yet: one in `new/`, or one in
279/// `cur/` without the S flag. Only names are read.
280pub fn has_unread(dir: &Path) -> bool {
281    let names = |sub: &str| std::fs::read_dir(dir.join(sub)).into_iter().flatten().filter_map(Result::ok).filter_map(|e| e.file_name().into_string().ok());
282    names("new").any(|n| !n.starts_with('.')) || names("cur").any(|n| !n.starts_with('.') && !flags_part(&n).is_some_and(|f| f.contains('S')))
283}
284
285/// The Message-IDs of a Maildir's messages, from their headers only.
286pub fn message_ids(dir: &Path) -> std::collections::BTreeSet<String> {
287    read_messages(dir).into_iter().filter_map(|card| card.message_id).collect()
288}
289
290/// Maildir wants the flag letters in ASCII order.
291fn sorted(flags: &str) -> String {
292    let mut letters: Vec<char> = flags.chars().collect();
293    letters.sort_unstable();
294    letters.dedup();
295    letters.into_iter().collect()
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn stores_and_reads_back_with_its_origin() {
304        let root = std::env::temp_dir().join(format!("sioul-maildir-{}", std::process::id()));
305        let raw = b"From: Jane <jane@example.org>\r\nSubject: Hello\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\nHi.\r\n";
306        let unread = Fetched { origin: ImapOrigin { validity: 7, uid: 41 }, flags: String::new(), received: Some(1_790_000_000), raw };
307        let read = Fetched { origin: ImapOrigin { validity: 7, uid: 42 }, flags: "SF".into(), received: None, raw };
308        let first = store(&root, &unread).unwrap();
309        let second = store(&root, &read).unwrap();
310        assert!(first.starts_with(root.join("new")));
311        assert!(second.to_str().unwrap().ends_with(&format!("{INFO}2,FS")));
312        #[cfg(unix)]
313        {
314            use std::os::unix::fs::PermissionsExt;
315            assert_eq!(std::fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600, "yours alone");
316        }
317        let cards = read_messages(&root);
318        assert_eq!(cards.len(), 2);
319        assert!(cards.iter().any(|c| c.origin == Some(ImapOrigin { validity: 7, uid: 42 })));
320        let newer = read_messages_where(&root, |o| o.is_some_and(|o| o.uid > 41));
321        assert_eq!(newer.len(), 1);
322        std::fs::remove_dir_all(&root).unwrap();
323    }
324
325    #[test]
326    fn flags_rename_the_file() {
327        let root = std::env::temp_dir().join(format!("sioul-flags-{}", std::process::id()));
328        let raw = b"Subject: x\r\n\r\nx\r\n";
329        let unread = store(&root, &Fetched { origin: ImapOrigin { validity: 3, uid: 9 }, flags: String::new(), received: None, raw }).unwrap();
330        assert_eq!(flags_of(&unread), "");
331        let read = set_flags(&unread, "SF").unwrap();
332        assert!(read.starts_with(root.join("cur")) && flags_of(&read) == "FS");
333        assert_eq!(origin_of(&read), Some(ImapOrigin { validity: 3, uid: 9 }));
334        let unflagged = set_flags(&read, "S").unwrap();
335        assert_eq!(flags_of(&unflagged), "S");
336        // The old name still finds the message.
337        assert_eq!(locate(&unread), Some(unflagged.clone()));
338        assert_eq!(locate(&read), Some(unflagged));
339        std::fs::remove_dir_all(&root).unwrap();
340    }
341
342    /// `$Junk` and `$NotJunk` are kept in the file name, as Dovecot keeps
343    /// keywords, from the first store: unread mail with one goes to `cur/`.
344    #[test]
345    fn junk_keywords_are_kept() {
346        let root = std::env::temp_dir().join(format!("sioul-keywords-{}", std::process::id()));
347        let raw = b"Subject: x\r\n\r\nx\r\n";
348        assert_eq!((keyword_letter("$NotJunk"), keyword_letter("$junk"), keyword_letter("NonJunk")), (Some(NOT_JUNK), Some(JUNK), None));
349        let plain = store(&root, &Fetched { origin: ImapOrigin { validity: 3, uid: 1 }, flags: String::new(), received: None, raw }).unwrap();
350        assert!(plain.starts_with(root.join("new")) && !root.join("dovecot-keywords").exists());
351        let said = store(&root, &Fetched { origin: ImapOrigin { validity: 3, uid: 2 }, flags: NOT_JUNK.to_string(), received: None, raw }).unwrap();
352        assert!(said.starts_with(root.join("cur")), "unread, with its keyword: {}", said.display());
353        assert_eq!(keywords_of(&said), Keywords { junk: false, not_junk: true });
354        assert_eq!(std::fs::read_to_string(root.join("dovecot-keywords")).unwrap(), "0 $Junk\n1 $NotJunk\n");
355        // Read, then said junk: the letters follow the flags, in ASCII order.
356        let junked = set_flags(&said, &format!("S{JUNK}")).unwrap();
357        assert_eq!(flags_of(&junked), format!("S{JUNK}"));
358        assert_eq!(keywords_of(&junked), Keywords { junk: true, not_junk: false });
359        assert_eq!(keywords_of(&plain), Keywords::default());
360        std::fs::remove_dir_all(&root).unwrap();
361    }
362
363    #[test]
364    fn origin_needs_the_sioul_pattern() {
365        assert_eq!(origin_of(Path::new("cur/1759400000.U17-4521.sioul:2,S")), Some(ImapOrigin { validity: 17, uid: 4521 }));
366        assert_eq!(origin_of(Path::new("01-bank.eml")), None);
367        assert_eq!(origin_of(Path::new("cur/1759400000.M20P3.host:2,S")), None);
368        // As Windows names them.
369        assert_eq!(origin_of(Path::new("cur/1759400000.U17-4521.sioul!2,S")), Some(ImapOrigin { validity: 17, uid: 4521 }));
370        assert_eq!(flags_of(Path::new("cur/1759400000.U17-4521.sioul!2,FS")), "FS");
371    }
372
373    /// Whether the phone parses each message once (the owner's question, 9
374    /// October 2026): within one look at the mail (`CardsKept`), each message
375    /// file is parsed once, however many passes read it, its flags moved
376    /// meanwhile too; outside one, as before.
377    #[test]
378    fn each_message_is_parsed_once_in_a_look() {
379        let dir = std::env::temp_dir().join(format!("sioul-cards-kept-{}", std::process::id()));
380        let _ = std::fs::remove_dir_all(&dir);
381        for sub in ["new", "cur", "tmp"] {
382            std::fs::create_dir_all(dir.join(sub)).unwrap();
383        }
384        for n in 0..3 {
385            std::fs::write(dir.join("new").join(format!("17594000{n}.U1700000000-{n}.sioul")), format!("From: a@example.org\r\nSubject: Hello {n}\r\nMessage-ID: <{n}@example.org>\r\n\r\nBody {n}\r\n")).unwrap();
386        }
387        let parsed = || PARSED.with(std::cell::Cell::get);
388        let before = parsed();
389        {
390            let _kept = CardsKept::begin();
391            assert_eq!(read_messages(&dir).len(), 3);
392            // Read, its flags moved: the same message.
393            let first = dir.join("new").join("175940000.U1700000000-0.sioul");
394            std::fs::rename(&first, dir.join("cur").join(format!("175940000.U1700000000-0.sioul{INFO}2,S"))).unwrap();
395            let again = read_messages(&dir);
396            assert_eq!(again.len(), 3);
397            assert!(again.iter().any(|c| c.path.as_ref().is_some_and(|p| p.to_string_lossy().ends_with(&format!("{INFO}2,S"))) && c.subject == "Hello 0"), "its path as it is now");
398            assert_eq!(parsed() - before, 3, "parsed once each");
399        }
400        assert_eq!(read_messages(&dir).len(), 3);
401        assert_eq!(parsed() - before, 6, "nothing kept once the look ended");
402        let _ = std::fs::remove_dir_all(&dir);
403    }
404}