use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Flags {
pub seen: bool,
pub answered: bool,
pub flagged: bool,
pub deleted: bool,
pub draft: bool,
}
impl Flags {
pub fn from_filename(name: &str) -> Self {
let mut flags = Flags::default();
if let Some((_, info)) = name.rsplit_once(":2,") {
for c in info.chars() {
match c {
'S' => flags.seen = true,
'R' => flags.answered = true,
'F' => flags.flagged = true,
'T' => flags.deleted = true,
'D' => flags.draft = true,
_ => {}
}
}
}
flags
}
pub fn to_info(self) -> String {
let mut info = String::from(":2,");
if self.draft {
info.push('D');
}
if self.flagged {
info.push('F');
}
if self.answered {
info.push('R');
}
if self.seen {
info.push('S');
}
if self.deleted {
info.push('T');
}
info
}
pub fn status_char(&self, is_new: bool) -> char {
if self.deleted {
'D'
} else if is_new {
'N'
} else if self.answered {
'r'
} else if !self.seen {
'O'
} else {
' '
}
}
}
#[derive(Debug, Clone)]
pub struct MailFile {
pub path: PathBuf,
pub is_new: bool,
pub flags: Flags,
pub size: u64,
}
pub fn scan(dir: &Path) -> Result<Vec<MailFile>> {
let cur = dir.join("cur");
let new = dir.join("new");
if !cur.is_dir() || !new.is_dir() {
bail!("{} is not a maildir (missing cur/ or new/)", dir.display());
}
let mut out = Vec::new();
for (sub, is_new) in [(cur, false), (new, true)] {
let entries = sub
.read_dir()
.with_context(|| format!("reading {}", sub.display()))?;
for entry in entries {
let entry = entry?;
let meta = match std::fs::metadata(entry.path()) {
Ok(meta) if meta.is_file() => meta,
Ok(_) => continue,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(e).with_context(|| format!("reading {}", entry.path().display()));
}
};
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') {
continue;
}
out.push(MailFile {
path: entry.path(),
is_new,
flags: Flags::from_filename(&name),
size: size_from_name(&name).unwrap_or(meta.len()),
});
}
}
Ok(out)
}
fn base_name(name: &str) -> &str {
name.rsplit_once(":2,").map_or(name, |(base, _)| base)
}
fn size_from_name(name: &str) -> Option<u64> {
let rest = base_name(name).rsplit_once(",S=")?.1;
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
rest[..end].parse().ok()
}
pub fn store_flags(file: &MailFile) -> Result<PathBuf> {
let name = file
.path
.file_name()
.context("mail file has no filename")?
.to_string_lossy()
.into_owned();
let maildir = file
.path
.parent()
.and_then(Path::parent)
.context("mail file is not inside a maildir")?;
let target = maildir
.join("cur")
.join(format!("{}{}", base_name(&name), file.flags.to_info()));
if target != file.path {
fs::rename(&file.path, &target)
.with_context(|| format!("renaming to {}", target.display()))?;
}
Ok(target)
}
pub fn remove(file: &MailFile) -> Result<()> {
fs::remove_file(&file.path).with_context(|| format!("removing {}", file.path.display()))
}
pub fn hostname() -> String {
fs::read_to_string("/proc/sys/kernel/hostname")
.map(|s| s.trim().to_string())
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "localhost".into())
}
pub fn create(dir: &Path) -> Result<()> {
for sub in ["cur", "new", "tmp"] {
fs::create_dir_all(dir.join(sub))
.with_context(|| format!("creating {}", dir.join(sub).display()))?;
}
Ok(())
}
pub fn deliver(dir: &Path, content: &[u8], flags: Flags) -> Result<PathBuf> {
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
if !dir.join("cur").is_dir() || !dir.join("tmp").is_dir() {
bail!("{} is not a maildir", dir.display());
}
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let name = format!(
"{epoch}.{}_{}.{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
hostname(),
);
let tmp = dir.join("tmp").join(&name);
fs::write(&tmp, content).with_context(|| format!("writing {}", tmp.display()))?;
let target = dir.join("cur").join(format!("{name}{}", flags.to_info()));
fs::rename(&tmp, &target).with_context(|| format!("renaming to {}", target.display()))?;
Ok(target)
}
pub fn new_count(dir: &Path) -> usize {
dir.join("new")
.read_dir()
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
.count()
})
.unwrap_or(0)
}
pub fn find_special(dir: &Path, names: &[&str]) -> Option<PathBuf> {
discover(dir).into_iter().find(|p| {
p.file_name().is_some_and(|n| {
let n = n.to_string_lossy();
let n = n.trim_start_matches('.');
names.iter().any(|w| n.eq_ignore_ascii_case(w))
})
})
}
pub fn discover(dir: &Path) -> Vec<PathBuf> {
fn push_children(parent: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = parent.read_dir() else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() && p.join("cur").is_dir() && p.join("new").is_dir() {
out.push(p);
}
}
}
let mut out = Vec::new();
push_children(dir, &mut out);
if let Some(parent) = dir.parent() {
push_children(parent, &mut out);
}
out.sort();
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
fn make_maildir(root: &Path) {
for sub in ["cur", "new", "tmp"] {
fs::create_dir_all(root.join(sub)).unwrap();
}
}
#[test]
fn flags_from_filename() {
let f = Flags::from_filename("1234.abc.host:2,FS");
assert!(f.seen && f.flagged);
assert!(!f.deleted && !f.answered && !f.draft);
assert_eq!(Flags::from_filename("1234.abc.host"), Flags::default());
}
#[test]
fn flags_roundtrip_info() {
let f = Flags {
seen: true,
flagged: true,
deleted: true,
..Default::default()
};
assert_eq!(f.to_info(), ":2,FST");
assert_eq!(Flags::from_filename(&format!("x{}", f.to_info())), f);
}
#[test]
fn size_comes_from_name_when_present() {
assert_eq!(size_from_name("12.rmut,S=345:2,S"), Some(345));
assert_eq!(size_from_name("12.rmut,S=345"), Some(345));
assert_eq!(size_from_name("1234.abc.host:2,S"), None);
let tmp = tempfile::tempdir().unwrap();
make_maildir(tmp.path());
fs::write(tmp.path().join("cur/9.rmut,S=777:2,S"), "tiny").unwrap();
let files = scan(tmp.path()).unwrap();
assert_eq!(files[0].size, 777);
}
#[test]
fn scan_skips_what_is_gone_instead_of_failing() {
let tmp = tempfile::tempdir().unwrap();
make_maildir(tmp.path());
fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: kept\n\nx\n").unwrap();
std::os::unix::fs::symlink(tmp.path().join("gone"), tmp.path().join("cur/2.host:2,S"))
.unwrap();
let files = scan(tmp.path()).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].size, 17);
}
#[test]
fn scan_rejects_non_maildir() {
let tmp = tempfile::tempdir().unwrap();
assert!(scan(tmp.path()).is_err());
}
#[test]
fn scan_finds_new_and_cur() {
let tmp = tempfile::tempdir().unwrap();
make_maildir(tmp.path());
fs::write(tmp.path().join("cur/1.host:2,S"), "Subject: a\n\nx\n").unwrap();
fs::write(tmp.path().join("new/2.host"), "Subject: b\n\ny\n").unwrap();
let mut files = scan(tmp.path()).unwrap();
files.sort_by(|a, b| a.path.cmp(&b.path));
assert_eq!(files.len(), 2);
assert!(!files[0].is_new && files[0].flags.seen);
assert!(files[1].is_new);
assert!(files[0].size > 0);
}
#[test]
fn store_flags_moves_new_to_cur_with_info() {
let tmp = tempfile::tempdir().unwrap();
make_maildir(tmp.path());
let src = tmp.path().join("new/99.host");
fs::write(&src, "Subject: a\n\nx\n").unwrap();
let file = MailFile {
path: src.clone(),
is_new: true,
flags: Flags {
seen: true,
..Default::default()
},
size: 14,
};
let new_path = store_flags(&file).unwrap();
assert_eq!(new_path, tmp.path().join("cur/99.host:2,S"));
assert!(!src.exists());
assert!(new_path.exists());
}
#[test]
fn store_flags_rewrites_existing_info() {
let tmp = tempfile::tempdir().unwrap();
make_maildir(tmp.path());
let src = tmp.path().join("cur/7.host:2,S");
fs::write(&src, "x").unwrap();
let mut file = MailFile {
path: src.clone(),
is_new: false,
flags: Flags::from_filename("7.host:2,S"),
size: 1,
};
file.flags.flagged = true;
let new_path = store_flags(&file).unwrap();
assert_eq!(new_path, tmp.path().join("cur/7.host:2,FS"));
assert!(!src.exists());
}
#[test]
fn discover_finds_children_and_siblings() {
let tmp = tempfile::tempdir().unwrap();
let inbox = tmp.path().join("inbox");
let sent = tmp.path().join("sent");
let sub = inbox.join(".archive");
for d in [&inbox, &sent, &sub] {
make_maildir(d);
}
fs::create_dir(tmp.path().join("not-a-maildir")).unwrap();
let found = discover(&inbox);
assert!(found.contains(&inbox));
assert!(found.contains(&sent));
assert!(found.contains(&sub));
assert!(!found.iter().any(|p| p.ends_with("not-a-maildir")));
}
}