Skip to main content

io_email/
client.rs

1//! Multi-protocol std-blocking email client.
2//!
3//! [`EmailClientStd`] is a thin container holding one optional per-protocol
4//! client per supported backend. The shared-API surface (`list_mailboxes`,
5//! `list_envelopes`, `get_message`, …) lives on each per-protocol client; this
6//! struct gives callers a single typed bag to pass around the backends they
7//! care about, plus a dispatch layer that picks the highest-priority registered
8//! backend.
9//!
10//! Two construction paths:
11//!
12//! - `with_<protocol>(client)`: register a client built externally (typically
13//!   via the per-protocol `new` or `connect`).
14//! - `connect_<protocol>(…)`: TLS-gated convenience that opens the connection
15//!   through the per-protocol `connect` and fills the slot in one shot.
16//!
17//! ## Dispatch priority
18//!
19//! Reads / mutations on storage backends: `Maildir → M2dir → JMAP → IMAP`
20//! (local before network, cheap before expensive).
21//!
22//! Sending: `JMAP → SMTP`.
23//!
24//! When no registered backend supports the op, the dispatch returns
25//! [`EmailClientStdError::NoBackendRegistered`].
26
27use core::sync::atomic::AtomicBool;
28
29use alloc::{string::String, sync::Arc, vec::Vec};
30
31use std::sync::mpsc::Sender;
32
33use thiserror::Error;
34
35#[cfg(feature = "imap")]
36use crate::imap::client::{ImapClientError, ImapClientStd};
37#[cfg(feature = "jmap")]
38use crate::jmap::client::{JmapClientError, JmapClientStd};
39#[cfg(feature = "m2dir")]
40use crate::m2dir::client::{M2dirClient, M2dirClientError};
41#[cfg(feature = "maildir")]
42use crate::maildir::client::{MaildirClient, MaildirClientError};
43#[cfg(feature = "search")]
44use crate::search::query::SearchEmailsQuery;
45#[cfg(feature = "smtp")]
46use crate::smtp::client::{SmtpClientError, SmtpClientStd};
47use crate::{
48    envelope::event::WatchEvent,
49    envelope::types::{Envelope, EnvelopeDiff},
50    flag::types::{Flag, FlagOp},
51    mailbox::types::{Mailbox, MailboxDiff},
52};
53
54#[cfg(feature = "imap")]
55use io_imap::types::core::{IString, NString};
56#[cfg(feature = "smtp")]
57#[cfg(any(
58    feature = "rustls-ring",
59    feature = "rustls-aws",
60    feature = "native-tls"
61))]
62use io_smtp::rfc5321::types::ehlo_domain::EhloDomain;
63#[cfg(feature = "imap")]
64#[cfg(any(
65    feature = "rustls-ring",
66    feature = "rustls-aws",
67    feature = "native-tls"
68))]
69use pimalaya_stream::sasl::Sasl as ImapSasl;
70#[cfg(feature = "jmap")]
71#[cfg(any(
72    feature = "rustls-ring",
73    feature = "rustls-aws",
74    feature = "native-tls"
75))]
76use secrecy::SecretString;
77#[cfg(any(feature = "imap", feature = "jmap", feature = "smtp"))]
78#[cfg(any(
79    feature = "rustls-ring",
80    feature = "rustls-aws",
81    feature = "native-tls"
82))]
83use {pimalaya_stream::tls::Tls, url::Url};
84
85/// Errors surfaced by [`EmailClientStd`].
86///
87/// Each variant flattens the per-protocol client's error type via
88/// `#[from]` so the matching `?` operator works on the shared client.
89#[derive(Debug, Error)]
90pub enum EmailClientStdError {
91    #[cfg(feature = "imap")]
92    #[error(transparent)]
93    Imap(#[from] ImapClientError),
94    #[cfg(feature = "jmap")]
95    #[error(transparent)]
96    Jmap(#[from] JmapClientError),
97    #[cfg(feature = "smtp")]
98    #[error(transparent)]
99    Smtp(#[from] SmtpClientError),
100    #[cfg(feature = "maildir")]
101    #[error(transparent)]
102    Maildir(#[from] MaildirClientError),
103    #[cfg(feature = "m2dir")]
104    #[error(transparent)]
105    M2dir(#[from] M2dirClientError),
106    #[error("No backend supporting this operation is registered")]
107    NoBackendRegistered,
108    #[error("Registered backend does not support this operation")]
109    UnsupportedOperation,
110}
111
112/// Std-blocking multi-protocol email client.
113///
114/// Each slot holds an optional per-protocol client. Empty by default;
115/// register backends through the `with_<protocol>` builders or the
116/// `connect_<protocol>` convenience helpers. Slots are `pub` so
117/// callers can read the registered client back out (e.g. to tweak its
118/// pub knobs after construction).
119#[derive(Default)]
120pub struct EmailClientStd {
121    #[cfg(feature = "imap")]
122    pub imap: Option<ImapClientStd>,
123    #[cfg(feature = "jmap")]
124    pub jmap: Option<JmapClientStd>,
125    #[cfg(feature = "smtp")]
126    pub smtp: Option<SmtpClientStd>,
127    #[cfg(feature = "maildir")]
128    pub maildir: Option<MaildirClient>,
129    #[cfg(feature = "m2dir")]
130    pub m2dir: Option<M2dirClient>,
131}
132
133impl EmailClientStd {
134    /// Builds an empty client with no backend registered.
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Registers the IMAP backend.
140    #[cfg(feature = "imap")]
141    pub fn with_imap(mut self, client: ImapClientStd) -> Self {
142        self.imap = Some(client);
143        self
144    }
145
146    /// Registers the JMAP backend.
147    #[cfg(feature = "jmap")]
148    pub fn with_jmap(mut self, client: JmapClientStd) -> Self {
149        self.jmap = Some(client);
150        self
151    }
152
153    /// Registers the SMTP backend.
154    #[cfg(feature = "smtp")]
155    pub fn with_smtp(mut self, client: SmtpClientStd) -> Self {
156        self.smtp = Some(client);
157        self
158    }
159
160    /// Registers the Maildir backend.
161    #[cfg(feature = "maildir")]
162    pub fn with_maildir(mut self, client: MaildirClient) -> Self {
163        self.maildir = Some(client);
164        self
165    }
166
167    /// Registers the m2dir backend.
168    #[cfg(feature = "m2dir")]
169    pub fn with_m2dir(mut self, client: M2dirClient) -> Self {
170        self.m2dir = Some(client);
171        self
172    }
173
174    /// Opens an IMAP connection via [`ImapClientStd::connect`] and
175    /// registers the resulting client. See [`ImapClientStd::connect`]
176    /// for the `auto_id` semantics.
177    #[cfg(feature = "imap")]
178    #[cfg(any(
179        feature = "rustls-ring",
180        feature = "rustls-aws",
181        feature = "native-tls"
182    ))]
183    pub fn connect_imap(
184        self,
185        url: &Url,
186        tls: &Tls,
187        starttls: bool,
188        sasl: Option<impl Into<ImapSasl>>,
189        auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
190    ) -> Result<Self, EmailClientStdError> {
191        Ok(self.with_imap(ImapClientStd::connect(url, tls, starttls, sasl, auto_id)?))
192    }
193
194    /// Opens a JMAP connection via [`JmapClientStd::connect`] and
195    /// registers the resulting client (session already discovered).
196    #[cfg(feature = "jmap")]
197    #[cfg(any(
198        feature = "rustls-ring",
199        feature = "rustls-aws",
200        feature = "native-tls"
201    ))]
202    pub fn connect_jmap(
203        self,
204        url: &Url,
205        tls: &Tls,
206        http_auth: SecretString,
207    ) -> Result<Self, EmailClientStdError> {
208        Ok(self.with_jmap(JmapClientStd::connect(url, tls, http_auth)?))
209    }
210
211    /// Opens an SMTP connection via [`SmtpClientStd::connect`] and
212    /// registers the resulting client.
213    #[cfg(feature = "smtp")]
214    #[cfg(any(
215        feature = "rustls-ring",
216        feature = "rustls-aws",
217        feature = "native-tls"
218    ))]
219    pub fn connect_smtp(
220        self,
221        url: &Url,
222        tls: &Tls,
223        starttls: bool,
224        domain: EhloDomain<'_>,
225        sasl: Option<impl Into<pimalaya_stream::sasl::Sasl>>,
226    ) -> Result<Self, EmailClientStdError> {
227        Ok(self.with_smtp(SmtpClientStd::connect(url, tls, starttls, domain, sasl)?))
228    }
229
230    /// Pings every registered network backend (IMAP, SMTP) to reset the
231    /// server's inactivity timer on long-idle sessions. Storage backends
232    /// (Maildir, M2dir) and JMAP (HTTP, stateless) have nothing to keep alive
233    /// and are skipped. Returns the first error encountered, or `Ok(())` when
234    /// every registered network backend acknowledged the NOOP.
235    pub fn ping(&mut self) -> Result<(), EmailClientStdError> {
236        #[cfg(feature = "imap")]
237        if let Some(c) = self.imap.as_mut() {
238            c.ping()?;
239        }
240        #[cfg(feature = "smtp")]
241        if let Some(c) = self.smtp.as_mut() {
242            c.ping()?;
243        }
244        Ok(())
245    }
246
247    // ---- Shared-API dispatch (storage: Maildir → M2dir → JMAP → IMAP) ----
248
249    /// Lists every visible mailbox via the highest-priority registered storage
250    /// backend.
251    pub fn list_mailboxes(
252        &mut self,
253        with_counts: bool,
254    ) -> Result<Vec<Mailbox>, EmailClientStdError> {
255        #[cfg(feature = "maildir")]
256        if let Some(c) = &self.maildir {
257            return Ok(c.list_mailboxes(with_counts)?);
258        }
259        #[cfg(feature = "m2dir")]
260        if let Some(c) = &self.m2dir {
261            return Ok(c.list_mailboxes(with_counts)?);
262        }
263        #[cfg(feature = "jmap")]
264        if let Some(c) = self.jmap.as_mut() {
265            return Ok(c.list_mailboxes(with_counts)?);
266        }
267        #[cfg(feature = "imap")]
268        if let Some(c) = self.imap.as_mut() {
269            return Ok(c.list_mailboxes(with_counts)?);
270        }
271        Err(EmailClientStdError::NoBackendRegistered)
272    }
273
274    /// Lists envelopes from `mailbox`. `with_attachment` is honoured by IMAP /
275    /// Maildir / M2dir; JMAP returns the attachment flag inline and ignores the
276    /// parameter.
277    pub fn list_envelopes(
278        &mut self,
279        mailbox: &str,
280        page: Option<u32>,
281        page_size: Option<u32>,
282        with_attachment: bool,
283    ) -> Result<Vec<Envelope>, EmailClientStdError> {
284        #[cfg(feature = "maildir")]
285        if let Some(c) = &self.maildir {
286            return Ok(c.list_envelopes(mailbox, page, page_size, with_attachment)?);
287        }
288        #[cfg(feature = "m2dir")]
289        if let Some(c) = &self.m2dir {
290            return Ok(c.list_envelopes(mailbox, page, page_size, with_attachment)?);
291        }
292        #[cfg(feature = "jmap")]
293        if let Some(c) = self.jmap.as_mut() {
294            return Ok(c.list_envelopes(mailbox, page, page_size)?);
295        }
296        #[cfg(feature = "imap")]
297        if let Some(c) = self.imap.as_mut() {
298            return Ok(c.list_envelopes(mailbox, page, page_size, with_attachment)?);
299        }
300        let _ = (mailbox, page, page_size, with_attachment);
301        Err(EmailClientStdError::NoBackendRegistered)
302    }
303
304    /// Searches envelopes against the shared query.
305    #[cfg(feature = "search")]
306    pub fn search_envelopes(
307        &mut self,
308        mailbox: &str,
309        query: Option<&SearchEmailsQuery>,
310        page: Option<u32>,
311        page_size: Option<u32>,
312        with_attachment: bool,
313    ) -> Result<Vec<Envelope>, EmailClientStdError> {
314        #[cfg(feature = "maildir")]
315        if let Some(c) = &self.maildir {
316            return Ok(c.search_envelopes(mailbox, query, page, page_size, with_attachment)?);
317        }
318        #[cfg(feature = "m2dir")]
319        if let Some(c) = &self.m2dir {
320            return Ok(c.search_envelopes(mailbox, query, page, page_size, with_attachment)?);
321        }
322        #[cfg(feature = "jmap")]
323        if let Some(c) = self.jmap.as_mut() {
324            return Ok(c.search_envelopes(mailbox, query, page, page_size)?);
325        }
326        #[cfg(feature = "imap")]
327        if let Some(c) = self.imap.as_mut() {
328            return Ok(c.search_envelopes(mailbox, query, page, page_size, with_attachment)?);
329        }
330        let _ = (mailbox, query, page, page_size, with_attachment);
331        Err(EmailClientStdError::NoBackendRegistered)
332    }
333
334    /// Adds, sets or removes flags on `ids`.
335    pub fn store_flags(
336        &mut self,
337        mailbox: &str,
338        ids: &[&str],
339        flags: &[Flag],
340        op: FlagOp,
341    ) -> Result<(), EmailClientStdError> {
342        #[cfg(feature = "maildir")]
343        if let Some(c) = &self.maildir {
344            return Ok(c.store_flags(mailbox, ids, flags, op)?);
345        }
346        #[cfg(feature = "m2dir")]
347        if let Some(c) = &self.m2dir {
348            return Ok(c.store_flags(mailbox, ids, flags, op)?);
349        }
350        #[cfg(feature = "jmap")]
351        if let Some(c) = self.jmap.as_mut() {
352            return Ok(c.store_flags(mailbox, ids, flags, op)?);
353        }
354        #[cfg(feature = "imap")]
355        if let Some(c) = self.imap.as_mut() {
356            return Ok(c.store_flags(mailbox, ids, flags, op)?);
357        }
358        let _ = (mailbox, ids, flags, op);
359        Err(EmailClientStdError::NoBackendRegistered)
360    }
361
362    /// Fetches one message's raw RFC 5322 bytes.
363    pub fn get_message(&mut self, mailbox: &str, id: &str) -> Result<Vec<u8>, EmailClientStdError> {
364        #[cfg(feature = "maildir")]
365        if let Some(c) = &self.maildir {
366            return Ok(c.get_message(mailbox, id)?);
367        }
368        #[cfg(feature = "m2dir")]
369        if let Some(c) = &self.m2dir {
370            return Ok(c.get_message(mailbox, id)?);
371        }
372        #[cfg(feature = "jmap")]
373        if let Some(c) = self.jmap.as_mut() {
374            return Ok(c.get_message(mailbox, id)?);
375        }
376        #[cfg(feature = "imap")]
377        if let Some(c) = self.imap.as_mut() {
378            return Ok(c.get_message(mailbox, id)?);
379        }
380        let _ = (mailbox, id);
381        Err(EmailClientStdError::NoBackendRegistered)
382    }
383
384    /// Adds `raw` to `mailbox` with the given flags. Returns the
385    /// newly-assigned id.
386    pub fn add_message(
387        &mut self,
388        mailbox: &str,
389        flags: &[Flag],
390        raw: Vec<u8>,
391    ) -> Result<String, EmailClientStdError> {
392        #[cfg(feature = "maildir")]
393        if let Some(c) = &self.maildir {
394            return Ok(c.add_message(mailbox, flags, raw)?);
395        }
396        #[cfg(feature = "m2dir")]
397        if let Some(c) = &self.m2dir {
398            return Ok(c.add_message(mailbox, flags, raw)?);
399        }
400        #[cfg(feature = "jmap")]
401        if let Some(c) = self.jmap.as_mut() {
402            return Ok(c.add_message(mailbox, flags, raw)?);
403        }
404        #[cfg(feature = "imap")]
405        if let Some(c) = self.imap.as_mut() {
406            return Ok(c.add_message(mailbox, flags, raw)?);
407        }
408        let _ = (mailbox, flags, raw);
409        Err(EmailClientStdError::NoBackendRegistered)
410    }
411
412    /// Creates `name` as a new mailbox.
413    pub fn create_mailbox(&mut self, name: &str) -> Result<(), EmailClientStdError> {
414        #[cfg(feature = "maildir")]
415        if let Some(c) = &self.maildir {
416            return Ok(c.create_mailbox(name)?);
417        }
418        #[cfg(feature = "m2dir")]
419        if let Some(c) = &self.m2dir {
420            return Ok(c.create_mailbox(name)?);
421        }
422        #[cfg(feature = "jmap")]
423        if let Some(c) = self.jmap.as_mut() {
424            return Ok(c.create_mailbox(name)?);
425        }
426        #[cfg(feature = "imap")]
427        if let Some(c) = self.imap.as_mut() {
428            return Ok(c.create_mailbox(name)?);
429        }
430        let _ = name;
431        Err(EmailClientStdError::NoBackendRegistered)
432    }
433
434    /// Deletes mailbox `name`.
435    pub fn delete_mailbox(&mut self, name: &str) -> Result<(), EmailClientStdError> {
436        #[cfg(feature = "maildir")]
437        if let Some(c) = &self.maildir {
438            return Ok(c.delete_mailbox(name)?);
439        }
440        #[cfg(feature = "m2dir")]
441        if let Some(c) = &self.m2dir {
442            return Ok(c.delete_mailbox(name)?);
443        }
444        #[cfg(feature = "jmap")]
445        if let Some(c) = self.jmap.as_mut() {
446            return Ok(c.delete_mailbox(name)?);
447        }
448        #[cfg(feature = "imap")]
449        if let Some(c) = self.imap.as_mut() {
450            return Ok(c.delete_mailbox(name)?);
451        }
452        let _ = name;
453        Err(EmailClientStdError::NoBackendRegistered)
454    }
455
456    /// Deletes one message permanently.
457    pub fn delete_message(&mut self, mailbox: &str, id: &str) -> Result<(), EmailClientStdError> {
458        #[cfg(feature = "maildir")]
459        if let Some(c) = &self.maildir {
460            return Ok(c.delete_message(mailbox, id)?);
461        }
462        #[cfg(feature = "m2dir")]
463        if let Some(c) = &self.m2dir {
464            return Ok(c.delete_message(mailbox, id)?);
465        }
466        #[cfg(feature = "jmap")]
467        if let Some(c) = self.jmap.as_mut() {
468            return Ok(c.delete_message(mailbox, id)?);
469        }
470        #[cfg(feature = "imap")]
471        if let Some(c) = self.imap.as_mut() {
472            return Ok(c.delete_message(mailbox, id)?);
473        }
474        let _ = (mailbox, id);
475        Err(EmailClientStdError::NoBackendRegistered)
476    }
477
478    /// Copies `ids` from `from` to `to`.
479    pub fn copy_messages(
480        &mut self,
481        from: &str,
482        to: &str,
483        ids: &[&str],
484    ) -> Result<(), EmailClientStdError> {
485        #[cfg(feature = "maildir")]
486        if let Some(c) = &self.maildir {
487            return Ok(c.copy_messages(from, to, ids)?);
488        }
489        #[cfg(feature = "m2dir")]
490        if let Some(c) = &self.m2dir {
491            return Ok(c.copy_messages(from, to, ids)?);
492        }
493        #[cfg(feature = "jmap")]
494        if let Some(c) = self.jmap.as_mut() {
495            return Ok(c.copy_messages(from, to, ids)?);
496        }
497        #[cfg(feature = "imap")]
498        if let Some(c) = self.imap.as_mut() {
499            return Ok(c.copy_messages(from, to, ids)?);
500        }
501        let _ = (from, to, ids);
502        Err(EmailClientStdError::NoBackendRegistered)
503    }
504
505    /// Moves `ids` from `from` to `to`.
506    pub fn move_messages(
507        &mut self,
508        from: &str,
509        to: &str,
510        ids: &[&str],
511    ) -> Result<(), EmailClientStdError> {
512        #[cfg(feature = "maildir")]
513        if let Some(c) = &self.maildir {
514            return Ok(c.move_messages(from, to, ids)?);
515        }
516        #[cfg(feature = "m2dir")]
517        if let Some(c) = &self.m2dir {
518            return Ok(c.move_messages(from, to, ids)?);
519        }
520        #[cfg(feature = "jmap")]
521        if let Some(c) = self.jmap.as_mut() {
522            return Ok(c.move_messages(from, to, ids)?);
523        }
524        #[cfg(feature = "imap")]
525        if let Some(c) = self.imap.as_mut() {
526            return Ok(c.move_messages(from, to, ids)?);
527        }
528        let _ = (from, to, ids);
529        Err(EmailClientStdError::NoBackendRegistered)
530    }
531
532    /// Surfaces a pre-diffed envelope delta against the opaque
533    /// per-backend `state` checkpoint, when the registered backend
534    /// supports it.
535    ///
536    /// `state` is the blob returned by a previous successful call (or
537    /// `None` on first sync); the caller stores the returned checkpoint
538    /// for next time. Returns [`EnvelopeDiff::FullListRequired`] when
539    /// the backend cannot produce an incremental view (capability
540    /// missing, state invalidated, server bumped UIDVALIDITY).
541    ///
542    /// Currently routed to IMAP (QRESYNC / CONDSTORE) and JMAP
543    /// (`Email/changes`); Maildir, m2dir and SMTP fall through to
544    /// [`EmailClientStdError::UnsupportedOperation`].
545    pub fn diff_envelopes(
546        &mut self,
547        mailbox: &str,
548        state: Option<&[u8]>,
549    ) -> Result<EnvelopeDiff, EmailClientStdError> {
550        #[cfg(feature = "jmap")]
551        if let Some(c) = self.jmap.as_mut() {
552            return Ok(c.diff_envelopes(mailbox, state)?);
553        }
554        #[cfg(feature = "imap")]
555        if let Some(c) = self.imap.as_mut() {
556            return Ok(c.diff_envelopes(mailbox, state)?);
557        }
558        let _ = (mailbox, state);
559        Err(EmailClientStdError::UnsupportedOperation)
560    }
561
562    /// Probes whether the mailbox set has changed since `state`. JMAP uses
563    /// `Mailbox/changes` for a constant-cost "anything changed?"  answer;
564    /// backends without an account-global mailbox state token (IMAP, Maildir,
565    /// m2dir) fall through to [`EmailClientStdError::UnsupportedOperation`] so
566    /// the caller can drop to a normal [`Self::list_mailboxes`].
567    pub fn diff_mailboxes(
568        &mut self,
569        state: Option<&[u8]>,
570    ) -> Result<MailboxDiff, EmailClientStdError> {
571        #[cfg(feature = "jmap")]
572        if let Some(c) = self.jmap.as_mut() {
573            return Ok(c.diff_mailboxes(state)?);
574        }
575        let _ = state;
576        Err(EmailClientStdError::UnsupportedOperation)
577    }
578
579    /// Watches `mailbox` for envelope-level deltas, forwarding events
580    /// through `tx`. Priority: JMAP → IMAP (no filesystem watch yet).
581    #[cfg(any(feature = "imap", feature = "jmap"))]
582    pub fn watch_mailbox(
583        &mut self,
584        mailbox: &str,
585        shutdown: Arc<AtomicBool>,
586        tx: Sender<WatchEvent>,
587    ) -> Result<(), EmailClientStdError> {
588        #[cfg(feature = "jmap")]
589        if let Some(c) = self.jmap.as_mut() {
590            return Ok(c.watch_mailbox(mailbox, shutdown, tx)?);
591        }
592        #[cfg(feature = "imap")]
593        if let Some(c) = self.imap.as_mut() {
594            return Ok(c.watch_mailbox(mailbox, shutdown, tx)?);
595        }
596        let _ = (mailbox, shutdown, tx);
597        Err(EmailClientStdError::NoBackendRegistered)
598    }
599
600    // ---- Sending (JMAP → SMTP) -------------------------------------
601
602    /// Sends a raw RFC 5322 message. JMAP routes via `EmailSubmission/set` when
603    /// registered; otherwise SMTP runs the RFC 5321 mail transaction.
604    #[cfg(any(feature = "jmap", feature = "smtp"))]
605    pub fn send_message(&mut self, raw: Vec<u8>) -> Result<(), EmailClientStdError> {
606        #[cfg(feature = "jmap")]
607        if let Some(c) = self.jmap.as_mut() {
608            return Ok(c.send_message(raw)?);
609        }
610        #[cfg(feature = "smtp")]
611        if let Some(c) = self.smtp.as_mut() {
612            return Ok(c.send_message(raw)?);
613        }
614        let _ = raw;
615        Err(EmailClientStdError::NoBackendRegistered)
616    }
617}