Skip to main content

jmap_metadata_client/methods/
metadata.rs

1//! JMAP Metadata — Metadata/* method implementations on SessionClient
2//! (draft-ietf-jmap-metadata-01 §3).
3//!
4//! Each method follows the standard five-step pattern:
5//!   1. Validate arguments (defence-in-depth empty-state guards).
6//!   2. Call `self.session_parts()?` → `(api_url, account_id)`.
7//!   3. Build args JSON with `serde_json::json!({…})`.
8//!   4. Call `build_request(method_name, args, USING_METADATA)`.
9//!   5. Call `self.call_internal(api_url, &req).await?`.
10//!   6. Call `jmap_base_client::extract_response(&resp, CALL_ID)?`.
11
12use std::collections::HashMap;
13
14use jmap_types::{Id, PatchObject, State};
15
16use super::{
17    ChangesResponse, GetResponse, MetadataChangesParams, MetadataGetParams,
18    MetadataQueryChangesParams, MetadataQueryParams, MetadataSetParams, QueryChangesResponse,
19    QueryResponse, SetResponse,
20};
21
22/// Flatten extras from a params struct into the args object, preserving
23/// typed-field values on key collisions. Centralises the
24/// `Map::entry(...).or_insert(...)` pattern used by every method builder so
25/// the collision semantics (typed-field wins, extras lose silently) stay
26/// uniform across `Metadata/*`.
27///
28/// Expected `extras` size is small — vendor / site / private-extension args
29/// on a JMAP method call are typically zero (the common case) or a single-
30/// digit number of keys. The per-key hash lookup is O(1) average and the
31/// outer loop is bounded by the same small count; no allocation budget
32/// concern in practice. Callers that genuinely need to inject hundreds of
33/// extras keys per call should use the lower-level `build_request` directly.
34fn merge_extras(args: &mut serde_json::Value, extras: serde_json::Map<String, serde_json::Value>) {
35    let obj = args
36        .as_object_mut()
37        .expect("merge_extras: args is constructed as an Object");
38    for (k, v) in extras {
39        obj.entry(k).or_insert(v);
40    }
41}
42
43impl super::SessionClient {
44    /// Fetch Metadata objects by IDs (draft-ietf-jmap-metadata-01 §3.2).
45    ///
46    /// If `ids` is `None`, the server returns all Metadata objects for
47    /// the account, SUBJECT TO the server's `maxObjectsInGet` cap (RFC
48    /// 8620 §5.1). For production use, scope the result set via
49    /// [`Self::metadata_query`] first and pass explicit ids here to
50    /// avoid `requestTooLarge` errors when the account holds more
51    /// objects than the cap.
52    ///
53    /// Pass `properties: None` to return all fields. Pass
54    /// `params: Some(MetadataGetParams { extra: ... })` to inject
55    /// vendor / site / private method-level extension args; pass
56    /// `None` (or `Some(Default::default())`) for the standard wire
57    /// shape.
58    ///
59    /// # Errors
60    ///
61    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
62    ///   if the bound session has no primary account for
63    ///   `urn:ietf:params:jmap:metadata`.
64    /// - Any transport / protocol variant returned by
65    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call):
66    ///   [`Http`](jmap_base_client::ClientError::Http),
67    ///   [`Parse`](jmap_base_client::ClientError::Parse),
68    ///   [`AuthFailed`](jmap_base_client::ClientError::AuthFailed),
69    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
70    ///   (wraps RFC 8620 §3.6.2 method-level errors such as
71    ///   `accountNotFound`, `invalidArguments`, `serverFail`),
72    ///   [`MethodNotFound`](jmap_base_client::ClientError::MethodNotFound),
73    ///   [`ResponseTooLarge`](jmap_base_client::ClientError::ResponseTooLarge),
74    ///   or
75    ///   [`UnexpectedResponse`](jmap_base_client::ClientError::UnexpectedResponse).
76    pub async fn metadata_get(
77        &self,
78        ids: Option<&[Id]>,
79        properties: Option<&[&str]>,
80        params: Option<MetadataGetParams>,
81    ) -> Result<GetResponse<jmap_metadata_types::Metadata>, jmap_base_client::ClientError> {
82        let (api_url, account_id) = self.session_parts()?;
83        // Omit `ids` / `properties` entirely when None rather than sending
84        // an explicit JSON null. RFC 8620 §5.1 accepts both shapes, but the
85        // crate's other builders (set/changes/query) consistently use the
86        // conditional-add idiom; matching it here keeps the wire request
87        // canonical and avoids "present-but-null vs absent" interop quirks
88        // in proxies / audit loggers.
89        let mut args = serde_json::json!({ "accountId": account_id });
90        if let Some(id_slice) = ids {
91            args["ids"] = serde_json::to_value(id_slice).expect("Id slice Serialize is infallible");
92        }
93        if let Some(props) = properties {
94            args["properties"] =
95                serde_json::to_value(props).expect("&[&str] Serialize is infallible");
96        }
97        if let Some(p) = params {
98            merge_extras(&mut args, p.extra);
99        }
100        let req = super::build_request("Metadata/get", args, super::USING_METADATA);
101        let resp = self.call_internal(api_url, &req).await?;
102        jmap_base_client::extract_response(&resp, super::CALL_ID)
103    }
104
105    /// Fetch changes to Metadata objects since `since_state`
106    /// (draft-ietf-jmap-metadata-01 §3.3).
107    ///
108    /// If `has_more_changes` is true in the response, call again with
109    /// `new_state` as `since_state` until the flag is false.
110    ///
111    /// `params` carries the Metadata-specific optional filters
112    /// `filterRelatedType` and `filterMetadataType`. Pass `None` (or
113    /// `Some(Default::default())`) to fetch unfiltered changes. Per §3.3,
114    /// clients that use these filters MUST re-use the same filter values
115    /// across subsequent calls to ensure consistent synchronisation,
116    /// because the returned state token represents the complete account
117    /// state and is independent of the filter selection.
118    ///
119    /// # Errors
120    ///
121    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
122    ///   if `since_state` is the empty string (defence-in-depth — `State`
123    ///   constructed via [`State::from`](jmap_types::State::from) accepts
124    ///   empty strings, but an empty `sinceState` is never useful and
125    ///   would otherwise generate a wasted round-trip producing a
126    ///   server-side `cannotCalculateChanges`).
127    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
128    ///   if the bound session has no primary account for
129    ///   `urn:ietf:params:jmap:metadata`.
130    /// - Any transport / protocol variant returned by
131    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
132    ///   the matching error list on [`Self::metadata_get`].
133    pub async fn metadata_changes(
134        &self,
135        since_state: &State,
136        max_changes: Option<u64>,
137        params: Option<MetadataChangesParams>,
138    ) -> Result<ChangesResponse, jmap_base_client::ClientError> {
139        // Defence-in-depth: even with the typed-`State` parameter (a transparent
140        // newtype around `String`), an empty state token is still a logically
141        // invalid value that should be caught client-side rather than producing
142        // a confusing server-side `cannotCalculateChanges` error.
143        if since_state.as_ref().is_empty() {
144            return Err(jmap_base_client::ClientError::InvalidArgument(
145                "metadata_changes: since_state may not be empty".into(),
146            ));
147        }
148        let (api_url, account_id) = self.session_parts()?;
149        let mut args = serde_json::json!({
150            "accountId": account_id,
151            "sinceState": since_state,
152        });
153        if let Some(mc) = max_changes {
154            args["maxChanges"] = mc.into();
155        }
156        if let Some(p) = params {
157            if let Some(v) = p.filter_related_type {
158                args["filterRelatedType"] = v.into();
159            }
160            if let Some(v) = p.filter_metadata_type {
161                args["filterMetadataType"] =
162                    serde_json::to_value(&v).expect("Vec<String> Serialize is infallible");
163            }
164            // Vendor extras: flatten caller-supplied keys directly into the
165            // args object. Typed-field wire names (`filterRelatedType`,
166            // `filterMetadataType`, `accountId`, `sinceState`, `maxChanges`)
167            // win on collision — the helper uses `Map::entry(...).or_insert`.
168            merge_extras(&mut args, p.extra);
169        }
170        let req = super::build_request("Metadata/changes", args, super::USING_METADATA);
171        let resp = self.call_internal(api_url, &req).await?;
172        jmap_base_client::extract_response(&resp, super::CALL_ID)
173    }
174
175    /// Create, update, or destroy Metadata objects
176    /// (draft-ietf-jmap-metadata-01 §3.1).
177    ///
178    /// Pass `create`, `update`, and/or `destroy` as needed. All three are
179    /// optional; pass `None` to omit any operation from the request.
180    ///
181    /// `create` is `Option<serde_json::Value>` and MUST be a JSON object
182    /// whose keys are caller-chosen creation ids (RFC 8620 §5.3 — the
183    /// creation id strings may be referenced via the `#`-prefixed result-
184    /// reference syntax in RFC 8620 §3.7) and whose values are partial
185    /// Metadata objects per draft-ietf-jmap-metadata-01 §3.1 (one of the
186    /// three `@type` variants: `Annotation` per §2.1.1, `ImapMetadata`
187    /// per §2.1.2, `WebDavMetadata` per §2.1.3, each with at minimum
188    /// `relatedType`, `relatedId`, and an optional `isPrivate`). The
189    /// server assigns the final `id` and returns it under `created` in
190    /// the response. Wire-shape mismatches (e.g. passing an array or a
191    /// primitive) are rejected by the server as `invalidArguments` after
192    /// a network round-trip.
193    ///
194    /// `update` is `Option<HashMap<Id, PatchObject>>` (RFC 8620 §5.3). Wire
195    /// format is unchanged from a plain JSON object because [`PatchObject`]
196    /// is `#[serde(transparent)]`; the typed parameter binds the JSON Pointer
197    /// key + null-leaf removal contract to the type system.
198    ///
199    /// The asymmetry between typed `update` and untyped `create` is
200    /// deliberate: PatchObject is a stable workspace-defined type with one
201    /// JSON-Pointer-keyed shape, whereas a Metadata create object is one of
202    /// three `@type` variants and the spec accepts the full JSON wire
203    /// shape (including vendor-defined extras at every level). Forcing
204    /// `create` through a single Rust type would fight the spec's
205    /// extensibility model. Mirrors the canonical mail-client `email_set`
206    /// signature.
207    ///
208    /// Pass `if_in_state: Some(&state)` to use an optimistic-concurrency
209    /// guard (RFC 8620 §5.3): the server returns `stateMismatch` if the
210    /// current state does not match. Required for safe read-modify-write
211    /// given the `(relatedType, relatedId, @type, isPrivate)` uniqueness
212    /// invariant.
213    ///
214    /// Pass `params: Some(MetadataSetParams { extra: ... })` to inject
215    /// vendor / site / private method-level extension args; pass `None`
216    /// (or `Some(Default::default())`) for the standard wire shape.
217    ///
218    /// The server enforces uniqueness on the tuple
219    /// `(relatedType, relatedId, @type, isPrivate)` and may return
220    /// `alreadyExists`, `forbidden` (if `maySetPrivate: false` and
221    /// `isPrivate: true` is requested), `overQuota`, or
222    /// `invalidProperties` SetErrors per §3.1.
223    ///
224    /// # Errors
225    ///
226    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
227    ///   if all three of `create`, `update`, and `destroy` are `None`
228    ///   (an all-`None` /set is a no-op round-trip; `if_in_state` and
229    ///   `params` alone are not sufficient to make the request useful,
230    ///   so the client rejects it before sending).
231    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
232    ///   if `serde_json::to_value` fails when serializing the `update`
233    ///   map. In practice this can occur only under pathological
234    ///   conditions (allocation failure on a very large `HashMap`; a
235    ///   `PatchObject` whose internal JSON tree exceeds `serde_json`'s
236    ///   recursion limit). The size of `update` is otherwise bounded
237    ///   only by available memory; the wire request is buffered by the
238    ///   HTTP client (reqwest's `RequestBuilder::json` serializes the
239    ///   body upfront via `serde_json::to_vec`), so the transient peak
240    ///   holds the source `HashMap`, the intermediate `serde_json::Value`
241    ///   tree, and the serialized `Vec<u8>` body simultaneously —
242    ///   roughly 3-4× the `HashMap`'s in-memory size. Callers dealing
243    ///   with thousands of patches per call may prefer to batch across
244    ///   multiple `metadata_set` invocations to bound that transient
245    ///   peak.
246    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
247    ///   if the bound session has no primary account for
248    ///   `urn:ietf:params:jmap:metadata`.
249    /// - Any transport / protocol variant returned by
250    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
251    ///   the matching error list on [`Self::metadata_get`]. Per-creation
252    ///   /set failures (e.g. `alreadyExists`, `forbidden`, `overQuota`,
253    ///   `invalidProperties`) appear in
254    ///   [`SetResponse::not_created`](super::SetResponse::not_created) /
255    ///   [`SetResponse::not_updated`](super::SetResponse::not_updated) /
256    ///   [`SetResponse::not_destroyed`](super::SetResponse::not_destroyed)
257    ///   as [`SetError`](super::SetError) values, not as `Err`. A
258    ///   `stateMismatch` on `if_in_state` surfaces as
259    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
260    ///   with `error_type == "stateMismatch"`.
261    pub async fn metadata_set(
262        &self,
263        create: Option<serde_json::Value>,
264        update: Option<HashMap<Id, PatchObject>>,
265        destroy: Option<Vec<Id>>,
266        if_in_state: Option<&State>,
267        params: Option<MetadataSetParams>,
268    ) -> Result<SetResponse<jmap_metadata_types::Metadata>, jmap_base_client::ClientError> {
269        if create.is_none() && update.is_none() && destroy.is_none() {
270            return Err(jmap_base_client::ClientError::InvalidArgument(
271                "metadata_set: at least one of create, update, destroy must be Some \
272                 (an all-None /set is a no-op round-trip; if_in_state and params alone \
273                 are not sufficient)"
274                    .into(),
275            ));
276        }
277        let (api_url, account_id) = self.session_parts()?;
278        let mut args = serde_json::json!({
279            "accountId": account_id,
280        });
281        if let Some(s) = if_in_state {
282            args["ifInState"] = s.as_ref().into();
283        }
284        if let Some(c) = create {
285            args["create"] = c;
286        }
287        if let Some(u) = update {
288            args["update"] = serde_json::to_value(&u).map_err(|e| {
289                jmap_base_client::ClientError::InvalidArgument(format!(
290                    "metadata_set: serializing update map failed: {e}"
291                ))
292            })?;
293        }
294        if let Some(d) = destroy {
295            args["destroy"] = serde_json::to_value(&d).expect("Id Vec Serialize is infallible");
296        }
297        if let Some(p) = params {
298            merge_extras(&mut args, p.extra);
299        }
300        let req = super::build_request("Metadata/set", args, super::USING_METADATA);
301        let resp = self.call_internal(api_url, &req).await?;
302        jmap_base_client::extract_response(&resp, super::CALL_ID)
303    }
304
305    /// Query Metadata IDs with optional filter and sort
306    /// (draft-ietf-jmap-metadata-01 §3.4).
307    ///
308    /// Pass `filter: None` and `sort: None` to return all Metadata IDs with
309    /// server-default ordering. Use `position` and `limit` for pagination.
310    ///
311    /// The `filter` value is typed by the caller; the canonical type is
312    /// [`jmap_metadata_types::MetadataFilterCondition`] (§3.4.1) wrapped in
313    /// `jmap_types::query::Filter`. This builder accepts a `serde_json::Value`
314    /// so callers can mix filter conditions with `FilterOperator` algebra
315    /// without forcing a single type through the API.
316    ///
317    /// Construct a filter via `serde_json::to_value` on the typed shape, or
318    /// inline as a JSON literal:
319    ///
320    /// ```ignore
321    /// // Match Annotations on a specific Email.
322    /// let filter = serde_json::json!({
323    ///     "relatedType": "Email",
324    ///     "relatedId":   "EM456",
325    ///     "@type":       "Annotation"
326    /// });
327    /// // Match Annotations on either of two Emails (FilterOperator algebra).
328    /// let filter = serde_json::json!({
329    ///     "operator": "OR",
330    ///     "conditions": [
331    ///         { "relatedType": "Email", "relatedId": "EM456" },
332    ///         { "relatedType": "Email", "relatedId": "EM789" }
333    ///     ]
334    /// });
335    /// ```
336    ///
337    /// Sort comparators MUST set `property` to one of `id`, `@type`,
338    /// `relatedType`, `relatedId`, or `isPrivate` per §3.4.2; `id` MUST be
339    /// supported by every server, the others SHOULD be supported.
340    ///
341    /// Construct sort as an array of comparator objects:
342    ///
343    /// ```ignore
344    /// // Ascending by @type, then by id.
345    /// let sort = serde_json::json!([
346    ///     { "property": "@type", "isAscending": true },
347    ///     { "property": "id",    "isAscending": true }
348    /// ]);
349    /// ```
350    ///
351    /// Pass `params: Some(MetadataQueryParams { extra: ... })` to inject
352    /// vendor / site / private method-level extension args; pass `None`
353    /// (or `Some(Default::default())`) for the standard wire shape.
354    ///
355    /// # Errors
356    ///
357    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
358    ///   if the bound session has no primary account for
359    ///   `urn:ietf:params:jmap:metadata`.
360    /// - Any transport / protocol variant returned by
361    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
362    ///   the matching error list on [`Self::metadata_get`]. RFC 8620 §5.5
363    ///   defines additional method-level errors specific to /query
364    ///   (`anchorNotFound`, `unsupportedFilter`, `unsupportedSort`,
365    ///   `tooManyChanges`); they surface here as
366    ///   [`MethodError`](jmap_base_client::ClientError::MethodError)
367    ///   with the corresponding `error_type` string.
368    pub async fn metadata_query(
369        &self,
370        filter: Option<serde_json::Value>,
371        sort: Option<serde_json::Value>,
372        position: Option<u64>,
373        limit: Option<u64>,
374        params: Option<MetadataQueryParams>,
375    ) -> Result<QueryResponse, jmap_base_client::ClientError> {
376        let (api_url, account_id) = self.session_parts()?;
377        let mut args = serde_json::json!({
378            "accountId": account_id,
379        });
380        if let Some(f) = filter {
381            args["filter"] = f;
382        }
383        if let Some(s) = sort {
384            args["sort"] = s;
385        }
386        if let Some(p) = position {
387            args["position"] = p.into();
388        }
389        if let Some(l) = limit {
390            args["limit"] = l.into();
391        }
392        if let Some(p) = params {
393            merge_extras(&mut args, p.extra);
394        }
395        let req = super::build_request("Metadata/query", args, super::USING_METADATA);
396        let resp = self.call_internal(api_url, &req).await?;
397        jmap_base_client::extract_response(&resp, super::CALL_ID)
398    }
399
400    /// Fetch query-result changes for Metadata since `since_query_state`
401    /// (draft-ietf-jmap-metadata-01 §3.5).
402    ///
403    /// Returns which Metadata IDs were removed from or added to the query
404    /// result set since the given state. `max_changes` may be `None`.
405    ///
406    /// `filter` and `sort` MUST match the `filter` / `sort` passed to the
407    /// original `Metadata/query` call that returned `since_query_state` —
408    /// RFC 8620 §5.6 is explicit that the server uses them to compute
409    /// which entries entered or left the result set.
410    ///
411    /// `up_to_id` is the highest-index id the client has cached;
412    /// `calculate_total` requests the new total result count.
413    ///
414    /// Pass `params: Some(MetadataQueryChangesParams { extra: ... })` to
415    /// inject vendor / site / private method-level extension args; pass
416    /// `None` (or `Some(Default::default())`) for the standard wire shape.
417    ///
418    /// # Errors
419    ///
420    /// - [`ClientError::InvalidArgument`](jmap_base_client::ClientError::InvalidArgument)
421    ///   if `since_query_state` is the empty string (defence-in-depth
422    ///   empty-state guard; see [`Self::metadata_changes`]).
423    /// - [`ClientError::InvalidSession`](jmap_base_client::ClientError::InvalidSession)
424    ///   if the bound session has no primary account for
425    ///   `urn:ietf:params:jmap:metadata`.
426    /// - Any transport / protocol variant returned by
427    ///   [`JmapClient::call`](jmap_base_client::JmapClient::call) — see
428    ///   the matching error list on [`Self::metadata_get`]. RFC 8620 §5.6
429    ///   also defines `cannotCalculateChanges` (returned when the server
430    ///   cannot honour the request given the supplied filter / sort);
431    ///   it surfaces as
432    ///   [`MethodError`](jmap_base_client::ClientError::MethodError).
433    #[allow(clippy::too_many_arguments)] // RFC 8620 §5.6 + extras struct
434    pub async fn metadata_query_changes(
435        &self,
436        since_query_state: &State,
437        max_changes: Option<u64>,
438        filter: Option<serde_json::Value>,
439        sort: Option<serde_json::Value>,
440        up_to_id: Option<&Id>,
441        calculate_total: Option<bool>,
442        params: Option<MetadataQueryChangesParams>,
443    ) -> Result<QueryChangesResponse, jmap_base_client::ClientError> {
444        // Defence-in-depth: see `metadata_changes`.
445        if since_query_state.as_ref().is_empty() {
446            return Err(jmap_base_client::ClientError::InvalidArgument(
447                "metadata_query_changes: since_query_state may not be empty".into(),
448            ));
449        }
450        let (api_url, account_id) = self.session_parts()?;
451        let mut args = serde_json::json!({
452            "accountId": account_id,
453            "sinceQueryState": since_query_state,
454        });
455        if let Some(f) = filter {
456            args["filter"] = f;
457        }
458        if let Some(s) = sort {
459            args["sort"] = s;
460        }
461        if let Some(mc) = max_changes {
462            args["maxChanges"] = mc.into();
463        }
464        if let Some(uti) = up_to_id {
465            args["upToId"] = serde_json::to_value(uti).expect("Id Serialize is infallible");
466        }
467        if let Some(ct) = calculate_total {
468            args["calculateTotal"] = ct.into();
469        }
470        if let Some(p) = params {
471            merge_extras(&mut args, p.extra);
472        }
473        let req = super::build_request("Metadata/queryChanges", args, super::USING_METADATA);
474        let resp = self.call_internal(api_url, &req).await?;
475        jmap_base_client::extract_response(&resp, super::CALL_ID)
476    }
477}
478
479// ---------------------------------------------------------------------------
480// Tests
481// ---------------------------------------------------------------------------
482
483#[cfg(test)]
484mod tests {
485    use serde_json::json;
486
487    // Production-path coverage lives in tests/metadata_tests.rs
488    // (wiremock-backed end-to-end). The tests here exercise the typed
489    // wire-shape oracles for response types using JSON literals taken
490    // from draft-ietf-jmap-metadata-01 and RFC 8620.
491
492    /// Oracle: Annotation deserialization composed from
493    /// draft-ietf-jmap-metadata-01 §7.5 ("Creating an Email with Annotation
494    /// Atomically"). The `id`, `relatedType`, and `relatedId` fields are
495    /// verbatim from the server's `Metadata/set` response object at
496    /// draft §7.5 lines 1591-1597 (id=MD789, relatedType=Email,
497    /// relatedId=EM456). The `isPrivate: true` and
498    /// `acme.example.com:workflowState: "pending-review"` fields are
499    /// verbatim from the client's `onSuccessCreateMetadata` Annotation
500    /// object earlier in the same §7.5 example (lines 1540-1544). The
501    /// test merges the two into a single deserialization fixture for the
502    /// post-creation server view.
503    #[test]
504    fn annotation_deserializes_from_spec_example() {
505        let json = json!({
506            "@type": "Annotation",
507            "id": "MD789",
508            "relatedType": "Email",
509            "relatedId": "EM456",
510            "isPrivate": true,
511            "acme.example.com:workflowState": "pending-review"
512        });
513        let meta: jmap_metadata_types::Metadata =
514            serde_json::from_value(json).expect("Annotation must deserialize");
515        let jmap_metadata_types::Metadata::Annotation(a) = meta else {
516            panic!("expected Annotation variant");
517        };
518        assert_eq!(a.related_type.as_deref(), Some("Email"));
519        assert_eq!(a.related_id.as_ref(), "EM456");
520        assert_eq!(a.is_private, Some(true));
521        assert_eq!(
522            a.extra
523                .get("acme.example.com:workflowState")
524                .and_then(|v| v.as_str()),
525            Some("pending-review")
526        );
527    }
528
529    /// Oracle: GetResponse<Metadata> deserializes from RFC 8620 §5.1 shape.
530    #[test]
531    fn get_response_metadata_deserializes() {
532        use super::super::GetResponse;
533
534        let json = json!({
535            "accountId": "acc1",
536            "state": "s42",
537            "list": [
538                {
539                    "@type": "Annotation",
540                    "id": "MD1",
541                    "relatedType": "Mailbox",
542                    "relatedId": "MB1",
543                    "isPrivate": false,
544                    "acme.example.com:color": "blue"
545                }
546            ],
547            "notFound": []
548        });
549        let resp: GetResponse<jmap_metadata_types::Metadata> =
550            serde_json::from_value(json).expect("GetResponse<Metadata> must deserialize");
551        assert_eq!(resp.account_id, "acc1");
552        assert_eq!(resp.state, "s42");
553        assert_eq!(resp.list.len(), 1);
554        assert_eq!(resp.list[0].related_type(), Some("Mailbox"));
555    }
556
557    /// Oracle: SetResponse<Metadata> deserializes with a created entry.
558    #[test]
559    fn set_response_metadata_with_created_deserializes() {
560        use super::super::SetResponse;
561
562        let json = json!({
563            "accountId": "acc1",
564            "oldState": "s1",
565            "newState": "s2",
566            "created": {
567                "newMeta": {
568                    "@type": "Annotation",
569                    "id": "server-md-id",
570                    "relatedType": "Email",
571                    "relatedId": "EM1",
572                    "isPrivate": false,
573                    "acme.example.com:tag": "important"
574                }
575            },
576            "updated": null,
577            "destroyed": null,
578            "notCreated": null,
579            "notUpdated": null,
580            "notDestroyed": null
581        });
582        let resp: SetResponse<jmap_metadata_types::Metadata> =
583            serde_json::from_value(json).expect("SetResponse<Metadata> must deserialize");
584        assert_eq!(resp.new_state, "s2");
585        let created = resp.created.expect("created must be present");
586        assert!(
587            created.contains_key("newMeta"),
588            "created must contain 'newMeta' key"
589        );
590        let meta = &created["newMeta"];
591        assert_eq!(meta.id().map(|id| id.as_ref()), Some("server-md-id"));
592        assert_eq!(meta.related_type(), Some("Email"));
593    }
594}