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//! `/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. Server crates may still hand-build wire JSON
8//! for their `/set` responses (so they can use the typed
9//! [`SetErrorType`](crate::backend) enum at construction time); this crate's
10//! [`SetError`] is the deserialization target on the client side.
11//!
12//! All types use camelCase JSON via `#[serde(rename_all = "camelCase")]` and
13//! are marked `#[non_exhaustive]` so future RFC errata or extensions can add
14//! fields without a SemVer break.
15//!
16//! # Spec references
17//!
18//! | Type | Spec |
19//! |---|---|
20//! | [`GetResponse`] | RFC 8620 §5.1 |
21//! | [`ChangesResponse`] | RFC 8620 §5.2 |
22//! | [`SetResponse`], [`SetError`] | RFC 8620 §5.3 |
23//! | [`QueryResponse`] | RFC 8620 §5.5 |
24//! | [`QueryChangesResponse`], [`AddedItem`] | RFC 8620 §5.6 |
25
26use std::collections::HashMap;
27
28use serde::{Deserialize, Serialize};
29
30use crate::{Id, State};
31
32// ---------------------------------------------------------------------------
33// /get
34// ---------------------------------------------------------------------------
35
36/// RFC 8620 §5.1 — `Foo/get` response shape.
37///
38/// `T` is the type of object being fetched (e.g. `Mailbox`, `CalendarEvent`).
39/// `state` is the opaque state token the server returns alongside the result;
40/// it advances every time any object of the requested type changes in the
41/// account. `not_found` lists ids the client requested that the server could
42/// not find — `null` is treated as an empty list per RFC 8620 §5.1.
43#[non_exhaustive]
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct GetResponse<T> {
47    /// The account the response refers to.
48    pub account_id: Id,
49    /// Opaque state token for this object type at the time of the response.
50    pub state: State,
51    /// The fetched objects, one per id that was found.
52    pub list: Vec<T>,
53    /// Ids that were requested but not found. `null` on the wire is treated
54    /// as an empty list per RFC 8620 §5.1.
55    pub not_found: Option<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// ---------------------------------------------------------------------------
65// /changes
66// ---------------------------------------------------------------------------
67
68/// RFC 8620 §5.2 — `Foo/changes` response shape.
69///
70/// Reports the ids of objects created, updated, or destroyed since
71/// `old_state`. If `has_more_changes` is `true`, the client should call
72/// `/changes` again with `since_state = new_state` to retrieve the next
73/// page; otherwise `new_state` is the current state.
74///
75/// # Extension fields and the foundation-coupling trade-off
76///
77/// Some JMAP data-type extensions add an `updatedProperties` field to
78/// their `/changes` response shape:
79///
80/// - RFC 8621 §2.2 (`Mailbox/changes`): set when only `totalEmails` /
81///   `unreadEmails` / `totalThreads` / `unreadThreads` changed.
82/// - RFC 9425 §5 (`Quota/changes`): set when only the `used` property
83///   changed.
84///
85/// For all other `/changes` methods (RFC 8621 §3.2 `Thread/changes`,
86/// §4.3 `Email/changes`, plus every extension `/changes` method not
87/// listed above) the server omits the field, and clients deserialize
88/// it as `None`.
89///
90/// **Architectural decision (bd:JMAP-6xs8.5).** Carrying
91/// `updatedProperties` on this base type, rather than on per-extension
92/// `MailboxChangesResponse` / `QuotaChangesResponse` newtypes, is a
93/// deliberate workspace trade-off:
94///
95/// - **Chosen shape**: one foundation `ChangesResponse` with an
96///   `Option<Vec<String>>` field that two extensions populate.
97/// - **Alternative (a)**: `ChangesResponse<Ext = ()>` generic with a
98///   per-type extension struct. Rejected: would force every
99///   `/changes` handler in every extension server to thread the `Ext`
100///   type parameter, and the 30-crate canonical-template family would
101///   have to settle on a single way to express "no extension" vs
102///   "Mailbox extension" vs "Quota extension".
103/// - **Alternative (b)**: `MailboxChangesResponse` and
104///   `QuotaChangesResponse` as separate types in `jmap-mail-types` and
105///   a future `jmap-quota-types`, delegating to a shared base via
106///   composition. Rejected: would duplicate the seven base fields
107///   (`accountId`, `oldState`, `newState`, `hasMoreChanges`, three
108///   id vectors) at every extension type, and require parallel
109///   handler / parse / dispatch code.
110/// - **Alternative (c)**: leave `updatedProperties` as an extras
111///   flatten entry that mail/quota types explicitly read via
112///   `resp.extra.get("updatedProperties")`. Rejected: loses typed
113///   access, and the field is RFC-defined (not vendor / site), so the
114///   extras pattern (workspace AGENTS.md "Extras-preservation policy")
115///   is the wrong tool.
116///
117/// **Drift risk acknowledged**: every future `/changes` extension
118/// (`jmap-sharing`, `jmap-tasks`, etc.) will face pressure to add its
119/// own typed field here "for parity". Each such field would extend
120/// this foundation type by one more extension-specific column.
121/// Cautionary precedent: bd:JMAP-kt5k removed eight cap-advertising
122/// fields from the chat capability after they accreted there for the
123/// same parity-pressure reason. New `/changes` extension fields land
124/// here only when the field is RFC-defined and the alternative shapes
125/// above have been re-evaluated against the new use case; a vendor
126/// extension belongs in the extras catch-all, not on the typed
127/// surface.
128#[non_exhaustive]
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct ChangesResponse {
132    /// The account the response refers to.
133    pub account_id: Id,
134    /// The state token the client passed in.
135    pub old_state: State,
136    /// The current (or next-page) state token.
137    pub new_state: State,
138    /// `true` if there are more changes the client must page through.
139    pub has_more_changes: bool,
140    /// Ids of objects created since `old_state`.
141    pub created: Vec<Id>,
142    /// Ids of objects updated since `old_state`.
143    pub updated: Vec<Id>,
144    /// Ids of objects destroyed since `old_state`.
145    pub destroyed: Vec<Id>,
146    /// Optional list of property names that changed (RFC 8621 §2.2,
147    /// RFC 9425 §5). Servers MAY set this for `Mailbox/changes` and
148    /// `Quota/changes` responses when the only changes are to a small
149    /// known subset of properties; clients can then back-reference
150    /// `/updatedProperties` into a follow-up `Mailbox/get` or
151    /// `Quota/get` to fetch only those fields. For all other `/changes`
152    /// methods the field is absent on the wire and `None` here.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub updated_properties: Option<Vec<String>>,
155    /// Catch-all for vendor / site / private extension fields not covered
156    /// by the typed fields above. Preserves unknown fields across
157    /// deserialize/serialize round-trip per workspace extras-preservation
158    /// policy (see workspace AGENTS.md).
159    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
160    pub extra: serde_json::Map<String, serde_json::Value>,
161}
162
163// ---------------------------------------------------------------------------
164// /set
165// ---------------------------------------------------------------------------
166
167/// A per-item failure in a `/set` response (RFC 8620 §5.3).
168///
169/// Appears as the value type in the `notCreated`, `notUpdated`, and
170/// `notDestroyed` maps of [`SetResponse`]. The `error_type` field uses
171/// `String` rather than a typed enum so extension errors (e.g.
172/// `"calendarHasEvent"`, `"noSupportedScheduleMethods"`) round-trip
173/// cleanly without requiring a version-bump on every new spec extension.
174///
175/// All fields beyond `error_type` are optional and present only when the
176/// corresponding error type calls for them per RFC 8620 §5.3 / RFC 8621
177/// §5.5, §5.7, §7.5:
178///
179/// | Field | Set when error_type is | Spec |
180/// |---|---|---|
181/// | `description` | any (optional human-readable detail) | RFC 8620 §5.3 |
182/// | `properties` | `invalidProperties` | RFC 8620 §5.3 |
183/// | `existing_id` | `alreadyExists` | RFC 8620 §5.4, RFC 8621 §5.7 |
184/// | `not_found` | `blobNotFound` | RFC 8621 §5.5 |
185/// | `max_recipients` | `tooManyRecipients` | RFC 8621 §7.5 |
186/// | `invalid_recipients` | `invalidRecipients` | RFC 8621 §7.5 |
187/// | `max_size` | `tooLarge` | RFC 8621 §7.5 |
188///
189/// # Extension fields
190///
191/// JMAP extensions (e.g. JMAP Chat's `serverRetryAfter` for slow-mode
192/// rate limiting) MAY add additional SetError fields beyond the RFC 8620
193/// base set. The `extra` field captures any such field via
194/// `#[serde(flatten)]` so it round-trips losslessly. Extension crates
195/// (e.g. `jmap-chat-client`) provide typed accessor helpers that read
196/// from `extra` — the base type stays free of extension-specific fields.
197#[non_exhaustive]
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct SetError {
201    /// The machine-readable error type (e.g. `"forbidden"`, `"notFound"`,
202    /// `"alreadyExists"`, or an extension-defined string).
203    #[serde(rename = "type")]
204    pub error_type: String,
205    /// Human-readable description of the error. Optional per RFC 8620 §5.3.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub description: Option<String>,
208    /// Property names that caused the error (for `invalidProperties`).
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub properties: Option<Vec<String>>,
211    /// The existing object id (for `alreadyExists`).
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub existing_id: Option<Id>,
214    /// Missing blob ids (for `blobNotFound`).
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub not_found: Option<Vec<Id>>,
217    /// Maximum recipients allowed (for `tooManyRecipients`).
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub max_recipients: Option<u64>,
220    /// Invalid recipient addresses (for `invalidRecipients`).
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub invalid_recipients: Option<Vec<String>>,
223    /// Maximum message size in octets (for `tooLarge`).
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub max_size: Option<u64>,
226    /// Catch-all for extension SetError fields not in the RFC 8620 base
227    /// set. Captured via `#[serde(flatten)]` so they round-trip losslessly.
228    /// Extension crates provide typed accessors (e.g.
229    /// `jmap-chat-client`'s helper for reading `serverRetryAfter`).
230    ///
231    /// Uses `serde_json::Map` (which, under the workspace's default
232    /// `serde_json` features — `preserve_order` is NOT enabled — is
233    /// backed by `BTreeMap` and therefore deterministically serializes
234    /// in lexicographic key order, NOT in insertion order) rather than
235    /// `HashMap` to match the workspace extras-preservation policy (see
236    /// workspace `AGENTS.md`) and to give callers deterministic serialized
237    /// output.
238    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
239    pub extra: serde_json::Map<String, serde_json::Value>,
240}
241
242impl SetError {
243    /// Construct a `SetError` with the given type string and all optional
244    /// fields `None` / empty. Use this when deserializing tests or when
245    /// constructing a wire-shaped error from a typed source. Server crates
246    /// that want a typed enum for construction should use
247    /// `jmap_server::backend::SetError` (declared in the `jmap-server`
248    /// crate, not linkable from here since `jmap-types` does not depend on
249    /// `jmap-server`) — this type is deliberately String-typed for
250    /// client-side parsing flexibility.
251    ///
252    /// # Caller contract — input is not validated
253    ///
254    /// `error_type` is stored verbatim. The constructor does not check
255    /// that the string is non-empty, that it matches an RFC 8620 §5.3
256    /// known type, or that the optional fields populated elsewhere on
257    /// the struct are consistent with the chosen type. `SetError::new("")`
258    /// succeeds and produces a wire-noncompliant `{"type":""}` shape.
259    ///
260    /// Callers who want compile-time guarantees should construct
261    /// `jmap_server::backend::SetError` (the typed enum) and convert,
262    /// rather than calling this constructor with a raw string. Callers
263    /// who do want raw-string construction (e.g. proxies forwarding an
264    /// upstream's error) MUST validate the input themselves before
265    /// passing it here. This matches the workspace pattern for the
266    /// other permissive constructors in this crate
267    /// ([`crate::Id::from`], [`crate::UTCDate::from`] — see the
268    /// jmap-types README "Gotchas" section).
269    pub fn new(error_type: impl Into<String>) -> Self {
270        Self {
271            error_type: error_type.into(),
272            description: None,
273            properties: None,
274            existing_id: None,
275            not_found: None,
276            max_recipients: None,
277            invalid_recipients: None,
278            max_size: None,
279            extra: serde_json::Map::new(),
280        }
281    }
282}
283
284impl std::fmt::Display for SetError {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        match &self.description {
287            Some(desc) => write!(f, "{}: {}", self.error_type, desc),
288            None => write!(f, "{}", self.error_type),
289        }
290    }
291}
292
293/// RFC 8620 §5.3 — `Foo/set` response shape.
294///
295/// Wire shape per RFC 8620 §5.3 (rfc8620.txt §5.3 around line 2033):
296///
297/// ```text
298/// created       Id[Foo]      | null
299/// updated       Id[Foo|null] | null   ← inner null is REQUIRED
300/// destroyed     Id[]         | null
301/// notCreated    Id[SetError] | null
302/// notUpdated    Id[SetError] | null
303/// notDestroyed  Id[SetError] | null
304/// ```
305///
306/// The inner `null` in `updated` is the server's signal that the patch was
307/// applied verbatim with no server-set property deltas to report; a typed
308/// `SetResponse<Foo>` MUST accept this rather than failing because `null`
309/// cannot become `Foo`.
310///
311/// `created` and `not_created` keys are caller-supplied creation ids
312/// (`String`); `updated`, `not_updated`, `not_destroyed` keys are
313/// server-assigned record ids ([`Id`]) — typed differently so callers can
314/// use `updated`/`destroyed` keys interchangeably with ids from any
315/// `/get` response.
316#[non_exhaustive]
317#[derive(Debug, Clone, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319#[serde(bound(
320    deserialize = "T: serde::de::DeserializeOwned",
321    serialize = "T: Serialize"
322))]
323pub struct SetResponse<T = serde_json::Value> {
324    /// The account the response refers to.
325    pub account_id: Id,
326    /// State token before this `/set` was applied. Optional because some
327    /// servers omit it on no-op responses (per RFC 8620 §5.3 the field is
328    /// nullable).
329    pub old_state: Option<State>,
330    /// State token after this `/set`.
331    pub new_state: State,
332    /// Successfully created objects, keyed by caller-supplied creation id.
333    pub created: Option<HashMap<String, T>>,
334    /// Successfully updated objects, keyed by record id. The value is
335    /// `Some(T)` when the server reports server-set property deltas, or
336    /// `None` when the patch was applied verbatim with nothing to echo.
337    pub updated: Option<HashMap<Id, Option<T>>>,
338    /// Ids of successfully destroyed objects.
339    pub destroyed: Option<Vec<Id>>,
340    /// Failed creates, keyed by caller-supplied creation id.
341    pub not_created: Option<HashMap<String, SetError>>,
342    /// Failed updates, keyed by record id.
343    pub not_updated: Option<HashMap<Id, SetError>>,
344    /// Failed destroys, keyed by record id.
345    pub not_destroyed: Option<HashMap<Id, SetError>>,
346    /// Catch-all for vendor / site / private extension fields not covered
347    /// by the typed fields above. Preserves unknown fields across
348    /// deserialize/serialize round-trip per workspace extras-preservation
349    /// policy (see workspace AGENTS.md).
350    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
351    pub extra: serde_json::Map<String, serde_json::Value>,
352}
353
354// ---------------------------------------------------------------------------
355// /query
356// ---------------------------------------------------------------------------
357
358/// RFC 8620 §5.5 — `Foo/query` response shape.
359///
360/// Returns the ids of objects matching a filter, in sort order. The
361/// `query_state` token can be passed to `Foo/queryChanges` to retrieve only
362/// the delta against this snapshot.
363#[non_exhaustive]
364#[derive(Debug, Clone, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct QueryResponse {
367    /// The account the response refers to.
368    pub account_id: Id,
369    /// Opaque state token for this query result; pass to `/queryChanges`.
370    pub query_state: State,
371    /// `true` if `/queryChanges` will give incremental updates against this
372    /// `query_state`; `false` if the client must re-run `/query` to refresh.
373    pub can_calculate_changes: bool,
374    /// Zero-based offset within the full result set of the first id in
375    /// `ids`. Per RFC 8620 §5.5, may differ from the requested `position`
376    /// when the requested offset exceeds the result count.
377    pub position: u64,
378    /// The matching ids in sort order.
379    pub ids: Vec<Id>,
380    /// Total number of matching objects, or `None` when the request did not
381    /// set `calculateTotal: true`.
382    pub total: Option<u64>,
383    /// Server's max page size; `None` when not advertised.
384    pub limit: Option<u64>,
385    /// Catch-all for vendor / site / private extension fields not covered
386    /// by the typed fields above. Preserves unknown fields across
387    /// deserialize/serialize round-trip per workspace extras-preservation
388    /// policy (see workspace AGENTS.md).
389    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
390    pub extra: serde_json::Map<String, serde_json::Value>,
391}
392
393// ---------------------------------------------------------------------------
394// /queryChanges
395// ---------------------------------------------------------------------------
396
397/// A single item added to a query result set (RFC 8620 §5.6).
398///
399/// The `index` is the position the new item occupies in the post-change
400/// result set, accounting for items also added in this batch.
401#[non_exhaustive]
402#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404pub struct AddedItem {
405    /// The id of the new item in the result set.
406    pub id: Id,
407    /// Zero-based position of the new item in the post-change result set.
408    pub index: u64,
409    /// Catch-all for vendor / site / private extension fields not covered
410    /// by the typed fields above. Preserves unknown fields across
411    /// deserialize/serialize round-trip per workspace extras-preservation
412    /// policy (see workspace AGENTS.md).
413    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
414    pub extra: serde_json::Map<String, serde_json::Value>,
415}
416
417/// RFC 8620 §5.6 — `Foo/queryChanges` response shape.
418///
419/// Reports the ids removed from and added to a query result set since
420/// `old_query_state`. Combined with the previous result, the client can
421/// reconstruct the new result without re-fetching all ids.
422#[non_exhaustive]
423#[derive(Debug, Clone, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase")]
425pub struct QueryChangesResponse {
426    /// The account the response refers to.
427    pub account_id: Id,
428    /// The state token the client passed in.
429    pub old_query_state: State,
430    /// The current state token.
431    pub new_query_state: State,
432    /// Total number of matching objects (only when
433    /// `calculateTotal: true` was set in the request).
434    pub total: Option<u64>,
435    /// Ids removed from the result set since `old_query_state`.
436    pub removed: Vec<Id>,
437    /// Items added to the result set, with their new positions.
438    pub added: Vec<AddedItem>,
439    /// Catch-all for vendor / site / private extension fields not covered
440    /// by the typed fields above. Preserves unknown fields across
441    /// deserialize/serialize round-trip per workspace extras-preservation
442    /// policy (see workspace AGENTS.md).
443    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
444    pub extra: serde_json::Map<String, serde_json::Value>,
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use serde_json::json;
451
452    // Independent oracles: hand-written JSON shapes from the RFC 8620
453    // examples and prose descriptions, NOT derived from the types being
454    // tested. Wire round-trips ensure the serde rename rules and field
455    // shapes match the spec.
456
457    #[test]
458    fn get_response_round_trips() {
459        let raw = json!({
460            "accountId": "A1",
461            "state": "s42",
462            "list": [{"id": "x", "name": "First"}],
463            "notFound": ["missing1"]
464        });
465        let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
466        assert_eq!(resp.account_id.as_ref(), "A1");
467        assert_eq!(resp.state, "s42");
468        assert_eq!(resp.list.len(), 1);
469        assert_eq!(resp.list[0]["name"], "First");
470        let nf = resp.not_found.as_ref().unwrap();
471        assert_eq!(nf.len(), 1);
472        assert_eq!(nf[0].as_ref(), "missing1");
473        // Round-trip back to JSON and confirm the camelCase keys.
474        let back = serde_json::to_value(&resp).unwrap();
475        assert_eq!(back["accountId"], "A1");
476        assert_eq!(back["notFound"][0], "missing1");
477    }
478
479    #[test]
480    fn get_response_null_not_found() {
481        // §5.1 allows notFound to be null when the request did not specify
482        // ids (null is treated as the empty list).
483        let raw = json!({
484            "accountId": "A1",
485            "state": "s1",
486            "list": [],
487            "notFound": null
488        });
489        let resp: GetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
490        assert!(resp.not_found.is_none());
491    }
492
493    #[test]
494    fn changes_response_round_trips() {
495        let raw = json!({
496            "accountId": "A1",
497            "oldState": "s0",
498            "newState": "s1",
499            "hasMoreChanges": false,
500            "created": ["a"],
501            "updated": ["b"],
502            "destroyed": ["c"]
503        });
504        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
505        assert_eq!(resp.old_state, "s0");
506        assert_eq!(resp.new_state, "s1");
507        assert!(!resp.has_more_changes);
508        assert_eq!(resp.created[0].as_ref(), "a");
509        assert_eq!(resp.updated[0].as_ref(), "b");
510        assert_eq!(resp.destroyed[0].as_ref(), "c");
511        // RFC 8620 §5.2 base `/changes` does not define `updatedProperties`;
512        // the field must default to `None` when absent from the wire.
513        assert!(resp.updated_properties.is_none());
514    }
515
516    /// Oracle: RFC 8621 §2.2 example response (lines 1015-1031 of rfc8621.txt
517    /// in this repo) — `Mailbox/changes` carries `updatedProperties` listing
518    /// `totalEmails`, `unreadEmails`, `totalThreads`, `unreadThreads`.
519    #[test]
520    fn changes_response_deserializes_mailbox_updated_properties() {
521        let raw = json!({
522            "accountId": "A1",
523            "oldState": "78541",
524            "newState": "78542",
525            "hasMoreChanges": false,
526            "updatedProperties": [
527                "totalEmails", "unreadEmails",
528                "totalThreads", "unreadThreads"
529            ],
530            "created": [],
531            "updated": ["B"],
532            "destroyed": []
533        });
534        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
535        let props = resp
536            .updated_properties
537            .expect("updatedProperties must be present");
538        assert_eq!(
539            props,
540            vec![
541                "totalEmails".to_string(),
542                "unreadEmails".to_string(),
543                "totalThreads".to_string(),
544                "unreadThreads".to_string()
545            ]
546        );
547    }
548
549    /// Oracle: RFC 9425 §5 example response — `Quota/changes` carries
550    /// `updatedProperties: ["used"]` when only quota usage changed.
551    #[test]
552    fn changes_response_deserializes_quota_updated_properties() {
553        let raw = json!({
554            "accountId": "A1",
555            "oldState": "78541",
556            "newState": "78542",
557            "hasMoreChanges": false,
558            "updatedProperties": ["used"],
559            "created": [],
560            "updated": ["2a06df0d-9865-4e74-a92f-74dcc814270e"],
561            "destroyed": []
562        });
563        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
564        let props = resp
565            .updated_properties
566            .expect("updatedProperties must be present");
567        assert_eq!(props, vec!["used".to_string()]);
568    }
569
570    /// `updatedProperties: null` on the wire (RFC 8621 §2.2: "If the server
571    /// is unable to tell if only counts have changed, it MUST just be null")
572    /// must also deserialize as `None` — distinct from omitted but
573    /// semantically equivalent on the typed side.
574    #[test]
575    fn changes_response_accepts_explicit_null_updated_properties() {
576        let raw = json!({
577            "accountId": "A1",
578            "oldState": "s0",
579            "newState": "s1",
580            "hasMoreChanges": false,
581            "updatedProperties": null,
582            "created": [],
583            "updated": ["B"],
584            "destroyed": []
585        });
586        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
587        assert!(resp.updated_properties.is_none());
588    }
589
590    /// Serializing a `ChangesResponse` without `updated_properties` must
591    /// NOT emit a `"updatedProperties": null` key — the
592    /// `skip_serializing_if = "Option::is_none"` attribute keeps the wire
593    /// shape minimal and matches the RFC 8620 §5.2 base envelope for
594    /// methods that don't define the extension field.
595    #[test]
596    fn changes_response_omits_updated_properties_when_none() {
597        let resp = ChangesResponse {
598            account_id: Id::from("A1"),
599            old_state: "s0".into(),
600            new_state: "s1".into(),
601            has_more_changes: false,
602            created: vec![],
603            updated: vec![],
604            destroyed: vec![],
605            updated_properties: None,
606            extra: serde_json::Map::new(),
607        };
608        let serialized = serde_json::to_value(&resp).expect("must serialize");
609        assert!(
610            serialized.get("updatedProperties").is_none(),
611            "updatedProperties must be omitted when None"
612        );
613    }
614
615    #[test]
616    fn set_response_updated_accepts_null_value() {
617        // §5.3 wire type: updated is Id[Foo|null]|null. The inner null
618        // signals "patch applied verbatim, no server-set fields to echo".
619        let raw = json!({
620            "accountId": "A1",
621            "oldState": "s1",
622            "newState": "s2",
623            "updated": { "ev1": null, "ev2": null }
624        });
625        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
626        let upd = resp.updated.unwrap();
627        assert!(upd.get(&Id::from("ev1")).unwrap().is_none());
628        assert!(upd.get(&Id::from("ev2")).unwrap().is_none());
629    }
630
631    #[test]
632    fn set_response_updated_accepts_object_value() {
633        let raw = json!({
634            "accountId": "A1",
635            "oldState": "s1",
636            "newState": "s2",
637            "updated": { "ev1": { "id": "ev1", "title": "Meeting" } }
638        });
639        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
640        let upd = resp.updated.unwrap();
641        let ev1 = upd.get(&Id::from("ev1")).unwrap().as_ref().unwrap();
642        assert_eq!(ev1["title"], "Meeting");
643    }
644
645    #[test]
646    fn set_response_not_updated_keys_are_ids() {
647        // §5.3: notUpdated is Id[SetError]|null. Keys are server-assigned
648        // ids, not creation ids — typing them as Id (not String) lets
649        // callers use the keys interchangeably with /get response ids.
650        let raw = json!({
651            "accountId": "A1",
652            "oldState": "s1",
653            "newState": "s1",
654            "notUpdated": {
655                "ev1": { "type": "stateMismatch" }
656            }
657        });
658        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
659        let nu = resp.not_updated.unwrap();
660        assert_eq!(
661            nu.get(&Id::from("ev1")).unwrap().error_type,
662            "stateMismatch"
663        );
664    }
665
666    #[test]
667    fn set_error_full_8_fields_round_trip() {
668        // §5.3 + RFC 8621 §5.5/§5.7/§7.5: SetError carries up to 8 fields
669        // depending on the error type. The client side must preserve all
670        // of them on deserialize, otherwise callers cannot recover the
671        // existingId / blobNotFound / recipient-list payload that the
672        // server relied on to make the error actionable.
673        let raw = json!({
674            "type": "alreadyExists",
675            "description": "conflict",
676            "properties": ["name"],
677            "existingId": "obj-7",
678            "notFound": ["blob-1", "blob-2"],
679            "maxRecipients": 50,
680            "invalidRecipients": ["bad@", "no@no"],
681            "maxSize": 10485760
682        });
683        let err = SetError::deserialize(&raw).unwrap();
684        assert_eq!(err.error_type, "alreadyExists");
685        assert_eq!(err.description.as_deref(), Some("conflict"));
686        assert_eq!(err.properties.as_ref().unwrap()[0], "name");
687        assert_eq!(err.existing_id.as_ref().unwrap().as_ref(), "obj-7");
688        assert_eq!(err.not_found.as_ref().unwrap().len(), 2);
689        assert_eq!(err.max_recipients, Some(50));
690        assert_eq!(err.invalid_recipients.as_ref().unwrap().len(), 2);
691        assert_eq!(err.max_size, Some(10_485_760));
692        // Round-trip preserves wire field names.
693        let back = serde_json::to_value(&err).unwrap();
694        assert_eq!(back, raw);
695    }
696
697    #[test]
698    fn set_error_minimal_omits_optional_fields_on_serialize() {
699        // §5.3: only `type` is required. Optional fields MUST be omitted
700        // (not serialized as `null`) so the wire matches the server's
701        // construction shape exactly.
702        let err = SetError::new("forbidden");
703        let json = serde_json::to_value(&err).unwrap();
704        assert_eq!(json["type"], "forbidden");
705        assert!(json.get("description").is_none());
706        assert!(json.get("properties").is_none());
707        assert!(json.get("existingId").is_none());
708        assert!(json.get("notFound").is_none());
709        assert!(json.get("maxRecipients").is_none());
710        assert!(json.get("invalidRecipients").is_none());
711        assert!(json.get("maxSize").is_none());
712        // Empty extra map must not appear at all in the wire output.
713        let obj = json.as_object().unwrap();
714        assert_eq!(
715            obj.len(),
716            1,
717            "minimal SetError must serialize to exactly {{type}}: {json}"
718        );
719    }
720
721    #[test]
722    fn set_error_extension_fields_round_trip_via_extra() {
723        // JMAP Chat's serverRetryAfter is a per-extension SetError field
724        // that must round-trip losslessly through extra without the base
725        // type knowing about it. Pin both directions.
726        let raw = json!({
727            "type": "rateLimited",
728            "description": "slow-mode active",
729            "serverRetryAfter": "2026-01-01T00:00:00Z"
730        });
731        let err = SetError::deserialize(&raw).unwrap();
732        assert_eq!(err.error_type, "rateLimited");
733        assert_eq!(err.description.as_deref(), Some("slow-mode active"));
734        assert_eq!(
735            err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
736            Some("2026-01-01T00:00:00Z"),
737            "extension field must land in extra map: {err:?}"
738        );
739        let back = serde_json::to_value(&err).unwrap();
740        assert_eq!(back, raw, "round-trip must preserve extension field");
741    }
742
743    #[test]
744    fn set_error_extension_type_round_trips() {
745        // Extension errors (e.g. calendars draft §10.7.2) MUST round-trip
746        // through the String error_type without a new variant being added.
747        let err = SetError::new("noSupportedScheduleMethods");
748        let json = serde_json::to_value(&err).unwrap();
749        assert_eq!(json["type"], "noSupportedScheduleMethods");
750        let back: SetError = serde_json::from_value(json).unwrap();
751        assert_eq!(back.error_type, "noSupportedScheduleMethods");
752    }
753
754    #[test]
755    fn set_error_display_with_description() {
756        let err = SetError {
757            error_type: "forbidden".to_owned(),
758            description: Some("not your calendar".to_owned()),
759            ..SetError::new("forbidden")
760        };
761        assert_eq!(err.to_string(), "forbidden: not your calendar");
762    }
763
764    #[test]
765    fn set_error_display_without_description() {
766        let err = SetError::new("forbidden");
767        assert_eq!(err.to_string(), "forbidden");
768    }
769
770    #[test]
771    fn query_response_round_trips() {
772        let raw = json!({
773            "accountId": "A1",
774            "queryState": "qs1",
775            "canCalculateChanges": true,
776            "position": 0,
777            "ids": ["a", "b", "c"],
778            "total": 3,
779            "limit": 100
780        });
781        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
782        assert_eq!(resp.query_state, "qs1");
783        assert!(resp.can_calculate_changes);
784        assert_eq!(resp.ids.len(), 3);
785        assert_eq!(resp.total, Some(3));
786        assert_eq!(resp.limit, Some(100));
787    }
788
789    #[test]
790    fn query_response_omits_optional_total_and_limit() {
791        let raw = json!({
792            "accountId": "A1",
793            "queryState": "qs1",
794            "canCalculateChanges": false,
795            "position": 0,
796            "ids": [],
797            "total": null,
798            "limit": null
799        });
800        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
801        assert!(resp.total.is_none());
802        assert!(resp.limit.is_none());
803    }
804
805    #[test]
806    fn query_changes_response_round_trips() {
807        let raw = json!({
808            "accountId": "A1",
809            "oldQueryState": "qs0",
810            "newQueryState": "qs1",
811            "total": 5,
812            "removed": ["x"],
813            "added": [
814                {"id": "y", "index": 2}
815            ]
816        });
817        let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
818        assert_eq!(resp.old_query_state, "qs0");
819        assert_eq!(resp.new_query_state, "qs1");
820        assert_eq!(resp.total, Some(5));
821        assert_eq!(resp.removed[0].as_ref(), "x");
822        assert_eq!(resp.added.len(), 1);
823        assert_eq!(resp.added[0].id.as_ref(), "y");
824        assert_eq!(resp.added[0].index, 2);
825    }
826
827    #[test]
828    fn added_item_round_trips() {
829        let raw = json!({"id": "foo", "index": 7});
830        let item = AddedItem::deserialize(&raw).unwrap();
831        assert_eq!(item.id.as_ref(), "foo");
832        assert_eq!(item.index, 7);
833        assert_eq!(serde_json::to_value(&item).unwrap(), raw);
834    }
835
836    // ── Extras-preservation policy tests (JMAP-lbdy.1) ───────────────────
837    //
838    // One round-trip preservation test per migrated type. Each test
839    // asserts that an unknown vendor / site / private-extension field
840    // survives deserialize/serialize unchanged. Per workspace
841    // AGENTS.md "Extras-preservation policy for vendor/site fields".
842
843    /// `GetResponse.extra` captures vendor fields and preserves them on
844    /// re-serialize.
845    #[test]
846    fn get_response_preserves_vendor_extras() {
847        let raw = json!({
848            "accountId": "A1",
849            "state": "s1",
850            "list": [],
851            "notFound": null,
852            "acmeCorpAuditTrail": {"sequence": 42}
853        });
854        let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
855        assert_eq!(
856            resp.extra
857                .get("acmeCorpAuditTrail")
858                .and_then(|v| v["sequence"].as_u64()),
859            Some(42),
860            "vendor field must land in extra: {:?}",
861            resp.extra
862        );
863        let back = serde_json::to_value(&resp).unwrap();
864        assert_eq!(
865            back["acmeCorpAuditTrail"]["sequence"], 42,
866            "vendor field must survive serialize: {back}"
867        );
868    }
869
870    /// `ChangesResponse.extra` captures vendor fields and preserves them.
871    #[test]
872    fn changes_response_preserves_vendor_extras() {
873        let raw = json!({
874            "accountId": "A1",
875            "oldState": "s0",
876            "newState": "s1",
877            "hasMoreChanges": false,
878            "created": [],
879            "updated": [],
880            "destroyed": [],
881            "acmeCorpReplayToken": "rt-99"
882        });
883        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
884        assert_eq!(
885            resp.extra
886                .get("acmeCorpReplayToken")
887                .and_then(|v| v.as_str()),
888            Some("rt-99")
889        );
890        let back = serde_json::to_value(&resp).unwrap();
891        assert_eq!(back["acmeCorpReplayToken"], "rt-99");
892    }
893
894    /// Pins the `#[serde(flatten)] extra` interaction with the typed
895    /// `updated_properties: Option<Vec<String>>` field on `ChangesResponse`:
896    ///
897    /// - Wire `"updatedProperties": null` MUST be consumed by the typed
898    ///   field (deserialized as `None`) and MUST NOT leak into `extra`.
899    /// - Vendor fields at the top level — including ones whose value is
900    ///   a nested object — MUST land in `extra` and survive round-trip.
901    ///
902    /// Without this regression test, a future serde or `#[serde(flatten)]`
903    /// behavior change could silently move the null-valued typed key into
904    /// `extra` (or fail to consume it from `extra` on the serialize side),
905    /// breaking the wire-format contract for any caller relying on either
906    /// the absence of `updatedProperties` from `extra` after parse or on
907    /// vendor extras being preserved alongside an explicit null. Filed
908    /// under bd:JMAP-6xs8.8.
909    #[test]
910    fn changes_response_null_updated_properties_and_extras_coexist() {
911        let raw = json!({
912            "accountId": "A1",
913            "oldState": "s0",
914            "newState": "s1",
915            "hasMoreChanges": false,
916            "created": [],
917            "updated": ["B"],
918            "destroyed": [],
919            "updatedProperties": null,
920            "acmeCorpReplayToken": "rt-99",
921            "acmeCorpMetadata": { "requestId": "r1", "trace": "x" }
922        });
923
924        let resp: ChangesResponse = serde_json::from_value(raw.clone()).expect("must deserialize");
925
926        // The typed field consumed the explicit null.
927        assert!(
928            resp.updated_properties.is_none(),
929            "explicit null updatedProperties must deserialize as None"
930        );
931
932        // The typed key MUST NOT have leaked into `extra` — flatten is
933        // supposed to visit only the keys the named fields did not
934        // consume, regardless of whether the consumed value was null.
935        assert!(
936            !resp.extra.contains_key("updatedProperties"),
937            "updatedProperties must not appear in extra after the typed \
938             field consumed it (was: {:?})",
939            resp.extra
940        );
941
942        // Top-level vendor fields land in `extra`, including one whose
943        // value is itself a nested object.
944        assert_eq!(
945            resp.extra
946                .get("acmeCorpReplayToken")
947                .and_then(|v| v.as_str()),
948            Some("rt-99")
949        );
950        let nested = resp
951            .extra
952            .get("acmeCorpMetadata")
953            .and_then(|v| v.as_object())
954            .expect("acmeCorpMetadata must be a nested object in extra");
955        assert_eq!(nested.get("requestId").and_then(|v| v.as_str()), Some("r1"));
956        assert_eq!(nested.get("trace").and_then(|v| v.as_str()), Some("x"));
957
958        // Round-trip: serialize and reparse, all three properties
959        // (None updatedProperties omitted, two vendor extras present)
960        // must survive.
961        let back = serde_json::to_value(&resp).expect("must serialize");
962
963        // None typed field is omitted via skip_serializing_if, so the
964        // serialized form should NOT contain updatedProperties at all.
965        assert!(
966            back.get("updatedProperties").is_none(),
967            "None updated_properties must not serialize an explicit null \
968             (skip_serializing_if = Option::is_none): {back}"
969        );
970        assert_eq!(back["acmeCorpReplayToken"], "rt-99");
971        assert_eq!(back["acmeCorpMetadata"]["requestId"], "r1");
972        assert_eq!(back["acmeCorpMetadata"]["trace"], "x");
973
974        // Reparse the serialized form and confirm equivalence on the
975        // typed surface + extras.
976        let resp2: ChangesResponse = serde_json::from_value(back).expect("reparse must succeed");
977        assert!(resp2.updated_properties.is_none());
978        assert_eq!(resp2.extra, resp.extra);
979    }
980
981    /// `SetResponse.extra` captures vendor fields and preserves them.
982    #[test]
983    fn set_response_preserves_vendor_extras() {
984        let raw = json!({
985            "accountId": "A1",
986            "oldState": "s1",
987            "newState": "s2",
988            "acmeCorpTransactionId": "txn-abc"
989        });
990        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
991        assert_eq!(
992            resp.extra
993                .get("acmeCorpTransactionId")
994                .and_then(|v| v.as_str()),
995            Some("txn-abc")
996        );
997        let back = serde_json::to_value(&resp).unwrap();
998        assert_eq!(back["acmeCorpTransactionId"], "txn-abc");
999    }
1000
1001    /// `QueryResponse.extra` captures vendor fields and preserves them.
1002    #[test]
1003    fn query_response_preserves_vendor_extras() {
1004        let raw = json!({
1005            "accountId": "A1",
1006            "queryState": "qs1",
1007            "canCalculateChanges": false,
1008            "position": 0,
1009            "ids": [],
1010            "total": null,
1011            "limit": null,
1012            "acmeCorpSearchTimingMs": 17
1013        });
1014        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
1015        assert_eq!(
1016            resp.extra
1017                .get("acmeCorpSearchTimingMs")
1018                .and_then(|v| v.as_u64()),
1019            Some(17)
1020        );
1021        let back = serde_json::to_value(&resp).unwrap();
1022        assert_eq!(back["acmeCorpSearchTimingMs"], 17);
1023    }
1024
1025    /// `QueryChangesResponse.extra` captures vendor fields and preserves them.
1026    #[test]
1027    fn query_changes_response_preserves_vendor_extras() {
1028        let raw = json!({
1029            "accountId": "A1",
1030            "oldQueryState": "qs0",
1031            "newQueryState": "qs1",
1032            "total": null,
1033            "removed": [],
1034            "added": [],
1035            "acmeCorpDeltaToken": "dt-2"
1036        });
1037        let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
1038        assert_eq!(
1039            resp.extra
1040                .get("acmeCorpDeltaToken")
1041                .and_then(|v| v.as_str()),
1042            Some("dt-2")
1043        );
1044        let back = serde_json::to_value(&resp).unwrap();
1045        assert_eq!(back["acmeCorpDeltaToken"], "dt-2");
1046    }
1047
1048    /// `AddedItem.extra` captures vendor fields and preserves them.
1049    #[test]
1050    fn added_item_preserves_vendor_extras() {
1051        let raw = json!({
1052            "id": "x",
1053            "index": 0,
1054            "acmeCorpHighlight": true
1055        });
1056        let item = AddedItem::deserialize(&raw).unwrap();
1057        assert_eq!(
1058            item.extra
1059                .get("acmeCorpHighlight")
1060                .and_then(|v| v.as_bool()),
1061            Some(true)
1062        );
1063        let back = serde_json::to_value(&item).unwrap();
1064        assert_eq!(back["acmeCorpHighlight"], true);
1065    }
1066
1067    /// Empty extras must NOT serialize as a key on the wire — the
1068    /// `skip_serializing_if = "serde_json::Map::is_empty"` attribute keeps
1069    /// the wire shape byte-identical to the pre-migration form when no
1070    /// vendor fields are present.
1071    #[test]
1072    fn empty_extras_omitted_from_wire() {
1073        let resp = AddedItem {
1074            id: Id::from("z"),
1075            index: 1,
1076            extra: serde_json::Map::new(),
1077        };
1078        let serialized = serde_json::to_value(&resp).expect("must serialize");
1079        let obj = serialized.as_object().expect("must be object");
1080        assert_eq!(
1081            obj.len(),
1082            2,
1083            "empty extras must not add any wire keys; got {serialized}"
1084        );
1085        assert!(obj.contains_key("id"));
1086        assert!(obj.contains_key("index"));
1087    }
1088}