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::BTreeMap;
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) to match the
235    /// workspace extras-preservation policy (see workspace `AGENTS.md`)
236    /// and to give callers deterministic serialized output.
237    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
238    pub extra: serde_json::Map<String, serde_json::Value>,
239}
240
241impl SetError {
242    /// Construct a `SetError` with the given type string and all optional
243    /// fields `None` / empty. Use this when deserializing tests or when
244    /// constructing a wire-shaped error from a typed source. Server crates
245    /// that want a typed enum for construction should use
246    /// `jmap_server::backend::SetError` (declared in the `jmap-server`
247    /// crate, not linkable from here since `jmap-types` does not depend on
248    /// `jmap-server`) — this type is deliberately String-typed for
249    /// client-side parsing flexibility.
250    ///
251    /// # Caller contract — input is not validated
252    ///
253    /// `error_type` is stored verbatim. The constructor does not check
254    /// that the string is non-empty, that it matches an RFC 8620 §5.3
255    /// known type, or that the optional fields populated elsewhere on
256    /// the struct are consistent with the chosen type. `SetError::new("")`
257    /// succeeds and produces a wire-noncompliant `{"type":""}` shape.
258    ///
259    /// Callers who want compile-time guarantees should construct
260    /// `jmap_server::backend::SetError` (the typed enum) and convert,
261    /// rather than calling this constructor with a raw string. Callers
262    /// who do want raw-string construction (e.g. proxies forwarding an
263    /// upstream's error) MUST validate the input themselves before
264    /// passing it here. This matches the workspace pattern for the
265    /// other permissive constructors in this crate
266    /// ([`crate::Id::from`], [`crate::UTCDate::from`] — see the
267    /// jmap-types README "Gotchas" section).
268    pub fn new(error_type: impl Into<String>) -> Self {
269        Self {
270            error_type: error_type.into(),
271            description: None,
272            properties: None,
273            existing_id: None,
274            not_found: None,
275            max_recipients: None,
276            invalid_recipients: None,
277            max_size: None,
278            extra: serde_json::Map::new(),
279        }
280    }
281}
282
283impl std::fmt::Display for SetError {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        match &self.description {
286            Some(desc) => write!(f, "{}: {}", self.error_type, desc),
287            None => write!(f, "{}", self.error_type),
288        }
289    }
290}
291
292/// RFC 8620 §5.3 — `Foo/set` response shape.
293///
294/// Wire shape per RFC 8620 §5.3 (rfc8620.txt §5.3 around line 2033):
295///
296/// ```text
297/// created       Id[Foo]      | null
298/// updated       Id[Foo|null] | null   ← inner null is REQUIRED
299/// destroyed     Id[]         | null
300/// notCreated    Id[SetError] | null
301/// notUpdated    Id[SetError] | null
302/// notDestroyed  Id[SetError] | null
303/// ```
304///
305/// The inner `null` in `updated` is the server's signal that the patch was
306/// applied verbatim with no server-set property deltas to report; a typed
307/// `SetResponse<Foo>` MUST accept this rather than failing because `null`
308/// cannot become `Foo`.
309///
310/// `created` and `not_created` keys are caller-supplied creation ids
311/// (`String`); `updated`, `not_updated`, `not_destroyed` keys are
312/// server-assigned record ids ([`Id`]) — typed differently so callers can
313/// use `updated`/`destroyed` keys interchangeably with ids from any
314/// `/get` response.
315#[non_exhaustive]
316#[derive(Debug, Clone, Serialize, Deserialize)]
317#[serde(rename_all = "camelCase")]
318#[serde(bound(
319    deserialize = "T: serde::de::DeserializeOwned",
320    serialize = "T: Serialize"
321))]
322pub struct SetResponse<T = serde_json::Value> {
323    /// The account the response refers to.
324    pub account_id: Id,
325    /// State token before this `/set` was applied. Optional because some
326    /// servers omit it on no-op responses (per RFC 8620 §5.3 the field is
327    /// nullable).
328    pub old_state: Option<State>,
329    /// State token after this `/set`.
330    pub new_state: State,
331    /// Successfully created objects, keyed by caller-supplied creation id.
332    pub created: Option<BTreeMap<String, T>>,
333    /// Successfully updated objects, keyed by record id. The value is
334    /// `Some(T)` when the server reports server-set property deltas, or
335    /// `None` when the patch was applied verbatim with nothing to echo.
336    pub updated: Option<BTreeMap<Id, Option<T>>>,
337    /// Ids of successfully destroyed objects.
338    pub destroyed: Option<Vec<Id>>,
339    /// Failed creates, keyed by caller-supplied creation id.
340    pub not_created: Option<BTreeMap<String, SetError>>,
341    /// Failed updates, keyed by record id.
342    pub not_updated: Option<BTreeMap<Id, SetError>>,
343    /// Failed destroys, keyed by record id.
344    pub not_destroyed: Option<BTreeMap<Id, SetError>>,
345    /// Catch-all for vendor / site / private extension fields not covered
346    /// by the typed fields above. Preserves unknown fields across
347    /// deserialize/serialize round-trip per workspace extras-preservation
348    /// policy (see workspace AGENTS.md).
349    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
350    pub extra: serde_json::Map<String, serde_json::Value>,
351}
352
353// ---------------------------------------------------------------------------
354// /query
355// ---------------------------------------------------------------------------
356
357/// RFC 8620 §5.5 — `Foo/query` response shape.
358///
359/// Returns the ids of objects matching a filter, in sort order. The
360/// `query_state` token can be passed to `Foo/queryChanges` to retrieve only
361/// the delta against this snapshot.
362#[non_exhaustive]
363#[derive(Debug, Clone, Serialize, Deserialize)]
364#[serde(rename_all = "camelCase")]
365pub struct QueryResponse {
366    /// The account the response refers to.
367    pub account_id: Id,
368    /// Opaque state token for this query result; pass to `/queryChanges`.
369    pub query_state: State,
370    /// `true` if `/queryChanges` will give incremental updates against this
371    /// `query_state`; `false` if the client must re-run `/query` to refresh.
372    pub can_calculate_changes: bool,
373    /// Zero-based offset within the full result set of the first id in
374    /// `ids`. Per RFC 8620 §5.5, may differ from the requested `position`
375    /// when the requested offset exceeds the result count.
376    pub position: u64,
377    /// The matching ids in sort order.
378    pub ids: Vec<Id>,
379    /// Total number of matching objects, or `None` when the request did not
380    /// set `calculateTotal: true`.
381    pub total: Option<u64>,
382    /// Server's max page size; `None` when not advertised.
383    pub limit: Option<u64>,
384    /// Catch-all for vendor / site / private extension fields not covered
385    /// by the typed fields above. Preserves unknown fields across
386    /// deserialize/serialize round-trip per workspace extras-preservation
387    /// policy (see workspace AGENTS.md).
388    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
389    pub extra: serde_json::Map<String, serde_json::Value>,
390}
391
392// ---------------------------------------------------------------------------
393// /queryChanges
394// ---------------------------------------------------------------------------
395
396/// A single item added to a query result set (RFC 8620 §5.6).
397///
398/// The `index` is the position the new item occupies in the post-change
399/// result set, accounting for items also added in this batch.
400#[non_exhaustive]
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct AddedItem {
404    /// The id of the new item in the result set.
405    pub id: Id,
406    /// Zero-based position of the new item in the post-change result set.
407    pub index: u64,
408    /// Catch-all for vendor / site / private extension fields not covered
409    /// by the typed fields above. Preserves unknown fields across
410    /// deserialize/serialize round-trip per workspace extras-preservation
411    /// policy (see workspace AGENTS.md).
412    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
413    pub extra: serde_json::Map<String, serde_json::Value>,
414}
415
416/// RFC 8620 §5.6 — `Foo/queryChanges` response shape.
417///
418/// Reports the ids removed from and added to a query result set since
419/// `old_query_state`. Combined with the previous result, the client can
420/// reconstruct the new result without re-fetching all ids.
421#[non_exhaustive]
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[serde(rename_all = "camelCase")]
424pub struct QueryChangesResponse {
425    /// The account the response refers to.
426    pub account_id: Id,
427    /// The state token the client passed in.
428    pub old_query_state: State,
429    /// The current state token.
430    pub new_query_state: State,
431    /// Total number of matching objects (only when
432    /// `calculateTotal: true` was set in the request).
433    pub total: Option<u64>,
434    /// Ids removed from the result set since `old_query_state`.
435    pub removed: Vec<Id>,
436    /// Items added to the result set, with their new positions.
437    pub added: Vec<AddedItem>,
438    /// Catch-all for vendor / site / private extension fields not covered
439    /// by the typed fields above. Preserves unknown fields across
440    /// deserialize/serialize round-trip per workspace extras-preservation
441    /// policy (see workspace AGENTS.md).
442    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
443    pub extra: serde_json::Map<String, serde_json::Value>,
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use serde_json::json;
450
451    // Independent oracles: hand-written JSON shapes from the RFC 8620
452    // examples and prose descriptions, NOT derived from the types being
453    // tested. Wire round-trips ensure the serde rename rules and field
454    // shapes match the spec.
455
456    #[test]
457    fn get_response_round_trips() {
458        let raw = json!({
459            "accountId": "A1",
460            "state": "s42",
461            "list": [{"id": "x", "name": "First"}],
462            "notFound": ["missing1"]
463        });
464        let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
465        assert_eq!(resp.account_id.as_ref(), "A1");
466        assert_eq!(resp.state, "s42");
467        assert_eq!(resp.list.len(), 1);
468        assert_eq!(resp.list[0]["name"], "First");
469        let nf = resp.not_found.as_ref().unwrap();
470        assert_eq!(nf.len(), 1);
471        assert_eq!(nf[0].as_ref(), "missing1");
472        // Round-trip back to JSON and confirm the camelCase keys.
473        let back = serde_json::to_value(&resp).unwrap();
474        assert_eq!(back["accountId"], "A1");
475        assert_eq!(back["notFound"][0], "missing1");
476    }
477
478    #[test]
479    fn get_response_null_not_found() {
480        // §5.1 allows notFound to be null when the request did not specify
481        // ids (null is treated as the empty list).
482        let raw = json!({
483            "accountId": "A1",
484            "state": "s1",
485            "list": [],
486            "notFound": null
487        });
488        let resp: GetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
489        assert!(resp.not_found.is_none());
490    }
491
492    #[test]
493    fn changes_response_round_trips() {
494        let raw = json!({
495            "accountId": "A1",
496            "oldState": "s0",
497            "newState": "s1",
498            "hasMoreChanges": false,
499            "created": ["a"],
500            "updated": ["b"],
501            "destroyed": ["c"]
502        });
503        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
504        assert_eq!(resp.old_state, "s0");
505        assert_eq!(resp.new_state, "s1");
506        assert!(!resp.has_more_changes);
507        assert_eq!(resp.created[0].as_ref(), "a");
508        assert_eq!(resp.updated[0].as_ref(), "b");
509        assert_eq!(resp.destroyed[0].as_ref(), "c");
510        // RFC 8620 §5.2 base `/changes` does not define `updatedProperties`;
511        // the field must default to `None` when absent from the wire.
512        assert!(resp.updated_properties.is_none());
513    }
514
515    /// Oracle: RFC 8621 §2.2 example response (lines 1015-1031 of rfc8621.txt
516    /// in this repo) — `Mailbox/changes` carries `updatedProperties` listing
517    /// `totalEmails`, `unreadEmails`, `totalThreads`, `unreadThreads`.
518    #[test]
519    fn changes_response_deserializes_mailbox_updated_properties() {
520        let raw = json!({
521            "accountId": "A1",
522            "oldState": "78541",
523            "newState": "78542",
524            "hasMoreChanges": false,
525            "updatedProperties": [
526                "totalEmails", "unreadEmails",
527                "totalThreads", "unreadThreads"
528            ],
529            "created": [],
530            "updated": ["B"],
531            "destroyed": []
532        });
533        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
534        let props = resp
535            .updated_properties
536            .expect("updatedProperties must be present");
537        assert_eq!(
538            props,
539            vec![
540                "totalEmails".to_string(),
541                "unreadEmails".to_string(),
542                "totalThreads".to_string(),
543                "unreadThreads".to_string()
544            ]
545        );
546    }
547
548    /// Oracle: RFC 9425 §5 example response — `Quota/changes` carries
549    /// `updatedProperties: ["used"]` when only quota usage changed.
550    #[test]
551    fn changes_response_deserializes_quota_updated_properties() {
552        let raw = json!({
553            "accountId": "A1",
554            "oldState": "78541",
555            "newState": "78542",
556            "hasMoreChanges": false,
557            "updatedProperties": ["used"],
558            "created": [],
559            "updated": ["2a06df0d-9865-4e74-a92f-74dcc814270e"],
560            "destroyed": []
561        });
562        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
563        let props = resp
564            .updated_properties
565            .expect("updatedProperties must be present");
566        assert_eq!(props, vec!["used".to_string()]);
567    }
568
569    /// `updatedProperties: null` on the wire (RFC 8621 §2.2: "If the server
570    /// is unable to tell if only counts have changed, it MUST just be null")
571    /// must also deserialize as `None` — distinct from omitted but
572    /// semantically equivalent on the typed side.
573    #[test]
574    fn changes_response_accepts_explicit_null_updated_properties() {
575        let raw = json!({
576            "accountId": "A1",
577            "oldState": "s0",
578            "newState": "s1",
579            "hasMoreChanges": false,
580            "updatedProperties": null,
581            "created": [],
582            "updated": ["B"],
583            "destroyed": []
584        });
585        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
586        assert!(resp.updated_properties.is_none());
587    }
588
589    /// Serializing a `ChangesResponse` without `updated_properties` must
590    /// NOT emit a `"updatedProperties": null` key — the
591    /// `skip_serializing_if = "Option::is_none"` attribute keeps the wire
592    /// shape minimal and matches the RFC 8620 §5.2 base envelope for
593    /// methods that don't define the extension field.
594    #[test]
595    fn changes_response_omits_updated_properties_when_none() {
596        let resp = ChangesResponse {
597            account_id: Id::from("A1"),
598            old_state: "s0".into(),
599            new_state: "s1".into(),
600            has_more_changes: false,
601            created: vec![],
602            updated: vec![],
603            destroyed: vec![],
604            updated_properties: None,
605            extra: serde_json::Map::new(),
606        };
607        let serialized = serde_json::to_value(&resp).expect("must serialize");
608        assert!(
609            serialized.get("updatedProperties").is_none(),
610            "updatedProperties must be omitted when None"
611        );
612    }
613
614    #[test]
615    fn set_response_updated_accepts_null_value() {
616        // §5.3 wire type: updated is Id[Foo|null]|null. The inner null
617        // signals "patch applied verbatim, no server-set fields to echo".
618        let raw = json!({
619            "accountId": "A1",
620            "oldState": "s1",
621            "newState": "s2",
622            "updated": { "ev1": null, "ev2": null }
623        });
624        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
625        let upd = resp.updated.unwrap();
626        assert!(upd.get(&Id::from("ev1")).unwrap().is_none());
627        assert!(upd.get(&Id::from("ev2")).unwrap().is_none());
628    }
629
630    #[test]
631    fn set_response_updated_accepts_object_value() {
632        let raw = json!({
633            "accountId": "A1",
634            "oldState": "s1",
635            "newState": "s2",
636            "updated": { "ev1": { "id": "ev1", "title": "Meeting" } }
637        });
638        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
639        let upd = resp.updated.unwrap();
640        let ev1 = upd.get(&Id::from("ev1")).unwrap().as_ref().unwrap();
641        assert_eq!(ev1["title"], "Meeting");
642    }
643
644    #[test]
645    fn set_response_not_updated_keys_are_ids() {
646        // §5.3: notUpdated is Id[SetError]|null. Keys are server-assigned
647        // ids, not creation ids — typing them as Id (not String) lets
648        // callers use the keys interchangeably with /get response ids.
649        let raw = json!({
650            "accountId": "A1",
651            "oldState": "s1",
652            "newState": "s1",
653            "notUpdated": {
654                "ev1": { "type": "stateMismatch" }
655            }
656        });
657        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
658        let nu = resp.not_updated.unwrap();
659        assert_eq!(
660            nu.get(&Id::from("ev1")).unwrap().error_type,
661            "stateMismatch"
662        );
663    }
664
665    #[test]
666    fn set_error_full_8_fields_round_trip() {
667        // §5.3 + RFC 8621 §5.5/§5.7/§7.5: SetError carries up to 8 fields
668        // depending on the error type. The client side must preserve all
669        // of them on deserialize, otherwise callers cannot recover the
670        // existingId / blobNotFound / recipient-list payload that the
671        // server relied on to make the error actionable.
672        let raw = json!({
673            "type": "alreadyExists",
674            "description": "conflict",
675            "properties": ["name"],
676            "existingId": "obj-7",
677            "notFound": ["blob-1", "blob-2"],
678            "maxRecipients": 50,
679            "invalidRecipients": ["bad@", "no@no"],
680            "maxSize": 10485760
681        });
682        let err = SetError::deserialize(&raw).unwrap();
683        assert_eq!(err.error_type, "alreadyExists");
684        assert_eq!(err.description.as_deref(), Some("conflict"));
685        assert_eq!(err.properties.as_ref().unwrap()[0], "name");
686        assert_eq!(err.existing_id.as_ref().unwrap().as_ref(), "obj-7");
687        assert_eq!(err.not_found.as_ref().unwrap().len(), 2);
688        assert_eq!(err.max_recipients, Some(50));
689        assert_eq!(err.invalid_recipients.as_ref().unwrap().len(), 2);
690        assert_eq!(err.max_size, Some(10_485_760));
691        // Round-trip preserves wire field names.
692        let back = serde_json::to_value(&err).unwrap();
693        assert_eq!(back, raw);
694    }
695
696    #[test]
697    fn set_error_minimal_omits_optional_fields_on_serialize() {
698        // §5.3: only `type` is required. Optional fields MUST be omitted
699        // (not serialized as `null`) so the wire matches the server's
700        // construction shape exactly.
701        let err = SetError::new("forbidden");
702        let json = serde_json::to_value(&err).unwrap();
703        assert_eq!(json["type"], "forbidden");
704        assert!(json.get("description").is_none());
705        assert!(json.get("properties").is_none());
706        assert!(json.get("existingId").is_none());
707        assert!(json.get("notFound").is_none());
708        assert!(json.get("maxRecipients").is_none());
709        assert!(json.get("invalidRecipients").is_none());
710        assert!(json.get("maxSize").is_none());
711        // Empty extra map must not appear at all in the wire output.
712        let obj = json.as_object().unwrap();
713        assert_eq!(
714            obj.len(),
715            1,
716            "minimal SetError must serialize to exactly {{type}}: {json}"
717        );
718    }
719
720    #[test]
721    fn set_error_extension_fields_round_trip_via_extra() {
722        // JMAP Chat's serverRetryAfter is a per-extension SetError field
723        // that must round-trip losslessly through extra without the base
724        // type knowing about it. Pin both directions.
725        let raw = json!({
726            "type": "rateLimited",
727            "description": "slow-mode active",
728            "serverRetryAfter": "2026-01-01T00:00:00Z"
729        });
730        let err = SetError::deserialize(&raw).unwrap();
731        assert_eq!(err.error_type, "rateLimited");
732        assert_eq!(err.description.as_deref(), Some("slow-mode active"));
733        assert_eq!(
734            err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
735            Some("2026-01-01T00:00:00Z"),
736            "extension field must land in extra map: {err:?}"
737        );
738        let back = serde_json::to_value(&err).unwrap();
739        assert_eq!(back, raw, "round-trip must preserve extension field");
740    }
741
742    #[test]
743    fn set_error_extension_type_round_trips() {
744        // Extension errors (e.g. calendars draft §10.7.2) MUST round-trip
745        // through the String error_type without a new variant being added.
746        let err = SetError::new("noSupportedScheduleMethods");
747        let json = serde_json::to_value(&err).unwrap();
748        assert_eq!(json["type"], "noSupportedScheduleMethods");
749        let back: SetError = serde_json::from_value(json).unwrap();
750        assert_eq!(back.error_type, "noSupportedScheduleMethods");
751    }
752
753    #[test]
754    fn set_error_display_with_description() {
755        let err = SetError {
756            error_type: "forbidden".to_owned(),
757            description: Some("not your calendar".to_owned()),
758            ..SetError::new("forbidden")
759        };
760        assert_eq!(err.to_string(), "forbidden: not your calendar");
761    }
762
763    #[test]
764    fn set_error_display_without_description() {
765        let err = SetError::new("forbidden");
766        assert_eq!(err.to_string(), "forbidden");
767    }
768
769    #[test]
770    fn query_response_round_trips() {
771        let raw = json!({
772            "accountId": "A1",
773            "queryState": "qs1",
774            "canCalculateChanges": true,
775            "position": 0,
776            "ids": ["a", "b", "c"],
777            "total": 3,
778            "limit": 100
779        });
780        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
781        assert_eq!(resp.query_state, "qs1");
782        assert!(resp.can_calculate_changes);
783        assert_eq!(resp.ids.len(), 3);
784        assert_eq!(resp.total, Some(3));
785        assert_eq!(resp.limit, Some(100));
786    }
787
788    #[test]
789    fn query_response_omits_optional_total_and_limit() {
790        let raw = json!({
791            "accountId": "A1",
792            "queryState": "qs1",
793            "canCalculateChanges": false,
794            "position": 0,
795            "ids": [],
796            "total": null,
797            "limit": null
798        });
799        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
800        assert!(resp.total.is_none());
801        assert!(resp.limit.is_none());
802    }
803
804    #[test]
805    fn query_changes_response_round_trips() {
806        let raw = json!({
807            "accountId": "A1",
808            "oldQueryState": "qs0",
809            "newQueryState": "qs1",
810            "total": 5,
811            "removed": ["x"],
812            "added": [
813                {"id": "y", "index": 2}
814            ]
815        });
816        let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
817        assert_eq!(resp.old_query_state, "qs0");
818        assert_eq!(resp.new_query_state, "qs1");
819        assert_eq!(resp.total, Some(5));
820        assert_eq!(resp.removed[0].as_ref(), "x");
821        assert_eq!(resp.added.len(), 1);
822        assert_eq!(resp.added[0].id.as_ref(), "y");
823        assert_eq!(resp.added[0].index, 2);
824    }
825
826    #[test]
827    fn added_item_round_trips() {
828        let raw = json!({"id": "foo", "index": 7});
829        let item = AddedItem::deserialize(&raw).unwrap();
830        assert_eq!(item.id.as_ref(), "foo");
831        assert_eq!(item.index, 7);
832        assert_eq!(serde_json::to_value(&item).unwrap(), raw);
833    }
834
835    // ── Extras-preservation policy tests (JMAP-lbdy.1) ───────────────────
836    //
837    // One round-trip preservation test per migrated type. Each test
838    // asserts that an unknown vendor / site / private-extension field
839    // survives deserialize/serialize unchanged. Per workspace
840    // AGENTS.md "Extras-preservation policy for vendor/site fields".
841
842    /// `GetResponse.extra` captures vendor fields and preserves them on
843    /// re-serialize.
844    #[test]
845    fn get_response_preserves_vendor_extras() {
846        let raw = json!({
847            "accountId": "A1",
848            "state": "s1",
849            "list": [],
850            "notFound": null,
851            "acmeCorpAuditTrail": {"sequence": 42}
852        });
853        let resp = GetResponse::<serde_json::Value>::deserialize(&raw).unwrap();
854        assert_eq!(
855            resp.extra
856                .get("acmeCorpAuditTrail")
857                .and_then(|v| v["sequence"].as_u64()),
858            Some(42),
859            "vendor field must land in extra: {:?}",
860            resp.extra
861        );
862        let back = serde_json::to_value(&resp).unwrap();
863        assert_eq!(
864            back["acmeCorpAuditTrail"]["sequence"], 42,
865            "vendor field must survive serialize: {back}"
866        );
867    }
868
869    /// `ChangesResponse.extra` captures vendor fields and preserves them.
870    #[test]
871    fn changes_response_preserves_vendor_extras() {
872        let raw = json!({
873            "accountId": "A1",
874            "oldState": "s0",
875            "newState": "s1",
876            "hasMoreChanges": false,
877            "created": [],
878            "updated": [],
879            "destroyed": [],
880            "acmeCorpReplayToken": "rt-99"
881        });
882        let resp: ChangesResponse = serde_json::from_value(raw).unwrap();
883        assert_eq!(
884            resp.extra
885                .get("acmeCorpReplayToken")
886                .and_then(|v| v.as_str()),
887            Some("rt-99")
888        );
889        let back = serde_json::to_value(&resp).unwrap();
890        assert_eq!(back["acmeCorpReplayToken"], "rt-99");
891    }
892
893    /// Pins the `#[serde(flatten)] extra` interaction with the typed
894    /// `updated_properties: Option<Vec<String>>` field on `ChangesResponse`:
895    ///
896    /// - Wire `"updatedProperties": null` MUST be consumed by the typed
897    ///   field (deserialized as `None`) and MUST NOT leak into `extra`.
898    /// - Vendor fields at the top level — including ones whose value is
899    ///   a nested object — MUST land in `extra` and survive round-trip.
900    ///
901    /// Without this regression test, a future serde or `#[serde(flatten)]`
902    /// behavior change could silently move the null-valued typed key into
903    /// `extra` (or fail to consume it from `extra` on the serialize side),
904    /// breaking the wire-format contract for any caller relying on either
905    /// the absence of `updatedProperties` from `extra` after parse or on
906    /// vendor extras being preserved alongside an explicit null. Filed
907    /// under bd:JMAP-6xs8.8.
908    #[test]
909    fn changes_response_null_updated_properties_and_extras_coexist() {
910        let raw = json!({
911            "accountId": "A1",
912            "oldState": "s0",
913            "newState": "s1",
914            "hasMoreChanges": false,
915            "created": [],
916            "updated": ["B"],
917            "destroyed": [],
918            "updatedProperties": null,
919            "acmeCorpReplayToken": "rt-99",
920            "acmeCorpMetadata": { "requestId": "r1", "trace": "x" }
921        });
922
923        let resp: ChangesResponse = serde_json::from_value(raw.clone()).expect("must deserialize");
924
925        // The typed field consumed the explicit null.
926        assert!(
927            resp.updated_properties.is_none(),
928            "explicit null updatedProperties must deserialize as None"
929        );
930
931        // The typed key MUST NOT have leaked into `extra` — flatten is
932        // supposed to visit only the keys the named fields did not
933        // consume, regardless of whether the consumed value was null.
934        assert!(
935            !resp.extra.contains_key("updatedProperties"),
936            "updatedProperties must not appear in extra after the typed \
937             field consumed it (was: {:?})",
938            resp.extra
939        );
940
941        // Top-level vendor fields land in `extra`, including one whose
942        // value is itself a nested object.
943        assert_eq!(
944            resp.extra
945                .get("acmeCorpReplayToken")
946                .and_then(|v| v.as_str()),
947            Some("rt-99")
948        );
949        let nested = resp
950            .extra
951            .get("acmeCorpMetadata")
952            .and_then(|v| v.as_object())
953            .expect("acmeCorpMetadata must be a nested object in extra");
954        assert_eq!(nested.get("requestId").and_then(|v| v.as_str()), Some("r1"));
955        assert_eq!(nested.get("trace").and_then(|v| v.as_str()), Some("x"));
956
957        // Round-trip: serialize and reparse, all three properties
958        // (None updatedProperties omitted, two vendor extras present)
959        // must survive.
960        let back = serde_json::to_value(&resp).expect("must serialize");
961
962        // None typed field is omitted via skip_serializing_if, so the
963        // serialized form should NOT contain updatedProperties at all.
964        assert!(
965            back.get("updatedProperties").is_none(),
966            "None updated_properties must not serialize an explicit null \
967             (skip_serializing_if = Option::is_none): {back}"
968        );
969        assert_eq!(back["acmeCorpReplayToken"], "rt-99");
970        assert_eq!(back["acmeCorpMetadata"]["requestId"], "r1");
971        assert_eq!(back["acmeCorpMetadata"]["trace"], "x");
972
973        // Reparse the serialized form and confirm equivalence on the
974        // typed surface + extras.
975        let resp2: ChangesResponse = serde_json::from_value(back).expect("reparse must succeed");
976        assert!(resp2.updated_properties.is_none());
977        assert_eq!(resp2.extra, resp.extra);
978    }
979
980    /// `SetResponse.extra` captures vendor fields and preserves them.
981    #[test]
982    fn set_response_preserves_vendor_extras() {
983        let raw = json!({
984            "accountId": "A1",
985            "oldState": "s1",
986            "newState": "s2",
987            "acmeCorpTransactionId": "txn-abc"
988        });
989        let resp: SetResponse<serde_json::Value> = serde_json::from_value(raw).unwrap();
990        assert_eq!(
991            resp.extra
992                .get("acmeCorpTransactionId")
993                .and_then(|v| v.as_str()),
994            Some("txn-abc")
995        );
996        let back = serde_json::to_value(&resp).unwrap();
997        assert_eq!(back["acmeCorpTransactionId"], "txn-abc");
998    }
999
1000    /// `QueryResponse.extra` captures vendor fields and preserves them.
1001    #[test]
1002    fn query_response_preserves_vendor_extras() {
1003        let raw = json!({
1004            "accountId": "A1",
1005            "queryState": "qs1",
1006            "canCalculateChanges": false,
1007            "position": 0,
1008            "ids": [],
1009            "total": null,
1010            "limit": null,
1011            "acmeCorpSearchTimingMs": 17
1012        });
1013        let resp: QueryResponse = serde_json::from_value(raw).unwrap();
1014        assert_eq!(
1015            resp.extra
1016                .get("acmeCorpSearchTimingMs")
1017                .and_then(|v| v.as_u64()),
1018            Some(17)
1019        );
1020        let back = serde_json::to_value(&resp).unwrap();
1021        assert_eq!(back["acmeCorpSearchTimingMs"], 17);
1022    }
1023
1024    /// `QueryChangesResponse.extra` captures vendor fields and preserves them.
1025    #[test]
1026    fn query_changes_response_preserves_vendor_extras() {
1027        let raw = json!({
1028            "accountId": "A1",
1029            "oldQueryState": "qs0",
1030            "newQueryState": "qs1",
1031            "total": null,
1032            "removed": [],
1033            "added": [],
1034            "acmeCorpDeltaToken": "dt-2"
1035        });
1036        let resp: QueryChangesResponse = serde_json::from_value(raw).unwrap();
1037        assert_eq!(
1038            resp.extra
1039                .get("acmeCorpDeltaToken")
1040                .and_then(|v| v.as_str()),
1041            Some("dt-2")
1042        );
1043        let back = serde_json::to_value(&resp).unwrap();
1044        assert_eq!(back["acmeCorpDeltaToken"], "dt-2");
1045    }
1046
1047    /// `AddedItem.extra` captures vendor fields and preserves them.
1048    #[test]
1049    fn added_item_preserves_vendor_extras() {
1050        let raw = json!({
1051            "id": "x",
1052            "index": 0,
1053            "acmeCorpHighlight": true
1054        });
1055        let item = AddedItem::deserialize(&raw).unwrap();
1056        assert_eq!(
1057            item.extra
1058                .get("acmeCorpHighlight")
1059                .and_then(|v| v.as_bool()),
1060            Some(true)
1061        );
1062        let back = serde_json::to_value(&item).unwrap();
1063        assert_eq!(back["acmeCorpHighlight"], true);
1064    }
1065
1066    /// Empty extras must NOT serialize as a key on the wire — the
1067    /// `skip_serializing_if = "serde_json::Map::is_empty"` attribute keeps
1068    /// the wire shape byte-identical to the pre-migration form when no
1069    /// vendor fields are present.
1070    #[test]
1071    fn empty_extras_omitted_from_wire() {
1072        let resp = AddedItem {
1073            id: Id::from("z"),
1074            index: 1,
1075            extra: serde_json::Map::new(),
1076        };
1077        let serialized = serde_json::to_value(&resp).expect("must serialize");
1078        let obj = serialized.as_object().expect("must be object");
1079        assert_eq!(
1080            obj.len(),
1081            2,
1082            "empty extras must not add any wire keys; got {serialized}"
1083        );
1084        assert!(obj.contains_key("id"));
1085        assert!(obj.contains_key("index"));
1086    }
1087
1088    // Oracle: workspace map-ordering policy (bd:JMAP-pdux) — wire-format
1089    // maps serialize in sorted key order. Input keys are written out of
1090    // order; expected bytes are hand-written.
1091    #[test]
1092    fn set_response_maps_serialize_in_sorted_key_order() {
1093        let raw = r#"{
1094            "accountId": "a1",
1095            "newState": "s2",
1096            "created": {"k2": {"id": "id2"}, "k1": {"id": "id1"}},
1097            "notUpdated": {"id9": {"type": "notFound"}, "id8": {"type": "notFound"}}
1098        }"#;
1099        let resp: SetResponse<serde_json::Value> = serde_json::from_str(raw).expect("deserialize");
1100        let j = serde_json::to_string(&resp).expect("serialize");
1101        assert!(
1102            j.contains(r#""created":{"k1":{"id":"id1"},"k2":{"id":"id2"}}"#),
1103            "created keys must be sorted: {j}"
1104        );
1105        assert!(
1106            j.contains(r#""notUpdated":{"id8":{"type":"notFound"},"id9":{"type":"notFound"}}"#),
1107            "notUpdated keys must be sorted: {j}"
1108        );
1109    }
1110}