1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub struct Flags {
10 pub seen: bool,
11 pub answered: bool,
12 pub flagged: bool,
13 pub deleted: bool,
14 pub draft: bool,
15}
16
17impl Flags {
18 pub fn from_filename(name: &str) -> Self {
19 let mut flags = Flags::default();
20 if let Some((_, info)) = name.rsplit_once(":2,") {
21 for c in info.chars() {
22 match c {
23 'S' => flags.seen = true,
24 'R' => flags.answered = true,
25 'F' => flags.flagged = true,
26 'T' => flags.deleted = true,
27 'D' => flags.draft = true,
28 _ => {}
29 }
30 }
31 }
32 flags
33 }
34
35 pub fn to_info(self) -> String {
38 let mut info = String::from(":2,");
39 if self.draft {
40 info.push('D');
41 }
42 if self.flagged {
43 info.push('F');
44 }
45 if self.answered {
46 info.push('R');
47 }
48 if self.seen {
49 info.push('S');
50 }
51 if self.deleted {
52 info.push('T');
53 }
54 info
55 }
56
57 pub fn status_char(&self, is_new: bool) -> char {
59 if self.deleted {
60 'D'
61 } else if is_new {
62 'N'
63 } else if self.answered {
64 'r'
65 } else if !self.seen {
66 'O'
67 } else {
68 ' '
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
74pub struct MailFile {
75 pub path: PathBuf,
76 pub is_new: bool,
77 pub flags: Flags,
78 pub size: u64,
79}
80
81pub fn scan(dir: &Path) -> Result<Vec<MailFile>> {
83 let cur = dir.join("cur");
84 let new = dir.join("new");
85 if !cur.is_dir() || !new.is_dir() {
86 bail!("{} is not a maildir (missing cur/ or new/)", dir.display());
87 }
88 let mut out = Vec::new();
89 for (sub, is_new) in [(cur, false), (new, true)] {
90 let entries = sub
91 .read_dir()
92 .with_context(|| format!("reading {}", sub.display()))?;
93 for entry in entries {
94 let entry = entry?;
95 let meta = match std::fs::metadata(entry.path()) {
99 Ok(meta) if meta.is_file() => meta,
100 Ok(_) => continue,
101 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
102 Err(e) => {
103 return Err(e).with_context(|| format!("reading {}", entry.path().display()));
104 }
105 };
106 let name = entry.file_name();
107 let name = name.to_string_lossy();
108 if name.starts_with('.') {
109 continue;
110 }
111 out.push(MailFile {
112 path: entry.path(),
113 is_new,
114 flags: Flags::from_filename(&name),
115 size: size_from_name(&name).unwrap_or(meta.len()),
116 });
117 }
118 }
119 Ok(out)
120}
121
122fn base_name(name: &str) -> &str {
124 name.rsplit_once(":2,").map_or(name, |(base, _)| base)
125}
126
127fn size_from_name(name: &str) -> Option<u64> {
131 let rest = base_name(name).rsplit_once(",S=")?.1;
132 let end = rest
133 .find(|c: char| !c.is_ascii_digit())
134 .unwrap_or(rest.len());
135 rest[..end].parse().ok()
136}
137
138pub fn store_flags(file: &MailFile) -> Result<PathBuf> {
141 let name = file
142 .path
143 .file_name()
144 .context("mail file has no filename")?
145 .to_string_lossy()
146 .into_owned();
147 let maildir = file
148 .path
149 .parent()
150 .and_then(Path::parent)
151 .context("mail file is not inside a maildir")?;
152 let target = maildir
153 .join("cur")
154 .join(format!("{}{}", base_name(&name), file.flags.to_info()));
155 if target != file.path {
156 fs::rename(&file.path, &target)
157 .with_context(|| format!("renaming to {}", target.display()))?;
158 }
159 Ok(target)
160}
161
162pub fn replace_content(path: &Path, bytes: &[u8]) -> Result<()> {
168 use std::io::Write as _;
169 use std::os::unix::fs::OpenOptionsExt as _;
170 use std::sync::atomic::{AtomicUsize, Ordering};
171 static COUNTER: AtomicUsize = AtomicUsize::new(0);
172 let real = fs::canonicalize(path).with_context(|| format!("reading {}", path.display()))?;
173 let parent = real.parent().context("a message path has a parent")?;
174 let dir = parent
177 .parent()
178 .map(|d| d.join("tmp"))
179 .filter(|t| t.is_dir())
180 .unwrap_or_else(|| parent.to_path_buf());
181 let tmp = dir.join(format!(
182 ".rmut-rewrite-{}-{}",
183 std::process::id(),
184 COUNTER.fetch_add(1, Ordering::Relaxed)
185 ));
186 let perms = fs::metadata(&real)?.permissions();
187 let written = (|| -> std::io::Result<()> {
188 let mut file = fs::OpenOptions::new()
189 .write(true)
190 .create_new(true)
191 .mode(0o600)
192 .open(&tmp)?;
193 file.write_all(bytes)?;
194 file.set_permissions(perms)?;
195 file.sync_all()?;
196 fs::rename(&tmp, &real)
197 })();
198 if let Err(err) = written {
199 let _ = fs::remove_file(&tmp);
200 return Err(err).with_context(|| format!("rewriting {}", real.display()));
201 }
202 Ok(())
203}
204
205pub fn remove(file: &MailFile) -> Result<()> {
206 fs::remove_file(&file.path).with_context(|| format!("removing {}", file.path.display()))
207}
208
209pub fn hostname() -> String {
210 fs::read_to_string("/proc/sys/kernel/hostname")
211 .map(|s| s.trim().to_string())
212 .ok()
213 .filter(|s| !s.is_empty())
214 .unwrap_or_else(|| "localhost".into())
215}
216
217pub fn create(dir: &Path) -> Result<()> {
219 for sub in ["cur", "new", "tmp"] {
220 fs::create_dir_all(dir.join(sub))
221 .with_context(|| format!("creating {}", dir.join(sub).display()))?;
222 }
223 Ok(())
224}
225
226pub fn deliver(dir: &Path, content: &[u8], flags: Flags) -> Result<PathBuf> {
229 use std::sync::atomic::{AtomicUsize, Ordering};
230 static COUNTER: AtomicUsize = AtomicUsize::new(0);
231 if !dir.join("cur").is_dir() || !dir.join("tmp").is_dir() {
232 bail!("{} is not a maildir", dir.display());
233 }
234 let epoch = std::time::SystemTime::now()
235 .duration_since(std::time::UNIX_EPOCH)
236 .unwrap_or_default()
237 .as_secs();
238 let name = format!(
239 "{epoch}.{}_{}.{}",
240 std::process::id(),
241 COUNTER.fetch_add(1, Ordering::Relaxed),
242 hostname(),
243 );
244 let tmp = dir.join("tmp").join(&name);
245 fs::write(&tmp, content).with_context(|| format!("writing {}", tmp.display()))?;
246 let target = dir.join("cur").join(format!("{name}{}", flags.to_info()));
247 fs::rename(&tmp, &target).with_context(|| format!("renaming to {}", target.display()))?;
248 Ok(target)
249}
250
251pub fn new_count(dir: &Path) -> usize {
254 dir.join("new")
255 .read_dir()
256 .map(|entries| {
257 entries
258 .filter_map(|e| e.ok())
259 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
260 .count()
261 })
262 .unwrap_or(0)
263}
264
265pub fn find_special(dir: &Path, names: &[&str]) -> Option<PathBuf> {
268 discover(dir).into_iter().find(|p| {
269 p.file_name().is_some_and(|n| {
270 let n = n.to_string_lossy();
271 let n = n.trim_start_matches('.');
272 names.iter().any(|w| n.eq_ignore_ascii_case(w))
273 })
274 })
275}
276
277pub fn discover(dir: &Path) -> Vec<PathBuf> {
280 fn push_children(parent: &Path, out: &mut Vec<PathBuf>) {
281 let Ok(entries) = parent.read_dir() else {
282 return;
283 };
284 for entry in entries.flatten() {
285 let p = entry.path();
286 if p.is_dir() && p.join("cur").is_dir() && p.join("new").is_dir() {
287 out.push(p);
288 }
289 }
290 }
291 let mut out = Vec::new();
292 push_children(dir, &mut out);
293 if let Some(parent) = dir.parent() {
294 push_children(parent, &mut out);
295 }
296 out.sort();
297 out.dedup();
298 out
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 fn make_maildir(root: &Path) {
306 for sub in ["cur", "new", "tmp"] {
307 fs::create_dir_all(root.join(sub)).unwrap();
308 }
309 }
310
311 #[test]
312 fn flags_from_filename() {
313 let f = Flags::from_filename("1234.abc.host:2,FS");
314 assert!(f.seen && f.flagged);
315 assert!(!f.deleted && !f.answered && !f.draft);
316 assert_eq!(Flags::from_filename("1234.abc.host"), Flags::default());
317 }
318
319 #[test]
320 fn flags_roundtrip_info() {
321 let f = Flags {
322 seen: true,
323 flagged: true,
324 deleted: true,
325 ..Default::default()
326 };
327 assert_eq!(f.to_info(), ":2,FST");
328 assert_eq!(Flags::from_filename(&format!("x{}", f.to_info())), f);
329 }
330
331 #[test]
332 fn size_comes_from_name_when_present() {
333 assert_eq!(size_from_name("12.rmut,S=345:2,S"), Some(345));
334 assert_eq!(size_from_name("12.rmut,S=345"), Some(345));
335 assert_eq!(size_from_name("1234.abc.host:2,S"), None);
336 let tmp = tempfile::tempdir().unwrap();
337 make_maildir(tmp.path());
338 fs::write(tmp.path().join("cur/9.rmut,S=777:2,S"), "tiny").unwrap();
339 let files = scan(tmp.path()).unwrap();
340 assert_eq!(files[0].size, 777);
341 }
342
343 #[test]
344 fn replace_content_swaps_whole_files_and_follows_links() {
345 use std::os::unix::fs::PermissionsExt as _;
346 let tmp = tempfile::tempdir().unwrap();
347 make_maildir(tmp.path());
348 let msg = tmp.path().join("cur/1.host:2,S");
349 fs::write(&msg, "Subject: old\n\nx\n").unwrap();
350 fs::set_permissions(&msg, fs::Permissions::from_mode(0o640)).unwrap();
351 replace_content(&msg, b"Subject: new\n\nx\n").unwrap();
352 assert_eq!(fs::read_to_string(&msg).unwrap(), "Subject: new\n\nx\n");
353 let mode = fs::metadata(&msg).unwrap().permissions().mode();
354 assert_eq!(mode & 0o777, 0o640, "the message keeps its mode");
355 assert_eq!(fs::read_dir(tmp.path().join("tmp")).unwrap().count(), 0);
356 let view = tempfile::tempdir().unwrap();
359 make_maildir(view.path());
360 let link = view.path().join("cur/0001.1.host:2,S");
361 std::os::unix::fs::symlink(&msg, &link).unwrap();
362 replace_content(&link, b"Subject: newer\n\nx\n").unwrap();
363 assert!(
364 fs::symlink_metadata(&link)
365 .unwrap()
366 .file_type()
367 .is_symlink()
368 );
369 assert_eq!(fs::read_to_string(&msg).unwrap(), "Subject: newer\n\nx\n");
370 }
371
372 #[test]
373 fn scan_skips_what_is_gone_instead_of_failing() {
374 let tmp = tempfile::tempdir().unwrap();
377 make_maildir(tmp.path());
378 fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: kept\n\nx\n").unwrap();
379 std::os::unix::fs::symlink(tmp.path().join("gone"), tmp.path().join("cur/2.host:2,S"))
380 .unwrap();
381 let files = scan(tmp.path()).unwrap();
382 assert_eq!(files.len(), 1);
383 assert_eq!(files[0].size, 17);
384 }
385
386 #[test]
387 fn scan_rejects_non_maildir() {
388 let tmp = tempfile::tempdir().unwrap();
389 assert!(scan(tmp.path()).is_err());
390 }
391
392 #[test]
393 fn scan_finds_new_and_cur() {
394 let tmp = tempfile::tempdir().unwrap();
395 make_maildir(tmp.path());
396 fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: a\n\nx\n").unwrap();
397 fs::write(tmp.path().join("new/2.host"), "Subject: b\n\ny\n").unwrap();
398 let mut files = scan(tmp.path()).unwrap();
399 files.sort_by(|a, b| a.path.cmp(&b.path));
400 assert_eq!(files.len(), 2);
401 assert!(!files[0].is_new && files[0].flags.seen);
402 assert!(files[1].is_new);
403 assert!(files[0].size > 0);
404 }
405
406 #[test]
407 fn store_flags_moves_new_to_cur_with_info() {
408 let tmp = tempfile::tempdir().unwrap();
409 make_maildir(tmp.path());
410 let src = tmp.path().join("new/99.host");
411 fs::write(&src, "Subject: a\n\nx\n").unwrap();
412 let file = MailFile {
413 path: src.clone(),
414 is_new: true,
415 flags: Flags {
416 seen: true,
417 ..Default::default()
418 },
419 size: 14,
420 };
421 let new_path = store_flags(&file).unwrap();
422 assert_eq!(new_path, tmp.path().join("cur/99.host:2,S"));
423 assert!(!src.exists());
424 assert!(new_path.exists());
425 }
426
427 #[test]
428 fn store_flags_rewrites_existing_info() {
429 let tmp = tempfile::tempdir().unwrap();
430 make_maildir(tmp.path());
431 let src = tmp.path().join("cur/7.host:2,S");
432 fs::write(&src, "x").unwrap();
433 let mut file = MailFile {
434 path: src.clone(),
435 is_new: false,
436 flags: Flags::from_filename("7.host:2,S"),
437 size: 1,
438 };
439 file.flags.flagged = true;
440 let new_path = store_flags(&file).unwrap();
441 assert_eq!(new_path, tmp.path().join("cur/7.host:2,FS"));
442 assert!(!src.exists());
443 }
444
445 #[test]
446 fn discover_finds_children_and_siblings() {
447 let tmp = tempfile::tempdir().unwrap();
448 let inbox = tmp.path().join("inbox");
449 let sent = tmp.path().join("sent");
450 let sub = inbox.join(".archive");
451 for d in [&inbox, &sent, &sub] {
452 make_maildir(d);
453 }
454 fs::create_dir(tmp.path().join("not-a-maildir")).unwrap();
455 let found = discover(&inbox);
456 assert!(found.contains(&inbox));
457 assert!(found.contains(&sent));
458 assert!(found.contains(&sub));
459 assert!(!found.iter().any(|p| p.ends_with("not-a-maildir")));
460 }
461}