Skip to main content

jmap_types/
methods.rs

1//! RFC 8620 §5 generic method-response shapes shared by all JMAP method types.
2//!
3//! These wire types are normative — every JMAP `/get`, `/set`, `/changes`,
4//! `/copy`, `/query`, and `/queryChanges` method (across mail, calendars, contacts,
5//! chat, etc.) returns one of these shapes. Centralising them here avoids
6//! drift between the seven `jmap-*-client` crates that previously each
7//! defined their own copies. [`SetResponse`] carries [`SetError`] values,
8//! defined in `set_error`.
9//!
10//! All types use camelCase JSON via `#[serde(rename_all = "camelCase")]` and
11//! are marked `#[non_exhaustive]` so future RFC errata or extensions can add
12//! fields without a SemVer break.
13//!
14//! # Spec references
15//!
16//! | Type | Spec |
17//! |---|---|
18//! | [`GetResponse`] | RFC 8620 §5.1 |
19//! | [`ChangesResponse`] | RFC 8620 §5.2 |
20//! | [`SetResponse`], [`PartialObject`] | RFC 8620 §5.3 |
21//! | [`CopyResponse`] | RFC 8620 §5.4 |
22//! | [`QueryResponse`] | RFC 8620 §5.5 |
23//! | [`QueryChangesResponse`], [`AddedItem`] | RFC 8620 §5.6 |
24
25use std::collections::BTreeMap;
26
27use serde::{Deserialize, Serialize};
28
29use crate::{Id, SetError, State};
30
31// ---------------------------------------------------------------------------
32// /get
33// ---------------------------------------------------------------------------
34
35/// RFC 8620 §5.1 — `Foo/get` response shape.
36///
37/// `T` is the type of object being fetched (e.g. `Mailbox`, `CalendarEvent`).
38/// `state` is the opaque state token the server returns alongside the result;
39/// it advances every time any object of the requested type changes in the
40/// account. `not_found` lists ids the client requested that the server could
41/// not find — `null` is treated as an empty list per RFC 8620 §5.1.
42#[non_exhaustive]
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct GetResponse<T> {
46    /// The account the response refers to.
47    pub account_id: Id,
48    /// Opaque state token for this object type at the time of the response.
49    pub state: State,
50    /// The fetched objects, one per id that was found.
51    pub list: Vec<T>,
52    /// Ids that were requested but not found (RFC 8620 §5.1 `notFound:
53    /// Id[]`). A peer that sends `null` or omits it decodes as empty.
54    #[serde(default, deserialize_with = "null_as_empty")]
55    pub not_found: Vec<Id>,
56    /// Catch-all for vendor / site / private extension fields not covered
57    /// by the typed fields above. Preserves unknown fields across
58    /// deserialize/serialize round-trip per workspace extras-preservation
59    /// policy (see workspace AGENTS.md).
60    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
61    pub extra: serde_json::Map<String, serde_json::Value>,
62}
63
64/// Deserialize a `null` collection as empty: for fields RFC 8620 types as
65/// a plain array that lenient peers still send as `null`, and for the
66/// `/set` and `/copy` result fields it defines as "null if none".
67fn null_as_empty<'de, D, C>(d: D) -> Result<C, D::Error>
68where
69    D: serde::Deserializer<'de>,
70    C: Deserialize<'de> + Default,
71{
72    Ok(Option::<C>::deserialize(d)?.unwrap_or_default())
73}
74
75/// Serialize an empty collection as `null`, the RFC 8620 §5.3 / §5.4 wire
76/// form for a `/set` or `/copy` result field with no entries.
77fn empty_as_null<S, C>(c: &C, s: S) -> Result<S::Ok, S::Error>
78where
79    S: serde::Serializer,
80    C: Serialize,
81    for<'a> &'a C: IntoIterator,
82{
83    if c.into_iter().next().is_none() {
84        s.serialize_none()
85    } else {
86        s.serialize_some(c)
87    }
88}
89
90// ---------------------------------------------------------------------------
91// /changes
92// ---------------------------------------------------------------------------
93
94/// RFC 8620 §5.2 — `Foo/changes` response shape.
95///
96/// Reports the ids of objects created, updated, or destroyed since
97/// `old_state`. If `has_more_changes` is `true`, the client should call
98/// `/changes` again with `since_state = new_state` to retrieve the next
99/// page; otherwise `new_state` is the current state.
100///
101/// # Extension fields and the foundation-coupling trade-off
102///
103/// Some JMAP data-type extensions add an `updatedProperties` field to
104/// their `/changes` response shape:
105///
106/// - RFC 8621 §2.2 (`Mailbox/changes`): set when only `totalEmails` /
107///   `unreadEmails` / `totalThreads` / `unreadThreads` changed.
108/// - RFC 9425 §5 (`Quota/changes`): set when only the `used` property
109///   changed.
110///
111/// - draft-ietf-jmap-metadata-04 §3.3: every data type the metadata
112///   capability lists, when the request's `using` includes
113///   `urn:ietf:params:jmap:metadata`. The field is then always present,
114///   `null` unless every updated id changed only in `metadata` and/or
115///   `privateMetadata` (plus the Mailbox counts).
116///
117/// For all other `/changes` methods (RFC 8621 §3.2 `Thread/changes`,
118/// §4.3 `Email/changes`, plus every extension `/changes` method not
119/// listed above) the server omits the field, and clients deserialize
120/// it as `None`.
121///
122/// **Architectural decision (bd:JMAP-6xs8.5).** Carrying
123/// `updatedProperties` on this base type, rather than on per-extension
124/// `MailboxChangesResponse` / `QuotaChangesResponse` newtypes, is a
125/// deliberate workspace trade-off:
126///
127/// - **Chosen shape**: one foundation `ChangesResponse` with an
128///   `Option<Option<Vec<String>>>` field that extensions populate.
129/// - **Alternative (a)**: `ChangesResponse<Ext = ()>` generic with a
130///   per-type extension struct. Rejected: would force every
131///   `/changes` handler in every extension server to thread the `Ext`
132///   type parameter, and the 30-crate canonical-template family would
133///   have to settle on a single way to express "no extension" vs
134///   "Mailbox extension" vs "Quota extension".
135/// - **Alternative (b)**: `MailboxChangesResponse` and
136///   `QuotaChangesResponse` as separate types in `jmap-mail-types` and
137///   a future `jmap-quota-types`, delegating to a shared base via
138///   composition. Rejected: would duplicate the seven base fields
139///   (`accountId`, `oldState`, `newState`, `hasMoreChanges`, three
140///   id vectors) at every extension type, and require parallel
141///   handler / parse / dispatch code.
142/// - **Alternative (c)**: leave `updatedProperties` as an extras
143///   flatten entry that mail/quota types explicitly read via
144///   `resp.extra.get("updatedProperties")`. Rejected: loses typed
145///   access, and the field is RFC-defined (not vendor / site), so the
146///   extras pattern (workspace AGENTS.md "Extras-preservation policy")
147///   is the wrong tool.
148///
149/// **Drift risk acknowledged**: every future `/changes` extension
150/// (`jmap-sharing`, `jmap-tasks`, etc.) will face pressure to add its
151/// own typed field here "for parity". Each such field would extend
152/// this foundation type by one more extension-specific column.
153/// Cautionary precedent: bd:JMAP-kt5k removed eight cap-advertising
154/// fields from the chat capability after they accreted there for the
155/// same parity-pressure reason. New `/changes` extension fields land
156/// here only when the field is RFC-defined and the alternative shapes
157/// above have been re-evaluated against the new use case; a vendor
158/// extension belongs in the extras catch-all, not on the typed
159/// surface.
160#[non_exhaustive]
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(rename_all = "camelCase")]
163pub struct ChangesResponse {
164    /// The account the response refers to.
165    pub account_id: Id,
166    /// The state token the client passed in.
167    pub old_state: State,
168    /// The current (or next-page) state token.
169    pub new_state: State,
170    /// `true` if there are more changes the client must page through.
171    pub has_more_changes: bool,
172    /// Ids of objects created since `old_state`.
173    pub created: Vec<Id>,
174    /// Ids of objects updated since `old_state`.
175    pub updated: Vec<Id>,
176    /// Ids of objects destroyed since `old_state`.
177    pub destroyed: Vec<Id>,
178    /// Property names that changed (RFC 8621 §2.2, RFC 9425 §5,
179    /// draft-ietf-jmap-metadata-04 §3.3), in three states: `None` when
180    /// absent, `Some(None)` for `null` and `Some(Some(names))` for a
181    /// list. Each state round-trips.
182    ///
183    /// `Mailbox/changes` and `Quota/changes` may list the properties
184    /// that changed, and clients can back-reference
185    /// `/updatedProperties` into a follow-up `/get`. When the request's
186    /// `using` includes `urn:ietf:params:jmap:metadata`, -04 §3.3 says
187    /// for every listed data type that the argument "is always present,
188    /// and is null when updated is empty", so a server sets `Some(None)`
189    /// rather than `None` there. Other responses leave it `None` unless
190    /// their type defines it, and it is then omitted.
191    #[serde(
192        default,
193        deserialize_with = "present",
194        skip_serializing_if = "Option::is_none"
195    )]
196    pub updated_properties: Option<Option<Vec<String>>>,
197    /// Catch-all for vendor / site / private extension fields not covered
198    /// by the typed fields above. Preserves unknown fields across
199    /// deserialize/serialize round-trip per workspace extras-preservation
200    /// policy (see workspace AGENTS.md).
201    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
202    pub extra: serde_json::Map<String, serde_json::Value>,
203}
204
205/// Deserialize a property that may be absent, `null` or a value into
206/// `Option<Option<T>>`: absent is `None` (via `#[serde(default)]`), `null`
207/// is `Some(None)` and a value is `Some(Some(v))`.
208fn present<'de, D, T>(d: D) -> Result<Option<Option<T>>, D::Error>
209where
210    D: serde::Deserializer<'de>,
211    T: Deserialize<'de>,
212{
213    Option::<T>::deserialize(d).map(Some)
214}
215
216// ---------------------------------------------------------------------------
217// /set
218// ---------------------------------------------------------------------------
219
220/// RFC 8620 §5.3 — `Foo/set` response shape.
221///
222/// Wire shape per RFC 8620 §5.3 (rfc8620.txt §5.3 around line 2033):
223///
224/// ```text
225/// created       Id[Foo]      | null
226/// updated       Id[Foo|null] | null   ← inner null is REQUIRED
227/// destroyed     Id[]         | null
228/// notCreated    Id[SetError] | null
229/// notUpdated    Id[SetError] | null
230/// notDestroyed  Id[SetError] | null
231/// ```
232///
233/// The inner `null` in `updated` is the server's signal that the patch was
234/// applied verbatim with no server-set property deltas to report; a typed
235/// `SetResponse<Foo>` MUST accept this rather than failing because `null`
236/// cannot become `Foo`.
237///
238/// `created` and `not_created` keys are caller-supplied creation ids
239/// (`String`); `updated`, `not_updated`, `not_destroyed` keys are
240/// server-assigned record ids ([`Id`]) — typed differently so callers can
241/// use `updated`/`destroyed` keys interchangeably with ids from any
242/// `/get` response.
243///
244/// The result maps and lists are plain collections, not `Option`s: RFC 8620
245/// defines each as "null if none", so an empty collection means none. A
246/// `null` or absent field decodes as empty and an empty one encodes as
247/// `null`.
248#[non_exhaustive]
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(rename_all = "camelCase")]
251#[serde(bound(
252    deserialize = "T: serde::de::DeserializeOwned",
253    serialize = "T: Serialize"
254))]
255pub struct SetResponse<T> {
256    /// The account the response refers to.
257    pub account_id: Id,
258    /// State token before this `/set` was applied. Optional because some
259    /// servers omit it on no-op responses (per RFC 8620 §5.3 the field is
260    /// nullable).
261    pub old_state: Option<State>,
262    /// State token after this `/set`.
263    pub new_state: State,
264    /// Successfully created objects, keyed by caller-supplied creation id.
265    #[serde(
266        default,
267        deserialize_with = "null_as_empty",
268        serialize_with = "empty_as_null"
269    )]
270    pub created: BTreeMap<String, T>,
271    /// Successfully updated objects, keyed by record id. The value is
272    /// `Some(T)` when the server reports server-set property deltas, or
273    /// `None` when the patch was applied verbatim with nothing to echo.
274    #[serde(
275        default,
276        deserialize_with = "null_as_empty",
277        serialize_with = "empty_as_null"
278    )]
279    pub updated: BTreeMap<Id, Option<T>>,
280    /// Ids of successfully destroyed objects.
281    #[serde(
282        default,
283        deserialize_with = "null_as_empty",
284        serialize_with = "empty_as_null"
285    )]
286    pub destroyed: Vec<Id>,
287    /// Failed creates, keyed by caller-supplied creation id.
288    #[serde(
289        default,
290        deserialize_with = "null_as_empty",
291        serialize_with = "empty_as_null"
292    )]
293    pub not_created: BTreeMap<String, SetError>,
294    /// Failed updates, keyed by record id.
295    #[serde(
296        default,
297        deserialize_with = "null_as_empty",
298        serialize_with = "empty_as_null"
299    )]
300    pub not_updated: BTreeMap<Id, SetError>,
301    /// Failed destroys, keyed by record id.
302    #[serde(
303        default,
304        deserialize_with = "null_as_empty",
305        serialize_with = "empty_as_null"
306    )]
307    pub not_destroyed: BTreeMap<Id, SetError>,
308    /// Catch-all for vendor / site / private extension fields not covered
309    /// by the typed fields above. Preserves unknown fields across
310    /// deserialize/serialize round-trip per workspace extras-preservation
311    /// policy (see workspace AGENTS.md).
312    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
313    pub extra: serde_json::Map<String, serde_json::Value>,
314}
315
316/// The partial object a server echoes in `/set` `created` / `updated` and
317/// `/copy` `created` (RFC 8620 §5.3, §5.4).
318///
319/// RFC 8620 §5.3 defines each `created` value as "an object containing any
320/// properties of the created Foo object that were not sent by the client",
321/// and §5.4 defines each `/copy` `created` value as the "properties of the
322/// copied Foo object that are set by the server". Neither is a full `Foo`,
323/// so a full typed `Foo` cannot hold them: required client-sent properties
324/// are absent. This type keeps the one property nearly every object type
325/// reports, `id`, and every other returned property in [`Self::extra`].
326///
327/// Deserialize a property out of `extra` with `serde_json::from_value`
328/// when it is needed (e.g. `blobId` / `threadId` / `size` for an Email,
329/// RFC 8621 §4.6).
330#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
331pub struct PartialObject {
332    /// The server-assigned id, when the server reported one. `None` for an
333    /// `updated` entry (the id is the map key) or an object type whose id
334    /// is not server-set.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub id: Option<Id>,
337    /// Every other property the server returned, keyed by wire name.
338    #[serde(flatten, default)]
339    pub extra: serde_json::Map<String, serde_json::Value>,
340}
341
342// ---------------------------------------------------------------------------
343// /copy
344// ---------------------------------------------------------------------------
345
346/// RFC 8620 §5.4 — `Foo/copy` response shape.
347///
348/// Unlike [`SetResponse`] it carries `fromAccountId` and has no `updated`
349/// / `destroyed` maps. When `onSuccessDestroyOriginal` is set the server
350/// sends a separate implicit `Foo/set` response with the same call id; that
351/// one is a [`SetResponse`].
352///
353/// The result maps and lists are plain collections, not `Option`s: RFC 8620
354/// defines each as "null if none", so an empty collection means none. A
355/// `null` or absent field decodes as empty and an empty one encodes as
356/// `null`.
357#[non_exhaustive]
358#[derive(Debug, Clone, Serialize, Deserialize)]
359#[serde(rename_all = "camelCase")]
360#[serde(bound(
361    deserialize = "T: serde::de::DeserializeOwned",
362    serialize = "T: Serialize"
363))]
364pub struct CopyResponse<T> {
365    /// The account the records were copied from.
366    pub from_account_id: Id,
367    /// The account the records were copied to.
368    pub account_id: Id,
369    /// State of the destination account before the copy, or `None` when
370    /// the server does not know it.
371    pub old_state: Option<State>,
372    /// State of the destination account after the copy.
373    pub new_state: State,
374    /// Successfully copied objects, keyed by caller-supplied creation id.
375    #[serde(
376        default,
377        deserialize_with = "null_as_empty",
378        serialize_with = "empty_as_null"
379    )]
380    pub created: BTreeMap<String, T>,
381    /// Failed copies, keyed by caller-supplied creation id.
382    #[serde(
383        default,
384        deserialize_with = "null_as_empty",
385        serialize_with = "empty_as_null"
386    )]
387    pub not_created: BTreeMap<String, SetError>,
388    /// Catch-all for vendor / site / private extension fields not covered
389    /// by the typed fields above. Preserves unknown fields across
390    /// deserialize/serialize round-trip per workspace extras-preservation
391    /// policy (see workspace AGENTS.md).
392    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
393    pub extra: serde_json::Map<String, serde_json::Value>,
394}
395
396// ---------------------------------------------------------------------------
397// /query
398// ---------------------------------------------------------------------------
399
400/// RFC 8620 §5.5 — `Foo/query` response shape.
401///
402/// Returns the ids of objects matching a filter, in sort order. The
403/// `query_state` token can be passed to `Foo/queryChanges` to retrieve only
404/// the delta against this snapshot.
405#[non_exhaustive]
406#[derive(Debug, Clone, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase")]
408pub struct QueryResponse {
409    /// The account the response refers to.
410    pub account_id: Id,
411    /// Opaque state token for this query result; pass to `/queryChanges`.
412    pub query_state: State,
413    /// `true` if `/queryChanges` will give incremental updates against this
414    /// `query_state`; `false` if the client must re-run `/query` to refresh.
415    pub can_calculate_changes: bool,
416    /// Zero-based offset within the full result set of the first id in
417    /// `ids`. Per RFC 8620 §5.5, may differ from the requested `position`
418    /// when the requested offset exceeds the result count.
419    pub position: u64,
420    /// The matching ids in sort order.
421    pub ids: Vec<Id>,
422    /// Total number of matching objects, or `None` when the request did not
423    /// set `calculateTotal: true`.
424    pub total: Option<u64>,
425    /// Server's max page size; `None` when not advertised.
426    pub limit: Option<u64>,
427    /// Catch-all for vendor / site / private extension fields not covered
428    /// by the typed fields above. Preserves unknown fields across
429    /// deserialize/serialize round-trip per workspace extras-preservation
430    /// policy (see workspace AGENTS.md).
431    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
432    pub extra: serde_json::Map<String, serde_json::Value>,
433}
434
435// ---------------------------------------------------------------------------
436// /queryChanges
437// ---------------------------------------------------------------------------
438
439/// A single item added to a query result set (RFC 8620 §5.6).
440///
441/// The `index` is the position the new item occupies in the post-change
442/// result set, accounting for items also added in this batch.
443#[non_exhaustive]
444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
445#[serde(rename_all = "camelCase")]
446pub struct AddedItem {
447    /// The id of the new item in the result set.
448    pub id: Id,
449    /// Zero-based position of the new item in the post-change result set.
450    pub index: u64,
451    /// Catch-all for vendor / site / private extension fields not covered
452    /// by the typed fields above. Preserves unknown fields across
453    /// deserialize/serialize round-trip per workspace extras-preservation
454    /// policy (see workspace AGENTS.md).
455    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
456    pub extra: serde_json::Map<String, serde_json::Value>,
457}
458
459/// RFC 8620 §5.6 — `Foo/queryChanges` response shape.
460///
461/// Reports the ids removed from and added to a query result set since
462/// `old_query_state`. Combined with the previous result, the client can
463/// reconstruct the new result without re-fetching all ids.
464#[non_exhaustive]
465#[derive(Debug, Clone, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467pub struct QueryChangesResponse {
468    /// The account the response refers to.
469    pub account_id: Id,
470    /// The state token the client passed in.
471    pub old_query_state: State,
472    /// The current state token.
473    pub new_query_state: State,
474    /// Total number of matching objects (only when
475    /// `calculateTotal: true` was set in the request).
476    pub total: Option<u64>,
477    /// Ids removed from the result set since `old_query_state`.
478    pub removed: Vec<Id>,
479    /// Items added to the result set, with their new positions.
480    pub added: Vec<AddedItem>,
481    /// Catch-all for vendor / site / private extension fields not covered
482    /// by the typed fields above. Preserves unknown fields across
483    /// deserialize/serialize round-trip per workspace extras-preservation
484    /// policy (see workspace AGENTS.md).
485    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
486    pub extra: serde_json::Map<String, serde_json::Value>,
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::SetErrorType;
493    use serde_json::json;
494
495    // Independent oracles: hand-written JSON shapes from the RFC 8620
496    // examples and prose descriptions, NOT derived from the types being
497    // tested. Wire round-trips ensure the serde rename rules and field
498    // shapes match the spec.
499
500    #[test]
501    fn get_response_round_trips() {
502        let raw = json!({
503            "accountId": "A1",
504            "state": "s42",
505            "list": [{"id": "x", "name": "First"}],
506            "notFound": ["missing1"]
507        });
508        let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
509        assert_eq!(resp.account_id.as_ref(), "A1");
510        assert_eq!(resp.state, "s42");
511        assert_eq!(resp.list.len(), 1);
512        assert_eq!(resp.list[0]["name"], "First");
513        assert_eq!(resp.not_found, [Id::from("missing1")]);
514        // Round-trip back to JSON and confirm the camelCase keys.
515        let back = serde_json::to_value(&resp).unwrap();
516        assert_eq!(back["accountId"], "A1");
517        assert_eq!(back["notFound"][0], "missing1");
518    }
519
520    #[test]
521    fn get_response_null_not_found() {
522        // RFC 8620 §5.1 types notFound as "Id[]": "The array is empty if all
523        // requested ids were found or if the "ids" argument passed in was
524        // either null or an empty array." A lenient peer's null decodes as
525        // that empty array and re-encodes as `[]`, never `null`.
526        let raw = json!({
527            "accountId": "A1",
528            "state": "s1",
529            "list": [],
530            "notFound": null
531        });
532        let resp: GetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
533        assert!(resp.not_found.is_empty());
534        assert_eq!(serde_json::to_value(&resp).unwrap()["notFound"], json!([]));
535    }
536
537    #[test]
538    fn changes_response_round_trips() {
539        let raw = json!({
540            "accountId": "A1",
541            "oldState": "s0",
542            "newState": "s1",
543            "hasMoreChanges": false,
544            "created": ["a"],
545            "updated": ["b"],
546            "destroyed": ["c"]
547        });
548        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
549        assert_eq!(resp.old_state, "s0");
550        assert_eq!(resp.new_state, "s1");
551        assert!(!resp.has_more_changes);
552        assert_eq!(resp.created[0].as_ref(), "a");
553        assert_eq!(resp.updated[0].as_ref(), "b");
554        assert_eq!(resp.destroyed[0].as_ref(), "c");
555        // RFC 8620 §5.2 base `/changes` does not define `updatedProperties`;
556        // the field must default to `None` when absent from the wire.
557        assert!(resp.updated_properties.is_none());
558    }
559
560    /// Oracle: RFC 8621 §2.2 example response (lines 1015-1031 of rfc8621.txt
561    /// in this repo) — `Mailbox/changes` carries `updatedProperties` listing
562    /// `totalEmails`, `unreadEmails`, `totalThreads`, `unreadThreads`.
563    #[test]
564    fn changes_response_deserializes_mailbox_updated_properties() {
565        let raw = json!({
566            "accountId": "A1",
567            "oldState": "78541",
568            "newState": "78542",
569            "hasMoreChanges": false,
570            "updatedProperties": [
571                "totalEmails", "unreadEmails",
572                "totalThreads", "unreadThreads"
573            ],
574            "created": [],
575            "updated": ["B"],
576            "destroyed": []
577        });
578        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
579        let props = resp
580            .updated_properties
581            .expect("updatedProperties must be present")
582            .expect("updatedProperties must be a list");
583        assert_eq!(
584            props,
585            vec![
586                "totalEmails".to_string(),
587                "unreadEmails".to_string(),
588                "totalThreads".to_string(),
589                "unreadThreads".to_string()
590            ]
591        );
592    }
593
594    /// Oracle: RFC 9425 §5 example response — `Quota/changes` carries
595    /// `updatedProperties: ["used"]` when only quota usage changed.
596    #[test]
597    fn changes_response_deserializes_quota_updated_properties() {
598        let raw = json!({
599            "accountId": "A1",
600            "oldState": "78541",
601            "newState": "78542",
602            "hasMoreChanges": false,
603            "updatedProperties": ["used"],
604            "created": [],
605            "updated": ["2a06df0d-9865-4e74-a92f-74dcc814270e"],
606            "destroyed": []
607        });
608        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
609        let props = resp
610            .updated_properties
611            .expect("updatedProperties must be present")
612            .expect("updatedProperties must be a list");
613        assert_eq!(props, vec!["used".to_string()]);
614    }
615
616    /// `updatedProperties: null` on the wire (RFC 8621 §2.2: "If the server
617    /// is unable to tell if only counts have changed, it MUST just be null")
618    /// deserializes as `Some(None)`, distinct from omitted, so it can be
619    /// re-emitted (draft-ietf-jmap-metadata-04 §3.3: "It is always
620    /// present, and is null when updated is empty").
621    #[test]
622    fn changes_response_accepts_explicit_null_updated_properties() {
623        let raw = json!({
624            "accountId": "A1",
625            "oldState": "s0",
626            "newState": "s1",
627            "hasMoreChanges": false,
628            "updatedProperties": null,
629            "created": [],
630            "updated": ["B"],
631            "destroyed": []
632        });
633        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
634        assert_eq!(resp.updated_properties, Some(None));
635    }
636
637    /// Serializing a `ChangesResponse` without `updated_properties` must
638    /// NOT emit a `"updatedProperties": null` key — the
639    /// `skip_serializing_if = "Option::is_none"` attribute keeps the wire
640    /// shape minimal and matches the RFC 8620 §5.2 base envelope for
641    /// methods that don't define the extension field.
642    #[test]
643    fn changes_response_omits_updated_properties_when_none() {
644        let resp = ChangesResponse {
645            account_id: Id::from("A1"),
646            old_state: "s0".into(),
647            new_state: "s1".into(),
648            has_more_changes: false,
649            created: vec![],
650            updated: vec![],
651            destroyed: vec![],
652            updated_properties: None,
653            extra: serde_json::Map::new(),
654        };
655        let serialized = serde_json::to_value(&resp).expect("must serialize");
656        assert!(
657            serialized.get("updatedProperties").is_none(),
658            "updatedProperties must be omitted when None"
659        );
660    }
661
662    /// draft-ietf-jmap-metadata-04 §3.3: when the request's `using`
663    /// includes the metadata capability, updatedProperties "is always
664    /// present, and is null when updated is empty". `Some(None)` must
665    /// therefore serialize as an explicit `null`.
666    #[test]
667    fn changes_response_emits_null_updated_properties_when_some_none() {
668        let resp = ChangesResponse {
669            account_id: Id::from("A1"),
670            old_state: "s0".into(),
671            new_state: "s1".into(),
672            has_more_changes: false,
673            created: vec![],
674            updated: vec![],
675            destroyed: vec![],
676            updated_properties: Some(None),
677            extra: serde_json::Map::new(),
678        };
679        let serialized = serde_json::to_value(&resp).expect("must serialize");
680        assert_eq!(
681            serialized,
682            json!({
683                "accountId": "A1",
684                "oldState": "s0",
685                "newState": "s1",
686                "hasMoreChanges": false,
687                "created": [],
688                "updated": [],
689                "destroyed": [],
690                "updatedProperties": null
691            })
692        );
693    }
694
695    /// Oracle: draft-ietf-jmap-metadata-04 §6.8 example `Email/changes`
696    /// response, which reports a metadata-only change with
697    /// `"updatedProperties": ["metadata"]`. It must decode and re-encode
698    /// unchanged.
699    #[test]
700    fn changes_response_round_trips_metadata_updated_properties() {
701        let raw = json!({
702            "accountId": "A1",
703            "oldState": "e-100",
704            "newState": "e-103",
705            "hasMoreChanges": false,
706            "created": [],
707            "updated": ["EM1", "EM2"],
708            "destroyed": [],
709            "updatedProperties": ["metadata"]
710        });
711        let resp: ChangesResponse = serde_json::from_value(raw.clone()).unwrap();
712        assert_eq!(
713            resp.updated_properties,
714            Some(Some(vec!["metadata".to_string()]))
715        );
716        assert_eq!(serde_json::to_value(&resp).unwrap(), raw);
717    }
718
719    #[test]
720    fn set_response_updated_accepts_null_value() {
721        // §5.3 wire type: updated is Id[Foo|null]|null. The inner null
722        // signals "patch applied verbatim, no server-set fields to echo".
723        let raw = json!({
724            "accountId": "A1",
725            "oldState": "s1",
726            "newState": "s2",
727            "updated": { "ev1": null, "ev2": null }
728        });
729        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
730        let upd = resp.updated;
731        assert!(upd.get(&Id::from("ev1")).unwrap().is_none());
732        assert!(upd.get(&Id::from("ev2")).unwrap().is_none());
733    }
734
735    #[test]
736    fn set_response_updated_accepts_object_value() {
737        let raw = json!({
738            "accountId": "A1",
739            "oldState": "s1",
740            "newState": "s2",
741            "updated": { "ev1": { "id": "ev1", "title": "Meeting" } }
742        });
743        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
744        let upd = resp.updated;
745        let ev1 = upd.get(&Id::from("ev1")).unwrap().as_ref().unwrap();
746        assert_eq!(ev1["title"], "Meeting");
747    }
748
749    #[test]
750    fn set_response_not_updated_keys_are_ids() {
751        // §5.3: notUpdated is Id[SetError]|null. Keys are server-assigned
752        // ids, not creation ids — typing them as Id (not String) lets
753        // callers use the keys interchangeably with /get response ids.
754        let raw = json!({
755            "accountId": "A1",
756            "oldState": "s1",
757            "newState": "s1",
758            "notUpdated": {
759                "ev1": { "type": "stateMismatch" }
760            }
761        });
762        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
763        let nu = resp.not_updated;
764        assert_eq!(
765            nu.get(&Id::from("ev1")).unwrap().error_type.to_string(),
766            "stateMismatch"
767        );
768    }
769
770    #[test]
771    fn set_error_full_8_fields_round_trip() {
772        // §5.3 + RFC 8621 §5.5/§5.7/§7.5: SetError carries up to 8 fields
773        // depending on the error type. The client side must preserve all
774        // of them on deserialize, otherwise callers cannot recover the
775        // existingId / blobNotFound / recipient-list payload that the
776        // server relied on to make the error actionable.
777        let raw = json!({
778            "type": "alreadyExists",
779            "description": "conflict",
780            "properties": ["name"],
781            "existingId": "obj-7",
782            "notFound": ["blob-1", "blob-2"],
783            "maxRecipients": 50,
784            "invalidRecipients": ["bad@", "no@no"],
785            "maxSize": 10485760
786        });
787        let err = SetError::deserialize(&raw).unwrap();
788        assert_eq!(err.error_type, SetErrorType::AlreadyExists);
789        assert_eq!(err.description.as_deref(), Some("conflict"));
790        assert_eq!(err.properties.as_ref().unwrap()[0], "name");
791        assert_eq!(err.existing_id.as_ref().unwrap().as_ref(), "obj-7");
792        assert_eq!(err.not_found.as_ref().unwrap().len(), 2);
793        assert_eq!(err.max_recipients, Some(50));
794        assert_eq!(err.invalid_recipients.as_ref().unwrap().len(), 2);
795        assert_eq!(err.max_size, Some(10_485_760));
796        // Round-trip preserves wire field names.
797        let back = serde_json::to_value(&err).unwrap();
798        assert_eq!(back, raw);
799    }
800
801    #[test]
802    fn set_error_minimal_omits_optional_fields_on_serialize() {
803        // §5.3: only `type` is required. Optional fields MUST be omitted
804        // (not serialized as `null`) so the wire matches the server's
805        // construction shape exactly.
806        let err = SetError::new("forbidden");
807        let json = serde_json::to_value(&err).unwrap();
808        assert_eq!(json["type"], "forbidden");
809        assert!(json.get("description").is_none());
810        assert!(json.get("properties").is_none());
811        assert!(json.get("existingId").is_none());
812        assert!(json.get("notFound").is_none());
813        assert!(json.get("maxRecipients").is_none());
814        assert!(json.get("invalidRecipients").is_none());
815        assert!(json.get("maxSize").is_none());
816        // Empty extra map must not appear at all in the wire output.
817        let obj = json.as_object().unwrap();
818        assert_eq!(
819            obj.len(),
820            1,
821            "minimal SetError must serialize to exactly {{type}}: {json}"
822        );
823    }
824
825    #[test]
826    fn set_error_extension_fields_round_trip_via_extra() {
827        // JMAP Chat's serverRetryAfter is a per-extension SetError field
828        // that must round-trip losslessly through extra without the base
829        // type knowing about it. Pin both directions.
830        let raw = json!({
831            "type": "rateLimited",
832            "description": "slow-mode active",
833            "serverRetryAfter": "2026-01-01T00:00:00Z"
834        });
835        let err = SetError::deserialize(&raw).unwrap();
836        assert_eq!(err.error_type.to_string(), "rateLimited");
837        assert_eq!(err.description.as_deref(), Some("slow-mode active"));
838        assert_eq!(
839            err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
840            Some("2026-01-01T00:00:00Z"),
841            "extension field must land in extra map: {err:?}"
842        );
843        let back = serde_json::to_value(&err).unwrap();
844        assert_eq!(back, raw, "round-trip must preserve extension field");
845    }
846
847    #[test]
848    fn set_error_extension_type_round_trips() {
849        // Extension errors (e.g. calendars draft §10.7.2) MUST round-trip
850        // through SetErrorType::Other without a new variant being added.
851        let err = SetError::new("noSupportedScheduleMethods");
852        let json = serde_json::to_value(&err).unwrap();
853        assert_eq!(json["type"], "noSupportedScheduleMethods");
854        let back: SetError = serde_json::from_value(json).unwrap();
855        assert_eq!(
856            back.error_type,
857            SetErrorType::Other("noSupportedScheduleMethods".to_owned())
858        );
859    }
860
861    #[test]
862    fn set_error_display_with_description() {
863        let err = SetError {
864            error_type: SetErrorType::Forbidden,
865            description: Some("not your calendar".to_owned()),
866            ..SetError::new("forbidden")
867        };
868        assert_eq!(err.to_string(), "forbidden: not your calendar");
869    }
870
871    #[test]
872    fn set_error_display_without_description() {
873        let err = SetError::new("forbidden");
874        assert_eq!(err.to_string(), "forbidden");
875    }
876
877    #[test]
878    fn query_response_round_trips() {
879        let raw = json!({
880            "accountId": "A1",
881            "queryState": "qs1",
882            "canCalculateChanges": true,
883            "position": 0,
884            "ids": ["a", "b", "c"],
885            "total": 3,
886            "limit": 100
887        });
888        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
889        assert_eq!(resp.query_state, "qs1");
890        assert!(resp.can_calculate_changes);
891        assert_eq!(resp.ids.len(), 3);
892        assert_eq!(resp.total, Some(3));
893        assert_eq!(resp.limit, Some(100));
894    }
895
896    #[test]
897    fn query_response_omits_optional_total_and_limit() {
898        let raw = json!({
899            "accountId": "A1",
900            "queryState": "qs1",
901            "canCalculateChanges": false,
902            "position": 0,
903            "ids": [],
904            "total": null,
905            "limit": null
906        });
907        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
908        assert!(resp.total.is_none());
909        assert!(resp.limit.is_none());
910    }
911
912    #[test]
913    fn query_changes_response_round_trips() {
914        let raw = json!({
915            "accountId": "A1",
916            "oldQueryState": "qs0",
917            "newQueryState": "qs1",
918            "total": 5,
919            "removed": ["x"],
920            "added": [
921                {"id": "y", "index": 2}
922            ]
923        });
924        let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
925        assert_eq!(resp.old_query_state, "qs0");
926        assert_eq!(resp.new_query_state, "qs1");
927        assert_eq!(resp.total, Some(5));
928        assert_eq!(resp.removed[0].as_ref(), "x");
929        assert_eq!(resp.added.len(), 1);
930        assert_eq!(resp.added[0].id.as_ref(), "y");
931        assert_eq!(resp.added[0].index, 2);
932    }
933
934    #[test]
935    fn added_item_round_trips() {
936        let raw = json!({"id": "foo", "index": 7});
937        let item = AddedItem::deserialize(&raw).unwrap();
938        assert_eq!(item.id.as_ref(), "foo");
939        assert_eq!(item.index, 7);
940        assert_eq!(serde_json::to_value(&item).unwrap(), raw);
941    }
942
943    // ── Extras-preservation policy tests (JMAP-lbdy.1) ───────────────────
944    //
945    // One round-trip preservation test per migrated type. Each test
946    // asserts that an unknown vendor / site / private-extension field
947    // survives deserialize/serialize unchanged. Per workspace
948    // AGENTS.md "Extras-preservation policy for vendor/site fields".
949
950    /// `GetResponse.extra` captures vendor fields and preserves them on
951    /// re-serialize.
952    #[test]
953    fn get_response_preserves_vendor_extras() {
954        let raw = json!({
955            "accountId": "A1",
956            "state": "s1",
957            "list": [],
958            "notFound": null,
959            "acmeCorpAuditTrail": {"sequence": 42}
960        });
961        let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
962        assert_eq!(
963            resp.extra
964                .get("acmeCorpAuditTrail")
965                .and_then(|v| v["sequence"].as_u64()),
966            Some(42),
967            "vendor field must land in extra: {:?}",
968            resp.extra
969        );
970        let back = serde_json::to_value(&resp).unwrap();
971        assert_eq!(
972            back["acmeCorpAuditTrail"]["sequence"], 42,
973            "vendor field must survive serialize: {back}"
974        );
975    }
976
977    /// `ChangesResponse.extra` captures vendor fields and preserves them.
978    #[test]
979    fn changes_response_preserves_vendor_extras() {
980        let raw = json!({
981            "accountId": "A1",
982            "oldState": "s0",
983            "newState": "s1",
984            "hasMoreChanges": false,
985            "created": [],
986            "updated": [],
987            "destroyed": [],
988            "acmeCorpReplayToken": "rt-99"
989        });
990        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
991        assert_eq!(
992            resp.extra
993                .get("acmeCorpReplayToken")
994                .and_then(|v| v.as_str()),
995            Some("rt-99")
996        );
997        let back = serde_json::to_value(&resp).unwrap();
998        assert_eq!(back["acmeCorpReplayToken"], "rt-99");
999    }
1000
1001    /// Pins the `#[serde(flatten)] extra` interaction with the typed
1002    /// `updated_properties: Option<Vec<String>>` field on `ChangesResponse`:
1003    ///
1004    /// - Wire `"updatedProperties": null` MUST be consumed by the typed
1005    ///   field (deserialized as `None`) and MUST NOT leak into `extra`.
1006    /// - Vendor fields at the top level — including ones whose value is
1007    ///   a nested object — MUST land in `extra` and survive round-trip.
1008    ///
1009    /// Without this regression test, a future serde or `#[serde(flatten)]`
1010    /// behavior change could silently move the null-valued typed key into
1011    /// `extra` (or fail to consume it from `extra` on the serialize side),
1012    /// breaking the wire-format contract for any caller relying on either
1013    /// the absence of `updatedProperties` from `extra` after parse or on
1014    /// vendor extras being preserved alongside an explicit null. Filed
1015    /// under bd:JMAP-6xs8.8.
1016    #[test]
1017    fn changes_response_null_updated_properties_and_extras_coexist() {
1018        let raw = json!({
1019            "accountId": "A1",
1020            "oldState": "s0",
1021            "newState": "s1",
1022            "hasMoreChanges": false,
1023            "created": [],
1024            "updated": ["B"],
1025            "destroyed": [],
1026            "updatedProperties": null,
1027            "acmeCorpReplayToken": "rt-99",
1028            "acmeCorpMetadata": { "requestId": "r1", "trace": "x" }
1029        });
1030
1031        let resp: ChangesResponse = serde_json::from_value(raw.clone()).expect("must deserialize");
1032
1033        // The typed field consumed the explicit null.
1034        assert_eq!(
1035            resp.updated_properties,
1036            Some(None),
1037            "explicit null updatedProperties must deserialize as Some(None)"
1038        );
1039
1040        // The typed key MUST NOT have leaked into `extra` — flatten is
1041        // supposed to visit only the keys the named fields did not
1042        // consume, regardless of whether the consumed value was null.
1043        assert!(
1044            !resp.extra.contains_key("updatedProperties"),
1045            "updatedProperties must not appear in extra after the typed \
1046             field consumed it (was: {:?})",
1047            resp.extra
1048        );
1049
1050        // Top-level vendor fields land in `extra`, including one whose
1051        // value is itself a nested object.
1052        assert_eq!(
1053            resp.extra
1054                .get("acmeCorpReplayToken")
1055                .and_then(|v| v.as_str()),
1056            Some("rt-99")
1057        );
1058        let nested = resp
1059            .extra
1060            .get("acmeCorpMetadata")
1061            .and_then(|v| v.as_object())
1062            .expect("acmeCorpMetadata must be a nested object in extra");
1063        assert_eq!(nested.get("requestId").and_then(|v| v.as_str()), Some("r1"));
1064        assert_eq!(nested.get("trace").and_then(|v| v.as_str()), Some("x"));
1065
1066        // Round-trip: serialize and reparse, all three properties
1067        // (null updatedProperties, two vendor extras) must survive.
1068        let back = serde_json::to_value(&resp).expect("must serialize");
1069
1070        // draft-ietf-jmap-metadata-04 §3.3: updatedProperties "is always
1071        // present, and is null when updated is empty", so an explicit
1072        // null is re-emitted rather than dropped.
1073        assert_eq!(
1074            back.get("updatedProperties"),
1075            Some(&serde_json::Value::Null),
1076            "explicit null updatedProperties must serialize as null: {back}"
1077        );
1078        assert_eq!(back["acmeCorpReplayToken"], "rt-99");
1079        assert_eq!(back["acmeCorpMetadata"]["requestId"], "r1");
1080        assert_eq!(back["acmeCorpMetadata"]["trace"], "x");
1081
1082        // Reparse the serialized form and confirm equivalence on the
1083        // typed surface + extras.
1084        let resp2: ChangesResponse = serde_json::from_value(back).expect("reparse must succeed");
1085        assert_eq!(resp2.updated_properties, Some(None));
1086        assert_eq!(resp2.extra, resp.extra);
1087    }
1088
1089    /// `SetResponse.extra` captures vendor fields and preserves them.
1090    #[test]
1091    fn set_response_preserves_vendor_extras() {
1092        let raw = json!({
1093            "accountId": "A1",
1094            "oldState": "s1",
1095            "newState": "s2",
1096            "acmeCorpTransactionId": "txn-abc"
1097        });
1098        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
1099        assert_eq!(
1100            resp.extra
1101                .get("acmeCorpTransactionId")
1102                .and_then(|v| v.as_str()),
1103            Some("txn-abc")
1104        );
1105        let back = serde_json::to_value(&resp).unwrap();
1106        assert_eq!(back["acmeCorpTransactionId"], "txn-abc");
1107    }
1108
1109    /// `QueryResponse.extra` captures vendor fields and preserves them.
1110    #[test]
1111    fn query_response_preserves_vendor_extras() {
1112        let raw = json!({
1113            "accountId": "A1",
1114            "queryState": "qs1",
1115            "canCalculateChanges": false,
1116            "position": 0,
1117            "ids": [],
1118            "total": null,
1119            "limit": null,
1120            "acmeCorpSearchTimingMs": 17
1121        });
1122        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
1123        assert_eq!(
1124            resp.extra
1125                .get("acmeCorpSearchTimingMs")
1126                .and_then(|v| v.as_u64()),
1127            Some(17)
1128        );
1129        let back = serde_json::to_value(&resp).unwrap();
1130        assert_eq!(back["acmeCorpSearchTimingMs"], 17);
1131    }
1132
1133    /// `QueryChangesResponse.extra` captures vendor fields and preserves them.
1134    #[test]
1135    fn query_changes_response_preserves_vendor_extras() {
1136        let raw = json!({
1137            "accountId": "A1",
1138            "oldQueryState": "qs0",
1139            "newQueryState": "qs1",
1140            "total": null,
1141            "removed": [],
1142            "added": [],
1143            "acmeCorpDeltaToken": "dt-2"
1144        });
1145        let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
1146        assert_eq!(
1147            resp.extra
1148                .get("acmeCorpDeltaToken")
1149                .and_then(|v| v.as_str()),
1150            Some("dt-2")
1151        );
1152        let back = serde_json::to_value(&resp).unwrap();
1153        assert_eq!(back["acmeCorpDeltaToken"], "dt-2");
1154    }
1155
1156    /// `AddedItem.extra` captures vendor fields and preserves them.
1157    #[test]
1158    fn added_item_preserves_vendor_extras() {
1159        let raw = json!({
1160            "id": "x",
1161            "index": 0,
1162            "acmeCorpHighlight": true
1163        });
1164        let item = AddedItem::deserialize(&raw).unwrap();
1165        assert_eq!(
1166            item.extra
1167                .get("acmeCorpHighlight")
1168                .and_then(|v| v.as_bool()),
1169            Some(true)
1170        );
1171        let back = serde_json::to_value(&item).unwrap();
1172        assert_eq!(back["acmeCorpHighlight"], true);
1173    }
1174
1175    /// Empty extras must NOT serialize as a key on the wire — the
1176    /// `skip_serializing_if = "serde_json::Map::is_empty"` attribute keeps
1177    /// the wire shape byte-identical to the pre-migration form when no
1178    /// vendor fields are present.
1179    #[test]
1180    fn empty_extras_omitted_from_wire() {
1181        let resp = AddedItem {
1182            id: Id::from("z"),
1183            index: 1,
1184            extra: serde_json::Map::new(),
1185        };
1186        let serialized = serde_json::to_value(&resp).expect("must serialize");
1187        let obj = serialized.as_object().expect("must be object");
1188        assert_eq!(
1189            obj.len(),
1190            2,
1191            "empty extras must not add any wire keys; got {serialized}"
1192        );
1193        assert!(obj.contains_key("id"));
1194        assert!(obj.contains_key("index"));
1195    }
1196
1197    // Oracle: workspace map-ordering policy (bd:JMAP-pdux) — wire-format
1198    // maps serialize in sorted key order. Input keys are written out of
1199    // order; expected bytes are hand-written.
1200    #[test]
1201    fn set_response_maps_serialize_in_sorted_key_order() {
1202        let raw = r#"{
1203            "accountId": "a1",
1204            "newState": "s2",
1205            "created": {"k2": {"id": "id2"}, "k1": {"id": "id1"}},
1206            "notUpdated": {"id9": {"type": "notFound"}, "id8": {"type": "notFound"}}
1207        }"#;
1208        let resp: SetResponse<serde_json::Value> = serde_json::from_str(raw).expect("deserialize");
1209        let j = serde_json::to_string(&resp).expect("serialize");
1210        assert!(
1211            j.contains(r#""created":{"k1":{"id":"id1"},"k2":{"id":"id2"}}"#),
1212            "created keys must be sorted: {j}"
1213        );
1214        assert!(
1215            j.contains(r#""notUpdated":{"id8":{"type":"notFound"},"id9":{"type":"notFound"}}"#),
1216            "notUpdated keys must be sorted: {j}"
1217        );
1218    }
1219
1220    /// RFC 8620 §5.3: `created` holds only "properties of the created Foo
1221    /// object that were not sent by the client". Oracle: the Email/set
1222    /// response in RFC 8621 §7 (page 66), `...` elision removed. Its
1223    /// `created` entry has no `mailboxIds`, `keywords` etc., so it would
1224    /// fail to parse as a full Email; it must parse as [`PartialObject`].
1225    #[test]
1226    fn set_response_partial_object_parses_rfc8621_created() {
1227        let raw = json!({
1228            "accountId": "ue150411c",
1229            "oldState": "780839",
1230            "newState": "780842",
1231            "created": {
1232                "k1546": {
1233                    "id": "Md45b47b4877521042cec0938",
1234                    "blobId": "Ge8de6c9f6de198239b982ea214e0f3a704e4af74",
1235                    "threadId": "Td957e72e89f516dc",
1236                    "size": 11721
1237                }
1238            },
1239            "updated": { "Mabc": null, "Mdef": { "size": 12 } },
1240            "destroyed": ["Mf40b5f831efa7233b9eb1c7f"]
1241        });
1242        let resp: SetResponse<PartialObject> = serde_json::from_value(raw).unwrap();
1243        let created = &resp.created;
1244        let k = &created["k1546"];
1245        assert_eq!(
1246            k.id.as_ref().map(|i| i.as_ref()),
1247            Some("Md45b47b4877521042cec0938")
1248        );
1249        assert_eq!(k.extra["threadId"], "Td957e72e89f516dc");
1250        assert_eq!(k.extra["size"], 11721);
1251        assert!(
1252            !k.extra.contains_key("id"),
1253            "id must not be duplicated in extra"
1254        );
1255        let updated = &resp.updated;
1256        assert_eq!(updated[&Id::from("Mabc")], None);
1257        let def = updated[&Id::from("Mdef")].as_ref().unwrap();
1258        assert_eq!(def.id, None);
1259        assert_eq!(def.extra["size"], 12);
1260    }
1261
1262    /// A created entry with no `id` (RFC 8620 §5.3: "such as the id in
1263    /// most object types", so not all) and with only client-omitted
1264    /// defaults must still parse, and serialize back without an `id` key.
1265    #[test]
1266    fn partial_object_without_id_round_trips() {
1267        let raw = json!({ "isSubscribed": false, "sortOrder": 0 });
1268        let p: PartialObject = serde_json::from_value(raw.clone()).unwrap();
1269        assert_eq!(p.id, None);
1270        assert_eq!(serde_json::to_value(&p).unwrap(), raw);
1271    }
1272
1273    /// RFC 8620 §5.4 response shape. Oracle: the Email/copy response in
1274    /// RFC 8621 §7 (page 67), verbatim.
1275    #[test]
1276    fn copy_response_parses_rfc8621_example() {
1277        let raw = json!({
1278            "fromAccountId": "ue150411c",
1279            "accountId": "u6c6c41ac",
1280            "oldState": "7ee7e9263a6d",
1281            "newState": "5a0d2447ed26",
1282            "created": {
1283                "k45": {
1284                    "id": "M138f9954a5cd2423daeafa55",
1285                    "blobId": "G6b9fb047cba722c48c611e79233d057c6b0b74e8",
1286                    "threadId": "T2f242ea424a4079a",
1287                    "size": 11721
1288                }
1289            },
1290            "notCreated": null
1291        });
1292        let resp: CopyResponse<PartialObject> = serde_json::from_value(raw).unwrap();
1293        assert_eq!(resp.from_account_id.as_ref(), "ue150411c");
1294        assert_eq!(resp.account_id.as_ref(), "u6c6c41ac");
1295        assert_eq!(
1296            resp.old_state.as_ref().map(|s| s.as_ref()),
1297            Some("7ee7e9263a6d")
1298        );
1299        assert_eq!(resp.new_state.as_ref(), "5a0d2447ed26");
1300        let k45 = &resp.created["k45"];
1301        assert_eq!(
1302            k45.id.as_ref().map(|i| i.as_ref()),
1303            Some("M138f9954a5cd2423daeafa55")
1304        );
1305        assert_eq!(
1306            k45.extra["blobId"],
1307            "G6b9fb047cba722c48c611e79233d057c6b0b74e8"
1308        );
1309        assert!(resp.not_created.is_empty());
1310        assert!(resp.extra.is_empty());
1311    }
1312
1313    /// RFC 8620 §5.3: `created` "is null if no Foo objects were
1314    /// successfully created", `destroyed` is "null if none", and the
1315    /// `not*` maps are "null if none". Null and absent both mean none.
1316    #[test]
1317    fn set_response_null_and_absent_results_decode_empty() {
1318        let raw = json!({
1319            "accountId": "A1", "oldState": null, "newState": "s2",
1320            "created": null, "updated": null, "destroyed": null,
1321            "notCreated": null
1322        });
1323        let resp: SetResponse<PartialObject> = serde_json::from_value(raw).unwrap();
1324        assert!(resp.created.is_empty());
1325        assert!(resp.updated.is_empty());
1326        assert!(resp.destroyed.is_empty());
1327        assert!(resp.not_created.is_empty());
1328        assert!(resp.not_updated.is_empty());
1329        assert!(resp.not_destroyed.is_empty());
1330    }
1331
1332    /// RFC 8620 §5.3 / §5.4: a result field with no entries is `null` on
1333    /// the wire, never `{}` or `[]`.
1334    #[test]
1335    fn set_and_copy_responses_encode_empty_results_as_null() {
1336        let set = json!({
1337            "accountId": "A1", "oldState": "s1", "newState": "s2",
1338            "created": null, "updated": null, "destroyed": ["X1"],
1339            "notCreated": null, "notUpdated": null, "notDestroyed": null
1340        });
1341        let resp: SetResponse<PartialObject> = serde_json::from_value(set.clone()).unwrap();
1342        assert_eq!(serde_json::to_value(&resp).unwrap(), set);
1343
1344        let copy = json!({
1345            "fromAccountId": "A0", "accountId": "A1",
1346            "oldState": null, "newState": "s2",
1347            "created": null, "notCreated": null
1348        });
1349        let resp: CopyResponse<PartialObject> = serde_json::from_value(copy.clone()).unwrap();
1350        assert_eq!(serde_json::to_value(&resp).unwrap(), copy);
1351    }
1352
1353    /// RFC 8620 §5.4: `oldState` is "String|null" and `fromAccountId` is
1354    /// required; a response without it is not a /copy response.
1355    #[test]
1356    fn copy_response_requires_from_account_id() {
1357        let ok = json!({
1358            "fromAccountId": "A0", "accountId": "A1",
1359            "oldState": null, "newState": "s2",
1360            "notCreated": { "c1": { "type": "alreadyExists" } }
1361        });
1362        let resp: CopyResponse<PartialObject> = serde_json::from_value(ok).unwrap();
1363        assert!(resp.old_state.is_none());
1364        assert_eq!(
1365            resp.not_created["c1"].error_type,
1366            SetErrorType::AlreadyExists
1367        );
1368        let missing = json!({ "accountId": "A1", "newState": "s2" });
1369        assert!(serde_json::from_value::<CopyResponse<PartialObject>>(missing).is_err());
1370    }
1371}