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}