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 | |
| 29 | use oxedyne_fe2o3_core::prelude::*; |
| 30 | use oxedyne_fe2o3_net::mail::store::{ |
| 31 | FolderName, |
| 32 | FolderStatus, |
| 33 | MailStore, |
| 34 | MailUser, |
| 35 | MessageFlags, |
| 36 | MessageMeta, |
| 37 | MessageUid, |
| 38 | }; |
| 39 | |
| 40 | use 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. |
| 65 | const UIDLIST_NAME: &str = "dovecot-uidlist"; |
| 66 | |
| 67 | /// File name prefix of the dovecot-compatible UID validity marker. |
| 68 | const UIDVALIDITY_PREFIX: &str = "dovecot-uidvalidity"; |
| 69 | |
| 70 | /// File name of the subscription list. |
| 71 | const SUBSCRIPTIONS_NAME: &str = "subscriptions"; |
| 72 | |
| 73 | /// File written into a sub-mailbox to mark it as a Maildir folder. |
| 74 | const 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)] |
| 83 | pub 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 | |
| 90 | impl 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 | |
| 326 | impl 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 ¤t { |
| 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. |
| 704 | fn 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). |
| 712 | fn 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. |
| 723 | fn 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. |
| 739 | fn 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). |
| 762 | fn 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. |
| 780 | fn 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. |
| 802 | fn 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. |
| 818 | fn 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 |