1use crate::card::{Card, ImapOrigin};
17
18pub const JUNK: char = 'a';
20pub const NOT_JUNK: char = 'b';
22pub const KEYWORDS: [(char, &str); 2] = [(JUNK, "$Junk"), (NOT_JUNK, "$NotJunk")];
24
25pub fn keyword_letter(keyword: &str) -> Option<char> {
28 KEYWORDS.iter().find(|(_, name)| name.eq_ignore_ascii_case(keyword)).map(|(letter, _)| *letter)
29}
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub struct Keywords {
34 pub junk: bool,
36 pub not_junk: bool,
38}
39
40pub 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
46fn 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
56pub const INFO: char = if cfg!(windows) { '!' } else { ':' };
60
61pub fn unique_part(name: &str) -> &str {
63 name.split([':', '!']).next().unwrap_or(name)
64}
65
66fn 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
74pub fn read_messages(dir: &Path) -> Vec<Card> {
76 read_messages_where(dir, |_| true)
77}
78
79pub 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
102pub 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 static KEPT: std::cell::RefCell<Option<KeptCards>> = const { std::cell::RefCell::new(None) };
134}
135
136#[cfg(test)]
137thread_local! {
138 static PARSED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
140}
141
142fn 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
152pub 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
186pub 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
194pub struct Fetched<'a> {
196 pub origin: ImapOrigin,
197 pub flags: String,
199 pub received: Option<i64>,
201 pub raw: &'a [u8],
202}
203
204pub 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 #[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 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
238pub 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
245pub 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
260pub 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
278pub 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
285pub fn message_ids(dir: &Path) -> std::collections::BTreeSet<String> {
287 read_messages(dir).into_iter().filter_map(|card| card.message_id).collect()
288}
289
290fn 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 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 #[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 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 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 #[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 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}