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}