Skip to main content

io_jmap/
client.rs

1//! Standard, blocking JMAP client.
2//!
3//! Wraps a single boxed stream plus the bearer token and discovered
4//! [`JmapSession`], with one method per common coroutine. [`JmapClientStd::new`]
5//! takes a pre-connected stream; with one of the TLS features enabled,
6//! [`JmapClientStd::connect`] handles `https://` URLs end-to-end.
7//!
8//! Run [`JmapClientStd::session_get`] once after construction to populate the
9//! session; subsequent calls resolve `accountId` and `apiUrl` from it.
10//!
11//! # Example
12//!
13//! ```rust,no_run
14//! use io_jmap::client::JmapClientStd;
15//! use pimalaya_stream::tls::Tls;
16//! use secrecy::SecretString;
17//! use url::Url;
18//!
19//! let url: Url = "https://api.example.com/jmap/session/".parse().unwrap();
20//! let auth = SecretString::from("Bearer xyz");
21//!
22//! let mut client = JmapClientStd::connect(&url, &Tls::default(), auth).unwrap();
23//! let session = client.session_get(&url).unwrap();
24//!
25//! println!("logged in as {}", session.username);
26//! ```
27
28use core::{any::Any, fmt, time::Duration};
29
30#[cfg(any(
31    feature = "rustls-aws",
32    feature = "rustls-ring",
33    feature = "native-tls"
34))]
35use alloc::string::ToString;
36use alloc::{boxed::Box, collections::BTreeMap, string::String, vec, vec::Vec};
37
38use std::io::{self, Read, Write};
39
40#[cfg(any(
41    feature = "rustls-aws",
42    feature = "rustls-ring",
43    feature = "native-tls"
44))]
45use pimalaya_stream::{std::stream::StreamStd, tls::Tls};
46use secrecy::SecretString;
47use thiserror::Error;
48use url::Url;
49
50use crate::{
51    coroutine::*,
52    rfc8620::{
53        JmapRequest, JmapResponse, JmapSession, blob_download::*, blob_upload::*,
54        changes::JmapChangesOutput, coroutine::JmapRedirectYield, send::*, session_get::*,
55    },
56    rfc8621::{
57        email::{
58            JmapEmailCopyArgs, JmapEmailImportArgs, changes::*, copy::*, get::*, import::*,
59            parse::*, query::*, set::*,
60        },
61        email_submission::{cancel::*, get::*, query::*, set::*, *},
62        identity::{get::*, set::*},
63        mailbox::{changes::*, get::*, query::*, set::*},
64        thread::{changes::*, get::*},
65        vacation_response::{get::*, set::*, *},
66    },
67};
68
69/// Errors returned by [`JmapClientStd`].
70#[derive(Debug, Error)]
71pub enum JmapClientStdError {
72    #[error(transparent)]
73    Send(#[from] JmapSendError),
74    #[error(transparent)]
75    SessionGet(#[from] JmapSessionGetError),
76    #[error(transparent)]
77    BlobUpload(#[from] JmapBlobUploadError),
78    #[error(transparent)]
79    BlobDownload(#[from] JmapBlobDownloadError),
80
81    #[error(transparent)]
82    MailboxGet(#[from] JmapMailboxGetError),
83    #[error(transparent)]
84    MailboxQuery(#[from] JmapMailboxQueryError),
85    #[error(transparent)]
86    MailboxSet(#[from] JmapMailboxSetError),
87    #[error(transparent)]
88    MailboxChanges(#[from] JmapMailboxChangesError),
89
90    #[error(transparent)]
91    EmailGet(#[from] JmapEmailGetError),
92    #[error(transparent)]
93    EmailQuery(#[from] JmapEmailQueryError),
94    #[error(transparent)]
95    EmailSet(#[from] JmapEmailSetError),
96    #[error(transparent)]
97    EmailChanges(#[from] JmapEmailChangesError),
98    #[error(transparent)]
99    JmapEmailCopyArgs(#[from] JmapEmailCopyError),
100    #[error(transparent)]
101    JmapEmailImportArgs(#[from] JmapEmailImportError),
102    #[error(transparent)]
103    EmailParse(#[from] JmapEmailParseError),
104
105    #[error(transparent)]
106    ThreadGet(#[from] JmapThreadGetError),
107    #[error(transparent)]
108    ThreadChanges(#[from] JmapThreadChangesError),
109
110    #[error(transparent)]
111    IdentityGet(#[from] JmapIdentityGetError),
112    #[error(transparent)]
113    IdentitySet(#[from] JmapIdentitySetError),
114
115    #[error(transparent)]
116    EmailSubmissionGet(#[from] JmapEmailSubmissionGetError),
117    #[error(transparent)]
118    EmailSubmissionQuery(#[from] JmapEmailSubmissionQueryError),
119    #[error(transparent)]
120    EmailSubmissionSet(#[from] JmapEmailSubmissionSetError),
121    #[error(transparent)]
122    EmailSubmissionCancel(#[from] JmapEmailSubmissionCancelError),
123
124    #[error(transparent)]
125    VacationResponseGet(#[from] JmapVacationResponseGetError),
126    #[error(transparent)]
127    VacationResponseSet(#[from] JmapVacationResponseSetError),
128
129    #[error(transparent)]
130    Io(#[from] io::Error),
131
132    #[cfg(any(
133        feature = "rustls-aws",
134        feature = "rustls-ring",
135        feature = "native-tls"
136    ))]
137    #[error(transparent)]
138    Tls(#[from] anyhow::Error),
139    #[cfg(any(
140        feature = "rustls-aws",
141        feature = "rustls-ring",
142        feature = "native-tls"
143    ))]
144    #[error("JMAP URL `{0}` has no host")]
145    UrlMissingHost(String),
146    #[cfg(any(
147        feature = "rustls-aws",
148        feature = "rustls-ring",
149        feature = "native-tls"
150    ))]
151    #[error(
152        "JMAP URL `{0}` has unsupported scheme `{1}` (expected `http`, `https`, `jmap` or `jmaps`)"
153    )]
154    UrlUnsupportedScheme(String, String),
155
156    #[error("JMAP server redirected to `{0}` during a non-redirectable operation")]
157    UnexpectedRedirect(Url),
158    #[error("JMAP client missing session; call `session_get` first")]
159    MissingSession,
160}
161
162const READ_BUFFER_SIZE: usize = 16 * 1024;
163
164/// Default ALPN list for JMAP TLS handshakes: `["http/1.1"]` (JMAP rides on
165/// HTTP/1.1). Exposed so config-driven callers can share one source of truth.
166pub fn default_alpn() -> Vec<String> {
167    vec![String::from("http/1.1")]
168}
169
170/// Std-blocking JMAP client wrapping a single boxed stream.
171pub struct JmapClientStd {
172    pub stream: Box<dyn JmapStream>,
173    pub http_auth: SecretString,
174    pub session: Option<JmapSession>,
175}
176
177impl JmapClientStd {
178    /// Builds a client around `stream`. The caller is responsible for opening
179    /// the connection (TCP, TLS handshake if needed) and for the bearer token /
180    /// authorization header value.
181    pub fn new<S: Read + Write + Send + 'static>(stream: S, http_auth: SecretString) -> Self {
182        Self {
183            stream: Box::new(stream),
184            http_auth,
185            session: None,
186        }
187    }
188
189    /// Drives any standard-shape coroutine (`Yield = JmapYield`) against the
190    /// wrapped stream until it terminates.
191    ///
192    /// Redirect-aware coroutines ([`JmapSessionGet`], [`JmapBlobUpload`],
193    /// [`JmapBlobDownload`]) and the streaming
194    /// [`JmapEventSource`](crate::rfc8620::event_source::subscribe::JmapEventSource)
195    /// have their own per-method loops.
196    pub fn run<C, T, E>(&mut self, mut coroutine: C) -> Result<T, JmapClientStdError>
197    where
198        C: JmapCoroutine<Yield = JmapYield, Return = Result<T, E>>,
199        JmapClientStdError: From<E>,
200    {
201        let mut buf = [0u8; READ_BUFFER_SIZE];
202        let mut arg: Option<&[u8]> = None;
203
204        loop {
205            match coroutine.resume(arg.take()) {
206                JmapCoroutineState::Complete(Ok(out)) => return Ok(out),
207                JmapCoroutineState::Complete(Err(err)) => return Err(err.into()),
208                JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
209                    let n = self.stream.read(&mut buf)?;
210                    arg = Some(&buf[..n]);
211                }
212                JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
213                    self.stream.write_all(&bytes)?;
214                    arg = None;
215                }
216            }
217        }
218    }
219
220    /// Builds a client from a pre-connected stream and an already-discovered
221    /// [`JmapSession`]. Skips [`Self::session_get`].
222    pub fn from_parts<S: Read + Write + Send + 'static>(
223        stream: S,
224        http_auth: SecretString,
225        session: JmapSession,
226    ) -> Self {
227        Self {
228            stream: Box::new(stream),
229            http_auth,
230            session: Some(session),
231        }
232    }
233
234    /// Connects to `url`, doing a TLS handshake for `https` / `jmaps` (plain
235    /// TCP for `http` / `jmap`). ALPN comes from `tls.rustls.alpn` (see
236    /// [`default_alpn`]); empty vec skips ALPN.
237    #[cfg(any(
238        feature = "rustls-aws",
239        feature = "rustls-ring",
240        feature = "native-tls"
241    ))]
242    pub fn connect(
243        url: &Url,
244        tls: &Tls,
245        http_auth: SecretString,
246    ) -> Result<Self, JmapClientStdError> {
247        let host = url
248            .host_str()
249            .ok_or_else(|| JmapClientStdError::UrlMissingHost(url.to_string()))?;
250
251        let stream = match url.scheme() {
252            "http" | "jmap" => StreamStd::connect_tcp(host, url.port().unwrap_or(80))?,
253            "https" | "jmaps" => StreamStd::connect_tls(host, url.port().unwrap_or(443), tls)?,
254            scheme => {
255                return Err(JmapClientStdError::UrlUnsupportedScheme(
256                    url.to_string(),
257                    scheme.to_string(),
258                ));
259            }
260        };
261
262        // NOTE: 5s per-read (not per-operation) timeout so the watch loop
263        // polls its shutdown atomic between SSE push frames; large JMAP
264        // responses keep working as long as TCP packets keep arriving.
265        stream.set_read_timeout(Some(Duration::from_secs(5)))?;
266
267        Ok(Self {
268            stream: Box::new(stream),
269            http_auth,
270            session: None,
271        })
272    }
273
274    /// Replaces the underlying stream; useful when `apiUrl`, `uploadUrl` or
275    /// `downloadUrl` resolves to a different authority than the first
276    /// connection target, or after a redirect.
277    pub fn set_stream<S: Read + Write + Send + 'static>(&mut self, stream: S) {
278        self.stream = Box::new(stream);
279    }
280
281    /// Returns the cached session, if [`Self::session_get`] has run.
282    pub fn session(&self) -> Option<&JmapSession> {
283        self.session.as_ref()
284    }
285
286    /// Returns the pre-formatted HTTP `Authorization` header value.
287    pub fn http_auth(&self) -> &SecretString {
288        &self.http_auth
289    }
290
291    fn session_or_err(&self) -> Result<&JmapSession, JmapClientStdError> {
292        self.session
293            .as_ref()
294            .ok_or(JmapClientStdError::MissingSession)
295    }
296
297    /// Runs [`JmapSessionGet`] and caches the discovered session.
298    ///
299    /// `url` is either a base URL for `/.well-known/jmap` discovery or a
300    /// direct session endpoint. A 3xx response terminates with
301    /// [`JmapClientStdError::UnexpectedRedirect`].
302    pub fn session_get(&mut self, url: &Url) -> Result<&JmapSession, JmapClientStdError> {
303        let mut coroutine = JmapSessionGet::new(&self.http_auth, url);
304        let mut buf = [0u8; READ_BUFFER_SIZE];
305        let mut arg: Option<&[u8]> = None;
306
307        loop {
308            match coroutine.resume(arg.take()) {
309                JmapCoroutineState::Complete(Ok(JmapSessionGetOutput { session, .. })) => {
310                    self.session = Some(session);
311                    return Ok(self.session.as_ref().unwrap());
312                }
313                JmapCoroutineState::Complete(Err(err)) => return Err(err.into()),
314                JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {
315                    let n = self.stream.read(&mut buf)?;
316                    arg = Some(&buf[..n]);
317                }
318                JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => {
319                    self.stream.write_all(&bytes)?;
320                    arg = None;
321                }
322                JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
323                    return Err(JmapClientStdError::UnexpectedRedirect(url));
324                }
325            }
326        }
327    }
328
329    /// Sends a raw JMAP request and returns the raw [`JmapResponse`]. Useful
330    /// for passthrough CLIs and ad-hoc requests with custom `using`
331    /// capabilities.
332    // TODO: move this to one level down
333    pub fn send_raw(&mut self, request: JmapRequest) -> Result<JmapResponse, JmapClientStdError> {
334        let session = self.session_or_err()?;
335        let coroutine = JmapSend::new(&self.http_auth, &session.api_url, request)?;
336        let out = self.run(coroutine)?;
337        Ok(out.response)
338    }
339
340    // ---- Blob (RFC 8620 §6) ----------------------------------------------
341
342    /// Uploads a blob to `upload_url` (RFC 8620 §6.1). The caller must resolve
343    /// the session's `uploadUrl` template (e.g. substitute `{accountId}`).
344    /// A 3xx response terminates with [`JmapClientStdError::UnexpectedRedirect`].
345    pub fn blob_upload(
346        &mut self,
347        upload_url: &Url,
348        content_type: &str,
349        data: Vec<u8>,
350    ) -> Result<JmapBlobUploadOutput, JmapClientStdError> {
351        let mut coroutine = JmapBlobUpload::new(&self.http_auth, upload_url, content_type, data);
352        let mut buf = [0u8; READ_BUFFER_SIZE];
353        let mut arg: Option<&[u8]> = None;
354
355        loop {
356            match coroutine.resume(arg.take()) {
357                JmapCoroutineState::Complete(Ok(out)) => return Ok(out),
358                JmapCoroutineState::Complete(Err(err)) => return Err(err.into()),
359                JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {
360                    let n = self.stream.read(&mut buf)?;
361                    arg = Some(&buf[..n]);
362                }
363                JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => {
364                    self.stream.write_all(&bytes)?;
365                    arg = None;
366                }
367                JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
368                    return Err(JmapClientStdError::UnexpectedRedirect(url));
369                }
370            }
371        }
372    }
373
374    /// Downloads a blob from `download_url` (RFC 8620 §6.2). The caller must
375    /// resolve the session's `downloadUrl` template. A 3xx response terminates
376    /// with [`JmapClientStdError::UnexpectedRedirect`].
377    pub fn blob_download(&mut self, download_url: &Url) -> Result<Vec<u8>, JmapClientStdError> {
378        let mut coroutine = JmapBlobDownload::new(&self.http_auth, download_url);
379        let mut buf = [0u8; READ_BUFFER_SIZE];
380        let mut arg: Option<&[u8]> = None;
381
382        loop {
383            match coroutine.resume(arg.take()) {
384                JmapCoroutineState::Complete(Ok(out)) => return Ok(out.data),
385                JmapCoroutineState::Complete(Err(err)) => return Err(err.into()),
386                JmapCoroutineState::Yielded(JmapRedirectYield::WantsRead) => {
387                    let n = self.stream.read(&mut buf)?;
388                    arg = Some(&buf[..n]);
389                }
390                JmapCoroutineState::Yielded(JmapRedirectYield::WantsWrite(bytes)) => {
391                    self.stream.write_all(&bytes)?;
392                    arg = None;
393                }
394                JmapCoroutineState::Yielded(JmapRedirectYield::WantsRedirect { url, .. }) => {
395                    return Err(JmapClientStdError::UnexpectedRedirect(url));
396                }
397            }
398        }
399    }
400
401    // ---- Mailbox (RFC 8621 §2) -------------------------------------------
402
403    /// Runs [`JmapMailboxGet`] (`Mailbox/get`).
404    pub fn mailbox_get(
405        &mut self,
406        opts: JmapMailboxGetOptions,
407    ) -> Result<JmapMailboxGetOutput, JmapClientStdError> {
408        let coroutine = JmapMailboxGet::new(self.session_or_err()?, &self.http_auth, opts)?;
409        self.run(coroutine)
410    }
411
412    /// Runs [`JmapMailboxQuery`] (batched `Mailbox/query` +
413    /// `Mailbox/get`).
414    pub fn mailbox_query(
415        &mut self,
416        opts: JmapMailboxQueryOptions,
417    ) -> Result<JmapMailboxQueryOutput, JmapClientStdError> {
418        let coroutine = JmapMailboxQuery::new(self.session_or_err()?, &self.http_auth, opts)?;
419        self.run(coroutine)
420    }
421
422    /// Runs [`JmapMailboxSet`] (`Mailbox/set`).
423    pub fn mailbox_set(
424        &mut self,
425        args: JmapMailboxSetArgs,
426    ) -> Result<JmapMailboxSetOutput, JmapClientStdError> {
427        let coroutine = JmapMailboxSet::new(self.session_or_err()?, &self.http_auth, args)?;
428        self.run(coroutine)
429    }
430
431    /// Runs [`JmapMailboxChanges`] (`Mailbox/changes`).
432    pub fn mailbox_changes(
433        &mut self,
434        since_state: impl Into<String>,
435        opts: JmapMailboxChangesOptions,
436    ) -> Result<JmapChangesOutput, JmapClientStdError> {
437        let coroutine =
438            JmapMailboxChanges::new(self.session_or_err()?, &self.http_auth, since_state, opts)?;
439        self.run(coroutine)
440    }
441
442    // ---- Email (RFC 8621 §4) ---------------------------------------------
443
444    /// Runs [`JmapEmailGet`] (`Email/get`).
445    pub fn email_get(
446        &mut self,
447        ids: Vec<String>,
448        opts: JmapEmailGetOptions,
449    ) -> Result<JmapEmailGetOutput, JmapClientStdError> {
450        let coroutine = JmapEmailGet::new(self.session_or_err()?, &self.http_auth, ids, opts)?;
451        self.run(coroutine)
452    }
453
454    /// Runs [`JmapEmailQuery`] (batched `Email/query` + `Email/get`).
455    pub fn email_query(
456        &mut self,
457        opts: JmapEmailQueryOptions,
458    ) -> Result<JmapEmailQueryOutput, JmapClientStdError> {
459        let coroutine = JmapEmailQuery::new(self.session_or_err()?, &self.http_auth, opts)?;
460        self.run(coroutine)
461    }
462
463    /// Runs [`JmapEmailSet`] (`Email/set`).
464    pub fn email_set(
465        &mut self,
466        args: JmapEmailSetArgs,
467    ) -> Result<JmapEmailSetOutput, JmapClientStdError> {
468        let coroutine = JmapEmailSet::new(self.session_or_err()?, &self.http_auth, args)?;
469        self.run(coroutine)
470    }
471
472    /// Runs [`JmapEmailChanges`] (`Email/changes`).
473    pub fn email_changes(
474        &mut self,
475        since_state: impl Into<String>,
476        opts: JmapEmailChangesOptions,
477    ) -> Result<JmapChangesOutput, JmapClientStdError> {
478        let coroutine =
479            JmapEmailChanges::new(self.session_or_err()?, &self.http_auth, since_state, opts)?;
480        self.run(coroutine)
481    }
482
483    /// Runs [`JmapEmailCopy`] (`Email/copy`).
484    pub fn email_copy(
485        &mut self,
486        from_account_id: impl Into<String>,
487        emails: BTreeMap<String, JmapEmailCopyArgs>,
488    ) -> Result<JmapEmailCopyOutput, JmapClientStdError> {
489        let coroutine = JmapEmailCopy::new(
490            self.session_or_err()?,
491            &self.http_auth,
492            from_account_id,
493            emails,
494        )?;
495        self.run(coroutine)
496    }
497
498    /// Runs [`JmapEmailImport`] (`Email/import`).
499    pub fn email_import(
500        &mut self,
501        emails: BTreeMap<String, JmapEmailImportArgs>,
502    ) -> Result<JmapEmailImportOutput, JmapClientStdError> {
503        let coroutine = JmapEmailImport::new(self.session_or_err()?, &self.http_auth, emails)?;
504        self.run(coroutine)
505    }
506
507    /// Runs [`JmapEmailParse`] (`Email/parse`).
508    pub fn email_parse(
509        &mut self,
510        blob_ids: Vec<String>,
511        opts: JmapEmailParseOptions,
512    ) -> Result<JmapEmailParseOutput, JmapClientStdError> {
513        let coroutine =
514            JmapEmailParse::new(self.session_or_err()?, &self.http_auth, blob_ids, opts)?;
515        self.run(coroutine)
516    }
517
518    // ---- Thread (RFC 8621 §3) --------------------------------------------
519
520    /// Runs [`JmapThreadGet`] (`Thread/get`).
521    pub fn thread_get(
522        &mut self,
523        ids: Vec<String>,
524    ) -> Result<JmapThreadGetOutput, JmapClientStdError> {
525        let coroutine = JmapThreadGet::new(self.session_or_err()?, &self.http_auth, ids)?;
526        self.run(coroutine)
527    }
528
529    /// Runs [`JmapThreadChanges`] (`Thread/changes`).
530    pub fn thread_changes(
531        &mut self,
532        since_state: impl Into<String>,
533        opts: JmapThreadChangesOptions,
534    ) -> Result<JmapChangesOutput, JmapClientStdError> {
535        let coroutine =
536            JmapThreadChanges::new(self.session_or_err()?, &self.http_auth, since_state, opts)?;
537        self.run(coroutine)
538    }
539
540    // ---- Identity (RFC 8621 §6) ------------------------------------------
541
542    /// Runs [`JmapIdentityGet`] (`Identity/get`).
543    pub fn identity_get(
544        &mut self,
545        opts: JmapIdentityGetOptions,
546    ) -> Result<JmapIdentityGetOutput, JmapClientStdError> {
547        let coroutine = JmapIdentityGet::new(self.session_or_err()?, &self.http_auth, opts)?;
548        self.run(coroutine)
549    }
550
551    /// Runs [`JmapIdentitySet`] (`Identity/set`).
552    pub fn identity_set(
553        &mut self,
554        args: JmapIdentitySetArgs,
555    ) -> Result<JmapIdentitySetOutput, JmapClientStdError> {
556        let coroutine = JmapIdentitySet::new(self.session_or_err()?, &self.http_auth, args)?;
557        self.run(coroutine)
558    }
559
560    // ---- EmailSubmission (RFC 8621 §7) -----------------------------------
561
562    /// Runs [`JmapEmailSubmissionGet`] (`EmailSubmission/get`).
563    pub fn email_submission_get(
564        &mut self,
565        opts: JmapEmailSubmissionGetOptions,
566    ) -> Result<JmapEmailSubmissionGetOutput, JmapClientStdError> {
567        let coroutine = JmapEmailSubmissionGet::new(self.session_or_err()?, &self.http_auth, opts)?;
568        self.run(coroutine)
569    }
570
571    /// Runs [`JmapEmailSubmissionQuery`] (batched
572    /// `EmailSubmission/query` + `EmailSubmission/get`).
573    pub fn email_submission_query(
574        &mut self,
575        opts: JmapEmailSubmissionQueryOptions,
576    ) -> Result<JmapEmailSubmissionQueryOutput, JmapClientStdError> {
577        let coroutine =
578            JmapEmailSubmissionQuery::new(self.session_or_err()?, &self.http_auth, opts)?;
579        self.run(coroutine)
580    }
581
582    /// Runs [`JmapEmailSubmissionSet`] (`EmailSubmission/set`).
583    pub fn email_submission_set(
584        &mut self,
585        submissions: BTreeMap<String, JmapEmailSubmissionCreate>,
586    ) -> Result<JmapEmailSubmissionSetOutput, JmapClientStdError> {
587        let coroutine =
588            JmapEmailSubmissionSet::new(self.session_or_err()?, &self.http_auth, submissions)?;
589        self.run(coroutine)
590    }
591
592    /// Runs [`JmapEmailSubmissionCancel`] (`EmailSubmission/set` with
593    /// `undoStatus: "canceled"`).
594    pub fn email_submission_cancel(
595        &mut self,
596        ids: Vec<String>,
597    ) -> Result<JmapEmailSubmissionCancelOutput, JmapClientStdError> {
598        let coroutine =
599            JmapEmailSubmissionCancel::new(self.session_or_err()?, &self.http_auth, ids)?;
600        self.run(coroutine)
601    }
602
603    // ---- VacationResponse (RFC 8621 §8) ----------------------------------
604
605    /// Runs [`JmapVacationResponseGet`]; returns the singleton, if any.
606    pub fn vacation_response_get(
607        &mut self,
608    ) -> Result<Option<JmapVacationResponse>, JmapClientStdError> {
609        let coroutine = JmapVacationResponseGet::new(self.session_or_err()?, &self.http_auth)?;
610        Ok(self.run(coroutine)?.vacation_response)
611    }
612
613    /// Runs [`JmapVacationResponseSet`]; returns the updated singleton if the
614    /// server echoed it back.
615    pub fn vacation_response_set(
616        &mut self,
617        patch: JmapVacationResponseUpdate,
618    ) -> Result<Option<JmapVacationResponse>, JmapClientStdError> {
619        let coroutine =
620            JmapVacationResponseSet::new(self.session_or_err()?, &self.http_auth, patch)?;
621        Ok(self.run(coroutine)?.updated)
622    }
623}
624
625impl fmt::Debug for JmapClientStd {
626    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
627        f.debug_struct("JmapClientStd")
628            .field("http_auth", &self.http_auth)
629            .field("session", &self.session)
630            .finish_non_exhaustive()
631    }
632}
633
634/// Erased stream the client can drive: auto-implemented for any blocking
635/// `Read + Write + Send + 'static`. `Send` flows through the `Box<dyn …>` so
636/// `JmapClientStd` can move between worker threads; [`Self::as_any_mut`] lets
637/// specialized callers downcast back to the concrete stream.
638pub trait JmapStream: Read + Write + Send + Any {
639    fn as_any_mut(&mut self) -> &mut dyn Any;
640}
641
642impl<T: Read + Write + Send + Any> JmapStream for T {
643    fn as_any_mut(&mut self) -> &mut dyn Any {
644        self
645    }
646}