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::session::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 alloc::{string::String, vec, vec::Vec};
69
70use secrecy::SecretString;
71use serde::{Deserialize, Serialize};
72use thiserror::Error;
73
74use crate::{
75    coroutine::*,
76    jmap_try,
77    rfc8620::{
78        JMAP_CORE_CAPABILITY, error::JmapMethodError, filter::JmapFilter, request::JmapBatch,
79        request::JmapResultReference, send::*, session::JmapSession,
80    },
81    rfc8621::{
82        JMAP_MAIL_CAPABILITY,
83        email::{JmapEmail, JmapEmailProperty},
84    },
85};
86
87/// Sort property for `Email/query` (RFC 8621 §4.4).
88#[derive(Clone, Debug, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub enum JmapEmailSortProperty {
91    /// Sort by receive time.
92    ReceivedAt,
93    /// Sort by the `Date` header.
94    SentAt,
95    /// Sort by message size.
96    Size,
97    /// Sort by the first `From` address.
98    From,
99    /// Sort by the first `To` address.
100    To,
101    /// Sort by the base subject.
102    Subject,
103    /// Sort by attachment presence.
104    HasAttachment,
105    /// Sort by keyword presence on the email (requires `keyword` field).
106    Keyword,
107    /// Sort by whether all emails in the thread have a keyword
108    /// (requires `keyword` field).
109    AllInThreadHaveKeyword,
110    /// Sort by whether some emails in the thread have a keyword
111    /// (requires `keyword` field).
112    SomeInThreadHaveKeyword,
113}
114
115/// Filter condition for `Email/query` (RFC 8621 §4.4).
116#[derive(Clone, Debug, Default, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct JmapEmailFilter {
119    /// Only messages in this mailbox ID.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub in_mailbox: Option<String>,
122    /// Exclude messages in any of these mailbox IDs.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub in_mailbox_other_than: Option<Vec<String>>,
125    /// RFC 3339 upper bound.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub before: Option<String>,
128    /// RFC 3339 lower bound.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub after: Option<String>,
131    /// Only messages of at least this size, in bytes.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub min_size: Option<u64>,
134    /// Only messages strictly below this size, in bytes.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub max_size: Option<u64>,
137    /// Only threads where every email carries this keyword.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub all_in_thread_have_keyword: Option<String>,
140    /// Only threads where at least one email carries this keyword.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub some_in_thread_have_keyword: Option<String>,
143    /// Only threads where no email carries this keyword.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub none_in_thread_have_keyword: Option<String>,
146    /// Only messages carrying this keyword.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub has_keyword: Option<String>,
149    /// Only messages not carrying this keyword.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub not_keyword: Option<String>,
152    /// Only messages with (or without) attachments.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub has_attachment: Option<bool>,
155    /// Full-text search query.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub text: Option<String>,
158    /// Text search over the `From` header.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub from: Option<String>,
161    /// Text search over the `To` header.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub to: Option<String>,
164    /// Text search over the `Cc` header.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub cc: Option<String>,
167    /// Text search over the `Bcc` header.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub bcc: Option<String>,
170    /// Text search over the `Subject` header.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub subject: Option<String>,
173    /// Text search over the message body.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub body: Option<String>,
176}
177
178/// Comparator for `Email/query` sorting (RFC 8621 §4.4).
179#[derive(Clone, Debug, Serialize)]
180#[serde(rename_all = "camelCase")]
181pub struct JmapEmailComparator {
182    /// The property to sort by.
183    pub property: JmapEmailSortProperty,
184    /// Ascending if `None` or `Some(true)`.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub is_ascending: Option<bool>,
187    /// String comparison collation.
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub collation: Option<String>,
190    /// Required when `property` is `Keyword`, `AllInThreadHaveKeyword`, or
191    /// `SomeInThreadHaveKeyword`.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub keyword: Option<String>,
194}
195
196impl JmapEmailComparator {
197    /// Sort by `receivedAt` descending (newest first).
198    pub fn received_at_desc() -> Self {
199        Self {
200            property: JmapEmailSortProperty::ReceivedAt,
201            is_ascending: Some(false),
202            collation: None,
203            keyword: None,
204        }
205    }
206}
207
208/// Failure causes during a batched JMAP `Email/query` + `Email/get` flow.
209#[derive(Debug, Error)]
210pub enum JmapEmailQueryError {
211    /// The response carried no query response.
212    #[error("JMAP Email/query failed: missing Email/query response in method_responses")]
213    MissingQueryResponse,
214    /// The response carried no get response.
215    #[error("JMAP Email/query failed: missing Email/get response in method_responses")]
216    MissingGetResponse,
217    /// The inner send coroutine failed.
218    #[error("JMAP Email/query failed: {0}")]
219    Send(#[from] JmapSendError),
220    /// The query response could not be parsed.
221    #[error("JMAP Email/query failed: parse Email/query response: {0}")]
222    ParseQueryResponse(#[source] serde_json::Error),
223    /// The get response could not be parsed.
224    #[error("JMAP Email/query failed: parse Email/get response: {0}")]
225    ParseGetResponse(#[source] serde_json::Error),
226    /// The server returned a method-level error for the query call.
227    #[error("JMAP Email/query failed: Email/query: {0}")]
228    QueryMethod(JmapMethodError),
229    /// The server returned a method-level error for the get call.
230    #[error("JMAP Email/query failed: Email/get: {0}")]
231    GetMethod(JmapMethodError),
232}
233
234/// Options for [`JmapEmailQuery::new`].
235#[derive(Clone, Debug, Default)]
236pub struct JmapEmailQueryOptions {
237    /// Filter criteria; `None` matches all emails.
238    pub filter: Option<JmapFilter<JmapEmailFilter>>,
239    /// Sort order; `None` uses the server default.
240    pub sort: Option<Vec<JmapEmailComparator>>,
241    /// Zero-based offset into the result list.
242    pub position: Option<u64>,
243    /// Max number of emails to return.
244    pub limit: Option<u64>,
245    /// Email properties to fetch; `None` returns all.
246    pub properties: Option<Vec<JmapEmailProperty>>,
247}
248
249/// Successful terminal output of [`JmapEmailQuery`].
250#[derive(Clone, Debug)]
251pub struct JmapEmailQueryOutput {
252    /// The fetched emails.
253    pub emails: Vec<JmapEmail>,
254    /// The total number of matching objects, when the server computed it.
255    pub total: Option<u64>,
256    /// Zero-based index of the first returned id.
257    pub position: u64,
258    /// The state the query results were computed at.
259    pub query_state: String,
260    /// Whether the server indicated the connection can be reused.
261    pub keep_alive: bool,
262}
263
264/// I/O-free coroutine for the combined `Email/query` + `Email/get` operation.
265pub struct JmapEmailQuery {
266    state: State,
267}
268
269impl JmapEmailQuery {
270    /// Prepares the method call request and builds the coroutine.
271    pub fn new(
272        session: &JmapSession,
273        http_auth: &SecretString,
274        opts: JmapEmailQueryOptions,
275    ) -> Result<Self, JmapEmailQueryError> {
276        let account_id = session
277            .primary_accounts
278            .get(JMAP_MAIL_CAPABILITY)
279            .cloned()
280            .unwrap_or_default();
281        let api_url = &session.api_url;
282
283        let query_args = EmailQueryArgs {
284            account_id: &account_id,
285            filter: opts.filter.as_ref(),
286            sort: opts.sort.as_deref(),
287            position: opts.position,
288            limit: opts.limit,
289            calculate_total: true,
290        };
291
292        let mut batch = JmapBatch::new();
293        let query_id = batch.add(
294            "Email/query",
295            serde_json::to_value(&query_args).map_err(JmapEmailQueryError::ParseQueryResponse)?,
296        );
297
298        let get_args = EmailGetByRefArgs {
299            account_id: &account_id,
300            ids_ref: JmapResultReference {
301                result_of: &query_id,
302                name: "Email/query",
303                path: "/ids",
304            },
305            properties: opts.properties.as_deref(),
306        };
307
308        batch.add(
309            "Email/get",
310            serde_json::to_value(&get_args).map_err(JmapEmailQueryError::ParseQueryResponse)?,
311        );
312
313        let request = batch.into_request(vec![
314            JMAP_CORE_CAPABILITY.into(),
315            JMAP_MAIL_CAPABILITY.into(),
316        ]);
317
318        Ok(Self {
319            state: State::Send(JmapSend::new(http_auth, api_url, request)?),
320        })
321    }
322}
323
324impl JmapCoroutine for JmapEmailQuery {
325    type Yield = JmapYield;
326    type Return = Result<JmapEmailQueryOutput, JmapEmailQueryError>;
327
328    fn resume(&mut self, arg: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
329        match &mut self.state {
330            State::Send(send) => {
331                let JmapSendOutput {
332                    response,
333                    keep_alive,
334                } = jmap_try!(send, arg);
335
336                let mut responses = response.method_responses.into_iter();
337
338                let Some((query_name, query_args, _)) = responses.next() else {
339                    return JmapCoroutineState::Complete(Err(
340                        JmapEmailQueryError::MissingQueryResponse,
341                    ));
342                };
343
344                if query_name == "error" {
345                    let err = serde_json::from_value::<JmapMethodError>(query_args)
346                        .unwrap_or(JmapMethodError::Unknown);
347                    return JmapCoroutineState::Complete(Err(JmapEmailQueryError::QueryMethod(
348                        err,
349                    )));
350                }
351
352                let query_response = match serde_json::from_value::<EmailQueryResponse>(query_args)
353                {
354                    Ok(r) => r,
355                    Err(err) => {
356                        return JmapCoroutineState::Complete(Err(
357                            JmapEmailQueryError::ParseQueryResponse(err),
358                        ));
359                    }
360                };
361
362                let Some((get_name, get_args, _)) = responses.next() else {
363                    return JmapCoroutineState::Complete(Err(
364                        JmapEmailQueryError::MissingGetResponse,
365                    ));
366                };
367
368                if get_name == "error" {
369                    let err = serde_json::from_value::<JmapMethodError>(get_args)
370                        .unwrap_or(JmapMethodError::Unknown);
371                    return JmapCoroutineState::Complete(Err(JmapEmailQueryError::GetMethod(err)));
372                }
373
374                match serde_json::from_value::<EmailGetResponse>(get_args) {
375                    Ok(r) => JmapCoroutineState::Complete(Ok(JmapEmailQueryOutput {
376                        emails: r.list,
377                        total: query_response.total,
378                        position: query_response.position,
379                        query_state: query_response.query_state,
380                        keep_alive,
381                    })),
382                    Err(err) => JmapCoroutineState::Complete(Err(
383                        JmapEmailQueryError::ParseGetResponse(err),
384                    )),
385                }
386            }
387        }
388    }
389}
390
391enum State {
392    Send(JmapSend),
393}
394
395#[derive(Serialize)]
396#[serde(rename_all = "camelCase")]
397struct EmailQueryArgs<'a> {
398    account_id: &'a str,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    filter: Option<&'a JmapFilter<JmapEmailFilter>>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    sort: Option<&'a [JmapEmailComparator]>,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    position: Option<u64>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    limit: Option<u64>,
407    calculate_total: bool,
408}
409
410#[derive(Serialize)]
411#[serde(rename_all = "camelCase")]
412struct EmailGetByRefArgs<'a> {
413    account_id: &'a str,
414    #[serde(rename = "#ids")]
415    ids_ref: JmapResultReference<'a>,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    properties: Option<&'a [JmapEmailProperty]>,
418}
419
420#[derive(Deserialize)]
421#[serde(rename_all = "camelCase")]
422struct EmailQueryResponse {
423    query_state: String,
424    #[serde(default)]
425    total: Option<u64>,
426    #[serde(default)]
427    position: u64,
428}
429
430#[derive(Deserialize)]
431#[serde(rename_all = "camelCase")]
432struct EmailGetResponse {
433    list: Vec<JmapEmail>,
434}