use alloc::{
collections::{BTreeMap, BTreeSet},
string::{String, ToString},
vec::Vec,
};
use std::{
fs, io,
path::Path,
process, thread,
time::{SystemTime, UNIX_EPOCH},
};
use gethostname::gethostname;
use log::trace;
use thiserror::Error;
use crate::{
coroutine::*,
dovecot::{load::*, store::*, utils::allocate_keyword_slot},
entry::{
copy::*,
get::*,
headers::{inject_header, strip_headers},
list::*,
locate::*,
r#move::*,
store::*,
types::{MaildirEntry, MaildirFullEntry},
},
flag::{
add::*,
remove::*,
set::*,
types::{KeywordHeader, MaildirFlags},
},
maildir::{
create::*,
delete::*,
list::*,
rename::*,
types::{CUR, Maildir, MaildirSubdir, NEW, TMP},
},
path::{FsPath, MaildirPath},
store::MaildirStore,
};
#[derive(Debug, Error)]
pub enum MaildirClientError {
#[error("path {0} is not a directory")]
NotDir(FsPath),
#[error("missing {0}/ subdirectory at Maildir {1}")]
MissingSubdir(&'static str, FsPath),
#[error(transparent)]
DovecotLoad(#[from] DovecotLoadError),
#[error(transparent)]
DovecotStore(#[from] DovecotStoreError),
#[error(transparent)]
FlagsAdd(#[from] MaildirFlagsAddError),
#[error(transparent)]
FlagsRemove(#[from] MaildirFlagsRemoveError),
#[error(transparent)]
FlagsSet(#[from] MaildirFlagsSetError),
#[error(transparent)]
MaildirCreate(#[from] MaildirCreateError),
#[error(transparent)]
MaildirDelete(#[from] MaildirDeleteError),
#[error(transparent)]
MaildirList(#[from] MaildirListError),
#[error(transparent)]
MaildirRename(#[from] MaildirRenameError),
#[error(transparent)]
EntryCopy(#[from] MaildirEntryCopyError),
#[error(transparent)]
EntryGet(#[from] MaildirEntryGetError),
#[error(transparent)]
EntryLocate(#[from] MaildirEntryLocateError),
#[error(transparent)]
EntryList(#[from] MaildirEntryListError),
#[error(transparent)]
EntryMove(#[from] MaildirEntryMoveError),
#[error(transparent)]
EntryStore(#[from] MaildirEntryStoreError),
#[error(transparent)]
Io(#[from] io::Error),
}
#[derive(Debug)]
pub struct MaildirClient {
pub store: MaildirStore,
pub dovecot_keywords: bool,
pub keywords_header: Option<KeywordHeader>,
pub strip_headers: Vec<String>,
}
impl MaildirClient {
pub fn new(root: impl Into<FsPath>) -> Self {
Self {
store: MaildirStore {
root: root.into(),
maildirpp: false,
},
dovecot_keywords: false,
keywords_header: None,
strip_headers: Vec::new(),
}
}
pub fn run<C, T, E>(&self, mut coroutine: C) -> Result<T, MaildirClientError>
where
C: MaildirCoroutine<Yield = MaildirYield, Return = Result<T, E>>,
MaildirClientError: From<E>,
{
let mut arg: Option<MaildirReply> = None;
loop {
match coroutine.resume(arg.take()) {
MaildirCoroutineState::Complete(Ok(out)) => return Ok(out),
MaildirCoroutineState::Complete(Err(err)) => return Err(err.into()),
MaildirCoroutineState::Yielded(MaildirYield::WantsFileExists(paths)) => {
let mut out = BTreeMap::new();
for path in paths {
let exists = fs::metadata(path.as_str())
.map(|m| m.is_file())
.unwrap_or(false);
trace!("file_exists {path}: {exists}");
out.insert(path, exists);
}
arg = Some(MaildirReply::FileExists(out));
}
MaildirCoroutineState::Yielded(MaildirYield::WantsDirExists(paths)) => {
let mut out = BTreeMap::new();
for path in paths {
let exists = fs::metadata(path.as_str())
.map(|m| m.is_dir())
.unwrap_or(false);
trace!("dir_exists {path}: {exists}");
out.insert(path, exists);
}
arg = Some(MaildirReply::DirExists(out));
}
MaildirCoroutineState::Yielded(MaildirYield::WantsDirRead(paths)) => {
let mut entries = BTreeMap::new();
for path in paths {
trace!("read_dir {path}");
let mut names = BTreeSet::new();
match fs::read_dir(path.as_str()) {
Ok(iter) => {
for entry in iter {
names.insert(FsPath::from(entry?.path()));
}
}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
entries.insert(path, names);
}
arg = Some(MaildirReply::DirRead(entries));
}
MaildirCoroutineState::Yielded(MaildirYield::WantsFileRead(paths)) => {
let mut contents = BTreeMap::new();
for path in paths {
trace!("read_file {path}");
let bytes = fs::read(path.as_str())?;
contents.insert(path, bytes);
}
arg = Some(MaildirReply::FileRead(contents));
}
MaildirCoroutineState::Yielded(MaildirYield::WantsFileCreate(files)) => {
for (path, contents) in files {
trace!("write {path} ({} bytes)", contents.len());
if let Some(parent) = Path::new(path.as_str()).parent() {
fs::create_dir_all(parent)?;
}
fs::write(path.as_str(), &contents)?;
}
arg = Some(MaildirReply::FileCreate);
}
MaildirCoroutineState::Yielded(MaildirYield::WantsDirCreate(paths)) => {
for path in paths {
trace!("create_dir_all {path}");
fs::create_dir_all(path.as_str())?;
}
arg = Some(MaildirReply::DirCreate);
}
MaildirCoroutineState::Yielded(MaildirYield::WantsDirRemove(paths)) => {
for path in paths {
trace!("remove_dir_all {path}");
fs::remove_dir_all(path.as_str())?;
}
arg = Some(MaildirReply::DirRemove);
}
MaildirCoroutineState::Yielded(MaildirYield::WantsRename(pairs)) => {
for (from, to) in pairs {
trace!("rename {from} to {to}");
fs::rename(from.as_str(), to.as_str())?;
}
arg = Some(MaildirReply::Rename);
}
MaildirCoroutineState::Yielded(MaildirYield::WantsCopy(pairs)) => {
for (from, to) in pairs {
trace!("copy {from} to {to}");
fs::copy(from.as_str(), to.as_str())?;
}
arg = Some(MaildirReply::Copy);
}
MaildirCoroutineState::Yielded(MaildirYield::WantsTime) => {
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
arg = Some(MaildirReply::Time {
secs: ts.as_secs(),
nanos: ts.subsec_nanos(),
});
}
MaildirCoroutineState::Yielded(MaildirYield::WantsPid) => {
arg = Some(MaildirReply::Pid(process::id()));
}
MaildirCoroutineState::Yielded(MaildirYield::WantsHostname) => {
let hostname = gethostname().into_string().unwrap_or_default();
arg = Some(MaildirReply::Hostname(hostname));
}
}
}
}
pub fn load_dovecot_keywords(
&self,
maildir: &Maildir,
) -> Result<BTreeMap<char, String>, MaildirClientError> {
self.run(DovecotLoad::new(maildir))
}
pub fn store_dovecot_keywords(
&self,
maildir: &Maildir,
table: &BTreeMap<char, String>,
) -> Result<(), MaildirClientError> {
self.run(DovecotStore::new(maildir, table))
}
pub fn load_maildir(
&self,
name: impl Into<MaildirPath>,
) -> Result<Maildir, MaildirClientError> {
let root = self.store.resolve(&name.into());
if !Path::new(root.as_str()).is_dir() {
return Err(MaildirClientError::NotDir(root));
}
for sub in [CUR, NEW, TMP] {
let path = root.join(sub);
if !Path::new(path.as_str()).is_dir() {
return Err(MaildirClientError::MissingSubdir(sub, root));
}
}
Ok(Maildir::from_path(root))
}
pub fn create_maildir(&self, name: impl Into<MaildirPath>) -> Result<(), MaildirClientError> {
self.run(MaildirCreate::new(&self.store, name.into()))
}
pub fn delete_maildir(&self, name: impl Into<MaildirPath>) -> Result<(), MaildirClientError> {
self.run(MaildirDelete::new(&self.store, name.into()))
}
pub fn list_maildirs(&self) -> Result<BTreeSet<Maildir>, MaildirClientError> {
self.run(MaildirList::new(&self.store))
}
pub fn rename_maildir(
&self,
from: impl Into<MaildirPath>,
to: impl Into<MaildirPath>,
) -> Result<(), MaildirClientError> {
self.run(MaildirRename::new(&self.store, from.into(), to.into()))
}
pub fn add_flags(
&self,
maildir: Maildir,
id: impl ToString,
mut flags: MaildirFlags,
) -> Result<(), MaildirClientError> {
self.resolve_keywords(&maildir, &mut flags)?;
self.run(MaildirFlagsAdd::new(maildir, id, flags))
}
pub fn remove_flags(
&self,
maildir: Maildir,
id: impl ToString,
mut flags: MaildirFlags,
) -> Result<(), MaildirClientError> {
self.resolve_keywords(&maildir, &mut flags)?;
self.run(MaildirFlagsRemove::new(maildir, id, flags))
}
pub fn set_flags(
&self,
maildir: Maildir,
id: impl ToString,
mut flags: MaildirFlags,
) -> Result<(), MaildirClientError> {
self.resolve_keywords(&maildir, &mut flags)?;
self.run(MaildirFlagsSet::new(maildir, id, flags))
}
pub fn locate(
&self,
maildir: Maildir,
id: impl ToString,
) -> Result<(FsPath, MaildirSubdir, MaildirFlags), MaildirClientError> {
let MaildirEntryLocateOutput {
path,
subdir,
flags,
} = self.run(MaildirEntryLocate::new(maildir, id))?;
Ok((path, subdir, flags))
}
pub fn get(
&self,
maildir: Maildir,
id: impl ToString,
) -> Result<MaildirFullEntry, MaildirClientError> {
self.run(MaildirEntryGet::new(maildir, id))
}
pub fn list_entries(
&self,
maildir: Maildir,
) -> Result<BTreeSet<MaildirEntry>, MaildirClientError> {
self.run(MaildirEntryList::new(maildir))
}
pub fn read_entry(&self, entry: &MaildirEntry) -> Result<MaildirFullEntry, MaildirClientError> {
let path = entry.path();
trace!("read entry at {path}");
let contents = fs::read(path.as_str())?;
let contents = if self.strip_headers.is_empty() {
contents
} else {
let names: Vec<&str> = self.strip_headers.iter().map(String::as_str).collect();
strip_headers(&contents, &names)
};
Ok(MaildirFullEntry::from((path.clone(), contents)))
}
pub fn read_entries(
&self,
entries: &[MaildirEntry],
) -> Result<BTreeSet<MaildirFullEntry>, MaildirClientError> {
entries.iter().map(|entry| self.read_entry(entry)).collect()
}
pub fn read_entries_par(
&self,
entries: &[MaildirEntry],
) -> Result<BTreeSet<MaildirFullEntry>, MaildirClientError> {
if entries.len() <= 1 {
return entries.iter().map(|entry| self.read_entry(entry)).collect();
}
let n_threads = thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(8)
.min(entries.len());
let chunk_size = entries.len().div_ceil(n_threads);
thread::scope(
|s| -> Result<BTreeSet<MaildirFullEntry>, MaildirClientError> {
let mut handles = Vec::with_capacity(n_threads);
for chunk in entries.chunks(chunk_size) {
let this = self;
handles.push(s.spawn(
move || -> Result<Vec<MaildirFullEntry>, MaildirClientError> {
chunk.iter().map(|entry| this.read_entry(entry)).collect()
},
));
}
let mut out = BTreeSet::new();
for handle in handles {
for msg in handle.join().expect("maildir worker thread panicked")? {
out.insert(msg);
}
}
Ok(out)
},
)
}
pub fn store(
&self,
maildir: Maildir,
subdir: MaildirSubdir,
mut flags: MaildirFlags,
mut contents: Vec<u8>,
) -> Result<(String, FsPath), MaildirClientError> {
let keywords = flags.drain_keywords();
if let Some(header) = self.keywords_header {
if !keywords.is_empty() {
let sep = match header.separator() {
' ' => " ",
_ => ", ",
};
let value = keywords.join(sep);
contents = inject_header(&contents, header.header_name(), &value);
}
}
if self.dovecot_keywords && !keywords.is_empty() {
let mut table = self.load_dovecot_keywords(&maildir)?;
let original_len = table.len();
for keyword in &keywords {
match allocate_keyword_slot(&mut table, keyword) {
Some(letter) => {
flags.extend_letters([letter]);
}
None => {
log::warn!(
"dovecot-keywords table full; dropping keyword `{keyword}` at {}",
maildir.path()
);
}
}
}
if table.len() != original_len {
self.store_dovecot_keywords(&maildir, &table)?;
}
}
let MaildirEntryStoreOutput { id, path } =
self.run(MaildirEntryStore::new(maildir, subdir, flags, contents))?;
Ok((id, path))
}
pub fn copy(
&self,
id: impl ToString,
source: Maildir,
target: Maildir,
target_subdir: Option<MaildirSubdir>,
) -> Result<(), MaildirClientError> {
self.run(MaildirEntryCopy::new(id, source, target, target_subdir))
}
pub fn r#move(
&self,
id: impl ToString,
source: Maildir,
target: Maildir,
target_subdir: Option<MaildirSubdir>,
) -> Result<(), MaildirClientError> {
self.run(MaildirEntryMove::new(id, source, target, target_subdir))
}
fn resolve_keywords(
&self,
maildir: &Maildir,
flags: &mut MaildirFlags,
) -> Result<(), MaildirClientError> {
let keywords = flags.drain_keywords();
if !self.dovecot_keywords || keywords.is_empty() {
return Ok(());
}
let mut table = self.load_dovecot_keywords(maildir)?;
let original_len = table.len();
for keyword in &keywords {
match allocate_keyword_slot(&mut table, keyword) {
Some(letter) => {
flags.extend_letters([letter]);
}
None => {
log::warn!(
"dovecot-keywords table full; dropping keyword `{keyword}` at {}",
maildir.path()
);
}
}
}
if table.len() != original_len {
self.store_dovecot_keywords(maildir, &table)?;
}
Ok(())
}
}