Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_mail/src/maildir.rs

30.0 KiB, 13 runs

created by r1870400018:9814, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Maildir-backed implementation of `MailStore`.
2//!
3//! Storage layout follows the Dovecot/Maildir++ convention so a freshly
4//! deployed Hematite instance can take over an existing Dovecot
5//! mailbox tree without copying data:
6//!
7//! ```text
8//! <root>/<domain>/<local>/
9//! cur/ INBOX cur (already-seen messages)
10//! new/ INBOX new (unprocessed deliveries)
11//! tmp/ INBOX tmp (writes in progress)
12//! .Sent/cur/ sub-folder using Maildir++ '.' prefix
13//! .Sent/new/
14//! ...
15//! dovecot-uidvalidity 8-byte hex marker (mtime is canon)
16//! dovecot-uidvalidity.<hex> empty companion file
17//! dovecot-uidlist "<uid> <filename>" map for UID stability
18//! subscriptions one folder name per line
19//! ```
20//!
21//! UIDs are assigned monotonically per folder. On open, the store
22//! reads the existing `dovecot-uidlist` if present, scans `cur` and
23//! `new` for any files not yet listed, assigns them fresh UIDs, and
24//! rewrites the file. Subsequent appends increment `uidnext` and
25//! append a new entry. UIDVALIDITY comes from the
26//! `dovecot-uidvalidity.<hex>` filename or, failing that, the current
27//! Unix time at first open.
28
29use oxedyne_fe2o3_core::prelude::*;
30use oxedyne_fe2o3_net::mail::store::{
31 FolderName,
32 FolderStatus,
33 MailStore,
34 MailUser,
35 MessageFlags,
36 MessageMeta,
37 MessageUid,
38};
39
40use std::{
41 collections::BTreeMap,
42 fs::{
43 self,
44 File,
45 },
46 io::{
47 BufRead,
48 BufReader,
49 Read,
50 Write,
51 },
52 path::{
53 Path,
54 PathBuf,
55 },
56 sync::Arc,
57 time::{
58 SystemTime,
59 UNIX_EPOCH,
60 },
61};
62
63
64/// File name of the dovecot-compatible UID list.
65const UIDLIST_NAME: &str = "dovecot-uidlist";
66
67/// File name prefix of the dovecot-compatible UID validity marker.
68const UIDVALIDITY_PREFIX: &str = "dovecot-uidvalidity";
69
70/// File name of the subscription list.
71const SUBSCRIPTIONS_NAME: &str = "subscriptions";
72
73/// File written into a sub-mailbox to mark it as a Maildir folder.
74const MAILDIRFOLDER_NAME: &str = "maildirfolder";
75
76
77/// Maildir-backed `MailStore`.
78///
79/// Holds a single root directory (typically `/var/mail/vhosts`) and
80/// nothing else: every operation derives its on-disk paths from the
81/// `MailUser::delivery_key` it is handed.
82#[derive(Clone, Debug)]
83pub struct MaildirStore {
84 /// Filesystem root holding `<domain>/<local>/` per-user trees.
85 root: Arc<PathBuf>,
86 /// Hostname appended to generated message filenames.
87 hostname: Arc<String>,
88}
89
90impl MaildirStore {
91 /// Build a new store rooted at `root`. The directory must exist.
92 pub fn new(root: PathBuf, hostname: impl Into<String>) -> Outcome<Self> {
93 if !root.is_dir() {
94 return Err(err!(
95 "MaildirStore root {:?} is not a directory.", root;
96 Init, Invalid, Path));
97 }
98 Ok(Self {
99 root: Arc::new(root),
100 hostname: Arc::new(hostname.into()),
101 })
102 }
103
104 /// Resolve the absolute on-disk path for a user's mailbox tree.
105 fn user_root(&self, user: &MailUser) -> PathBuf {
106 // delivery_key is a relative path under root, set by
107 // PasswdFileUserStore. Falls back to "<domain>/<local>" if
108 // empty.
109 if user.delivery_key.is_empty() {
110 self.root.join(&user.domain).join(&user.local)
111 } else {
112 self.root.join(&user.delivery_key)
113 }
114 }
115
116 /// Resolve the absolute on-disk path for one folder. INBOX is the
117 /// user root itself; every other name lives at `.<name>` under the
118 /// root, with `/` separators in the IMAP name turned into `.`.
119 fn folder_path(&self, user: &MailUser, folder: &FolderName) -> PathBuf {
120 let user_root = self.user_root(user);
121 if folder.as_str().eq_ignore_ascii_case("INBOX") {
122 user_root
123 } else {
124 let mapped = folder.as_str().replace('/', ".");
125 user_root.join(fmt!(".{}", mapped))
126 }
127 }
128
129 /// Map an on-disk Maildir++ subdirectory back to an IMAP-friendly
130 /// folder name (`.Sent.Archive` → `Sent/Archive`).
131 fn folder_from_subdir(name: &str) -> String {
132 let stripped = name.strip_prefix('.').unwrap_or(name);
133 stripped.replace('.', "/")
134 }
135
136 /// Ensure cur/new/tmp exist under `dir`.
137 fn ensure_subdirs(dir: &Path) -> Outcome<()> {
138 for sub in ["cur", "new", "tmp"] {
139 let p = dir.join(sub);
140 if !p.exists() {
141 if let Err(e) = fs::create_dir_all(&p) {
142 return Err(err!(e,
143 "Creating Maildir subdir {:?}.", p;
144 IO, File, Init));
145 }
146 }
147 }
148 Ok(())
149 }
150
151 /// Read the UID validity for a folder, or generate and persist one.
152 fn read_or_init_uidvalidity(folder_dir: &Path) -> Outcome<u32> {
153 // Look for `dovecot-uidvalidity.<hex>` -- the hex suffix is
154 // canonical.
155 if let Ok(rd) = fs::read_dir(folder_dir) {
156 for entry in rd.flatten() {
157 let name = entry.file_name();
158 let name = name.to_string_lossy();
159 if let Some(rest) = name.strip_prefix(&fmt!("{}.", UIDVALIDITY_PREFIX)) {
160 if let Ok(n) = u32::from_str_radix(rest, 16) {
161 return Ok(n);
162 }
163 }
164 }
165 }
166 // Fall back to the contents of dovecot-uidvalidity if present.
167 let plain = folder_dir.join(UIDVALIDITY_PREFIX);
168 if let Ok(s) = fs::read_to_string(&plain) {
169 let s = s.trim();
170 if let Ok(n) = u32::from_str_radix(s, 16) {
171 return Ok(n);
172 }
173 if let Ok(n) = s.parse::<u32>() {
174 return Ok(n);
175 }
176 }
177 // Generate.
178 let now = SystemTime::now()
179 .duration_since(UNIX_EPOCH)
180 .map(|d| d.as_secs() as u32)
181 .unwrap_or(1);
182 let marker = folder_dir.join(fmt!("{}.{:08x}", UIDVALIDITY_PREFIX, now));
183 let _ = File::create(&marker);
184 let mut p = match File::create(&plain) {
185 Ok(f) => f,
186 Err(e) => return Err(err!(e,
187 "Writing {:?}.", plain; IO, File, Write)),
188 };
189 let _ = p.write_all(fmt!("{:08x}", now).as_bytes());
190 Ok(now)
191 }
192
193 /// Open and refresh the per-folder UID list, returning every
194 /// known message in UID order.
195 fn read_messages(
196 &self,
197 folder_dir: &Path,
198 clear_recent: bool,
199 )
200 -> Outcome<(Vec<MessageMeta>, u32, u32)>
201 {
202 res!(Self::ensure_subdirs(folder_dir));
203
204 // Walk cur/ and new/ to find every file currently present.
205 // Files in new/ are RECENT and will be moved to cur/ if we
206 // are clearing the recent flag (a SELECT, not EXAMINE).
207 let mut cur_files: Vec<(String, PathBuf)> = Vec::new();
208 let mut new_files: Vec<(String, PathBuf)> = Vec::new();
209 for sub in ["cur", "new"] {
210 let dir = folder_dir.join(sub);
211 if let Ok(rd) = fs::read_dir(&dir) {
212 for entry in rd.flatten() {
213 let path = entry.path();
214 if path.is_file() {
215 let name = entry.file_name().to_string_lossy().into_owned();
216 if sub == "cur" {
217 cur_files.push((name, path));
218 } else {
219 new_files.push((name, path));
220 }
221 }
222 }
223 }
224 }
225
226 // If clearing recent, move files from new/ into cur/ adding
227 // the `:2,` flag suffix so they parse cleanly.
228 if clear_recent {
229 for (name, path) in std::mem::take(&mut new_files) {
230 let with_flags = if name.contains(":2,") {
231 name.clone()
232 } else {
233 fmt!("{}:2,", name)
234 };
235 let target = folder_dir.join("cur").join(&with_flags);
236 if let Err(e) = fs::rename(&path, &target) {
237 warn!("Failed to move {:?} -> {:?}: {}", path, target, e);
238 cur_files.push((name, path));
239 } else {
240 cur_files.push((with_flags, target));
241 }
242 }
243 }
244
245 let recent_count = new_files.len();
246 let mut all_files: Vec<(String, PathBuf, bool)> = Vec::new();
247 for (n, p) in &cur_files { all_files.push((n.clone(), p.clone(), false)); }
248 for (n, p) in &new_files { all_files.push((n.clone(), p.clone(), true)); }
249
250 // Read the existing uid list.
251 let uidlist_path = folder_dir.join(UIDLIST_NAME);
252 let mut uid_by_name: BTreeMap<String, u32> = BTreeMap::new();
253 let mut uid_next: u32 = 1;
254 if let Ok(file) = File::open(&uidlist_path) {
255 let reader = BufReader::new(file);
256 let mut header_seen = false;
257 for line in reader.lines().flatten() {
258 if !header_seen {
259 header_seen = true;
260 // Extract `N<num>` if present.
261 for tok in line.split_whitespace() {
262 if let Some(rest) = tok.strip_prefix('N') {
263 if let Ok(n) = rest.parse::<u32>() {
264 uid_next = n;
265 }
266 }
267 }
268 if line.contains(' ') && !line.starts_with(char::is_alphabetic) {
269 // Treat as data row, not header.
270 if let Some((u, n)) = parse_uid_line(&line) {
271 uid_by_name.insert(n, u);
272 if u >= uid_next { uid_next = u + 1; }
273 }
274 }
275 continue;
276 }
277 if let Some((u, n)) = parse_uid_line(&line) {
278 uid_by_name.insert(n, u);
279 if u >= uid_next { uid_next = u + 1; }
280 }
281 }
282 }
283
284 // Assign UIDs to any new files. dovecot-uidlist keys by the
285 // unique part of the filename (everything before `:2,` if
286 // present), which we mirror here so a flag change does not
287 // re-allocate a UID.
288 let mut by_uid: BTreeMap<u32, MessageMeta> = BTreeMap::new();
289 let mut keys_seen: Vec<String> = Vec::new();
290 for (name, path, recent) in &all_files {
291 let key = key_of(name);
292 keys_seen.push(key.clone());
293 let uid = match uid_by_name.get(&key) {
294 Some(u) => *u,
295 None => {
296 let u = uid_next;
297 uid_next = uid_next.saturating_add(1);
298 uid_by_name.insert(key.clone(), u);
299 u
300 }
301 };
302 let meta = file_to_meta(path, name, uid, *recent);
303 by_uid.insert(uid, meta);
304 }
305
306 // Rewrite the uid list with current state. Drop any entries
307 // for files that no longer exist on disk.
308 let mut keep: BTreeMap<String, u32> = BTreeMap::new();
309 for k in &keys_seen {
310 if let Some(u) = uid_by_name.get(k) {
311 keep.insert(k.clone(), *u);
312 }
313 }
314 let _ = write_uidlist(&uidlist_path, &keep, uid_next);
315
316 let messages: Vec<MessageMeta> = by_uid.into_values().collect();
317 Ok((messages, uid_next, recent_count as u32))
318 }
319}
320
321
322// ┌───────────────────────────────────────────────────────────────────────────┐
323// │ MailStore IMPLEMENTATION │
324// └───────────────────────────────────────────────────────────────────────────┘
325
326impl MailStore for MaildirStore {
327
328 fn ensure_user(&self, user: &MailUser) -> Outcome<()> {
329 let root = self.user_root(user);
330 if !root.exists() {
331 if let Err(e) = fs::create_dir_all(&root) {
332 return Err(err!(e,
333 "Creating user mailbox {:?}.", root;
334 IO, File, Init));
335 }
336 }
337 res!(Self::ensure_subdirs(&root));
338 let _ = Self::read_or_init_uidvalidity(&root);
339 // Pre-create the special-use folders so IMAP clients can
340 // discover them via LIST + SPECIAL-USE attributes without
341 // having to issue CREATE first. This unblocks Thunderbird's
342 // "save sent messages on the server" behaviour, which
343 // otherwise silently falls back to Local Folders when the
344 // target Sent folder does not exist on the server.
345 for f in ["Sent", "Drafts", "Trash", "Junk", "Archive"] {
346 let _ = self.create_folder(user, &FolderName::new(f));
347 let _ = self.subscribe(user, &FolderName::new(f));
348 }
349 Ok(())
350 }
351
352 fn append(
353 &self,
354 user: &MailUser,
355 folder: &FolderName,
356 bytes: &[u8],
357 flags: MessageFlags,
358 internal: Option<SystemTime>,
359 )
360 -> Outcome<MessageUid>
361 {
362 let folder_dir = self.folder_path(user, folder);
363 if !folder_dir.exists() {
364 res!(self.create_folder(user, folder));
365 }
366 res!(Self::ensure_subdirs(&folder_dir));
367
368 // Generate a unique filename. Format:
369 // `<unix>.M<usec>P<pid>.<host>:2,<flags>`
370 let now = SystemTime::now();
371 let secs = now.duration_since(UNIX_EPOCH)
372 .map(|d| d.as_secs())
373 .unwrap_or(0);
374 let usec = now.duration_since(UNIX_EPOCH)
375 .map(|d| d.subsec_micros())
376 .unwrap_or(0);
377 let pid = std::process::id();
378 let unique = fmt!("{}.M{}P{}.{}", secs, usec, pid, self.hostname);
379 let flag_suffix = flag_suffix(flags);
380 let filename = fmt!("{}:2,{}", unique, flag_suffix);
381
382 // Write to tmp then rename into cur/.
383 let tmp_path = folder_dir.join("tmp").join(&filename);
384 let cur_path = folder_dir.join("cur").join(&filename);
385 {
386 let mut f = match File::create(&tmp_path) {
387 Ok(f) => f,
388 Err(e) => return Err(err!(e,
389 "Creating tmp file {:?}.", tmp_path;
390 IO, File, Write)),
391 };
392 if let Err(e) = f.write_all(bytes) {
393 return Err(err!(e,
394 "Writing tmp file {:?}.", tmp_path;
395 IO, File, Write));
396 }
397 if let Err(e) = f.sync_all() {
398 warn!("sync_all on {:?}: {}", tmp_path, e);
399 }
400 }
401 if let Err(e) = fs::rename(&tmp_path, &cur_path) {
402 return Err(err!(e,
403 "Renaming {:?} -> {:?}.", tmp_path, cur_path;
404 IO, File, Write));
405 }
406 // Best-effort: set the file mtime so INTERNALDATE round-trips.
407 if let Some(t) = internal {
408 let _ = filetime_set(&cur_path, t);
409 }
410
411 // Reload UID list to allocate the new UID.
412 let (msgs, _next, _rec) = res!(self.read_messages(&folder_dir, false));
413 let mut uid = MessageUid(0);
414 for m in &msgs {
415 if cur_path.file_name().map(|f| f.to_string_lossy().into_owned())
416 == Some(filename.clone())
417 {
418 uid = m.uid;
419 break;
420 }
421 }
422 // If the loop did not find it (because the cur entry was named
423 // differently after we moved it), grab the maximum UID just
424 // assigned.
425 if uid.0 == 0 {
426 if let Some(m) = msgs.last() { uid = m.uid; }
427 }
428 Ok(uid)
429 }
430
431 fn list_folders(&self, user: &MailUser) -> Outcome<Vec<FolderName>> {
432 let root = self.user_root(user);
433 let mut out = vec![FolderName::new("INBOX")];
434 if let Ok(rd) = fs::read_dir(&root) {
435 for entry in rd.flatten() {
436 if !entry.path().is_dir() { continue; }
437 let name = entry.file_name().to_string_lossy().into_owned();
438 if name.starts_with('.') && name != "." && name != ".." {
439 out.push(FolderName::new(Self::folder_from_subdir(&name)));
440 }
441 }
442 }
443 Ok(out)
444 }
445
446 fn folder_status(
447 &self,
448 user: &MailUser,
449 folder: &FolderName,
450 )
451 -> Outcome<FolderStatus>
452 {
453 let folder_dir = self.folder_path(user, folder);
454 let uidvalidity = res!(Self::read_or_init_uidvalidity(&folder_dir));
455 let (msgs, next, recent) = res!(self.read_messages(&folder_dir, false));
456 let unseen = msgs.iter().filter(|m| !m.flags.seen).count() as u32;
457 Ok(FolderStatus {
458 exists: msgs.len() as u32,
459 recent,
460 unseen,
461 uid_validity: uidvalidity,
462 uid_next: next,
463 })
464 }
465
466 fn list_messages(
467 &self,
468 user: &MailUser,
469 folder: &FolderName,
470 read_only: bool,
471 )
472 -> Outcome<Vec<MessageMeta>>
473 {
474 let folder_dir = self.folder_path(user, folder);
475 let (msgs, _next, _rec) = res!(self.read_messages(&folder_dir, !read_only));
476 Ok(msgs)
477 }
478
479 fn fetch_bytes(
480 &self,
481 user: &MailUser,
482 folder: &FolderName,
483 uid: MessageUid,
484 )
485 -> Outcome<Vec<u8>>
486 {
487 let folder_dir = self.folder_path(user, folder);
488 let (msgs, _next, _rec) = res!(self.read_messages(&folder_dir, false));
489 let meta = match msgs.iter().find(|m| m.uid == uid) {
490 Some(m) => m,
491 None => return Err(err!(
492 "No message with UID {} in folder {}.",
493 uid.0, folder.as_str();
494 Missing, Input)),
495 };
496 // The on-disk path is encoded in MessageMeta via the size /
497 // internal date alone -- we need to walk the directory again
498 // and locate the file by UID. Use the uidlist key.
499 let uidlist_path = folder_dir.join(UIDLIST_NAME);
500 let key = read_uidlist_key_for(&uidlist_path, uid.0);
501 let key = match key {
502 Some(k) => k,
503 None => return Err(err!(
504 "UID {} present in cache but not in {:?}.", uid.0, uidlist_path;
505 Bug, Missing)),
506 };
507 // Look in cur/ and new/ for any file whose key matches.
508 for sub in ["cur", "new"] {
509 if let Ok(rd) = fs::read_dir(folder_dir.join(sub)) {
510 for entry in rd.flatten() {
511 let name = entry.file_name().to_string_lossy().into_owned();
512 if key_of(&name) == key {
513 let mut f = match File::open(entry.path()) {
514 Ok(f) => f,
515 Err(e) => return Err(err!(e,
516 "Opening {:?}.", entry.path();
517 IO, File, Read)),
518 };
519 let mut bytes = Vec::with_capacity(meta.size as usize);
520 if let Err(e) = f.read_to_end(&mut bytes) {
521 return Err(err!(e,
522 "Reading {:?}.", entry.path();
523 IO, File, Read));
524 }
525 return Ok(bytes);
526 }
527 }
528 }
529 }
530 Err(err!(
531 "Could not locate file for UID {} on disk.", uid.0;
532 Missing, IO, File))
533 }
534
535 fn set_flags(
536 &self,
537 user: &MailUser,
538 folder: &FolderName,
539 uid: MessageUid,
540 flags: MessageFlags,
541 )
542 -> Outcome<MessageFlags>
543 {
544 let folder_dir = self.folder_path(user, folder);
545 let uidlist_path = folder_dir.join(UIDLIST_NAME);
546 let key = match read_uidlist_key_for(&uidlist_path, uid.0) {
547 Some(k) => k,
548 None => return Err(err!(
549 "UID {} not in {:?}.", uid.0, uidlist_path; Missing)),
550 };
551 // Locate the current file.
552 for sub in ["cur", "new"] {
553 if let Ok(rd) = fs::read_dir(folder_dir.join(sub)) {
554 for entry in rd.flatten() {
555 let name = entry.file_name().to_string_lossy().into_owned();
556 if key_of(&name) != key { continue; }
557 // Build the new filename with updated flag suffix.
558 let new_name = fmt!("{}:2,{}", key, flag_suffix(flags));
559 let new_path = folder_dir.join("cur").join(&new_name);
560 if let Err(e) = fs::rename(entry.path(), &new_path) {
561 return Err(err!(e,
562 "Renaming for flag update {:?} -> {:?}.",
563 entry.path(), new_path;
564 IO, File, Write));
565 }
566 return Ok(flags);
567 }
568 }
569 }
570 Err(err!(
571 "No file on disk for UID {}.", uid.0; Missing, IO, File))
572 }
573
574 fn expunge(
575 &self,
576 user: &MailUser,
577 folder: &FolderName,
578 )
579 -> Outcome<Vec<MessageUid>>
580 {
581 let folder_dir = self.folder_path(user, folder);
582 let (msgs, _next, _rec) = res!(self.read_messages(&folder_dir, false));
583 let mut removed: Vec<MessageUid> = Vec::new();
584 for m in &msgs {
585 if !m.flags.deleted { continue; }
586 let key = match read_uidlist_key_for(&folder_dir.join(UIDLIST_NAME), m.uid.0) {
587 Some(k) => k,
588 None => continue,
589 };
590 for sub in ["cur", "new"] {
591 if let Ok(rd) = fs::read_dir(folder_dir.join(sub)) {
592 for entry in rd.flatten() {
593 let name = entry.file_name().to_string_lossy().into_owned();
594 if key_of(&name) == key {
595 let _ = fs::remove_file(entry.path());
596 }
597 }
598 }
599 }
600 removed.push(m.uid);
601 }
602 // Refresh uidlist.
603 let _ = self.read_messages(&folder_dir, false);
604 Ok(removed)
605 }
606
607 fn create_folder(
608 &self,
609 user: &MailUser,
610 folder: &FolderName,
611 )
612 -> Outcome<()>
613 {
614 let folder_dir = self.folder_path(user, folder);
615 if let Err(e) = fs::create_dir_all(&folder_dir) {
616 return Err(err!(e,
617 "Creating folder {:?}.", folder_dir;
618 IO, File, Init));
619 }
620 res!(Self::ensure_subdirs(&folder_dir));
621 // Maildir++ marker.
622 if !folder.as_str().eq_ignore_ascii_case("INBOX") {
623 let marker = folder_dir.join(MAILDIRFOLDER_NAME);
624 if !marker.exists() {
625 let _ = File::create(&marker);
626 }
627 }
628 let _ = Self::read_or_init_uidvalidity(&folder_dir);
629 Ok(())
630 }
631
632 fn subscribe(
633 &self,
634 user: &MailUser,
635 folder: &FolderName,
636 )
637 -> Outcome<()>
638 {
639 let path = self.user_root(user).join(SUBSCRIPTIONS_NAME);
640 let mut current: Vec<String> = Vec::new();
641 if let Ok(s) = fs::read_to_string(&path) {
642 for line in s.lines() {
643 let trimmed = line.trim();
644 if !trimmed.is_empty() {
645 current.push(trimmed.to_string());
646 }
647 }
648 }
649 let imap_name = folder.as_str().to_string();
650 if !current.iter().any(|x| x == &imap_name) {
651 current.push(imap_name);
652 }
653 let mut f = match File::create(&path) {
654 Ok(f) => f,
655 Err(e) => return Err(err!(e,
656 "Writing {:?}.", path; IO, File, Write)),
657 };
658 for line in &current {
659 let _ = f.write_all(line.as_bytes());
660 let _ = f.write_all(b"\n");
661 }
662 Ok(())
663 }
664
665 fn list_subscribed(&self, user: &MailUser) -> Outcome<Vec<FolderName>> {
666 let path = self.user_root(user).join(SUBSCRIPTIONS_NAME);
667 let mut out: Vec<FolderName> = Vec::new();
668 // Always include INBOX.
669 out.push(FolderName::new("INBOX"));
670 if let Ok(s) = fs::read_to_string(&path) {
671 for line in s.lines() {
672 let trimmed = line.trim();
673 if trimmed.is_empty() { continue; }
674 if trimmed.eq_ignore_ascii_case("INBOX") { continue; }
675 // Dovecot writes a `V<TAB><version>` header at the
676 // top of its subscriptions file. Skip any line that
677 // starts with the literal `V` followed by whitespace
678 // and a digit -- a real folder name will not look
679 // like this.
680 let mut it = trimmed.chars();
681 if it.next() == Some('V') {
682 if let Some(c) = it.next() {
683 if c.is_whitespace() {
684 if let Some(d) = it.next() {
685 if d.is_ascii_digit() { continue; }
686 }
687 }
688 }
689 }
690 out.push(FolderName::new(trimmed));
691 }
692 }
693 Ok(out)
694 }
695}
696
697
698// ┌───────────────────────────────────────────────────────────────────────────┐
699// │ HELPERS │
700// └───────────────────────────────────────────────────────────────────────────┘
701
702/// Strip the `:2,FLAGS` suffix from a Maildir filename, returning the
703/// stable unique part used as the UID list key.
704fn key_of(name: &str) -> String {
705 match name.find(":2,") {
706 Some(i) => name[..i].to_string(),
707 None => name.to_string(),
708 }
709}
710
711/// Translate a `MessageFlags` set into its Maildir suffix (sorted).
712fn flag_suffix(flags: MessageFlags) -> String {
713 let mut s = String::new();
714 if flags.draft { s.push('D'); }
715 if flags.flagged { s.push('F'); }
716 if flags.answered { s.push('R'); }
717 if flags.seen { s.push('S'); }
718 if flags.deleted { s.push('T'); }
719 s
720}
721
722/// Inverse of `flag_suffix`. Recognises the standard letters.
723fn flags_from_suffix(suffix: &str) -> MessageFlags {
724 let mut f = MessageFlags::default();
725 for c in suffix.chars() {
726 match c {
727 'D' => f.draft = true,
728 'F' => f.flagged = true,
729 'R' => f.answered = true,
730 'S' => f.seen = true,
731 'T' => f.deleted = true,
732 _ => (),
733 }
734 }
735 f
736}
737
738/// Build a `MessageMeta` from a Maildir file path.
739fn file_to_meta(path: &Path, name: &str, uid: u32, recent: bool) -> MessageMeta {
740 let suffix = match name.rfind(":2,") {
741 Some(i) => &name[i + 3..],
742 None => "",
743 };
744 let mut flags = flags_from_suffix(suffix);
745 flags.recent = recent;
746 let md = fs::metadata(path).ok();
747 let size = md.as_ref().map(|m| m.len()).unwrap_or(0);
748 let internal = md.and_then(|m| m.modified().ok()).unwrap_or(SystemTime::now());
749 MessageMeta {
750 uid: MessageUid(uid),
751 size,
752 internal,
753 flags,
754 }
755}
756
757/// Parse a single dovecot-uidlist data row into `(uid, key)`. The
758/// first line of the file is a header (`3 V0 N7 [G<guid>]`) that
759/// happens to look superficially like a data row -- we reject it by
760/// requiring the second token to begin with `':'` (uidlist v3
761/// format) or a digit (timestamp prefix used in older formats).
762fn parse_uid_line(line: &str) -> Option<(u32, String)> {
763 let line = line.trim();
764 let mut it = line.splitn(2, ' ');
765 let uid: u32 = ok!(ok!(it.next()).parse().ok());
766 let rest = ok!(it.next());
767 let first = ok!(rest.chars().next());
768 if first != ':' && !first.is_ascii_digit() {
769 return None;
770 }
771 let name = match rest.find(':') {
772 Some(i) => &rest[i + 1..],
773 None => rest,
774 };
775 Some((uid, key_of(name)))
776}
777
778/// Look up the uidlist key for a given UID without keeping the whole
779/// file in memory.
780fn read_uidlist_key_for(path: &Path, uid: u32) -> Option<String> {
781 let file = ok!(File::open(path).ok());
782 let reader = BufReader::new(file);
783 let mut header_seen = false;
784 for line in reader.lines().flatten() {
785 if !header_seen {
786 header_seen = true;
787 // Header lines start with a digit (version) but may also
788 // be a data line in older formats.
789 if let Some((u, k)) = parse_uid_line(&line) {
790 if u == uid { return Some(k); }
791 }
792 continue;
793 }
794 if let Some((u, k)) = parse_uid_line(&line) {
795 if u == uid { return Some(k); }
796 }
797 }
798 None
799}
800
801/// Rewrite the dovecot-uidlist with a fresh map.
802fn write_uidlist(path: &Path, map: &BTreeMap<String, u32>, uidnext: u32) -> Outcome<()> {
803 let mut f = match File::create(path) {
804 Ok(f) => f,
805 Err(e) => return Err(err!(e,
806 "Writing {:?}.", path; IO, File, Write)),
807 };
808 let _ = writeln!(f, "3 V0 N{}", uidnext);
809 let mut by_uid: BTreeMap<u32, String> = BTreeMap::new();
810 for (k, u) in map { by_uid.insert(*u, k.clone()); }
811 for (u, k) in &by_uid {
812 let _ = writeln!(f, "{} :{}", u, k);
813 }
814 Ok(())
815}
816
817/// Set a file's mtime to the given SystemTime. Best effort.
818fn filetime_set(path: &Path, t: SystemTime) -> Outcome<()> {
819 let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
820 // Use the libc utimes via std::fs::File and set_modified.
821 let f = match File::options().write(true).open(path) {
822 Ok(f) => f,
823 Err(_) => return Ok(()),
824 };
825 let _ = f.set_modified(UNIX_EPOCH + std::time::Duration::from_secs(secs));
826 Ok(())
827}
828