jmap_mail_client/methods/search_snippet.rs
1//! JMAP Mail — SearchSnippet/get method implementation on SessionClient.
2//!
3//! SearchSnippet/get (RFC 8621 §5.1) is not a standard /get method: it takes
4//! `filter` and `emailIds` instead of a plain `ids` array, and the response
5//! shape differs (no `state` field, but does include `notFound`). We
6//! therefore return `serde_json::Value` and let the caller deserialize.
7//!
8//! Each method follows the standard five-step pattern:
9//! 1. Validate arguments (defence-in-depth empty-state guards).
10//! 2. Call `self.session_parts()?` → `(api_url, account_id)`.
11//! 3. Build args JSON with `serde_json::json!({…})`.
12//! 4. Call `build_request(method_name, args, USING_MAIL)`.
13//! 5. Call `self.call_internal(api_url, &req).await?`.
14//! 6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
15
16use jmap_types::Id;
17
18impl super::SessionClient {
19 /// Fetch SearchSnippet objects (RFC 8621 §5.1 — SearchSnippet/get).
20 ///
21 /// `filter` is the same filter object used in `Email/query`. `email_ids`
22 /// is the spec-defined list of Email ids to fetch snippets for; the
23 /// server returns one [`SearchSnippet`](jmap_mail_types::SearchSnippet)
24 /// per email in the result set.
25 ///
26 /// Callers wishing to fetch snippets for all emails in a set of threads
27 /// must resolve the thread membership first via `Thread/get`, then pass
28 /// the resulting email ids here — RFC 8621 §5.1 does not define a
29 /// `threadIds` argument on this method.
30 ///
31 /// Returns the raw response value because the SearchSnippet/get response
32 /// shape differs from the standard /get shape (no `state`).
33 /// Callers should deserialize into `Vec<jmap_mail_types::SearchSnippet>` via
34 /// `response["list"].as_array()`.
35 ///
36 /// The account is the one bound to this [`SessionClient`](super::SessionClient)
37 /// — there is no caller-supplied override (closed bd:JMAP-tjvm.30 for
38 /// consistency with every other `SessionClient` method, which all derive
39 /// `accountId` from the session unconditionally).
40 ///
41 /// # Errors
42 ///
43 /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
44 /// if the bound session has no primary account for
45 /// `urn:ietf:params:jmap:mail`.
46 /// - Any transport / protocol variant returned by
47 /// [`JmapClient::call`](jmap_base_client::JmapClient::call):
48 /// [`Http`](jmap_base_client::ClientError::Http),
49 /// [`Parse`](jmap_base_client::ClientError::Parse),
50 /// [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
51 /// [`MethodError`](jmap_base_client::ClientError::MethodError)
52 /// (wraps RFC 8620 §3.6.2 method-level errors such as
53 /// `accountNotFound`, `invalidArguments`, `serverFail`, and the
54 /// /query-shape errors `unsupportedFilter` / `unsupportedSort`
55 /// per RFC 8621 §5.1),
56 /// [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
57 /// [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
58 /// or
59 /// [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
60 pub async fn search_snippet_get(
61 &self,
62 filter: serde_json::Value,
63 email_ids: Option<&[Id]>,
64 ) -> Result<serde_json::Value, jmap_base_client::ClientError> {
65 let (api_url, account_id) = self.session_parts()?;
66 let mut args = serde_json::json!({
67 "accountId": account_id,
68 "filter": filter,
69 });
70 if let Some(eids) = email_ids {
71 args["emailIds"] =
72 serde_json::to_value(eids).expect("Id slice Serialize is infallible");
73 }
74 let req = super::build_request("SearchSnippet/get", args, super::USING_MAIL);
75 let resp = self.call_internal(api_url, &req).await?;
76 jmap_base_client::extract_response(&resp, super::CALL_ID)
77 }
78}
79
80// ---------------------------------------------------------------------------
81// Tests
82// ---------------------------------------------------------------------------
83
84#[cfg(test)]
85mod tests {
86 use serde_json::json;
87
88 // search_snippet_get_empty_email_id_returns_invalid_argument was deleted in
89 // JMAP-6by7.2 (typed-Id refactor): under `Option<&[Id]>` the empty-Id case
90 // becomes impossible to express through the typed API.
91
92 // Deleted in JMAP-tco1.5 as Pattern E (vacuous inline tests):
93 // - search_snippet_get_request_shape
94 // - search_snippet_get_with_thread_ids_request_shape
95 // Each hand-built `args = json!({...})` and fed it to `build_request`,
96 // never invoking the `search_snippet_get` production builder. Real
97 // production-path coverage for this method is provided by the wiremock
98 // tests in `tests/search_snippet_smoke_tests.rs`.
99 //
100 // The non-spec `thread_ids` parameter that those deleted tests covered
101 // was itself removed in JMAP-tjvm.6: RFC 8621 §5.1 defines only
102 // `emailIds` on SearchSnippet/get.
103 //
104 // `build_request`, `CALL_ID`, and `USING_MAIL` themselves have their
105 // own focused tests in `methods/mod.rs`.
106
107 /// Oracle: SearchSnippet response JSON deserializes into SearchSnippet list.
108 /// RFC 8621 §5.1 example response shape.
109 #[test]
110 fn search_snippet_response_deserializes() {
111 // SearchSnippet/get response uses "accountId" and "list" per RFC 8621 §5.1.
112 let list_json = json!([
113 {
114 "emailId": "e1",
115 "subject": "Hello <mark>world</mark>",
116 "preview": "This is a <mark>world</mark>-class message."
117 },
118 {
119 "emailId": "e2"
120 }
121 ]);
122 let snippets: Vec<jmap_mail_types::SearchSnippet> =
123 serde_json::from_value(list_json).expect("must deserialize snippet list");
124 assert_eq!(snippets.len(), 2);
125 assert_eq!(snippets[0].email_id.as_ref(), "e1");
126 assert!(snippets[0].subject.is_some());
127 assert!(snippets[1].subject.is_none());
128 }
129}