jmap_mail_types/snippet.rs
1//! RFC 8621 §5 SearchSnippet object.
2//!
3//! Provides [`SearchSnippet`] — a highlighted text excerpt returned by
4//! `SearchSnippet/get` for each matched [`crate::Email`] — and
5//! [`SearchSnippetGetResponse`], the `SearchSnippet/get` response.
6
7use jmap_types::Id;
8use serde::{Deserialize, Serialize};
9
10/// A search result snippet for one Email (RFC 8621 §5).
11///
12/// Returned by `SearchSnippet/get`. Note that `SearchSnippet` has **no `id`
13/// field** — it is identified by `email_id` instead.
14///
15/// The `subject` and `preview` fields are `None` when the server could not
16/// determine a snippet for that Email. Per RFC 8620 §5.1, absent and `null`
17/// are semantically equivalent for `|null` properties, so these fields are
18/// omitted from serialized JSON (rather than emitted as `null`) when `None`.
19#[non_exhaustive]
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct SearchSnippet {
23 /// The Email id the snippet applies to.
24 pub email_id: Id,
25 /// Subject of the Email with matching words wrapped in `<mark>` tags, or
26 /// `null` if the subject did not match the filter.
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub subject: Option<String>,
29 /// Relevant section of the body with matching words wrapped in `<mark>`
30 /// tags (max 255 octets), or `null` if the body did not match the filter.
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub preview: Option<String>,
33 /// Catch-all for vendor / site / private extension fields not covered
34 /// by the typed fields above. Preserves unknown fields across
35 /// deserialize/serialize round-trip per workspace extras-preservation
36 /// policy (see workspace AGENTS.md).
37 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
38 pub extra: serde_json::Map<String, serde_json::Value>,
39}
40
41impl SearchSnippet {
42 /// Construct a [`SearchSnippet`] with no subject or preview match.
43 pub fn new(email_id: Id) -> Self {
44 Self {
45 email_id,
46 subject: None,
47 preview: None,
48 extra: serde_json::Map::new(),
49 }
50 }
51}
52
53/// RFC 8621 §5.1 — `SearchSnippet/get` response.
54///
55/// Not a standard RFC 8620 §5.1 `/get` response: there is no `state`, and
56/// `notFound` is `Id[]|null`.
57#[non_exhaustive]
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct SearchSnippetGetResponse {
61 /// The account used for the call.
62 pub account_id: Id,
63 /// One snippet per requested Email id, in no particular order.
64 pub list: Vec<SearchSnippet>,
65 /// Requested Email ids the server could not find, or `None` if all
66 /// were found.
67 pub not_found: Option<Vec<Id>>,
68 /// Catch-all for vendor / site / private extension fields not covered
69 /// by the typed fields above. Preserves unknown fields across
70 /// deserialize/serialize round-trip per workspace extras-preservation
71 /// policy (see workspace AGENTS.md).
72 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
73 pub extra: serde_json::Map<String, serde_json::Value>,
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use serde_json::json;
80
81 // ── Extras-preservation policy tests (JMAP-lbdy.2) ───────────────────
82
83 /// `SearchSnippet.extra` captures vendor fields and preserves them across
84 /// deserialize/serialize round-trip.
85 #[test]
86 fn search_snippet_preserves_vendor_extras() {
87 let raw = json!({
88 "emailId": "e1",
89 "subject": "Hello <mark>world</mark>",
90 "preview": "...the <mark>world</mark>...",
91 "acmeCorpRelevanceScore": 0.87
92 });
93 let snip: SearchSnippet = serde_json::from_value(raw).unwrap();
94 assert_eq!(
95 snip.extra
96 .get("acmeCorpRelevanceScore")
97 .and_then(|v| v.as_f64()),
98 Some(0.87)
99 );
100 let back = serde_json::to_value(&snip).unwrap();
101 assert_eq!(back["acmeCorpRelevanceScore"], 0.87);
102 }
103
104 /// Oracle: the RFC 8621 §5.2 example response (page 71), with the
105 /// line break inside the second preview joined and the third snippet,
106 /// which the RFC cuts off with `...`, left out.
107 #[test]
108 fn search_snippet_get_response_parses_rfc8621_example() {
109 let raw = json!({
110 "accountId": "ue150411c",
111 "list": [{
112 "emailId": "M44200ec123de277c0c1ce69c",
113 "subject": null,
114 "preview": null
115 }, {
116 "emailId": "M7bcbcb0b58d7729686e83d99",
117 "subject": "The <mark>Foo</mark>sball competition",
118 "preview": "...year the <mark>foo</mark>sball competition will be held in the Stadium de ..."
119 }],
120 "notFound": null
121 });
122 let resp: SearchSnippetGetResponse = serde_json::from_value(raw).unwrap();
123 assert_eq!(resp.account_id.as_ref(), "ue150411c");
124 assert_eq!(resp.list.len(), 2);
125 assert_eq!(resp.list[0].subject, None);
126 assert_eq!(
127 resp.list[1].subject.as_deref(),
128 Some("The <mark>Foo</mark>sball competition")
129 );
130 assert_eq!(resp.not_found, None);
131 assert!(resp.extra.is_empty());
132 }
133
134 /// RFC 8621 §5.1: `notFound` lists requested ids that were not found;
135 /// unknown response properties land in `extra`.
136 #[test]
137 fn search_snippet_get_response_not_found_and_extras() {
138 let raw = json!({
139 "accountId": "A1",
140 "list": [],
141 "notFound": ["M9"],
142 "filter": { "text": "foo" }
143 });
144 let resp: SearchSnippetGetResponse = serde_json::from_value(raw.clone()).unwrap();
145 assert_eq!(resp.not_found, Some(vec![Id::from("M9")]));
146 assert_eq!(resp.extra["filter"], json!({ "text": "foo" }));
147 assert_eq!(serde_json::to_value(&resp).unwrap(), raw);
148 }
149}