use { super::*, crate::{ message::Parser as MessageParser, prelude::{ClientCertificateStore, Certificate, ParseMailboxError}, }, rustls_pemfile::{read_one, Item}, std::{ ffi::OsString, fs::{self, File}, io::{self, BufReader, BufWriter, Write}, iter, os::{fd::AsRawFd, unix::{fs::DirBuilderExt, prelude::OpenOptionsExt}}, path::{Path, PathBuf}, }, }; mod error; pub use error::Error; pub trait MultiDomain: MailStore { type Error; fn domains(&self) -> Result, ::Error>; fn add_domain(&mut self, domain: &str) -> Result<(), ::Error>; fn remove_domain( &mut self, domain: &str, ) -> Result<(), ::Error>; } /// The Filestore storage backend uses the filesystem to map folders to domains, /// mail users and folders of messages. It can also be used as a `ClientCertificateStore`. /// The layout of files inside a filesystem mailstore is as below. /// ```Sh /// test/mailstore/ /// └── misfin.example.org /// ├── dick /// │   ├── Inbox /// │   │   ├── 619310.gmi /// │   │   └── 868379.gmi /// │   ├── blurb /// │   └── certificate.pem /// └── jane /// ├── Inbox /// │   ├── 252366.gmi /// │   └── 654064.gmi /// ├── blurb /// └── certificate.pem /// ``` /// In this made up example the Misfin server in question serves the domain /// "misfin.example.org", which has the users "dick" and "jane", whose mailbox /// addresses would be "dick@misfin.example.org" and "jane@misfin.example.org". /// Within each account folder any subfolders are to be considered as mail folders, /// with the default folder for delivering new mail being "Inbox". The "blurb" /// file (optional) contains the display name of the user, and their client /// certificate is stored in pem-encoded ascii armor format in "certificate.pem", /// if the `Filesystem` is also to be used as a `ClientCertificateStore`. /// /// The permissions on each individual account folder are set to 0o2700 at the /// time of creation, such that the folder is read/write for the user and group /// and any entries created within will have the same group membership. If the /// username of the account corresponds to a group name on the system, and the /// user which the server runs as is a member of this group, then said group will /// own the directory. In this way the server will be able to write incoming /// mail into the folder and the user will be able to read and manage their /// folders and messages, without being able to see any other user's mail and /// without the server requiring elevated privileges. #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(Deserialize, Serialize))] pub struct Filesystem { pub path: PathBuf, } impl From for Filesystem { fn from(value: String) -> Self { Self { path: value.into() } } } impl From<&str> for Filesystem { fn from(value: &str) -> Self { Self { path: value.into() } } } impl From<&Path> for Filesystem { fn from(value: &Path) -> Self { Self { path: value.into() } } } impl From for Filesystem { fn from(value: PathBuf) -> Self { Self { path: value } } } impl From for Filesystem { fn from(value: OsString) -> Self { Self { path: value.into() } } } impl MailStore for Filesystem { type Error = Error; fn users(&self) -> Vec { let mut users = vec![]; let Ok(domains) = fs::read_dir(&self.path) else { return users; }; for d in domains { let Ok(dom) = d else { continue; }; let name = dom.file_name().to_string_lossy().to_string(); let Ok(host) = name.parse::() else { continue; }; let dom_path = dom.path(); let Ok(dom_users) = fs::read_dir(&dom_path) else { continue; }; for u in dom_users { let Ok(user) = u else { continue; }; if let Ok(ft) = user.file_type() { if ft.is_dir() { let username = user.file_name().to_string_lossy().to_string(); users.push(Mailuser { username, host: host.clone(), }); } } } } users } fn serves_domain(&self, domain: &str) -> bool { let mut path = self.path.clone(); path.push(domain); path.exists() } fn has_mailuser(&self, mailuser: &str) -> bool { if let Some((user, host)) = mailuser.rsplit_once('@') { let mut path = self.path.clone(); if host.contains('.') && !host.contains(char::is_whitespace) { path.push(host); path.push(user); return path.exists(); } } false } fn get_folder(&self, user: &str, folder: &str) -> Option { let Some((user, host)) = user.rsplit_once('@') else { return None; }; let mut path = self.path.clone(); path.push(host); path.push(user); path.push(folder); let Ok(dir) = fs::read_dir(path) else { return None; }; let mut folder = Folder { name: folder.to_string(), messages: HashMap::new(), }; dir.filter_map(Result::ok).for_each(|e| { if let Ok(contents) = fs::read_to_string(e.path()) { if let Some(Some(p)) = e.path().file_stem().map(|s| s.to_str()) { let parser = MessageParser::new(p); if let Ok(message) = parser.parse(&contents) { folder.messages.insert(message.id.clone(), message); } } } }); Some(folder) } fn create_folder(&self, user: &str, folder: &str) -> Result<(), Error> { if self.has_mailuser(user) { let user: Mailbox = user.parse()?; let user: Mailuser = user.into(); let mut path = self.path.clone(); path.push(&user.host.to_string()); path.push(&user.username); path.push(folder); fs::create_dir_all(&path)?; } Ok(()) } fn get_message(&self, user: &str, folder: &str, id: &str) -> Option { self.get_folder(user, folder) .and_then(|f| f.messages.get(id).cloned()) } fn add_message( &mut self, user: &str, folder: &str, message: Message, ) -> Result<(), Self::Error> { let mb: Mailbox = user.parse()?; let mut path = self.path.clone(); path.push(&mb.host.to_string()); path.push(&mb.username); path.push(folder); path.push(&message.id); path.set_extension("gmi"); let mut fd = File::create(path)?; write!(fd, "{message}")?; Ok(()) } fn delete_message(&mut self, user: &str, folder: &str, id: &str) -> Result<(), Self::Error> { let mb: Mailbox = user.parse()?; let mut path = self.path.clone(); path.push(&mb.host.to_string()); path.push(&mb.username); path.push(folder); path.push(id); path.set_extension("gmi"); if path.exists() { fs::remove_file(&path)?; } Ok(()) } fn move_message( &mut self, user: &str, id: &str, folder1: &str, folder2: &str, ) -> Result<(), Self::Error> { let mb: Mailbox = user.parse()?; let mut infile = self.path.clone(); infile.push(&mb.host.to_string()); infile.push(&mb.username); let mut outfile = infile.clone(); infile.push(folder1); infile.push(id); infile.set_extension("gmi"); outfile.push(folder2); outfile.push(id); outfile.set_extension("gmi"); fs::copy(&infile, &outfile)?; fs::remove_file(infile)?; Ok(()) } fn add_user(&mut self, user: &str) -> Result<(), Self::Error> { let mb: Mailbox = user.parse()?; let mut path = self.path.clone(); path.push(&mb.host.to_string()); path.push(&mb.username); fs::create_dir_all(&path)?; if let Some(ref blurb) = mb.blurb { path.push("blurb"); let fd = File::options() .create(true) .write(true) .truncate(true) .mode(0o2770) .open(&path)?; if let Some(pw) = pw::Passwd::getpw()? { let groups = pw.groups()?; if let Some(gr) = groups.iter().find(|g| g.name == mb.username) { unsafe { if libc::fchown(fd.as_raw_fd(), pw.uid, gr.gid) != 0 { return Err(io::Error::last_os_error().into()); } } } } let mut writer = BufWriter::new(fd); writer.write_all(blurb.as_bytes())?; } let _last = path.pop(); path.push("Inbox"); fs::create_dir_all(&path)?; Ok(()) } fn remove_user(&mut self, user: &str) -> bool { let Ok(mb) = user.parse::() else { return false; }; let mut path = self.path.clone(); path.push(&mb.host.to_string()); path.push(&mb.username); if path.exists() { if fs::remove_dir_all(path).is_ok() { return true; } } false } } impl MultiDomain for Filesystem { type Error = io::Error; fn domains(&self) -> Result, io::Error> { Ok(self .path .read_dir()? .filter(|x| { if let Ok(x) = x { if let Ok(t) = x.file_type() { return t.is_dir(); } } false }) .map(|x| x.unwrap().file_name().to_string_lossy().to_string()) .collect::>()) } fn add_domain(&mut self, domain: &str) -> Result<(), io::Error> { let mut path = self.path.clone(); path.push(domain); if !path.exists() { fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(path)?; } Ok(()) } fn remove_domain(&mut self, domain: &str) -> Result<(), io::Error> { let mut path = self.path.clone(); path.push(domain); if path.exists() { fs::remove_dir_all(path)?; } Ok(()) } } impl ClientCertificateStore for Filesystem { fn get_certificate(&self, user: &Mailuser) -> Option { let mut path = self.path.clone(); path.push(&user.host.to_string()); path.push(&user.username); path.push("certificate.pem"); if path.exists() { let Ok(fd) = File::open(&path) else { return None; }; let mut reader = BufReader::new(fd); let mut certificate = Certificate { der: vec![], key: vec![], }; for item in iter::from_fn(|| read_one(&mut reader).transpose()) { match item { Ok(Item::X509Certificate(cert)) => { if certificate.der.is_empty() { certificate.der = cert; } else { return None; } } Ok(Item::RSAKey(key)) => { if certificate.key.is_empty() { certificate.key = key; } else { return None; } } Ok(Item::PKCS8Key(key)) => { if certificate.key.is_empty() { certificate.key = key; } else { return None; } } Ok(Item::ECKey(key)) => { if certificate.key.is_empty() { certificate.key = key; } else { return None; } } _ => {} } } if certificate.der.is_empty() || certificate.key.is_empty() { None } else { Some(certificate) } } else { None } } fn insert_certificate( &mut self, user: &Mailuser, certificate: Certificate, ) -> Option { todo!() } fn contains_certificate(&self, user: &Mailuser) -> bool { self.get_certificate(user).is_some() } } #[cfg(test)] mod tests { use super::*; fn store() -> Filesystem { "test/mailstore".into() } #[test] fn users() { let users = store().users(); assert!(users .iter() .any(|u| u.host.to_string() == "mail.gmi.org" && u.username == "jane")); assert!(users .iter() .any(|u| u.host.to_string() == "mail.gmi.org" && u.username == "dick")); assert!(users .iter() .any(|u| u.host.to_string() == "misfin.example.org" && u.username == "jane")); assert!(users .iter() .any(|u| u.host.to_string() == "misfin.example.org" && u.username == "dick")); assert!(users.len() >= 4 && users.len() < 6); } #[test] fn serves_domain() { let store = store(); assert!(store.serves_domain("mail.gmi.org")); assert!(store.serves_domain("misfin.example.org")); assert!(!store.serves_domain("iron.maiden.org")); } #[test] fn has_user() { let store = store(); assert!(store.has_mailuser("jane@mail.gmi.org")); assert!(store.has_mailuser("dick@mail.gmi.org")); assert!(store.has_mailuser("jane@misfin.example.org")); assert!(store.has_mailuser("dick@misfin.example.org")); assert!(!store.has_mailuser("fred@mail.gmi.org")); } #[test] fn get_folder() { let store = store(); let folder = store.get_folder("jane@mail.gmi.org", "Inbox").unwrap(); let msg = folder.messages.get("867017"); assert!(msg.is_some()); let msg = folder.messages.get("488571"); assert!(msg.is_some()); } #[test] fn get_message() { let msg = store() .get_message("jane@mail.gmi.org", "Inbox", "867017") .unwrap(); assert_eq!(msg.from.username, "april"); assert_eq!(msg.from.host.subdomain.unwrap(), "we.wear"); } #[test] fn add_move_delete_message() { let msg = Message { id: String::from("1687407844"), from: Mailbox::from_str("suzie@thebanshees.legs").unwrap(), senders: vec![], recipients: vec![], timestamp: None, title: Some(String::from("Get a look at 'is 'orse")), body: String::from("Gorgeous, innit?"), }; store() .add_message("dick@mail.gmi.org", "Inbox", msg) .unwrap(); assert!(PathBuf::from("test/mailstore/mail.gmi.org/dick/Inbox/1687407844.gmi").exists()); store() .move_message("dick@mail.gmi.org", "1687407844", "Inbox", "Lists") .unwrap(); assert!(!PathBuf::from("test/mailstore/mail.gmi.org/dick/Inbox/1687407844.gmi").exists()); assert!(PathBuf::from("test/mailstore/mail.gmi.org/dick/Lists/1687407844.gmi").exists()); store() .delete_message("dick@mail.gmi.org", "Lists", "1687407844") .unwrap(); assert!(!PathBuf::from("test/mailstore/mail.gmi.org/dick/Lists/1687407844.gmi").exists()); } #[test] fn add_remove_user() { store() .add_user("rob@misfin.example.org Rob Zombie") .unwrap(); assert!(store().has_mailuser("rob@misfin.example.org")); assert!(store().remove_user("rob@misfin.example.org")); assert!(!store().has_mailuser("rob@misfin.example.org")); } }