Skip to main content

io_jmap/rfc8621/email_submission/
query.rs

1//! Batched JMAP `EmailSubmission/query` + `EmailSubmission/get` coroutine (RFC
2//! 8621 §7.3 + §7.2): one HTTP request, server-side `#ids` back-reference.
3//!
4//! # Example
5//!
6//! ```rust,no_run
7//! use std::{
8//!     io::{Read, Write},
9//!     net::TcpStream,
10//! };
11//!
12//! use io_jmap::{
13//!     coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
14//!     rfc8620::session::JmapSession,
15//!     rfc8621::email_submission::query::{
16//!         JmapEmailSubmissionQuery, JmapEmailSubmissionQueryOptions,
17//!     },
18//! };
19//! use secrecy::SecretString;
20//!
21//! // Ready stream needed (TCP-connected, TLS-negociated)
22//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
23//! let mut buf = [0u8; 4096];
24//!
25//! let session: JmapSession = serde_json::from_str(r#"{
26//!     "username": "",
27//!     "accounts": {},
28//!     "primaryAccounts": {"urn:ietf:params:jmap:mail": "a1"},
29//!     "capabilities": {},
30//!     "apiUrl": "https://api.example.com/jmap/",
31//!     "downloadUrl": "",
32//!     "uploadUrl": "",
33//!     "eventSourceUrl": "",
34//!     "state": ""
35//! }"#).unwrap();
36//! let auth = SecretString::from("Bearer xyz");
37//! let mut coroutine = JmapEmailSubmissionQuery::new(
38//!     &session,
39//!     &auth,
40//!     JmapEmailSubmissionQueryOptions::default(),
41//! )
42//! .unwrap();
43//! let mut arg = None;
44//!
45//! let out = loop {
46//!     match coroutine.resume(arg.take()) {
47//!         JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
48//!             stream.write_all(&bytes).unwrap();
49//!         }
50//!         JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
51//!             let n = stream.read(&mut buf).unwrap();
52//!             arg = Some(&buf[..n]);
53//!         }
54//!         JmapCoroutineState::Complete(Ok(out)) => break out,
55//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
56//!     }
57//! };
58//!
59//! println!("{} submissions", out.submissions.len());
60//! ```
61
62use alloc::{string::String, vec, vec::Vec};
63
64use secrecy::SecretString;
65use serde::{Deserialize, Serialize};
66use thiserror::Error;
67
68use crate::{
69    coroutine::*,
70    jmap_try,
71    rfc8620::{
72        JMAP_CORE_CAPABILITY, error::JmapMethodError, request::JmapBatch,
73        request::JmapResultReference, send::*, session::JmapSession,
74    },
75    rfc8621::{
76        JMAP_MAIL_CAPABILITY,
77        email_submission::{JMAP_SUBMISSION_CAPABILITY, JmapEmailSubmission, JmapUndoStatus},
78    },
79};
80
81/// Filter condition for `EmailSubmission/query` (RFC 8621 §7.4).
82#[derive(Clone, Debug, Default, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct JmapEmailSubmissionFilter {
85    /// Only submissions sent from these identities.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub identity_ids: Option<Vec<String>>,
88    /// Only submissions of these emails.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub email_ids: Option<Vec<String>>,
91    /// Only submissions of emails in these threads.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub thread_ids: Option<Vec<String>>,
94    /// Only submissions with this undo status.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub undo_status: Option<JmapUndoStatus>,
97    /// RFC 3339 upper bound on the sendAt date.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub before: Option<String>,
100    /// RFC 3339 lower bound on the sendAt date.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub after: Option<String>,
103}
104
105/// Sort property for `EmailSubmission/query`.
106#[derive(Clone, Debug, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub enum JmapEmailSubmissionSortProperty {
109    /// Sort by email id.
110    EmailId,
111    /// Sort by thread id.
112    ThreadId,
113    /// Sort by the sendAt date.
114    SentAt,
115}
116
117/// Sort comparator for `EmailSubmission/query`.
118#[derive(Clone, Debug, Serialize)]
119#[serde(rename_all = "camelCase")]
120pub struct JmapEmailSubmissionComparator {
121    /// The property to sort by.
122    pub property: JmapEmailSubmissionSortProperty,
123    /// Ascending if `None` or `Some(true)`.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub is_ascending: Option<bool>,
126}
127
128/// Failure causes during a batched JMAP `EmailSubmission/query` + `/get` flow.
129#[derive(Debug, Error)]
130pub enum JmapEmailSubmissionQueryError {
131    /// The response carried no query response.
132    #[error(
133        "JMAP EmailSubmission/query failed: missing EmailSubmission/query response in method_responses"
134    )]
135    MissingQueryResponse,
136    /// The response carried no get response.
137    #[error(
138        "JMAP EmailSubmission/query failed: missing EmailSubmission/get response in method_responses"
139    )]
140    MissingGetResponse,
141    /// The inner send coroutine failed.
142    #[error("JMAP EmailSubmission/query failed: {0}")]
143    Send(#[from] JmapSendError),
144    /// The method arguments could not be serialized.
145    #[error("JMAP EmailSubmission/query failed: serialize args: {0}")]
146    SerializeArgs(#[source] serde_json::Error),
147    /// The query response could not be parsed.
148    #[error("JMAP EmailSubmission/query failed: parse EmailSubmission/query response: {0}")]
149    ParseQueryResponse(#[source] serde_json::Error),
150    /// The get response could not be parsed.
151    #[error("JMAP EmailSubmission/query failed: parse EmailSubmission/get response: {0}")]
152    ParseGetResponse(#[source] serde_json::Error),
153    /// The server returned a method-level error for the query call.
154    #[error("JMAP EmailSubmission/query failed: EmailSubmission/query: {0}")]
155    QueryMethod(JmapMethodError),
156    /// The server returned a method-level error for the get call.
157    #[error("JMAP EmailSubmission/query failed: EmailSubmission/get: {0}")]
158    GetMethod(JmapMethodError),
159}
160
161/// Options for [`JmapEmailSubmissionQuery::new`].
162#[derive(Clone, Debug, Default)]
163pub struct JmapEmailSubmissionQueryOptions {
164    /// The filter conditions the submissions must match.
165    pub filter: Option<JmapEmailSubmissionFilter>,
166    /// The sort comparators applied to the results.
167    pub sort: Option<Vec<JmapEmailSubmissionComparator>>,
168    /// Zero-based index of the first result to return.
169    pub position: Option<u64>,
170    /// Maximum number of results to return.
171    pub limit: Option<u64>,
172}
173
174/// Successful terminal output of [`JmapEmailSubmissionQuery`].
175#[derive(Clone, Debug)]
176pub struct JmapEmailSubmissionQueryOutput {
177    /// The fetched email submissions.
178    pub submissions: Vec<JmapEmailSubmission>,
179    /// The total number of matching objects, when the server computed it.
180    pub total: Option<u64>,
181    /// Zero-based index of the first returned id.
182    pub position: u64,
183    /// The state the query results were computed at.
184    pub query_state: String,
185    /// Whether the server indicated the connection can be reused.
186    pub keep_alive: bool,
187}
188
189/// I/O-free coroutine for batched `EmailSubmission/query` +
190/// `EmailSubmission/get`.
191pub struct JmapEmailSubmissionQuery {
192    state: State,
193}
194
195impl JmapEmailSubmissionQuery {
196    /// Prepares the method call request and builds the coroutine.
197    pub fn new(
198        session: &JmapSession,
199        http_auth: &SecretString,
200        opts: JmapEmailSubmissionQueryOptions,
201    ) -> Result<Self, JmapEmailSubmissionQueryError> {
202        let account_id = session
203            .primary_accounts
204            .get(JMAP_MAIL_CAPABILITY)
205            .cloned()
206            .unwrap_or_default();
207        let api_url = &session.api_url;
208
209        let query_args = SubmissionQueryArgs {
210            account_id: &account_id,
211            filter: opts.filter.as_ref(),
212            sort: opts.sort.as_deref(),
213            position: opts.position,
214            limit: opts.limit,
215            calculate_total: true,
216        };
217
218        let mut batch = JmapBatch::new();
219        let query_id = batch.add(
220            "EmailSubmission/query",
221            serde_json::to_value(&query_args)
222                .map_err(JmapEmailSubmissionQueryError::SerializeArgs)?,
223        );
224
225        let get_args = SubmissionGetByRefArgs {
226            account_id: &account_id,
227            ids_ref: JmapResultReference {
228                result_of: &query_id,
229                name: "EmailSubmission/query",
230                path: "/ids",
231            },
232        };
233
234        batch.add(
235            "EmailSubmission/get",
236            serde_json::to_value(&get_args)
237                .map_err(JmapEmailSubmissionQueryError::SerializeArgs)?,
238        );
239
240        let request = batch.into_request(vec![
241            JMAP_CORE_CAPABILITY.into(),
242            JMAP_MAIL_CAPABILITY.into(),
243            JMAP_SUBMISSION_CAPABILITY.into(),
244        ]);
245
246        Ok(Self {
247            state: State::Send(JmapSend::new(http_auth, api_url, request)?),
248        })
249    }
250}
251
252impl JmapCoroutine for JmapEmailSubmissionQuery {
253    type Yield = JmapYield;
254    type Return = Result<JmapEmailSubmissionQueryOutput, JmapEmailSubmissionQueryError>;
255
256    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
257        match &mut self.state {
258            State::Send(send) => {
259                let JmapSendOutput {
260                    response,
261                    keep_alive,
262                } = jmap_try!(send, arg);
263
264                let mut responses = response.method_responses.into_iter();
265
266                let Some((query_name, query_args, _)) = responses.next() else {
267                    return JmapCoroutineState::Complete(Err(
268                        JmapEmailSubmissionQueryError::MissingQueryResponse,
269                    ));
270                };
271
272                if query_name == "error" {
273                    let err = serde_json::from_value::<JmapMethodError>(query_args)
274                        .unwrap_or(JmapMethodError::Unknown);
275                    return JmapCoroutineState::Complete(Err(
276                        JmapEmailSubmissionQueryError::QueryMethod(err),
277                    ));
278                }
279
280                let query_response =
281                    match serde_json::from_value::<SubmissionQueryResponse>(query_args) {
282                        Ok(r) => r,
283                        Err(err) => {
284                            return JmapCoroutineState::Complete(Err(
285                                JmapEmailSubmissionQueryError::ParseQueryResponse(err),
286                            ));
287                        }
288                    };
289
290                let Some((get_name, get_args, _)) = responses.next() else {
291                    return JmapCoroutineState::Complete(Err(
292                        JmapEmailSubmissionQueryError::MissingGetResponse,
293                    ));
294                };
295
296                if get_name == "error" {
297                    let err = serde_json::from_value::<JmapMethodError>(get_args)
298                        .unwrap_or(JmapMethodError::Unknown);
299                    return JmapCoroutineState::Complete(Err(
300                        JmapEmailSubmissionQueryError::GetMethod(err),
301                    ));
302                }
303
304                match serde_json::from_value::<SubmissionGetResponse>(get_args) {
305                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapEmailSubmissionQueryOutput {
306                        submissions: r.list,
307                        total: query_response.total,
308                        position: query_response.position,
309                        query_state: query_response.query_state,
310                        keep_alive,
311                    })),
312                    Err(err) => JmapCoroutineState::Complete(Err(
313                        JmapEmailSubmissionQueryError::ParseGetResponse(err),
314                    )),
315                }
316            }
317        }
318    }
319}
320
321enum State {
322    Send(JmapSend),
323}
324
325#[derive(Serialize)]
326#[serde(rename_all = "camelCase")]
327struct SubmissionQueryArgs<'a> {
328    account_id: &'a str,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    filter: Option<&'a JmapEmailSubmissionFilter>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    sort: Option<&'a [JmapEmailSubmissionComparator]>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    position: Option<u64>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    limit: Option<u64>,
337    calculate_total: bool,
338}
339
340#[derive(Serialize)]
341#[serde(rename_all = "camelCase")]
342struct SubmissionGetByRefArgs<'a> {
343    account_id: &'a str,
344    #[serde(rename = "#ids")]
345    ids_ref: JmapResultReference<'a>,
346}
347
348#[derive(Deserialize)]
349#[serde(rename_all = "camelCase")]
350struct SubmissionQueryResponse {
351    query_state: String,
352    #[serde(default)]
353    total: Option<u64>,
354    #[serde(default)]
355    position: u64,
356}
357
358#[derive(Deserialize)]
359#[serde(rename_all = "camelCase")]
360struct SubmissionGetResponse {
361    list: Vec<JmapEmailSubmission>,
362}