Skip to main content

jmap_mail_client/methods/
email.rs

1//! JMAP Mail — Email/* method implementations on SessionClient.
2//!
3//! Each method follows the standard five-step pattern:
4//!   1. Validate arguments (defence-in-depth empty-state guards).
5//!   2. Call `self.session_parts()?` → `(api_url, account_id)`.
6//!   3. Build args JSON with `serde_json::json!({…})`.
7//!   4. Call `build_request(method_name, args, USING_MAIL)`.
8//!   5. Call `self.call_internal(api_url, &req).await?`.
9//!   6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
10
11use std::collections::HashMap;
12
13use jmap_types::{Id, PatchObject, State};
14
15use super::{
16    ChangesResponse, EmailCopyParams, EmailGetParams, EmailImportInput, EmailImportResponse,
17    EmailParseParams, EmailParseResponse, GetResponse, QueryChangesResponse, QueryResponse,
18    SetResponse,
19};
20
21impl super::SessionClient {
22    /// Fetch Email objects by IDs (RFC 8621 §4.1.8 — Email/get).
23    ///
24    /// If `ids` is `None`, the server returns all Emails for the account.
25    /// Pass `properties: None` to return all fields.
26    /// Pass `params: None` to use server defaults for body-fetch options.
27    pub async fn email_get(
28        &self,
29        ids: Option<&[Id]>,
30        properties: Option<&[&str]>,
31        params: Option<EmailGetParams>,
32    ) -> Result<GetResponse<jmap_mail_types::Email>, jmap_base_client::ClientError> {
33        let (api_url, account_id) = self.session_parts()?;
34        // Omit `ids` / `properties` entirely when None rather than sending
35        // an explicit JSON null. RFC 8620 §5.1 accepts both shapes, but the
36        // crate's other builders (set/changes/query) consistently use the
37        // conditional-add idiom; matching it here keeps the wire request
38        // canonical and avoids "present-but-null vs absent" interop quirks
39        // in proxies / audit loggers.
40        let mut args = serde_json::json!({ "accountId": account_id });
41        if let Some(id_slice) = ids {
42            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
43        }
44        if let Some(props) = properties {
45            args["properties"] = serde_json::Value::Array(
46                props.iter().copied().map(serde_json::Value::from).collect(),
47            );
48        }
49        if let Some(p) = params {
50            let pv = serde_json::to_value(p).map_err(|e| {
51                jmap_base_client::ClientError::InvalidArgument(format!(
52                    "email_get: failed to serialize params: {e}"
53                ))
54            })?;
55            if let serde_json::Value::Object(map) = pv {
56                for (k, v) in map {
57                    args[k] = v;
58                }
59            }
60        }
61        let req = super::build_request("Email/get", args, super::USING_MAIL);
62        let resp = self.call_internal(api_url, &req).await?;
63        jmap_base_client::extract_response(&resp, super::CALL_ID)
64    }
65
66    /// Fetch changes to Email objects since `since_state` (RFC 8621 §4.2 — Email/changes).
67    ///
68    /// If `has_more_changes` is true in the response, call again with `new_state`
69    /// as `since_state` until the flag is false.
70    pub async fn email_changes(
71        &self,
72        since_state: &State,
73        max_changes: Option<u64>,
74    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
75        // Defence-in-depth: see `thread_changes`.
76        if since_state.as_ref().is_empty() {
77            return Err(jmap_base_client::ClientError::InvalidArgument(
78                "email_changes: since_state may not be empty".into(),
79            ));
80        }
81        let (api_url, account_id) = self.session_parts()?;
82        let mut args = serde_json::json!({
83            "accountId": account_id,
84            "sinceState": since_state,
85        });
86        if let Some(mc) = max_changes {
87            args["maxChanges"] = mc.into();
88        }
89        let req = super::build_request("Email/changes", args, super::USING_MAIL);
90        let resp = self.call_internal(api_url, &req).await?;
91        jmap_base_client::extract_response(&resp, super::CALL_ID)
92    }
93
94    /// Create, update, or destroy Email objects (RFC 8621 §4.3 — Email/set).
95    ///
96    /// Pass `create`, `update`, and/or `destroy` as needed. All three are
97    /// optional; pass `None` to omit any operation from the request.
98    /// Pass `if_in_state: Some(&state)` to use an optimistic-concurrency guard.
99    ///
100    /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). Wire
101    /// format is unchanged from a plain JSON object because [`PatchObject`]
102    /// is `#[serde(transparent)]`; the typed parameter binds the JSON Pointer
103    /// key + null-leaf removal contract to the type system.
104    pub async fn email_set(
105        &self,
106        create: Option<serde_json::Value>,
107        update: Option<HashMap<Id, PatchObject>>,
108        destroy: Option<Vec<Id>>,
109        if_in_state: Option<&State>,
110    ) -> Result<SetResponse<jmap_mail_types::Email>, jmap_base_client::ClientError> {
111        let (api_url, account_id) = self.session_parts()?;
112        let mut args = serde_json::json!({
113            "accountId": account_id,
114        });
115        if let Some(s) = if_in_state {
116            args["ifInState"] = s.as_ref().into();
117        }
118        if let Some(c) = create {
119            args["create"] = c;
120        }
121        if let Some(u) = update {
122            args["update"] = serde_json::to_value(&u).map_err(|e| {
123                jmap_base_client::ClientError::InvalidArgument(format!(
124                    "email_set: serializing update map failed: {e}"
125                ))
126            })?;
127        }
128        if let Some(d) = destroy {
129            args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
130        }
131        let req = super::build_request("Email/set", args, super::USING_MAIL);
132        let resp = self.call_internal(api_url, &req).await?;
133        jmap_base_client::extract_response(&resp, super::CALL_ID)
134    }
135
136    /// Query Email IDs with optional filter and sort (RFC 8621 §4.4 — Email/query).
137    ///
138    /// Pass `filter: None` and `sort: None` to return all Emails with
139    /// server-default ordering. Use `position` and `limit` for pagination.
140    /// Pass `collapse_threads: Some(true)` to return at most one email per thread.
141    pub async fn email_query(
142        &self,
143        filter: Option<serde_json::Value>,
144        sort: Option<serde_json::Value>,
145        position: Option<u64>,
146        limit: Option<u64>,
147        collapse_threads: Option<bool>,
148    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
149        let (api_url, account_id) = self.session_parts()?;
150        let mut args = serde_json::json!({
151            "accountId": account_id,
152        });
153        if let Some(f) = filter {
154            args["filter"] = f;
155        }
156        if let Some(s) = sort {
157            args["sort"] = s;
158        }
159        if let Some(p) = position {
160            args["position"] = p.into();
161        }
162        if let Some(l) = limit {
163            args["limit"] = l.into();
164        }
165        if let Some(ct) = collapse_threads {
166            args["collapseThreads"] = ct.into();
167        }
168        let req = super::build_request("Email/query", args, super::USING_MAIL);
169        let resp = self.call_internal(api_url, &req).await?;
170        jmap_base_client::extract_response(&resp, super::CALL_ID)
171    }
172
173    /// Fetch query-result changes for Email since `since_query_state`
174    /// (RFC 8621 §4.5 — Email/queryChanges).
175    pub async fn email_query_changes(
176        &self,
177        since_query_state: &State,
178        max_changes: Option<u64>,
179        collapse_threads: Option<bool>,
180    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
181        // Defence-in-depth: see `thread_changes`.
182        if since_query_state.as_ref().is_empty() {
183            return Err(jmap_base_client::ClientError::InvalidArgument(
184                "email_query_changes: since_query_state may not be empty".into(),
185            ));
186        }
187        let (api_url, account_id) = self.session_parts()?;
188        let mut args = serde_json::json!({
189            "accountId": account_id,
190            "sinceQueryState": since_query_state,
191        });
192        if let Some(mc) = max_changes {
193            args["maxChanges"] = mc.into();
194        }
195        if let Some(ct) = collapse_threads {
196            args["collapseThreads"] = ct.into();
197        }
198        let req = super::build_request("Email/queryChanges", args, super::USING_MAIL);
199        let resp = self.call_internal(api_url, &req).await?;
200        jmap_base_client::extract_response(&resp, super::CALL_ID)
201    }
202
203    /// Copy Emails from another account (RFC 8621 §4.7 — Email/copy).
204    ///
205    /// `params` carries `fromAccountId` and optional destroy-after-copy flags.
206    /// `create` is a map of creation keys to partial Email objects (with new
207    /// mailboxIds etc.) as described in RFC 8621 §4.7.
208    pub async fn email_copy(
209        &self,
210        params: EmailCopyParams,
211        create: serde_json::Value,
212    ) -> Result<SetResponse<jmap_mail_types::Email>, jmap_base_client::ClientError> {
213        let (api_url, account_id) = self.session_parts()?;
214        let mut args = serde_json::json!({
215            "accountId": account_id,
216            "fromAccountId": params.from_account_id,
217            "create": create,
218        });
219        if let Some(v) = params.on_success_destroy_original {
220            args["onSuccessDestroyOriginal"] = v.into();
221        }
222        if let Some(v) = params.destroy_from_if_in_state {
223            args["destroyFromIfInState"] = v.as_ref().into();
224        }
225        let req = super::build_request("Email/copy", args, super::USING_MAIL);
226        let resp = self.call_internal(api_url, &req).await?;
227        jmap_base_client::extract_response(&resp, super::CALL_ID)
228    }
229
230    /// Import raw RFC 5322 messages into the account (RFC 8621 §4.8 — Email/import).
231    ///
232    /// Each entry in `emails` maps a caller-chosen creation id to an
233    /// [`EmailImportInput`] referencing a previously uploaded blob and the
234    /// target mailbox(es). The blob must have been uploaded via the standard
235    /// blob-upload mechanism on `jmap-base-client` before calling this method.
236    ///
237    /// At least one mailbox id is required per RFC 8621 §4.8; the empty-set
238    /// case is rejected client-side as `InvalidArgument`. An empty `emails`
239    /// map is also rejected — a no-op import is never useful and would
240    /// generate a round-trip with no successful creations.
241    ///
242    /// Pass `if_in_state: Some(&state)` for an optimistic-concurrency guard
243    /// against the Email object state (RFC 8621 §4.8 returns `stateMismatch`
244    /// if the supplied state does not match).
245    ///
246    /// Per-creation failures appear in [`EmailImportResponse::not_created`]
247    /// as [`super::SetError`] values; possible error codes include `alreadyExists`
248    /// (with an `existingId` extra field), `invalidProperties`, `overQuota`,
249    /// and `invalidEmail`.
250    pub async fn email_import(
251        &self,
252        emails: &HashMap<String, EmailImportInput<'_>>,
253        if_in_state: Option<&State>,
254    ) -> Result<EmailImportResponse, jmap_base_client::ClientError> {
255        if emails.is_empty() {
256            return Err(jmap_base_client::ClientError::InvalidArgument(
257                "email_import: emails map may not be empty".into(),
258            ));
259        }
260        for (key, input) in emails {
261            if input.mailbox_ids.is_empty() {
262                return Err(jmap_base_client::ClientError::InvalidArgument(format!(
263                    "email_import: mailboxIds for creation id '{key}' may not be empty (RFC 8621 §4.8)"
264                )));
265            }
266        }
267        let (api_url, account_id) = self.session_parts()?;
268        let emails_value = serde_json::to_value(emails).map_err(|e| {
269            jmap_base_client::ClientError::InvalidArgument(format!(
270                "email_import: serializing emails map failed: {e}"
271            ))
272        })?;
273        let mut args = serde_json::json!({
274            "accountId": account_id,
275            "emails": emails_value,
276        });
277        if let Some(s) = if_in_state {
278            args["ifInState"] = s.as_ref().into();
279        }
280        let req = super::build_request("Email/import", args, super::USING_MAIL);
281        let resp = self.call_internal(api_url, &req).await?;
282        jmap_base_client::extract_response(&resp, super::CALL_ID)
283    }
284
285    /// Parse uploaded blobs as RFC 5322 messages without importing them
286    /// (RFC 8621 §4.9 — Email/parse).
287    ///
288    /// Returns Email objects derived from each blob. Per RFC 8621 §4.9 the
289    /// returned Emails have `id`, `mailboxIds`, `keywords`, and `receivedAt`
290    /// set to `null` (the messages are not in the mail store); `threadId`
291    /// MAY be present if the server can predict the assignment.
292    ///
293    /// Pass `params: None` to use server defaults for properties and body
294    /// fetching. The set of properties returned defaults to the spec-listed
295    /// header/body fields documented in RFC 8621 §4.9.
296    ///
297    /// Empty `blob_ids` is rejected as `InvalidArgument` — a no-op parse
298    /// round-trip is never useful.
299    pub async fn email_parse(
300        &self,
301        blob_ids: &[Id],
302        params: Option<EmailParseParams>,
303    ) -> Result<EmailParseResponse, jmap_base_client::ClientError> {
304        if blob_ids.is_empty() {
305            return Err(jmap_base_client::ClientError::InvalidArgument(
306                "email_parse: blob_ids may not be empty".into(),
307            ));
308        }
309        let (api_url, account_id) = self.session_parts()?;
310        let mut args = serde_json::json!({
311            "accountId": account_id,
312            "blobIds": blob_ids,
313        });
314        if let Some(p) = params {
315            let pv = serde_json::to_value(&p).map_err(|e| {
316                jmap_base_client::ClientError::InvalidArgument(format!(
317                    "email_parse: failed to serialize params: {e}"
318                ))
319            })?;
320            if let serde_json::Value::Object(map) = pv {
321                for (k, v) in map {
322                    args[k] = v;
323                }
324            }
325        }
326        let req = super::build_request("Email/parse", args, super::USING_MAIL);
327        let resp = self.call_internal(api_url, &req).await?;
328        jmap_base_client::extract_response(&resp, super::CALL_ID)
329    }
330}
331
332// ---------------------------------------------------------------------------
333// Tests
334// ---------------------------------------------------------------------------
335
336#[cfg(test)]
337mod tests {
338    use serde_json::json;
339
340    // email_get_empty_id_returns_invalid_argument was deleted in JMAP-6by7.2
341    // (typed-Id refactor): under `Option<&[Id]>` the empty-Id case becomes
342    // impossible to express through the typed API.
343
344    // The InvalidArgument guards for empty since_state and since_query_state
345    // live in email_changes / email_query_changes production code; testing them
346    // requires a wiremock-backed async harness. See JMAP-sc1b.64.
347
348    // Deleted in JMAP-tco1.5 as Pattern E (vacuous inline tests):
349    //   - email_get_request_shape
350    //   - email_changes_request_includes_since_state
351    //   - email_set_destroy_request_shape
352    //   - email_copy_request_shape
353    //   - email_query_request_shape
354    // Each hand-built `args = json!({...})` and fed it to `build_request`,
355    // never invoking the `email_get` / `email_changes` / `email_set` /
356    // `email_copy` / `email_query` production builders. Real production-path
357    // coverage for these methods is tracked as a wiremock-smoke gap under
358    // JMAP-uuoi (no `tests/email_*.rs` smoke files exist yet).
359    //
360    // `build_request`, `CALL_ID`, and `USING_MAIL` themselves have their
361    // own focused tests in `methods/mod.rs`.
362
363    /// Oracle: Email deserialization from RFC 8621 §4 example JSON subset.
364    /// Only fields present in the fixture are checked; Email has many optional fields.
365    #[test]
366    fn email_get_response_deserializes() {
367        let json = json!({
368            "accountId": "acc1",
369            "state": "s10",
370            "list": [
371                {
372                    "id": "e1",
373                    "blobId": "b1",
374                    "threadId": "t1",
375                    "mailboxIds": { "mb1": true },
376                    "keywords": { "$seen": true },
377                    "size": 1024,
378                    "receivedAt": "2024-01-01T00:00:00Z"
379                }
380            ],
381            "notFound": []
382        });
383        use super::super::GetResponse;
384        let resp: GetResponse<jmap_mail_types::Email> =
385            serde_json::from_value(json).expect("must deserialize Email GetResponse");
386        assert_eq!(resp.list.len(), 1);
387        assert_eq!(resp.list[0].id.as_ref(), "e1");
388    }
389}