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 remove(file: &MailFile) -> Result<()> {
163 fs::remove_file(&file.path).with_context(|| format!("removing {}", file.path.display()))
164}
165
166pub fn hostname() -> String {
167 fs::read_to_string("/proc/sys/kernel/hostname")
168 .map(|s| s.trim().to_string())
169 .ok()
170 .filter(|s| !s.is_empty())
171 .unwrap_or_else(|| "localhost".into())
172}
173
174pub fn create(dir: &Path) -> Result<()> {
176 for sub in ["cur", "new", "tmp"] {
177 fs::create_dir_all(dir.join(sub))
178 .with_context(|| format!("creating {}", dir.join(sub).display()))?;
179 }
180 Ok(())
181}
182
183pub fn deliver(dir: &Path, content: &[u8], flags: Flags) -> Result<PathBuf> {
186 use std::sync::atomic::{AtomicUsize, Ordering};
187 static COUNTER: AtomicUsize = AtomicUsize::new(0);
188 if !dir.join("cur").is_dir() || !dir.join("tmp").is_dir() {
189 bail!("{} is not a maildir", dir.display());
190 }
191 let epoch = std::time::SystemTime::now()
192 .duration_since(std::time::UNIX_EPOCH)
193 .unwrap_or_default()
194 .as_secs();
195 let name = format!(
196 "{epoch}.{}_{}.{}",
197 std::process::id(),
198 COUNTER.fetch_add(1, Ordering::Relaxed),
199 hostname(),
200 );
201 let tmp = dir.join("tmp").join(&name);
202 fs::write(&tmp, content).with_context(|| format!("writing {}", tmp.display()))?;
203 let target = dir.join("cur").join(format!("{name}{}", flags.to_info()));
204 fs::rename(&tmp, &target).with_context(|| format!("renaming to {}", target.display()))?;
205 Ok(target)
206}
207
208pub fn new_count(dir: &Path) -> usize {
211 dir.join("new")
212 .read_dir()
213 .map(|entries| {
214 entries
215 .filter_map(|e| e.ok())
216 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
217 .count()
218 })
219 .unwrap_or(0)
220}
221
222pub fn find_special(dir: &Path, names: &[&str]) -> Option<PathBuf> {
225 discover(dir).into_iter().find(|p| {
226 p.file_name().is_some_and(|n| {
227 let n = n.to_string_lossy();
228 let n = n.trim_start_matches('.');
229 names.iter().any(|w| n.eq_ignore_ascii_case(w))
230 })
231 })
232}
233
234pub fn discover(dir: &Path) -> Vec<PathBuf> {
237 fn push_children(parent: &Path, out: &mut Vec<PathBuf>) {
238 let Ok(entries) = parent.read_dir() else {
239 return;
240 };
241 for entry in entries.flatten() {
242 let p = entry.path();
243 if p.is_dir() && p.join("cur").is_dir() && p.join("new").is_dir() {
244 out.push(p);
245 }
246 }
247 }
248 let mut out = Vec::new();
249 push_children(dir, &mut out);
250 if let Some(parent) = dir.parent() {
251 push_children(parent, &mut out);
252 }
253 out.sort();
254 out.dedup();
255 out
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn make_maildir(root: &Path) {
263 for sub in ["cur", "new", "tmp"] {
264 fs::create_dir_all(root.join(sub)).unwrap();
265 }
266 }
267
268 #[test]
269 fn flags_from_filename() {
270 let f = Flags::from_filename("1234.abc.host:2,FS");
271 assert!(f.seen && f.flagged);
272 assert!(!f.deleted && !f.answered && !f.draft);
273 assert_eq!(Flags::from_filename("1234.abc.host"), Flags::default());
274 }
275
276 #[test]
277 fn flags_roundtrip_info() {
278 let f = Flags {
279 seen: true,
280 flagged: true,
281 deleted: true,
282 ..Default::default()
283 };
284 assert_eq!(f.to_info(), ":2,FST");
285 assert_eq!(Flags::from_filename(&format!("x{}", f.to_info())), f);
286 }
287
288 #[test]
289 fn size_comes_from_name_when_present() {
290 assert_eq!(size_from_name("12.rmut,S=345:2,S"), Some(345));
291 assert_eq!(size_from_name("12.rmut,S=345"), Some(345));
292 assert_eq!(size_from_name("1234.abc.host:2,S"), None);
293 let tmp = tempfile::tempdir().unwrap();
294 make_maildir(tmp.path());
295 fs::write(tmp.path().join("cur/9.rmut,S=777:2,S"), "tiny").unwrap();
296 let files = scan(tmp.path()).unwrap();
297 assert_eq!(files[0].size, 777);
298 }
299
300 #[test]
301 fn scan_skips_what_is_gone_instead_of_failing() {
302 let tmp = tempfile::tempdir().unwrap();
305 make_maildir(tmp.path());
306 fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: kept\n\nx\n").unwrap();
307 std::os::unix::fs::symlink(tmp.path().join("gone"), tmp.path().join("cur/2.host:2,S"))
308 .unwrap();
309 let files = scan(tmp.path()).unwrap();
310 assert_eq!(files.len(), 1);
311 assert_eq!(files[0].size, 17);
312 }
313
314 #[test]
315 fn scan_rejects_non_maildir() {
316 let tmp = tempfile::tempdir().unwrap();
317 assert!(scan(tmp.path()).is_err());
318 }
319
320 #[test]
321 fn scan_finds_new_and_cur() {
322 let tmp = tempfile::tempdir().unwrap();
323 make_maildir(tmp.path());
324 fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: a\n\nx\n").unwrap();
325 fs::write(tmp.path().join("new/2.host"), "Subject: b\n\ny\n").unwrap();
326 let mut files = scan(tmp.path()).unwrap();
327 files.sort_by(|a, b| a.path.cmp(&b.path));
328 assert_eq!(files.len(), 2);
329 assert!(!files[0].is_new && files[0].flags.seen);
330 assert!(files[1].is_new);
331 assert!(files[0].size > 0);
332 }
333
334 #[test]
335 fn store_flags_moves_new_to_cur_with_info() {
336 let tmp = tempfile::tempdir().unwrap();
337 make_maildir(tmp.path());
338 let src = tmp.path().join("new/99.host");
339 fs::write(&src, "Subject: a\n\nx\n").unwrap();
340 let file = MailFile {
341 path: src.clone(),
342 is_new: true,
343 flags: Flags {
344 seen: true,
345 ..Default::default()
346 },
347 size: 14,
348 };
349 let new_path = store_flags(&file).unwrap();
350 assert_eq!(new_path, tmp.path().join("cur/99.host:2,S"));
351 assert!(!src.exists());
352 assert!(new_path.exists());
353 }
354
355 #[test]
356 fn store_flags_rewrites_existing_info() {
357 let tmp = tempfile::tempdir().unwrap();
358 make_maildir(tmp.path());
359 let src = tmp.path().join("cur/7.host:2,S");
360 fs::write(&src, "x").unwrap();
361 let mut file = MailFile {
362 path: src.clone(),
363 is_new: false,
364 flags: Flags::from_filename("7.host:2,S"),
365 size: 1,
366 };
367 file.flags.flagged = true;
368 let new_path = store_flags(&file).unwrap();
369 assert_eq!(new_path, tmp.path().join("cur/7.host:2,FS"));
370 assert!(!src.exists());
371 }
372
373 #[test]
374 fn discover_finds_children_and_siblings() {
375 let tmp = tempfile::tempdir().unwrap();
376 let inbox = tmp.path().join("inbox");
377 let sent = tmp.path().join("sent");
378 let sub = inbox.join(".archive");
379 for d in [&inbox, &sent, &sub] {
380 make_maildir(d);
381 }
382 fs::create_dir(tmp.path().join("not-a-maildir")).unwrap();
383 let found = discover(&inbox);
384 assert!(found.contains(&inbox));
385 assert!(found.contains(&sent));
386 assert!(found.contains(&sub));
387 assert!(!found.iter().any(|p| p.ends_with("not-a-maildir")));
388 }
389}