Skip to main content

io_email/maildir/
client.rs

1//! Std-blocking Maildir client.
2//!
3//! Holds an inner [`io_maildir::client::MaildirClient`] wrapping the
4//! filesystem root and its per-store options (`dovecot_keywords`,
5//! `keywords_header`, `strip_headers`, plus the `MaildirStore`'s
6//! `maildirpp` switch).
7//!
8//! [`MaildirClient::run`] pumps io-email Maildir coroutines directly
9//! against the local filesystem; the inner client's own helpers stay
10//! reachable through [`MaildirClient::inner`] for ops that the shared
11//! API does not cover.
12
13use alloc::{string::String, vec::Vec};
14use std::{
15    fs, io, process,
16    time::{SystemTime, UNIX_EPOCH},
17};
18
19use gethostname::gethostname;
20use io_maildir::{client::MaildirClient as InnerMaildirClient, coroutine::*, path::FsPath};
21use log::trace;
22use thiserror::Error;
23
24#[cfg(feature = "search")]
25use crate::{
26    envelope::maildir::search::{MaildirEnvelopeSearch, MaildirEnvelopeSearchError},
27    search::query::SearchEmailsQuery,
28};
29use crate::{
30    envelope::{
31        maildir::list::{MaildirEnvelopeList, MaildirEnvelopeListError},
32        types::Envelope,
33    },
34    flag::{
35        maildir::store::{MaildirFlagStore, MaildirFlagStoreError},
36        types::{Flag, FlagOp},
37    },
38    mailbox::{
39        maildir::{
40            create::{MaildirMailboxCreate, MaildirMailboxCreateError},
41            delete::{MaildirMailboxDelete, MaildirMailboxDeleteError},
42            list::{MaildirMailboxList, MaildirMailboxListError},
43        },
44        types::Mailbox,
45    },
46    message::maildir::{
47        add::{MaildirMessageAdd, MaildirMessageAddError},
48        copy::{MaildirMessageCopy, MaildirMessageCopyError},
49        delete::{MaildirMessageDelete, MaildirMessageDeleteError},
50        get::{MaildirMessageGet, MaildirMessageGetError},
51        r#move::{MaildirMessageMove, MaildirMessageMoveError},
52    },
53};
54
55/// Errors surfaced by [`MaildirClient`] while running a coroutine.
56///
57/// One variant per shared-API Maildir coroutine.
58#[derive(Debug, Error)]
59pub enum MaildirClientError {
60    #[error(transparent)]
61    Io(#[from] io::Error),
62    #[error(transparent)]
63    MailboxList(#[from] MaildirMailboxListError),
64    #[error(transparent)]
65    EnvelopeList(#[from] MaildirEnvelopeListError),
66    #[cfg(feature = "search")]
67    #[error(transparent)]
68    EnvelopeSearch(#[from] MaildirEnvelopeSearchError),
69    #[error(transparent)]
70    FlagStore(#[from] MaildirFlagStoreError),
71    #[error(transparent)]
72    MailboxCreate(#[from] MaildirMailboxCreateError),
73    #[error(transparent)]
74    MailboxDelete(#[from] MaildirMailboxDeleteError),
75    #[error(transparent)]
76    MessageAdd(#[from] MaildirMessageAddError),
77    #[error(transparent)]
78    MessageCopy(#[from] MaildirMessageCopyError),
79    #[error(transparent)]
80    MessageDelete(#[from] MaildirMessageDeleteError),
81    #[error(transparent)]
82    MessageGet(#[from] MaildirMessageGetError),
83    #[error(transparent)]
84    MessageMove(#[from] MaildirMessageMoveError),
85    #[error(transparent)]
86    Inner(#[from] io_maildir::client::MaildirClientError),
87}
88
89/// Std-blocking Maildir client built on a filesystem root.
90///
91/// All per-store behaviour options (`store.maildirpp`,
92/// `dovecot_keywords`, `keywords_header`, `strip_headers`) live on
93/// [`Self::inner`] and are read through it on every shared-API call.
94pub struct MaildirClient {
95    pub inner: InnerMaildirClient,
96}
97
98impl MaildirClient {
99    /// Wraps a fresh inner client rooted at `root`. All options default
100    /// to strict-Maildir behaviour; flip them on [`Self::inner`] before
101    /// running coroutines.
102    pub fn new(root: impl Into<FsPath>) -> Self {
103        Self {
104            inner: InnerMaildirClient::new(root),
105        }
106    }
107
108    /// Pumps any standard-shape Maildir coroutine
109    /// (`Yield = MaildirYield`, `Return = Result<T, E>`) against the
110    /// local filesystem until it terminates.
111    ///
112    /// Reaches into [`Self::inner`] for the root rather than delegating
113    /// to [`io_maildir::client::MaildirClient::run`] so error variants
114    /// route through [`MaildirClientError`] directly.
115    pub fn run<C, T, E>(&self, mut coroutine: C) -> Result<T, MaildirClientError>
116    where
117        C: MaildirCoroutine<Yield = MaildirYield, Return = Result<T, E>>,
118        MaildirClientError: From<E>,
119    {
120        let mut arg: Option<MaildirReply> = None;
121
122        loop {
123            match coroutine.resume(arg.take()) {
124                MaildirCoroutineState::Complete(Ok(out)) => return Ok(out),
125                MaildirCoroutineState::Complete(Err(err)) => return Err(err.into()),
126                MaildirCoroutineState::Yielded(MaildirYield::WantsFileExists(paths)) => {
127                    let mut out = alloc::collections::BTreeMap::new();
128                    for path in paths {
129                        let exists = fs::metadata(path.as_str())
130                            .map(|m| m.is_file())
131                            .unwrap_or(false);
132                        trace!("file_exists {path}: {exists}");
133                        out.insert(path, exists);
134                    }
135                    arg = Some(MaildirReply::FileExists(out));
136                }
137                MaildirCoroutineState::Yielded(MaildirYield::WantsDirExists(paths)) => {
138                    let mut out = alloc::collections::BTreeMap::new();
139                    for path in paths {
140                        let exists = fs::metadata(path.as_str())
141                            .map(|m| m.is_dir())
142                            .unwrap_or(false);
143                        trace!("dir_exists {path}: {exists}");
144                        out.insert(path, exists);
145                    }
146                    arg = Some(MaildirReply::DirExists(out));
147                }
148                MaildirCoroutineState::Yielded(MaildirYield::WantsDirRead(paths)) => {
149                    let mut entries = alloc::collections::BTreeMap::new();
150                    for path in paths {
151                        trace!("read_dir {path}");
152                        let mut names = alloc::collections::BTreeSet::new();
153                        match fs::read_dir(path.as_str()) {
154                            Ok(iter) => {
155                                for entry in iter {
156                                    let entry = entry?;
157                                    names.insert(FsPath::from(entry.path()));
158                                }
159                            }
160                            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
161                            Err(err) => return Err(err.into()),
162                        }
163                        entries.insert(path, names);
164                    }
165                    arg = Some(MaildirReply::DirRead(entries));
166                }
167                MaildirCoroutineState::Yielded(MaildirYield::WantsFileRead(paths)) => {
168                    let mut contents = alloc::collections::BTreeMap::new();
169                    for path in paths {
170                        trace!("read_file {path}");
171                        let bytes = fs::read(path.as_str())?;
172                        contents.insert(path, bytes);
173                    }
174                    arg = Some(MaildirReply::FileRead(contents));
175                }
176                MaildirCoroutineState::Yielded(MaildirYield::WantsFileCreate(files)) => {
177                    for (path, bytes) in files {
178                        trace!("write {path} ({} bytes)", bytes.len());
179                        if let Some(parent) = std::path::Path::new(path.as_str()).parent() {
180                            fs::create_dir_all(parent)?;
181                        }
182                        fs::write(path.as_str(), &bytes)?;
183                    }
184                    arg = Some(MaildirReply::FileCreate);
185                }
186                MaildirCoroutineState::Yielded(MaildirYield::WantsDirCreate(paths)) => {
187                    for path in paths {
188                        trace!("create_dir_all {path}");
189                        fs::create_dir_all(path.as_str())?;
190                    }
191                    arg = Some(MaildirReply::DirCreate);
192                }
193                MaildirCoroutineState::Yielded(MaildirYield::WantsDirRemove(paths)) => {
194                    for path in paths {
195                        trace!("remove_dir_all {path}");
196                        fs::remove_dir_all(path.as_str())?;
197                    }
198                    arg = Some(MaildirReply::DirRemove);
199                }
200                MaildirCoroutineState::Yielded(MaildirYield::WantsRename(pairs)) => {
201                    for (from, to) in pairs {
202                        trace!("rename {from} -> {to}");
203                        fs::rename(from.as_str(), to.as_str())?;
204                    }
205                    arg = Some(MaildirReply::Rename);
206                }
207                MaildirCoroutineState::Yielded(MaildirYield::WantsCopy(pairs)) => {
208                    for (from, to) in pairs {
209                        trace!("copy {from} -> {to}");
210                        fs::copy(from.as_str(), to.as_str())?;
211                    }
212                    arg = Some(MaildirReply::Copy);
213                }
214                MaildirCoroutineState::Yielded(MaildirYield::WantsTime) => {
215                    let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
216                    arg = Some(MaildirReply::Time {
217                        secs: ts.as_secs(),
218                        nanos: ts.subsec_nanos(),
219                    });
220                }
221                MaildirCoroutineState::Yielded(MaildirYield::WantsPid) => {
222                    arg = Some(MaildirReply::Pid(process::id()));
223                }
224                MaildirCoroutineState::Yielded(MaildirYield::WantsHostname) => {
225                    let hostname = gethostname().into_string().unwrap_or_default();
226                    arg = Some(MaildirReply::Hostname(hostname));
227                }
228            }
229        }
230    }
231
232    /// Lists every Maildir under the configured root. `with_counts`
233    /// is currently a no-op; see [`MaildirMailboxList`] for the path
234    /// to surfacing per-mailbox totals.
235    pub fn list_mailboxes(&self, with_counts: bool) -> Result<Vec<Mailbox>, MaildirClientError> {
236        self.run(MaildirMailboxList::new(&self.inner.store, with_counts))
237    }
238
239    /// Lists envelopes from `mailbox`. `page = None` and
240    /// `page_size = None` return the whole listing. The
241    /// `with_attachment` switch is currently ignored on Maildir.
242    pub fn list_envelopes(
243        &self,
244        mailbox: &str,
245        page: Option<u32>,
246        page_size: Option<u32>,
247        _with_attachment: bool,
248    ) -> Result<Vec<Envelope>, MaildirClientError> {
249        self.run(MaildirEnvelopeList::new(
250            &self.inner.store,
251            mailbox,
252            page,
253            page_size,
254        )?)
255    }
256
257    /// Searches envelopes in `mailbox` against the shared query.
258    /// Filter / sort / paginate are applied client-side.
259    #[cfg(feature = "search")]
260    pub fn search_envelopes(
261        &self,
262        mailbox: &str,
263        query: Option<&SearchEmailsQuery>,
264        page: Option<u32>,
265        page_size: Option<u32>,
266        _with_attachment: bool,
267    ) -> Result<Vec<Envelope>, MaildirClientError> {
268        self.run(MaildirEnvelopeSearch::new(
269            &self.inner.store,
270            mailbox,
271            query,
272            page,
273            page_size,
274        )?)
275    }
276
277    /// Adds, sets, or removes `flags` on a Maildir id set.
278    pub fn store_flags(
279        &self,
280        mailbox: &str,
281        ids: &[&str],
282        flags: &[Flag],
283        op: FlagOp,
284    ) -> Result<(), MaildirClientError> {
285        self.run(MaildirFlagStore::new(
286            &self.inner.store,
287            mailbox,
288            ids,
289            flags,
290            op,
291        )?)
292    }
293
294    /// Reads one message's raw RFC 5322 bytes from `mailbox`.
295    pub fn get_message(&self, mailbox: &str, id: &str) -> Result<Vec<u8>, MaildirClientError> {
296        self.run(MaildirMessageGet::new(&self.inner.store, mailbox, id)?)
297    }
298
299    /// Appends `raw` to `mailbox` under `cur/` with the given flags.
300    /// Returns the Maildir filename minus the `:2,FLAGS` suffix.
301    pub fn add_message(
302        &self,
303        mailbox: &str,
304        flags: &[Flag],
305        raw: Vec<u8>,
306    ) -> Result<String, MaildirClientError> {
307        self.run(MaildirMessageAdd::new(
308            &self.inner.store,
309            mailbox,
310            flags,
311            raw,
312        )?)
313    }
314
315    /// Creates `name` as a new Maildir under the configured root.
316    pub fn create_mailbox(&self, name: &str) -> Result<(), MaildirClientError> {
317        self.run(MaildirMailboxCreate::new(&self.inner.store, name)?)
318    }
319
320    /// Recursively removes the Maildir named `name`.
321    pub fn delete_mailbox(&self, name: &str) -> Result<(), MaildirClientError> {
322        self.run(MaildirMailboxDelete::new(&self.inner.store, name)?)
323    }
324
325    /// Flags `id` in `mailbox` as Trashed. Maildir has no atomic
326    /// "remove" primitive; pair with a periodic expunge to reclaim
327    /// space.
328    pub fn delete_message(&self, mailbox: &str, id: &str) -> Result<(), MaildirClientError> {
329        self.run(MaildirMessageDelete::new(&self.inner.store, mailbox, id)?)
330    }
331
332    /// Copies every id from `from` to `to`.
333    pub fn copy_messages(
334        &self,
335        from: &str,
336        to: &str,
337        ids: &[&str],
338    ) -> Result<(), MaildirClientError> {
339        self.run(MaildirMessageCopy::new(&self.inner.store, from, to, ids)?)
340    }
341
342    /// Moves every id from `from` to `to`.
343    pub fn move_messages(
344        &self,
345        from: &str,
346        to: &str,
347        ids: &[&str],
348    ) -> Result<(), MaildirClientError> {
349        self.run(MaildirMessageMove::new(&self.inner.store, from, to, ids)?)
350    }
351}