Skip to main content

io_jmap/rfc8621/email/
query.rs

1//! Batched JMAP `Email/query` + `Email/get` coroutine (RFC 8621 ยง4): a single
2//! HTTP request that runs `Email/query` to find matching ids and `Email/get`
3//! (via a Result Reference) to fetch their properties.
4//!
5//! Equivalent to IMAP's `SELECT` + `SEARCH` + `FETCH` but in one round trip;
6//! the result reference (`#ids`) keeps the two method calls linked server-side.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use std::{
12//!     io::{Read, Write},
13//!     net::TcpStream,
14//! };
15//!
16//! use io_jmap::{
17//!     coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
18//!     rfc8620::JmapSession,
19//!     rfc8621::email::query::{JmapEmailQuery, JmapEmailQueryOptions},
20//! };
21//! use secrecy::SecretString;
22//!
23//! // Ready stream needed (TCP-connected, TLS-negociated)
24//! let mut stream = TcpStream::connect("api.example.com:443").unwrap();
25//! let mut buf = [0u8; 4096];
26//!
27//! let session: JmapSession = serde_json::from_str(r#"{
28//!     "username": "",
29//!     "accounts": {},
30//!     "primaryAccounts": {"urn:ietf:params:jmap:mail": "a1"},
31//!     "capabilities": {},
32//!     "apiUrl": "https://api.example.com/jmap/",
33//!     "downloadUrl": "",
34//!     "uploadUrl": "",
35//!     "eventSourceUrl": "",
36//!     "state": ""
37//! }"#).unwrap();
38//! let auth = SecretString::from("Bearer xyz");
39//! let mut coroutine = JmapEmailQuery::new(
40//!     &session,
41//!     &auth,
42//!     JmapEmailQueryOptions {
43//!         position: Some(0),
44//!         limit: Some(20),
45//!         ..Default::default()
46//!     },
47//! )
48//! .unwrap();
49//! let mut arg = None;
50//!
51//! let out = loop {
52//!     match coroutine.resume(arg.take()) {
53//!         JmapCoroutineState::Yielded(JmapYield::WantsWrite(bytes)) => {
54//!             stream.write_all(&bytes).unwrap();
55//!         }
56//!         JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
57//!             let n = stream.read(&mut buf).unwrap();
58//!             arg = Some(&buf[..n]);
59//!         }
60//!         JmapCoroutineState::Complete(Ok(out)) => break out,
61//!         JmapCoroutineState::Complete(Err(err)) => panic!("{err}"),
62//!     }
63//! };
64//!
65//! println!("{} emails", out.emails.len());
66//! ```
67
68use core::fmt;
69
70use alloc::{string::String, vec, vec::Vec};
71
72use log::trace;
73use secrecy::SecretString;
74use serde::{Deserialize, Serialize};
75use thiserror::Error;
76
77use crate::{
78    coroutine::*,
79    jmap_try,
80    rfc8620::{
81        CORE_CAPABILITY, JmapBatch, JmapFilter, JmapMethodError, JmapResultReference, JmapSession,
82        send::*,
83    },
84    rfc8621::{
85        MAIL_CAPABILITY,
86        email::{JmapEmail, JmapEmailComparator, JmapEmailFilter, JmapEmailProperty},
87    },
88};
89
90/// Failure causes during a batched JMAP `Email/query` + `Email/get` flow.
91#[derive(Debug, Error)]
92pub enum JmapEmailQueryError {
93    #[error("JMAP Email/query failed: missing Email/query response in method_responses")]
94    MissingQueryResponse,
95    #[error("JMAP Email/query failed: missing Email/get response in method_responses")]
96    MissingGetResponse,
97    #[error("JMAP Email/query failed: {0}")]
98    Send(#[from] JmapSendError),
99    #[error("JMAP Email/query failed: parse Email/query response: {0}")]
100    ParseQueryResponse(#[source] serde_json::Error),
101    #[error("JMAP Email/query failed: parse Email/get response: {0}")]
102    ParseGetResponse(#[source] serde_json::Error),
103    #[error("JMAP Email/query failed: Email/query: {0}")]
104    QueryMethod(JmapMethodError),
105    #[error("JMAP Email/query failed: Email/get: {0}")]
106    GetMethod(JmapMethodError),
107}
108
109/// Options for [`JmapEmailQuery::new`].
110#[derive(Clone, Debug, Default)]
111pub struct JmapEmailQueryOptions {
112    /// Filter criteria; `None` matches all emails.
113    pub filter: Option<JmapFilter<JmapEmailFilter>>,
114    /// Sort order; `None` uses the server default.
115    pub sort: Option<Vec<JmapEmailComparator>>,
116    /// Zero-based offset into the result list.
117    pub position: Option<u64>,
118    /// Max number of emails to return.
119    pub limit: Option<u64>,
120    /// Email properties to fetch; `None` returns all.
121    pub properties: Option<Vec<JmapEmailProperty>>,
122}
123
124/// Successful terminal output of [`JmapEmailQuery`].
125#[derive(Clone, Debug)]
126pub struct JmapEmailQueryOutput {
127    pub emails: Vec<JmapEmail>,
128    pub total: Option<u64>,
129    pub position: u64,
130    pub query_state: String,
131    pub keep_alive: bool,
132}
133
134/// I/O-free coroutine for the combined `Email/query` + `Email/get` operation.
135pub struct JmapEmailQuery {
136    state: State,
137}
138
139impl JmapEmailQuery {
140    pub fn new(
141        session: &JmapSession,
142        http_auth: &SecretString,
143        opts: JmapEmailQueryOptions,
144    ) -> Result<Self, JmapEmailQueryError> {
145        let account_id = session
146            .primary_accounts
147            .get(MAIL_CAPABILITY)
148            .cloned()
149            .unwrap_or_default();
150        let api_url = &session.api_url;
151
152        let query_args = EmailQueryArgs {
153            account_id: &account_id,
154            filter: opts.filter.as_ref(),
155            sort: opts.sort.as_deref(),
156            position: opts.position,
157            limit: opts.limit,
158            calculate_total: true,
159        };
160
161        let mut batch = JmapBatch::new();
162        let query_id = batch.add(
163            "Email/query",
164            serde_json::to_value(&query_args).map_err(JmapEmailQueryError::ParseQueryResponse)?,
165        );
166
167        let get_args = EmailGetByRefArgs {
168            account_id: &account_id,
169            ids_ref: JmapResultReference {
170                result_of: &query_id,
171                name: "Email/query",
172                path: "/ids",
173            },
174            properties: opts.properties.as_deref(),
175        };
176
177        batch.add(
178            "Email/get",
179            serde_json::to_value(&get_args).map_err(JmapEmailQueryError::ParseQueryResponse)?,
180        );
181
182        let request = batch.into_request(vec![CORE_CAPABILITY.into(), MAIL_CAPABILITY.into()]);
183
184        Ok(Self {
185            state: State::Send(JmapSend::new(http_auth, api_url, request)?),
186        })
187    }
188}
189
190impl JmapCoroutine for JmapEmailQuery {
191    type Yield = JmapYield;
192    type Return = Result<JmapEmailQueryOutput, JmapEmailQueryError>;
193
194    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
195        trace!("Email/query: {}", self.state);
196        match &mut self.state {
197            State::Send(send) => {
198                let JmapSendOutput {
199                    response,
200                    keep_alive,
201                } = jmap_try!(send, arg);
202
203                let mut responses = response.method_responses.into_iter();
204
205                let Some((query_name, query_args, _)) = responses.next() else {
206                    return JmapCoroutineState::Complete(Err(
207                        JmapEmailQueryError::MissingQueryResponse,
208                    ));
209                };
210
211                if query_name == "error" {
212                    let err = serde_json::from_value::<JmapMethodError>(query_args)
213                        .unwrap_or(JmapMethodError::Unknown);
214                    return JmapCoroutineState::Complete(Err(JmapEmailQueryError::QueryMethod(
215                        err,
216                    )));
217                }
218
219                let query_response = match serde_json::from_value::<EmailQueryResponse>(query_args)
220                {
221                    Ok(r) => r,
222                    Err(err) => {
223                        return JmapCoroutineState::Complete(Err(
224                            JmapEmailQueryError::ParseQueryResponse(err),
225                        ));
226                    }
227                };
228
229                let Some((get_name, get_args, _)) = responses.next() else {
230                    return JmapCoroutineState::Complete(Err(
231                        JmapEmailQueryError::MissingGetResponse,
232                    ));
233                };
234
235                if get_name == "error" {
236                    let err = serde_json::from_value::<JmapMethodError>(get_args)
237                        .unwrap_or(JmapMethodError::Unknown);
238                    return JmapCoroutineState::Complete(Err(JmapEmailQueryError::GetMethod(err)));
239                }
240
241                match serde_json::from_value::<EmailGetResponse>(get_args) {
242                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapEmailQueryOutput {
243                        emails: r.list,
244                        total: query_response.total,
245                        position: query_response.position,
246                        query_state: query_response.query_state,
247                        keep_alive,
248                    })),
249                    Err(err) => JmapCoroutineState::Complete(Err(
250                        JmapEmailQueryError::ParseGetResponse(err),
251                    )),
252                }
253            }
254        }
255    }
256}
257
258enum State {
259    Send(JmapSend),
260}
261
262impl fmt::Display for State {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        match self {
265            Self::Send(_) => f.write_str("send"),
266        }
267    }
268}
269
270#[derive(Serialize)]
271#[serde(rename_all = "camelCase")]
272struct EmailQueryArgs<'a> {
273    account_id: &'a str,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    filter: Option<&'a JmapFilter<JmapEmailFilter>>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    sort: Option<&'a [JmapEmailComparator]>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    position: Option<u64>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    limit: Option<u64>,
282    calculate_total: bool,
283}
284
285#[derive(Serialize)]
286#[serde(rename_all = "camelCase")]
287struct EmailGetByRefArgs<'a> {
288    account_id: &'a str,
289    #[serde(rename = "#ids")]
290    ids_ref: JmapResultReference<'a>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    properties: Option<&'a [JmapEmailProperty]>,
293}
294
295#[derive(Deserialize)]
296#[serde(rename_all = "camelCase")]
297struct EmailQueryResponse {
298    query_state: String,
299    #[serde(default)]
300    total: Option<u64>,
301    #[serde(default)]
302    position: u64,
303}
304
305#[derive(Deserialize)]
306#[serde(rename_all = "camelCase")]
307struct EmailGetResponse {
308    list: Vec<JmapEmail>,
309}