Skip to main content

io_email/jmap/
client.rs

1//! Std-blocking JMAP client.
2//!
3//! Holds an inner [`JmapClientStd`] (from io-jmap) wrapping the boxed
4//! stream, the bearer / basic HTTP credential and the discovered
5//! [`JmapSession`]. Unlike IMAP there is no `auto_select` policy
6//! (JMAP destroys are global) and no separate capability list (the
7//! capabilities live inside [`JmapSession::capabilities`]).
8//!
9//! [`JmapClientStd::run`] pumps io-email JMAP coroutines directly
10//! against the inner client's stream; the inner client's own
11//! request/response helpers stay reachable through
12//! [`JmapClientStd::inner`] for protocol-specific paths (blob_upload,
13//! event_source, raw send_raw, ...) that the shared API does not cover.
14//!
15//! [`JmapClientStd`]: io_jmap::client::JmapClientStd
16//! [`JmapSession`]: io_jmap::rfc8620::JmapSession
17//! [`JmapSession::capabilities`]: io_jmap::rfc8620::JmapSession::capabilities
18
19use alloc::{string::String, sync::Arc, vec::Vec};
20use core::sync::atomic::AtomicBool;
21use std::{
22    io::{self, ErrorKind, Read, Write},
23    sync::mpsc::Sender,
24};
25
26use io_jmap::{
27    client::{JmapClientStd as InnerJmapClientStd, JmapClientStdError as InnerJmapClientStdError},
28    coroutine::*,
29    rfc8620::{JmapMethodError, JmapSession, changes::JmapChangesError},
30    rfc8621::{
31        email::{changes::JmapEmailChangesOptions, get::JmapEmailGetOptions},
32        mailbox::{
33            changes::{JmapMailboxChangesError, JmapMailboxChangesOptions},
34            get::JmapMailboxGetOptions,
35        },
36    },
37};
38#[cfg(any(
39    feature = "rustls-ring",
40    feature = "rustls-aws",
41    feature = "native-tls"
42))]
43use pimalaya_stream::tls::Tls;
44use secrecy::SecretString;
45use thiserror::Error;
46use url::Url;
47
48#[cfg(feature = "search")]
49use crate::{
50    envelope::jmap::search::{JmapEnvelopeSearch, JmapEnvelopeSearchError},
51    search::query::SearchEmailsQuery,
52};
53use crate::{
54    envelope::{
55        event::WatchEvent,
56        jmap::{
57            diff as envelope_diff,
58            list::{JmapEnvelopeList, JmapEnvelopeListError},
59            watch::{JmapWatchMailbox, JmapWatchMailboxError, JmapWatchMailboxYield},
60        },
61        types::{Envelope, EnvelopeDiff, FlagUpdate},
62    },
63    flag::{
64        jmap::store::{JmapFlagStore, JmapFlagStoreError},
65        types::{Flag, FlagOp},
66    },
67    jmap::convert::{envelope_from, envelope_properties},
68    mailbox::{
69        jmap::{
70            create::{JmapMailboxCreate, JmapMailboxCreateError},
71            delete::{JmapMailboxDelete, JmapMailboxDeleteError},
72            list::{JmapMailboxList, JmapMailboxListError},
73        },
74        types::{Mailbox, MailboxDiff},
75    },
76    message::jmap::{
77        add::{JmapMessageAdd, JmapMessageAddError},
78        copy::{JmapMessageCopy, JmapMessageCopyError},
79        delete::{JmapMessageDelete, JmapMessageDeleteError},
80        get::{JmapMessageGet, JmapMessageGetError},
81        r#move::{JmapMessageMove, JmapMessageMoveError},
82        send::{JmapMessageSend, JmapMessageSendError},
83    },
84};
85
86/// Errors surfaced by [`JmapClientStd`] while running a coroutine.
87///
88/// One variant per shared-API JMAP coroutine.
89#[derive(Debug, Error)]
90pub enum JmapClientError {
91    #[error(transparent)]
92    Io(#[from] io::Error),
93    #[error("JMAP session is not initialised; call connect or session_get first")]
94    MissingSession,
95    #[error(transparent)]
96    MailboxList(#[from] JmapMailboxListError),
97    #[error(transparent)]
98    EnvelopeList(#[from] JmapEnvelopeListError),
99    #[cfg(feature = "search")]
100    #[error(transparent)]
101    EnvelopeSearch(#[from] JmapEnvelopeSearchError),
102    #[error(transparent)]
103    FlagStore(#[from] JmapFlagStoreError),
104    #[error(transparent)]
105    MailboxCreate(#[from] JmapMailboxCreateError),
106    #[error(transparent)]
107    MailboxDelete(#[from] JmapMailboxDeleteError),
108    #[error(transparent)]
109    MessageAdd(#[from] JmapMessageAddError),
110    #[error(transparent)]
111    MessageCopy(#[from] JmapMessageCopyError),
112    #[error(transparent)]
113    MessageDelete(#[from] JmapMessageDeleteError),
114    #[error(transparent)]
115    MessageGet(#[from] JmapMessageGetError),
116    #[error(transparent)]
117    MessageMove(#[from] JmapMessageMoveError),
118    #[error(transparent)]
119    MessageSend(#[from] JmapMessageSendError),
120    #[error(transparent)]
121    WatchMailbox(#[from] JmapWatchMailboxError),
122    #[error(transparent)]
123    Inner(#[from] InnerJmapClientStdError),
124}
125
126const READ_BUFFER_SIZE: usize = 16 * 1024;
127
128/// Light JMAP client built on top of the io-jmap type-erased inner.
129///
130/// Unlike IMAP, JMAP carries no `auto_select` (destroys are global)
131/// and no separate capability list (capabilities are exposed via the
132/// inner client's cached [`JmapSession`]).
133///
134/// Two extra options are required for [`Self::send_message`]: the
135/// JMAP identity to submit under (`Identity/get` `type=role:identity`)
136/// and the drafts mailbox id (`Mailbox/query` `role: drafts`).
137/// Populate them after [`Self::session_get`] when sending is in scope.
138pub struct JmapClientStd {
139    pub inner: InnerJmapClientStd,
140    pub identity_id: Option<String>,
141    pub drafts_mailbox_id: Option<String>,
142}
143
144impl JmapClientStd {
145    /// Wraps an already-connected stream with the bearer / basic HTTP
146    /// credential. The session must be discovered via
147    /// [`Self::session_get`] before any shared-API method is called.
148    pub fn new<S: Read + Write + Send + 'static>(stream: S, http_auth: SecretString) -> Self {
149        Self {
150            inner: InnerJmapClientStd::new(stream, http_auth),
151            identity_id: None,
152            drafts_mailbox_id: None,
153        }
154    }
155
156    /// Pumps any standard-shape JMAP coroutine
157    /// (`Yield = JmapYield`, `Return = Result<T, E>`) against the
158    /// inner client's stream until it terminates.
159    ///
160    /// Reaches into [`Self::inner`] for raw field access rather than
161    /// delegating to [`InnerJmapClientStd::run`] so error variants
162    /// route through [`JmapClientError`] directly.
163    pub fn run<C, T, E>(&mut self, mut coroutine: C) -> Result<T, JmapClientError>
164    where
165        C: JmapCoroutine<Yield = JmapYield, Return = Result<T, E>>,
166        JmapClientError: From<E>,
167    {
168        let mut buf = [0u8; READ_BUFFER_SIZE];
169        let mut arg: Option<&[u8]> = None;
170
171        loop {
172            match coroutine.resume(arg.take()) {
173                JmapCoroutineState::Complete(Ok(out)) => return Ok(out),
174                JmapCoroutineState::Complete(Err(err)) => return Err(err.into()),
175                JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
176                    let n = self.inner.stream.read(&mut buf)?;
177                    arg = Some(&buf[..n]);
178                }
179                JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
180                    self.inner.stream.write_all(&bytes)?;
181                }
182            }
183        }
184    }
185
186    /// Discovers the JMAP session against `url` and caches it on the
187    /// inner client. Pass either a base URL for `/.well-known/jmap`
188    /// discovery or a direct session endpoint URL.
189    pub fn session_get(&mut self, url: &Url) -> Result<(), JmapClientError> {
190        self.inner.session_get(url)?;
191        Ok(())
192    }
193
194    /// Lists every JMAP mailbox visible to the session's primary
195    /// mail account. When `with_counts` is set, includes
196    /// `totalEmails` / `unreadEmails` (still one round-trip; JMAP
197    /// returns counts inline).
198    pub fn list_mailboxes(&mut self, with_counts: bool) -> Result<Vec<Mailbox>, JmapClientError> {
199        let coroutine = {
200            let session = self.session_or_err()?;
201            let http_auth = &self.inner.http_auth;
202            JmapMailboxList::new(session, http_auth, with_counts)?
203        };
204        self.run(coroutine)
205    }
206
207    /// Lists envelopes from `mailbox`. `page = None` and
208    /// `page_size = None` fetch the whole mailbox.
209    pub fn list_envelopes(
210        &mut self,
211        mailbox: &str,
212        page: Option<u32>,
213        page_size: Option<u32>,
214    ) -> Result<Vec<Envelope>, JmapClientError> {
215        let coroutine = {
216            let session = self.session_or_err()?;
217            let http_auth = &self.inner.http_auth;
218            JmapEnvelopeList::new(session, http_auth, mailbox, page, page_size)?
219        };
220        self.run(coroutine)
221    }
222
223    /// Searches envelopes in `mailbox` against the shared query.
224    /// Pagination is applied to the SORT-ordered id list; date
225    /// predicates are re-checked client-side because JMAP filters on
226    /// `receivedAt` while the shared DSL targets `sentAt`.
227    #[cfg(feature = "search")]
228    pub fn search_envelopes(
229        &mut self,
230        mailbox: &str,
231        query: Option<&SearchEmailsQuery>,
232        page: Option<u32>,
233        page_size: Option<u32>,
234    ) -> Result<Vec<Envelope>, JmapClientError> {
235        let coroutine = {
236            let session = self.session_or_err()?;
237            let http_auth = &self.inner.http_auth;
238            JmapEnvelopeSearch::new(session, http_auth, mailbox, query, page, page_size)?
239        };
240        self.run(coroutine)
241    }
242
243    /// Adds, sets, or removes `flags` (JMAP keywords) on a JMAP email
244    /// id set. `mailbox` is unused: JMAP keywords are global per
245    /// email, not per mailbox.
246    pub fn store_flags(
247        &mut self,
248        mailbox: &str,
249        ids: &[&str],
250        flags: &[Flag],
251        op: FlagOp,
252    ) -> Result<(), JmapClientError> {
253        let coroutine = {
254            let session = self.session_or_err()?;
255            let http_auth = &self.inner.http_auth;
256            JmapFlagStore::new(session, http_auth, mailbox, ids, flags, op)?
257        };
258        self.run(coroutine)
259    }
260
261    /// Fetches one message's raw RFC 5322 bytes via `Email/get`
262    /// (resolving the blob id) then `Blob/download`.
263    pub fn get_message(&mut self, mailbox: &str, id: &str) -> Result<Vec<u8>, JmapClientError> {
264        let coroutine = {
265            let session = self.session_or_err()?;
266            let http_auth = &self.inner.http_auth;
267            JmapMessageGet::new(session, http_auth, mailbox, id)?
268        };
269        self.run(coroutine)
270    }
271
272    /// Uploads `raw` as a blob then imports it into `mailbox` with
273    /// the requested keywords. Returns the created email id.
274    pub fn add_message(
275        &mut self,
276        mailbox: &str,
277        flags: &[Flag],
278        raw: Vec<u8>,
279    ) -> Result<String, JmapClientError> {
280        let coroutine = {
281            let session = self.session_or_err()?;
282            let http_auth = &self.inner.http_auth;
283            JmapMessageAdd::new(session, http_auth, mailbox, flags, raw)?
284        };
285        self.run(coroutine)
286    }
287
288    /// Creates `name` as a top-level JMAP mailbox.
289    pub fn create_mailbox(&mut self, name: &str) -> Result<(), JmapClientError> {
290        let coroutine = {
291            let session = self.session_or_err()?;
292            let http_auth = &self.inner.http_auth;
293            JmapMailboxCreate::new(session, http_auth, name)?
294        };
295        self.run(coroutine)
296    }
297
298    /// Deletes the JMAP mailbox named `name`; drops every email that
299    /// lives only in that mailbox (`onDestroyRemoveEmails: true`).
300    pub fn delete_mailbox(&mut self, name: &str) -> Result<(), JmapClientError> {
301        let coroutine = {
302            let session = self.session_or_err()?;
303            let http_auth = &self.inner.http_auth;
304            JmapMailboxDelete::new(session, http_auth, name)?
305        };
306        self.run(coroutine)
307    }
308
309    /// Destroys the JMAP email by id (global delete; removes the
310    /// email from every mailbox it references).
311    pub fn delete_message(&mut self, mailbox: &str, id: &str) -> Result<(), JmapClientError> {
312        let coroutine = {
313            let session = self.session_or_err()?;
314            let http_auth = &self.inner.http_auth;
315            JmapMessageDelete::new(session, http_auth, mailbox, id)?
316        };
317        self.run(coroutine)
318    }
319
320    /// Copies a JMAP email id set into `to` by adding `to`'s
321    /// mailbox-id reference to each email. The `from` argument is
322    /// part of the shared signature for symmetry with IMAP / Maildir
323    /// but unused: existing `mailboxIds` carry the source reference.
324    pub fn copy_messages(
325        &mut self,
326        from: &str,
327        to: &str,
328        ids: &[&str],
329    ) -> Result<(), JmapClientError> {
330        let coroutine = {
331            let session = self.session_or_err()?;
332            let http_auth = &self.inner.http_auth;
333            JmapMessageCopy::new(session, http_auth, from, to, ids)?
334        };
335        self.run(coroutine)
336    }
337
338    /// Moves a JMAP email id set from `from` to `to`; adds `to`'s
339    /// id and removes `from`'s id in the same `Email/set` patch.
340    pub fn move_messages(
341        &mut self,
342        from: &str,
343        to: &str,
344        ids: &[&str],
345    ) -> Result<(), JmapClientError> {
346        let coroutine = {
347            let session = self.session_or_err()?;
348            let http_auth = &self.inner.http_auth;
349            JmapMessageMove::new(session, http_auth, from, to, ids)?
350        };
351        self.run(coroutine)
352    }
353
354    /// Queues `raw` for delivery via `EmailSubmission/set`.
355    /// Requires [`Self::identity_id`] and [`Self::drafts_mailbox_id`]
356    /// to be populated.
357    pub fn send_message(&mut self, raw: Vec<u8>) -> Result<(), JmapClientError> {
358        let coroutine = {
359            let session = self.session_or_err()?;
360            let http_auth = &self.inner.http_auth;
361            let identity_id = self
362                .identity_id
363                .as_deref()
364                .ok_or(JmapClientError::MissingSession)?;
365            let drafts_id = self
366                .drafts_mailbox_id
367                .as_deref()
368                .ok_or(JmapClientError::MissingSession)?;
369            JmapMessageSend::new(session, http_auth, identity_id, drafts_id, raw)?
370        };
371        self.run(coroutine)
372    }
373
374    /// Watches `mailbox` for envelope-level deltas via the JMAP
375    /// EventSource (`closeafter=state`) + `Email/changes` +
376    /// `Email/get` loop, forwarding every event through `tx`.
377    ///
378    /// **Blocks** the current thread. Returns `Ok(())` when
379    /// `shutdown` flips, when the receiver behind `tx` is dropped,
380    /// or when the protocol layer errors out.
381    pub fn watch_mailbox(
382        &mut self,
383        mailbox: &str,
384        shutdown: Arc<AtomicBool>,
385        tx: Sender<WatchEvent>,
386    ) -> Result<(), JmapClientError> {
387        let mut coroutine = {
388            let session = self.session_or_err()?;
389            let http_auth = &self.inner.http_auth;
390            JmapWatchMailbox::new(session, http_auth, mailbox, shutdown)?
391        };
392        let mut buf = [0u8; READ_BUFFER_SIZE];
393        let mut bytes: Option<&[u8]> = None;
394
395        loop {
396            match coroutine.resume(bytes) {
397                JmapCoroutineState::Complete(result) => return Ok(result?),
398                JmapCoroutineState::Yielded(JmapWatchMailboxYield::WantsRead) => {
399                    match self.inner.stream.read(&mut buf) {
400                        Ok(n) => bytes = Some(&buf[..n]),
401                        Err(err) if err.kind() == ErrorKind::WouldBlock => bytes = None,
402                        Err(err) if err.kind() == ErrorKind::TimedOut => bytes = None,
403                        Err(err) => return Err(err.into()),
404                    }
405                }
406                JmapCoroutineState::Yielded(JmapWatchMailboxYield::WantsWrite(out)) => {
407                    self.inner.stream.write_all(&out)?;
408                    bytes = None;
409                }
410                JmapCoroutineState::Yielded(JmapWatchMailboxYield::Event(evt)) => {
411                    if tx.send(evt).is_err() {
412                        return Ok(());
413                    }
414                    bytes = None;
415                }
416            }
417        }
418    }
419
420    /// Returns the `Email/changes`-driven envelope delta against the
421    /// opaque per-backend `state` checkpoint.
422    ///
423    /// Decodes `state` as the cached `Email/state` token, then walks
424    /// `Email/changes` rounds until `hasMoreChanges` clears. Created
425    /// ids are turned into new envelopes via `Email/get`; updated ids
426    /// surface as [`FlagUpdate`] entries. The `mailbox` argument is
427    /// part of the shared signature: JMAP `Email/changes` is global
428    /// per account and ignores it. Falls back to
429    /// [`EnvelopeDiff::FullListRequired`] when the cached state is
430    /// unusable or the server returns `cannotCalculateChanges`.
431    pub fn diff_envelopes(
432        &mut self,
433        _mailbox: &str,
434        state: Option<&[u8]>,
435    ) -> Result<EnvelopeDiff, JmapClientError> {
436        let Some(since_state) = state.and_then(envelope_diff::decode) else {
437            return self.diff_baseline();
438        };
439
440        let mut created_ids: Vec<String> = Vec::new();
441        let mut updated_ids: Vec<String> = Vec::new();
442        let mut destroyed_ids: Vec<String> = Vec::new();
443        let mut cursor = since_state;
444
445        loop {
446            let changes = match self
447                .inner
448                .email_changes(cursor.clone(), JmapEmailChangesOptions::default())
449            {
450                Ok(c) => c,
451                Err(err) if envelope_diff::is_cannot_calculate_changes(&err) => {
452                    return self.diff_baseline();
453                }
454                Err(err) => return Err(err.into()),
455            };
456
457            created_ids.extend(changes.created);
458            updated_ids.extend(changes.updated);
459            destroyed_ids.extend(changes.destroyed);
460
461            cursor = changes.new_state;
462            if !changes.has_more_changes {
463                break;
464            }
465        }
466
467        let properties = envelope_properties();
468
469        let new_envelopes = if created_ids.is_empty() {
470            Vec::new()
471        } else {
472            let opts = JmapEmailGetOptions {
473                properties: Some(properties.clone()),
474                ..Default::default()
475            };
476            let output = self.inner.email_get(created_ids, opts)?;
477            output.emails.into_iter().map(envelope_from).collect()
478        };
479
480        let flag_updates = if updated_ids.is_empty() {
481            Vec::new()
482        } else {
483            let opts = JmapEmailGetOptions {
484                properties: Some(properties),
485                ..Default::default()
486            };
487            let output = self.inner.email_get(updated_ids, opts)?;
488            output
489                .emails
490                .into_iter()
491                .map(envelope_from)
492                .map(|env| FlagUpdate {
493                    id: env.id,
494                    flags: env.flags,
495                })
496                .collect()
497        };
498
499        Ok(EnvelopeDiff::Incremental {
500            new_state: envelope_diff::encode(&cursor),
501            flag_updates,
502            new_envelopes,
503            vanished_ids: destroyed_ids,
504        })
505    }
506
507    /// Returns the `Mailbox/changes`-driven mailbox-set delta against
508    /// the opaque per-backend `state` checkpoint. A single round trip:
509    /// any non-empty bucket (or the server bumping the state) maps to
510    /// [`MailboxDiff::Changed`]; an idle bucket maps to
511    /// [`MailboxDiff::Unchanged`]. On `cannotCalculateChanges` the
512    /// caller is told to re-list via `Changed { new_state: None }`.
513    pub fn diff_mailboxes(&mut self, state: Option<&[u8]>) -> Result<MailboxDiff, JmapClientError> {
514        let Some(since_state) = state.and_then(envelope_diff::decode) else {
515            let opts = JmapMailboxGetOptions {
516                ids: Some(Vec::new()),
517                ..Default::default()
518            };
519            let output = self.inner.mailbox_get(opts)?;
520            return Ok(MailboxDiff::Changed {
521                new_state: Some(envelope_diff::encode(&output.new_state)),
522            });
523        };
524
525        match self
526            .inner
527            .mailbox_changes(since_state, JmapMailboxChangesOptions::default())
528        {
529            Ok(changes)
530                if !changes.has_more_changes
531                    && changes.created.is_empty()
532                    && changes.updated.is_empty()
533                    && changes.destroyed.is_empty() =>
534            {
535                Ok(MailboxDiff::Unchanged {
536                    new_state: envelope_diff::encode(&changes.new_state),
537                })
538            }
539            Ok(changes) => Ok(MailboxDiff::Changed {
540                new_state: Some(envelope_diff::encode(&changes.new_state)),
541            }),
542            Err(InnerJmapClientStdError::MailboxChanges(JmapMailboxChangesError::Changes(
543                JmapChangesError::Method(JmapMethodError::CannotCalculateChanges { .. }),
544            ))) => Ok(MailboxDiff::Changed { new_state: None }),
545            Err(err) => Err(err.into()),
546        }
547    }
548
549    /// Captures a fresh `Email/state` checkpoint via an empty
550    /// `Email/get`. Used on first sync, when the cached state is
551    /// unusable, or when the server returns `cannotCalculateChanges`.
552    fn diff_baseline(&mut self) -> Result<EnvelopeDiff, JmapClientError> {
553        let output = self
554            .inner
555            .email_get(Vec::new(), JmapEmailGetOptions::default())?;
556        Ok(EnvelopeDiff::FullListRequired {
557            new_state: Some(envelope_diff::encode(&output.new_state)),
558        })
559    }
560
561    fn session_or_err(&self) -> Result<&JmapSession, JmapClientError> {
562        self.inner
563            .session
564            .as_ref()
565            .ok_or(JmapClientError::MissingSession)
566    }
567}
568
569#[cfg(any(
570    feature = "rustls-ring",
571    feature = "rustls-aws",
572    feature = "native-tls"
573))]
574impl JmapClientStd {
575    /// Opens a TCP / TLS connection to `url`, builds the inner
576    /// client around it, then runs `session_get` to discover the
577    /// JMAP session.
578    ///
579    /// `url` is either a base URL for `/.well-known/jmap` discovery
580    /// or a direct session endpoint.
581    pub fn connect(url: &Url, tls: &Tls, http_auth: SecretString) -> Result<Self, JmapClientError> {
582        let mut inner = InnerJmapClientStd::connect(url, tls, http_auth)?;
583        inner.session_get(url)?;
584        Ok(Self {
585            inner,
586            identity_id: None,
587            drafts_mailbox_id: None,
588        })
589    }
590}