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
290pub fn forget_account(account: &crate::config::Account) -> Result<Vec<PathBuf>, String> {
296 forget_account_under(account, &crate::config::data_dir().join("mail"), &crate::shield::AiCache::path(&account.id))
297}
298
299fn forget_account_under(account: &crate::config::Account, mail_root: &Path, ai_cache: &Path) -> Result<Vec<PathBuf>, String> {
300 let mut gone = Vec::new();
301 let plain = matches!(Path::new(&account.id).components().collect::<Vec<_>>().as_slice(), [std::path::Component::Normal(_)]);
303 if plain && account.maildir.is_none() {
304 let folder = mail_root.join(&account.id);
305 if folder.is_dir() && folder.parent() == Some(mail_root) {
306 std::fs::remove_dir_all(&folder).map_err(|e| format!("{}: {e}", folder.display()))?;
307 gone.push(folder);
308 }
309 }
310 if plain && ai_cache.is_file() {
311 std::fs::remove_file(ai_cache).map_err(|e| format!("{}: {e}", ai_cache.display()))?;
312 gone.push(ai_cache.to_path_buf());
313 }
314 Ok(gone)
315}
316
317fn sorted(flags: &str) -> String {
319 let mut letters: Vec<char> = flags.chars().collect();
320 letters.sort_unstable();
321 letters.dedup();
322 letters.into_iter().collect()
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
333 fn an_accounts_mail_is_forgotten_and_nothing_else() {
334 let root = std::env::temp_dir().join(format!("sioul-forget-{}", std::process::id()));
335 let mail = root.join("mail");
336 let cache = root.join("shield-work.toml");
337 for folder in ["work/cur", "work/new", "other/cur"] {
338 std::fs::create_dir_all(mail.join(folder)).unwrap();
339 }
340 std::fs::write(mail.join("work/cur/1:2,S"), "Subject: kept\r\n\r\nhello").unwrap();
341 std::fs::write(&cache, "x").unwrap();
342 let account = crate::config::Account::imap("work", "you@contoso.example", "outlook.office365.com", 993, crate::config::Security::Tls, None);
343 assert_eq!(forget_account_under(&account, &mail, &cache).unwrap(), vec![mail.join("work"), cache.clone()]);
344 assert!(!mail.join("work").exists() && mail.join("other").is_dir(), "that account's mail alone");
345 let mut chosen = account.clone();
347 chosen.id = "other".into();
348 chosen.maildir = Some(mail.join("other").display().to_string());
349 assert!(forget_account_under(&chosen, &mail, &root.join("none.toml")).unwrap().is_empty());
350 let mut odd = account.clone();
351 odd.id = "../mail".into();
352 assert!(forget_account_under(&odd, &mail, &root.join("none.toml")).unwrap().is_empty());
353 assert!(mail.join("other").is_dir());
354 std::fs::remove_dir_all(&root).unwrap();
355 }
356
357 #[test]
358 fn stores_and_reads_back_with_its_origin() {
359 let root = std::env::temp_dir().join(format!("sioul-maildir-{}", std::process::id()));
360 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";
361 let unread = Fetched { origin: ImapOrigin { validity: 7, uid: 41 }, flags: String::new(), received: Some(1_790_000_000), raw };
362 let read = Fetched { origin: ImapOrigin { validity: 7, uid: 42 }, flags: "SF".into(), received: None, raw };
363 let first = store(&root, &unread).unwrap();
364 let second = store(&root, &read).unwrap();
365 assert!(first.starts_with(root.join("new")));
366 assert!(second.to_str().unwrap().ends_with(&format!("{INFO}2,FS")));
367 #[cfg(unix)]
368 {
369 use std::os::unix::fs::PermissionsExt;
370 assert_eq!(std::fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600, "yours alone");
371 }
372 let cards = read_messages(&root);
373 assert_eq!(cards.len(), 2);
374 assert!(cards.iter().any(|c| c.origin == Some(ImapOrigin { validity: 7, uid: 42 })));
375 let newer = read_messages_where(&root, |o| o.is_some_and(|o| o.uid > 41));
376 assert_eq!(newer.len(), 1);
377 std::fs::remove_dir_all(&root).unwrap();
378 }
379
380 #[test]
381 fn flags_rename_the_file() {
382 let root = std::env::temp_dir().join(format!("sioul-flags-{}", std::process::id()));
383 let raw = b"Subject: x\r\n\r\nx\r\n";
384 let unread = store(&root, &Fetched { origin: ImapOrigin { validity: 3, uid: 9 }, flags: String::new(), received: None, raw }).unwrap();
385 assert_eq!(flags_of(&unread), "");
386 let read = set_flags(&unread, "SF").unwrap();
387 assert!(read.starts_with(root.join("cur")) && flags_of(&read) == "FS");
388 assert_eq!(origin_of(&read), Some(ImapOrigin { validity: 3, uid: 9 }));
389 let unflagged = set_flags(&read, "S").unwrap();
390 assert_eq!(flags_of(&unflagged), "S");
391 assert_eq!(locate(&unread), Some(unflagged.clone()));
393 assert_eq!(locate(&read), Some(unflagged));
394 std::fs::remove_dir_all(&root).unwrap();
395 }
396
397 #[test]
400 fn junk_keywords_are_kept() {
401 let root = std::env::temp_dir().join(format!("sioul-keywords-{}", std::process::id()));
402 let raw = b"Subject: x\r\n\r\nx\r\n";
403 assert_eq!((keyword_letter("$NotJunk"), keyword_letter("$junk"), keyword_letter("NonJunk")), (Some(NOT_JUNK), Some(JUNK), None));
404 let plain = store(&root, &Fetched { origin: ImapOrigin { validity: 3, uid: 1 }, flags: String::new(), received: None, raw }).unwrap();
405 assert!(plain.starts_with(root.join("new")) && !root.join("dovecot-keywords").exists());
406 let said = store(&root, &Fetched { origin: ImapOrigin { validity: 3, uid: 2 }, flags: NOT_JUNK.to_string(), received: None, raw }).unwrap();
407 assert!(said.starts_with(root.join("cur")), "unread, with its keyword: {}", said.display());
408 assert_eq!(keywords_of(&said), Keywords { junk: false, not_junk: true });
409 assert_eq!(std::fs::read_to_string(root.join("dovecot-keywords")).unwrap(), "0 $Junk\n1 $NotJunk\n");
410 let junked = set_flags(&said, &format!("S{JUNK}")).unwrap();
412 assert_eq!(flags_of(&junked), format!("S{JUNK}"));
413 assert_eq!(keywords_of(&junked), Keywords { junk: true, not_junk: false });
414 assert_eq!(keywords_of(&plain), Keywords::default());
415 std::fs::remove_dir_all(&root).unwrap();
416 }
417
418 #[test]
419 fn origin_needs_the_sioul_pattern() {
420 assert_eq!(origin_of(Path::new("cur/1759400000.U17-4521.sioul:2,S")), Some(ImapOrigin { validity: 17, uid: 4521 }));
421 assert_eq!(origin_of(Path::new("01-bank.eml")), None);
422 assert_eq!(origin_of(Path::new("cur/1759400000.M20P3.host:2,S")), None);
423 assert_eq!(origin_of(Path::new("cur/1759400000.U17-4521.sioul!2,S")), Some(ImapOrigin { validity: 17, uid: 4521 }));
425 assert_eq!(flags_of(Path::new("cur/1759400000.U17-4521.sioul!2,FS")), "FS");
426 }
427
428 #[test]
433 fn each_message_is_parsed_once_in_a_look() {
434 let dir = std::env::temp_dir().join(format!("sioul-cards-kept-{}", std::process::id()));
435 let _ = std::fs::remove_dir_all(&dir);
436 for sub in ["new", "cur", "tmp"] {
437 std::fs::create_dir_all(dir.join(sub)).unwrap();
438 }
439 for n in 0..3 {
440 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();
441 }
442 let parsed = || PARSED.with(std::cell::Cell::get);
443 let before = parsed();
444 {
445 let _kept = CardsKept::begin();
446 assert_eq!(read_messages(&dir).len(), 3);
447 let first = dir.join("new").join("175940000.U1700000000-0.sioul");
449 std::fs::rename(&first, dir.join("cur").join(format!("175940000.U1700000000-0.sioul{INFO}2,S"))).unwrap();
450 let again = read_messages(&dir);
451 assert_eq!(again.len(), 3);
452 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");
453 assert_eq!(parsed() - before, 3, "parsed once each");
454 }
455 assert_eq!(read_messages(&dir).len(), 3);
456 assert_eq!(parsed() - before, 6, "nothing kept once the look ended");
457 let _ = std::fs::remove_dir_all(&dir);
458 }
459}