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 if !entry.path().is_file() {
98 continue;
99 }
100 let name = entry.file_name();
101 let name = name.to_string_lossy();
102 if name.starts_with('.') {
103 continue;
104 }
105 out.push(MailFile {
106 path: entry.path(),
107 is_new,
108 flags: Flags::from_filename(&name),
109 size: size_from_name(&name).unwrap_or(std::fs::metadata(entry.path())?.len()),
110 });
111 }
112 }
113 Ok(out)
114}
115
116fn base_name(name: &str) -> &str {
118 name.rsplit_once(":2,").map_or(name, |(base, _)| base)
119}
120
121fn size_from_name(name: &str) -> Option<u64> {
125 let rest = base_name(name).rsplit_once(",S=")?.1;
126 let end = rest
127 .find(|c: char| !c.is_ascii_digit())
128 .unwrap_or(rest.len());
129 rest[..end].parse().ok()
130}
131
132pub fn store_flags(file: &MailFile) -> Result<PathBuf> {
135 let name = file
136 .path
137 .file_name()
138 .context("mail file has no filename")?
139 .to_string_lossy()
140 .into_owned();
141 let maildir = file
142 .path
143 .parent()
144 .and_then(Path::parent)
145 .context("mail file is not inside a maildir")?;
146 let target = maildir
147 .join("cur")
148 .join(format!("{}{}", base_name(&name), file.flags.to_info()));
149 if target != file.path {
150 fs::rename(&file.path, &target)
151 .with_context(|| format!("renaming to {}", target.display()))?;
152 }
153 Ok(target)
154}
155
156pub fn remove(file: &MailFile) -> Result<()> {
157 fs::remove_file(&file.path).with_context(|| format!("removing {}", file.path.display()))
158}
159
160pub fn hostname() -> String {
161 fs::read_to_string("/proc/sys/kernel/hostname")
162 .map(|s| s.trim().to_string())
163 .ok()
164 .filter(|s| !s.is_empty())
165 .unwrap_or_else(|| "localhost".into())
166}
167
168pub fn create(dir: &Path) -> Result<()> {
170 for sub in ["cur", "new", "tmp"] {
171 fs::create_dir_all(dir.join(sub))
172 .with_context(|| format!("creating {}", dir.join(sub).display()))?;
173 }
174 Ok(())
175}
176
177pub fn deliver(dir: &Path, content: &[u8], flags: Flags) -> Result<PathBuf> {
180 use std::sync::atomic::{AtomicUsize, Ordering};
181 static COUNTER: AtomicUsize = AtomicUsize::new(0);
182 if !dir.join("cur").is_dir() || !dir.join("tmp").is_dir() {
183 bail!("{} is not a maildir", dir.display());
184 }
185 let epoch = std::time::SystemTime::now()
186 .duration_since(std::time::UNIX_EPOCH)
187 .unwrap_or_default()
188 .as_secs();
189 let name = format!(
190 "{epoch}.{}_{}.{}",
191 std::process::id(),
192 COUNTER.fetch_add(1, Ordering::Relaxed),
193 hostname(),
194 );
195 let tmp = dir.join("tmp").join(&name);
196 fs::write(&tmp, content).with_context(|| format!("writing {}", tmp.display()))?;
197 let target = dir.join("cur").join(format!("{name}{}", flags.to_info()));
198 fs::rename(&tmp, &target).with_context(|| format!("renaming to {}", target.display()))?;
199 Ok(target)
200}
201
202pub fn new_count(dir: &Path) -> usize {
205 dir.join("new")
206 .read_dir()
207 .map(|entries| {
208 entries
209 .filter_map(|e| e.ok())
210 .filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
211 .count()
212 })
213 .unwrap_or(0)
214}
215
216pub fn find_special(dir: &Path, names: &[&str]) -> Option<PathBuf> {
219 discover(dir).into_iter().find(|p| {
220 p.file_name().is_some_and(|n| {
221 let n = n.to_string_lossy();
222 let n = n.trim_start_matches('.');
223 names.iter().any(|w| n.eq_ignore_ascii_case(w))
224 })
225 })
226}
227
228pub fn discover(dir: &Path) -> Vec<PathBuf> {
231 fn push_children(parent: &Path, out: &mut Vec<PathBuf>) {
232 let Ok(entries) = parent.read_dir() else {
233 return;
234 };
235 for entry in entries.flatten() {
236 let p = entry.path();
237 if p.is_dir() && p.join("cur").is_dir() && p.join("new").is_dir() {
238 out.push(p);
239 }
240 }
241 }
242 let mut out = Vec::new();
243 push_children(dir, &mut out);
244 if let Some(parent) = dir.parent() {
245 push_children(parent, &mut out);
246 }
247 out.sort();
248 out.dedup();
249 out
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn make_maildir(root: &Path) {
257 for sub in ["cur", "new", "tmp"] {
258 fs::create_dir_all(root.join(sub)).unwrap();
259 }
260 }
261
262 #[test]
263 fn flags_from_filename() {
264 let f = Flags::from_filename("1234.abc.host:2,FS");
265 assert!(f.seen && f.flagged);
266 assert!(!f.deleted && !f.answered && !f.draft);
267 assert_eq!(Flags::from_filename("1234.abc.host"), Flags::default());
268 }
269
270 #[test]
271 fn flags_roundtrip_info() {
272 let f = Flags {
273 seen: true,
274 flagged: true,
275 deleted: true,
276 ..Default::default()
277 };
278 assert_eq!(f.to_info(), ":2,FST");
279 assert_eq!(Flags::from_filename(&format!("x{}", f.to_info())), f);
280 }
281
282 #[test]
283 fn size_comes_from_name_when_present() {
284 assert_eq!(size_from_name("12.rmut,S=345:2,S"), Some(345));
285 assert_eq!(size_from_name("12.rmut,S=345"), Some(345));
286 assert_eq!(size_from_name("1234.abc.host:2,S"), None);
287 let tmp = tempfile::tempdir().unwrap();
288 make_maildir(tmp.path());
289 fs::write(tmp.path().join("cur/9.rmut,S=777:2,S"), "tiny").unwrap();
290 let files = scan(tmp.path()).unwrap();
291 assert_eq!(files[0].size, 777);
292 }
293
294 #[test]
295 fn scan_rejects_non_maildir() {
296 let tmp = tempfile::tempdir().unwrap();
297 assert!(scan(tmp.path()).is_err());
298 }
299
300 #[test]
301 fn scan_finds_new_and_cur() {
302 let tmp = tempfile::tempdir().unwrap();
303 make_maildir(tmp.path());
304 fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: a\n\nx\n").unwrap();
305 fs::write(tmp.path().join("new/2.host"), "Subject: b\n\ny\n").unwrap();
306 let mut files = scan(tmp.path()).unwrap();
307 files.sort_by(|a, b| a.path.cmp(&b.path));
308 assert_eq!(files.len(), 2);
309 assert!(!files[0].is_new && files[0].flags.seen);
310 assert!(files[1].is_new);
311 assert!(files[0].size > 0);
312 }
313
314 #[test]
315 fn store_flags_moves_new_to_cur_with_info() {
316 let tmp = tempfile::tempdir().unwrap();
317 make_maildir(tmp.path());
318 let src = tmp.path().join("new/99.host");
319 fs::write(&src, "Subject: a\n\nx\n").unwrap();
320 let file = MailFile {
321 path: src.clone(),
322 is_new: true,
323 flags: Flags {
324 seen: true,
325 ..Default::default()
326 },
327 size: 14,
328 };
329 let new_path = store_flags(&file).unwrap();
330 assert_eq!(new_path, tmp.path().join("cur/99.host:2,S"));
331 assert!(!src.exists());
332 assert!(new_path.exists());
333 }
334
335 #[test]
336 fn store_flags_rewrites_existing_info() {
337 let tmp = tempfile::tempdir().unwrap();
338 make_maildir(tmp.path());
339 let src = tmp.path().join("cur/7.host:2,S");
340 fs::write(&src, "x").unwrap();
341 let mut file = MailFile {
342 path: src.clone(),
343 is_new: false,
344 flags: Flags::from_filename("7.host:2,S"),
345 size: 1,
346 };
347 file.flags.flagged = true;
348 let new_path = store_flags(&file).unwrap();
349 assert_eq!(new_path, tmp.path().join("cur/7.host:2,FS"));
350 assert!(!src.exists());
351 }
352
353 #[test]
354 fn discover_finds_children_and_siblings() {
355 let tmp = tempfile::tempdir().unwrap();
356 let inbox = tmp.path().join("inbox");
357 let sent = tmp.path().join("sent");
358 let sub = inbox.join(".archive");
359 for d in [&inbox, &sent, &sub] {
360 make_maildir(d);
361 }
362 fs::create_dir(tmp.path().join("not-a-maildir")).unwrap();
363 let found = discover(&inbox);
364 assert!(found.contains(&inbox));
365 assert!(found.contains(&sent));
366 assert!(found.contains(&sub));
367 assert!(!found.iter().any(|p| p.ends_with("not-a-maildir")));
368 }
369}