
creation of user account folder so that the entire directory has the correct permissions, bot just the Inbox folder
520 lines
17 KiB
Rust
520 lines
17 KiB
Rust
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<Vec<String>, <Self as filesystem::MultiDomain>::Error>;
|
|
fn add_domain(&mut self, domain: &str) -> Result<(), <Self as filesystem::MultiDomain>::Error>;
|
|
fn remove_domain(
|
|
&mut self,
|
|
domain: &str,
|
|
) -> Result<(), <Self as filesystem::MultiDomain>::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<String> 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<PathBuf> for Filesystem {
|
|
fn from(value: PathBuf) -> Self {
|
|
Self { path: value }
|
|
}
|
|
}
|
|
|
|
impl From<OsString> for Filesystem {
|
|
fn from(value: OsString) -> Self {
|
|
Self { path: value.into() }
|
|
}
|
|
}
|
|
|
|
impl MailStore for Filesystem {
|
|
type Error = Error;
|
|
|
|
fn users(&self) -> Vec<Mailuser> {
|
|
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::<Host>() 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<Folder> {
|
|
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<Message> {
|
|
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::<Mailbox>() 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<Vec<String>, 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::<Vec<String>>())
|
|
}
|
|
|
|
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<Certificate> {
|
|
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<Certificate> {
|
|
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"));
|
|
}
|
|
}
|