Skip to main content

io_email/m2dir/
client.rs

1//! Std-blocking m2dir client.
2//!
3//! Wraps an inner [`InnerM2dirClient`] and pumps any standard-shape
4//! [`M2dirCoroutine`] (Yield = [`M2dirYield`]) against the local
5//! filesystem via [`std::fs`].
6
7use alloc::{
8    collections::{BTreeMap, BTreeSet},
9    string::String,
10    vec::Vec,
11};
12use std::{
13    collections::hash_map::RandomState,
14    fs,
15    hash::{BuildHasher, Hasher},
16    io,
17    path::{Path, PathBuf},
18    process,
19};
20
21use io_m2dir::{client::M2dirClient as InnerM2dirClient, coroutine::*, path::M2dirPath};
22use log::trace;
23use thiserror::Error;
24
25#[cfg(feature = "search")]
26use crate::{
27    envelope::m2dir::search::{M2dirEnvelopeSearch, M2dirEnvelopeSearchError},
28    search::query::SearchEmailsQuery,
29};
30use crate::{
31    envelope::{
32        m2dir::list::{M2dirEnvelopeList, M2dirEnvelopeListError},
33        types::Envelope,
34    },
35    flag::{
36        m2dir::store::{M2dirFlagStore, M2dirFlagStoreError},
37        types::{Flag, FlagOp},
38    },
39    mailbox::{
40        m2dir::{
41            create::{M2dirMailboxCreate, M2dirMailboxCreateError},
42            delete::{M2dirMailboxDelete, M2dirMailboxDeleteError},
43            list::{M2dirMailboxList, M2dirMailboxListError},
44        },
45        types::Mailbox,
46    },
47    message::m2dir::{
48        add::{M2dirMessageAdd, M2dirMessageAddError},
49        copy::{M2dirMessageCopy, M2dirMessageCopyError},
50        delete::{M2dirMessageDelete, M2dirMessageDeleteError},
51        get::{M2dirMessageGet, M2dirMessageGetError},
52        r#move::{M2dirMessageMove, M2dirMessageMoveError},
53    },
54};
55
56/// Errors surfaced by [`M2dirClient`] while running a coroutine.
57///
58/// One variant per shared-API m2dir coroutine.
59#[derive(Debug, Error)]
60pub enum M2dirClientError {
61    #[error(transparent)]
62    Io(#[from] io::Error),
63    #[error(transparent)]
64    MailboxList(#[from] M2dirMailboxListError),
65    #[error(transparent)]
66    EnvelopeList(#[from] M2dirEnvelopeListError),
67    #[cfg(feature = "search")]
68    #[error(transparent)]
69    EnvelopeSearch(#[from] M2dirEnvelopeSearchError),
70    #[error(transparent)]
71    FlagStore(#[from] M2dirFlagStoreError),
72    #[error(transparent)]
73    MailboxCreate(#[from] M2dirMailboxCreateError),
74    #[error(transparent)]
75    MailboxDelete(#[from] M2dirMailboxDeleteError),
76    #[error(transparent)]
77    MessageAdd(#[from] M2dirMessageAddError),
78    #[error(transparent)]
79    MessageCopy(#[from] M2dirMessageCopyError),
80    #[error(transparent)]
81    MessageDelete(#[from] M2dirMessageDeleteError),
82    #[error(transparent)]
83    MessageGet(#[from] M2dirMessageGetError),
84    #[error(transparent)]
85    MessageMove(#[from] M2dirMessageMoveError),
86    #[error(transparent)]
87    Inner(#[from] io_m2dir::client::M2dirClientError),
88}
89
90/// Light m2dir client wrapping a filesystem root.
91///
92/// The shared root lives on [`Self::inner`]; m2dir has no extra
93/// per-session knobs (no auto_select, no capability list).
94pub struct M2dirClient {
95    pub inner: InnerM2dirClient,
96}
97
98impl M2dirClient {
99    /// Wraps a fresh inner client rooted at `root`. No filesystem
100    /// check is performed at construction time.
101    pub fn new(root: impl Into<M2dirPath>) -> Self {
102        Self {
103            inner: InnerM2dirClient::new(root),
104        }
105    }
106
107    /// Pumps any standard-shape m2dir coroutine
108    /// (`Yield = M2dirYield`, `Return = Result<T, E>`) against the
109    /// local filesystem until it terminates.
110    ///
111    /// Duplicates the body of [`InnerM2dirClient::run`] so error
112    /// variants route through [`M2dirClientError`] directly.
113    pub fn run<C, T, E>(&self, mut coroutine: C) -> Result<T, M2dirClientError>
114    where
115        C: M2dirCoroutine<Yield = M2dirYield, Return = Result<T, E>>,
116        M2dirClientError: From<E>,
117    {
118        let mut arg: Option<M2dirArg> = None;
119
120        loop {
121            match coroutine.resume(arg.take()) {
122                M2dirCoroutineState::Complete(Ok(out)) => return Ok(out),
123                M2dirCoroutineState::Complete(Err(err)) => return Err(err.into()),
124                M2dirCoroutineState::Yielded(M2dirYield::WantsPid) => {
125                    arg = Some(M2dirArg::Pid(process::id()));
126                }
127                M2dirCoroutineState::Yielded(M2dirYield::WantsRandom { len }) => {
128                    arg = Some(M2dirArg::Random(random_bytes(len)));
129                }
130                M2dirCoroutineState::Yielded(M2dirYield::WantsFileExists(paths)) => {
131                    arg = Some(M2dirArg::FileExists(file_exists(paths)));
132                }
133                M2dirCoroutineState::Yielded(M2dirYield::WantsDirRead(paths)) => {
134                    arg = Some(M2dirArg::DirRead(read_dirs(paths)?));
135                }
136                M2dirCoroutineState::Yielded(M2dirYield::WantsDirCreate(paths)) => {
137                    create_dirs(paths)?;
138                    arg = Some(M2dirArg::DirCreate);
139                }
140                M2dirCoroutineState::Yielded(M2dirYield::WantsDirRemove(paths)) => {
141                    remove_dirs(paths)?;
142                    arg = Some(M2dirArg::DirRemove);
143                }
144                M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths)) => {
145                    arg = Some(M2dirArg::FileRead(read_files_tolerant(paths)?));
146                }
147                M2dirCoroutineState::Yielded(M2dirYield::WantsFileCreate(files)) => {
148                    write_files(files)?;
149                    arg = Some(M2dirArg::FileCreate);
150                }
151                M2dirCoroutineState::Yielded(M2dirYield::WantsFileRemove(paths)) => {
152                    remove_files_tolerant(paths)?;
153                    arg = Some(M2dirArg::FileRemove);
154                }
155                M2dirCoroutineState::Yielded(M2dirYield::WantsRename(pairs)) => {
156                    rename_paths(pairs)?;
157                    arg = Some(M2dirArg::Rename);
158                }
159            }
160        }
161    }
162
163    /// Lists every m2dir under the store root. `with_counts` is
164    /// accepted for symmetry with the other backends; surfacing
165    /// totals/unread needs a follow-up walk and is currently a no-op.
166    pub fn list_mailboxes(&self, with_counts: bool) -> Result<Vec<Mailbox>, M2dirClientError> {
167        self.run(M2dirMailboxList::new(
168            PathBuf::from(self.inner.root().as_str()),
169            with_counts,
170        ))
171    }
172
173    /// Lists envelopes from `mailbox`. `page = None` and
174    /// `page_size = None` fetch the whole mailbox; envelopes are
175    /// sorted by date descending.
176    pub fn list_envelopes(
177        &self,
178        mailbox: &str,
179        page: Option<u32>,
180        page_size: Option<u32>,
181        with_attachment: bool,
182    ) -> Result<Vec<Envelope>, M2dirClientError> {
183        self.run(M2dirEnvelopeList::new(
184            PathBuf::from(self.inner.root().as_str()),
185            mailbox,
186            page,
187            page_size,
188            with_attachment,
189        )?)
190    }
191
192    /// Searches envelopes in `mailbox` against the shared query.
193    /// Filtering and sorting happen client-side after a full scan.
194    #[cfg(feature = "search")]
195    pub fn search_envelopes(
196        &self,
197        mailbox: &str,
198        query: Option<&SearchEmailsQuery>,
199        page: Option<u32>,
200        page_size: Option<u32>,
201        with_attachment: bool,
202    ) -> Result<Vec<Envelope>, M2dirClientError> {
203        self.run(M2dirEnvelopeSearch::new(
204            PathBuf::from(self.inner.root().as_str()),
205            mailbox,
206            query,
207            page,
208            page_size,
209            with_attachment,
210        )?)
211    }
212
213    /// Adds, sets, or removes `flags` on every id by rewriting each
214    /// `.meta/<id>.flags` sidecar.
215    pub fn store_flags(
216        &self,
217        mailbox: &str,
218        ids: &[&str],
219        flags: &[Flag],
220        op: FlagOp,
221    ) -> Result<(), M2dirClientError> {
222        self.run(M2dirFlagStore::new(
223            PathBuf::from(self.inner.root().as_str()),
224            mailbox,
225            ids,
226            flags,
227            op,
228        )?)
229    }
230
231    /// Reads one message's raw bytes by id, validating the checksum
232    /// embedded in its filename.
233    pub fn get_message(&self, mailbox: &str, id: &str) -> Result<Vec<u8>, M2dirClientError> {
234        self.run(M2dirMessageGet::new(
235            PathBuf::from(self.inner.root().as_str()),
236            mailbox,
237            id,
238        )?)
239    }
240
241    /// Appends `raw` to `mailbox`, then persists `flags` as the
242    /// `.meta/<id>.flags` sidecar when non-empty. Returns the minted
243    /// entry id.
244    pub fn add_message(
245        &self,
246        mailbox: &str,
247        flags: &[Flag],
248        raw: Vec<u8>,
249    ) -> Result<String, M2dirClientError> {
250        self.run(M2dirMessageAdd::new(
251            PathBuf::from(self.inner.root().as_str()),
252            mailbox,
253            flags,
254            raw,
255        )?)
256    }
257
258    /// Creates `name` as a new m2dir mailbox: the folder, the
259    /// `.m2dir` marker and the `.meta` sub-directory.
260    pub fn create_mailbox(&self, name: &str) -> Result<(), M2dirClientError> {
261        self.run(M2dirMailboxCreate::new(
262            PathBuf::from(self.inner.root().as_str()),
263            name,
264        )?)
265    }
266
267    /// Recursively removes the m2dir at `name`.
268    pub fn delete_mailbox(&self, name: &str) -> Result<(), M2dirClientError> {
269        self.run(M2dirMailboxDelete::new(
270            PathBuf::from(self.inner.root().as_str()),
271            name,
272        )?)
273    }
274
275    /// Removes the entry `id` and every matching `.meta/<id>*` file.
276    pub fn delete_message(&self, mailbox: &str, id: &str) -> Result<(), M2dirClientError> {
277        self.run(M2dirMessageDelete::new(
278            PathBuf::from(self.inner.root().as_str()),
279            mailbox,
280            id,
281        )?)
282    }
283
284    /// Copies every id from `from` to `to`. Flag sidecars are not
285    /// propagated; callers add flags explicitly after the copy when
286    /// needed.
287    pub fn copy_messages(
288        &self,
289        from: &str,
290        to: &str,
291        ids: &[&str],
292    ) -> Result<(), M2dirClientError> {
293        self.run(M2dirMessageCopy::new(
294            PathBuf::from(self.inner.root().as_str()),
295            from,
296            to,
297            ids,
298        )?)
299    }
300
301    /// Moves every id from `from` to `to`. Flag sidecars are not
302    /// propagated; callers add flags explicitly after the move when
303    /// needed.
304    pub fn move_messages(
305        &self,
306        from: &str,
307        to: &str,
308        ids: &[&str],
309    ) -> Result<(), M2dirClientError> {
310        self.run(M2dirMessageMove::new(
311            PathBuf::from(self.inner.root().as_str()),
312            from,
313            to,
314            ids,
315        )?)
316    }
317}
318
319// ---- Filesystem helpers (duplicated from io_m2dir::client) ----
320
321fn create_dirs(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
322    for path in paths {
323        trace!("create_dir_all {path}");
324        fs::create_dir_all(path.as_str())?;
325    }
326    Ok(())
327}
328
329fn remove_dirs(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
330    for path in paths {
331        trace!("remove_dir_all {path}");
332        fs::remove_dir_all(path.as_str())?;
333    }
334    Ok(())
335}
336
337fn write_files(files: BTreeMap<M2dirPath, Vec<u8>>) -> Result<(), io::Error> {
338    for (path, contents) in files {
339        trace!("write {path} ({} bytes)", contents.len());
340
341        if let Some(parent) = Path::new(path.as_str()).parent() {
342            fs::create_dir_all(parent)?;
343        }
344        fs::write(path.as_str(), &contents)?;
345    }
346    Ok(())
347}
348
349fn remove_files_tolerant(paths: BTreeSet<M2dirPath>) -> Result<(), io::Error> {
350    for path in paths {
351        trace!("remove_file (tolerant) {path}");
352        match fs::remove_file(path.as_str()) {
353            Ok(()) => {}
354            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
355            Err(err) => return Err(err),
356        }
357    }
358    Ok(())
359}
360
361fn read_dirs(
362    paths: BTreeSet<M2dirPath>,
363) -> Result<BTreeMap<M2dirPath, BTreeSet<M2dirPath>>, io::Error> {
364    let mut entries = BTreeMap::new();
365
366    for path in paths {
367        trace!("read_dir {path}");
368
369        let mut names = BTreeSet::new();
370        match fs::read_dir(path.as_str()) {
371            Ok(iter) => {
372                for entry in iter {
373                    let entry = entry?;
374                    names.insert(normalize_path(entry.path()));
375                }
376            }
377            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
378            Err(err) if err.kind() == io::ErrorKind::NotADirectory => {}
379            Err(err) => return Err(err),
380        }
381
382        entries.insert(path, names);
383    }
384
385    Ok(entries)
386}
387
388fn read_files_tolerant(
389    paths: BTreeSet<M2dirPath>,
390) -> Result<BTreeMap<M2dirPath, Vec<u8>>, io::Error> {
391    let mut contents = BTreeMap::new();
392
393    for path in paths {
394        trace!("read_file (tolerant) {path}");
395        match fs::read(path.as_str()) {
396            Ok(bytes) => {
397                contents.insert(path, bytes);
398            }
399            Err(err) if err.kind() == io::ErrorKind::NotFound => {
400                contents.insert(path, Vec::new());
401            }
402            Err(err) => return Err(err),
403        }
404    }
405
406    Ok(contents)
407}
408
409fn rename_paths(pairs: Vec<(M2dirPath, M2dirPath)>) -> Result<(), io::Error> {
410    for (from, to) in pairs {
411        trace!("rename {from} -> {to}");
412        fs::rename(from.as_str(), to.as_str())?;
413    }
414    Ok(())
415}
416
417fn file_exists(paths: BTreeSet<M2dirPath>) -> BTreeMap<M2dirPath, bool> {
418    let mut out = BTreeMap::new();
419    for path in paths {
420        let exists = fs::metadata(path.as_str())
421            .map(|m| m.is_file())
422            .unwrap_or(false);
423        trace!("file_exists {path}: {exists}");
424        out.insert(path, exists);
425    }
426    out
427}
428
429fn normalize_path(path: PathBuf) -> M2dirPath {
430    let s = path.to_string_lossy().into_owned();
431    #[cfg(windows)]
432    let s = s.replace('\\', "/");
433    M2dirPath::new(s)
434}
435
436/// Generates `len` pseudo-random bytes seeded from [`RandomState`],
437/// iterated via xorshift64*. Mirrors io-m2dir's own helper.
438fn random_bytes(len: usize) -> Vec<u8> {
439    let mut state = RandomState::new().build_hasher().finish();
440    if state == 0 {
441        state = 0xdeadbeef;
442    }
443
444    let mut out = Vec::with_capacity(len);
445    let mut buf = 0u64;
446    let mut i = 8;
447
448    while out.len() < len {
449        if i == 8 {
450            state ^= state << 13;
451            state ^= state >> 7;
452            state ^= state << 17;
453            buf = state;
454            i = 0;
455        }
456        out.push(buf as u8);
457        buf >>= 8;
458        i += 1;
459    }
460
461    out
462}