jmap_types/set_error.rs
1//! RFC 8620 §5.3 per-object `/set` error ([`SetError`], [`SetErrorType`]).
2
3use serde::{Deserialize, Serialize};
4
5use crate::Id;
6
7/// A per-item error in a `/set` response (`notCreated`, `notUpdated`,
8/// `notDestroyed` maps) (RFC 8620 §5.3).
9///
10/// One type serves both sides: servers construct it with [`SetError::new`]
11/// and the builder methods, clients decode it from [`SetResponse`]. Unknown
12/// extension error types decode as [`SetErrorType::Other`] and unknown
13/// extension fields land in [`Self::extra`], so both round-trip losslessly.
14///
15/// [`SetResponse`]: crate::SetResponse
16#[non_exhaustive]
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct SetError {
20 /// The machine-readable error type.
21 #[serde(rename = "type")]
22 pub error_type: SetErrorType,
23 /// Optional human-readable description of the error.
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub description: Option<String>,
26 /// Property names that caused the error (for `invalidProperties`).
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub properties: Option<Vec<String>>,
29 /// The existing object id (for `alreadyExists` — RFC 8621 §5.7).
30 #[serde(rename = "existingId", skip_serializing_if = "Option::is_none")]
31 pub existing_id: Option<Id>,
32 /// Maximum recipients allowed (for `tooManyRecipients` — RFC 8621 §7.5).
33 #[serde(rename = "maxRecipients", skip_serializing_if = "Option::is_none")]
34 pub max_recipients: Option<u64>,
35 /// Invalid recipient addresses (for `invalidRecipients` — RFC 8621 §7.5).
36 #[serde(rename = "invalidRecipients", skip_serializing_if = "Option::is_none")]
37 pub invalid_recipients: Option<Vec<String>>,
38 /// Missing blob IDs (for `blobNotFound` — RFC 8621 §5.5).
39 #[serde(rename = "notFound", skip_serializing_if = "Option::is_none")]
40 pub not_found: Option<Vec<Id>>,
41 /// Maximum message size in octets (for `tooLarge` on EmailSubmission — RFC 8621 §7.5).
42 #[serde(rename = "maxSize", skip_serializing_if = "Option::is_none")]
43 pub max_size: Option<u64>,
44 /// Catch-all for extension-defined SetError fields not covered by
45 /// the typed members above.
46 ///
47 /// JMAP extensions sometimes ship error variants whose wire shape
48 /// includes additional structured fields beyond the RFC 8620 §5.3
49 /// base set — e.g. JMAP Chat's slow-mode `rateLimit` SetError carries a
50 /// `serverRetryAfter` UTCDate telling the client when it may
51 /// retry, and `mdnAlreadySent` (RFC 8621 §7.7) is a typed
52 /// extension error variant. This map preserves any such field
53 /// across serialize / deserialize round-trip.
54 ///
55 /// Use [`SetError::with_extra`] to populate from handler code:
56 ///
57 /// ```ignore
58 /// SetError::new(SetErrorType::RateLimit)
59 /// .with_description("Slow mode is active for this chat")
60 /// .with_extra("serverRetryAfter", json!(retry_after_str))
61 /// ```
62 ///
63 /// Per workspace AGENTS.md "Extras-preservation policy" — wire
64 /// format is byte-identical to a pre-extras SetError when the
65 /// map is empty (the `skip_serializing_if` collapses it).
66 ///
67 /// # Reserved-name invariant (bd:JMAP-jfia.17)
68 ///
69 /// Keys that the typed fields above serialize to (`type`,
70 /// `description`, `properties`, ...) MUST NOT appear in this map:
71 /// a colliding extras key produces a JSON object with two keys at
72 /// the same level — RFC 8259 §4 permits duplicate keys but the
73 /// behaviour is implementation-defined and the resulting SetError
74 /// is malformed in practice.
75 ///
76 /// [`SetError::with_extra`] enforces this in debug builds via
77 /// `debug_assert!`; direct field mutation (this field is `pub` per
78 /// the workspace extras-preservation policy) bypasses that guard.
79 /// Test and audit code SHOULD call [`SetError::validate_extras`]
80 /// to detect collisions deterministically across build profiles.
81 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
82 pub extra: serde_json::Map<String, serde_json::Value>,
83}
84
85impl SetError {
86 /// Construct a [`SetError`] with the given type and all optional fields `None`.
87 ///
88 /// Takes anything that converts into [`SetErrorType`]: a typed variant,
89 /// or an extension wire string such as `"addressBookHasContents"`. A
90 /// string naming a known variant builds that variant
91 /// (`SetError::new("forbidden")` equals
92 /// `SetError::new(SetErrorType::Forbidden)`).
93 pub fn new(error_type: impl Into<SetErrorType>) -> Self {
94 Self {
95 error_type: error_type.into(),
96 description: None,
97 properties: None,
98 existing_id: None,
99 max_recipients: None,
100 invalid_recipients: None,
101 not_found: None,
102 max_size: None,
103 extra: serde_json::Map::new(),
104 }
105 }
106
107 /// Set the human-readable description.
108 ///
109 /// # Security
110 ///
111 /// `SetError.description` is serialized verbatim into the JMAP wire
112 /// response (RFC 8620 §5.3 `notCreated` / `notUpdated` /
113 /// `notDestroyed` maps) and is visible to any client that can
114 /// dispatch the failing `/set` call. The MUST-NOT rules that apply
115 /// to the `Display` output of `jmap_server::JmapBackend::Error`
116 /// also apply to this string:
117 ///
118 /// - **Credential material** — auth tokens, passwords, push
119 /// verification codes, invite codes, session cookies, or anything
120 /// derived byte-for-byte from an `Authorization`-header value.
121 /// - **Blob content** — email bodies, sieve scripts, file
122 /// contents, or any user-supplied opaque payload.
123 /// - **PII shaped like an email address** in any code path that
124 /// an unauthenticated caller can trigger.
125 ///
126 /// Wrap downstream errors with `jmap_server::server_fail_from_backend`
127 /// (which always emits the static "internal error" description)
128 /// rather than interpolating them into a SetError description.
129 ///
130 /// `SetError` paths are MORE leak-prone than `serverFail` because
131 /// adversarial clients can probe for descriptions by sending
132 /// crafted `/set` arguments — the typed-error contract guarantees
133 /// the response includes a `SetError` for every failing target.
134 /// Static, caller-meaningful descriptions ("rate limit exceeded —
135 /// retry in N seconds", "patch nesting exceeds server limit") are
136 /// fine; backend-error interpolations are not.
137 ///
138 /// Precedent: the parallel contract on
139 /// `jmap_server::JmapBackend::Error` (bd:JMAP-sc1b.100) and the matching
140 /// handler-side leak path closed in bd:JMAP-wlip.2. This warning
141 /// added in bd:JMAP-wlip.26.
142 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
143 self.description = Some(desc.into());
144 self
145 }
146
147 /// Set the list of property names that caused the error.
148 pub fn with_properties<I, S>(mut self, props: I) -> Self
149 where
150 I: IntoIterator<Item = S>,
151 S: Into<String>,
152 {
153 self.properties = Some(props.into_iter().map(|s| s.into()).collect());
154 self
155 }
156
157 /// Set the existing object id (used with `alreadyExists`).
158 pub fn with_existing_id(mut self, id: Id) -> Self {
159 self.existing_id = Some(id);
160 self
161 }
162
163 /// Set the maximum recipients (used with `tooManyRecipients` — RFC 8621 §7.5).
164 pub fn with_max_recipients(mut self, n: u64) -> Self {
165 self.max_recipients = Some(n);
166 self
167 }
168
169 /// Set the invalid recipient addresses (used with `invalidRecipients` — RFC 8621 §7.5).
170 pub fn with_invalid_recipients<I, S>(mut self, addrs: I) -> Self
171 where
172 I: IntoIterator<Item = S>,
173 S: Into<String>,
174 {
175 self.invalid_recipients = Some(addrs.into_iter().map(|s| s.into()).collect());
176 self
177 }
178
179 /// Set the missing blob IDs (used with `blobNotFound` — RFC 8621 §5.5).
180 pub fn with_not_found(mut self, ids: Vec<Id>) -> Self {
181 self.not_found = Some(ids);
182 self
183 }
184
185 /// Set the maximum message size in octets (used with `tooLarge` on EmailSubmission — RFC 8621 §7.5).
186 pub fn with_max_size(mut self, n: u64) -> Self {
187 self.max_size = Some(n);
188 self
189 }
190
191 /// Insert an extension-defined field into [`Self::extra`].
192 ///
193 /// Used by handlers to attach typed wire fields that no `with_*`
194 /// builder covers — for example JMAP Chat's slow-mode `rateLimit`
195 /// SetError carries a `serverRetryAfter` UTCDate:
196 ///
197 /// ```ignore
198 /// SetError::new(SetErrorType::RateLimit)
199 /// .with_description("Slow mode is active for this chat")
200 /// .with_extra("serverRetryAfter", serde_json::json!(retry_after_str))
201 /// ```
202 ///
203 /// The serialized wire shape merges `key`/`value` at the same
204 /// level as the typed fields (via `#[serde(flatten)]` on
205 /// [`Self::extra`]). Calling `with_extra("type", ...)`,
206 /// `with_extra("properties", ...)`, or any other reserved
207 /// wire-name will produce a malformed SetError on the wire —
208 /// callers are responsible for choosing extension-namespace keys
209 /// that do not collide with the typed-field wire names.
210 ///
211 /// In debug builds, a `with_extra(key, ...)` call where `key` is in
212 /// one of the typed-field wire names panics via
213 /// `debug_assert!` to catch the bug at first test run
214 /// (bd:JMAP-wlip.3). Release builds preserve the current
215 /// no-validation behaviour to avoid silent runtime cost on a
216 /// correctly-written caller.
217 pub fn with_extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
218 // bd:JMAP-jfia.32 — accept impl Into<String> to match the
219 // sibling builders on the same type (with_description,
220 // with_properties, with_invalid_recipients). Existing
221 // &str-passing call sites compile unchanged via
222 // impl From<&str> for String.
223 let key: String = key.into();
224 debug_assert!(
225 !RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()),
226 "SetError::with_extra called with reserved wire-name key {key:?} \
227 — would produce a malformed JSON SetError on the wire. \
228 Choose an extension-namespace key that does not collide \
229 with the typed-field wire names \
230 ({RESERVED_SET_ERROR_WIRE_NAMES:?})."
231 );
232 self.extra.insert(key, value);
233 self
234 }
235
236 /// Validate that [`Self::extra`] does not contain any typed-field
237 /// wire name (bd:JMAP-jfia.17).
238 ///
239 /// [`Self::with_extra`] enforces the same invariant in debug builds
240 /// via `debug_assert!`, but direct field mutation (e.g.
241 /// `err.extra.insert("type", json!("evil"))`) bypasses that guard.
242 /// This method is the deterministic, build-profile-independent
243 /// gate: callers and tests that construct SetError values
244 /// programmatically should run it before serializing, to catch
245 /// the collision case that would produce a malformed wire shape
246 /// with two keys at the same name.
247 ///
248 /// Returns the first colliding key on `Err`; check `validate_extras`
249 /// in a loop if you need to surface all collisions.
250 ///
251 /// # Errors
252 ///
253 /// Returns [`ReservedExtrasKey`] with the first reserved key
254 /// encountered in [`Self::extra`].
255 pub fn validate_extras(&self) -> Result<(), ReservedExtrasKey> {
256 for key in self.extra.keys() {
257 if RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()) {
258 return Err(ReservedExtrasKey { key: key.clone() });
259 }
260 }
261 Ok(())
262 }
263}
264
265/// Returned by [`SetError::validate_extras`] when [`SetError::extra`]
266/// contains a key that collides with a typed-field wire-name
267/// (bd:JMAP-jfia.17).
268#[non_exhaustive]
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct ReservedExtrasKey {
271 /// The first reserved wire-name found in `SetError.extra`.
272 pub key: String,
273}
274
275impl std::fmt::Display for ReservedExtrasKey {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 write!(
278 f,
279 "SetError.extra contains reserved wire-name key {:?} — would \
280 produce a malformed JSON SetError on the wire",
281 self.key
282 )
283 }
284}
285
286impl std::error::Error for ReservedExtrasKey {}
287
288/// Reserved wire-name keys that [`SetError::with_extra`] MUST NOT receive.
289///
290/// These are the JSON keys emitted by the typed `#[serde(rename)]` /
291/// `#[serde(rename_all = "camelCase")]` fields on [`SetError`]. Passing
292/// any of these as the `key` argument to `with_extra` would produce a
293/// JSON object with two keys at the same name — technically RFC 8259
294/// §4 permits duplicate keys but the behaviour is implementation-defined
295/// and the resulting SetError on the wire is malformed in practice.
296///
297/// Crate-private (bd:JMAP-jfia.18) so adding a typed field to
298/// [`SetError`] stays additive: consumers rely on the `with_extra` /
299/// `validate_extras` checks, not on the list's contents.
300pub(crate) const RESERVED_SET_ERROR_WIRE_NAMES: &[&str] = &[
301 "type",
302 "description",
303 "properties",
304 "existingId",
305 "maxRecipients",
306 "invalidRecipients",
307 "notFound",
308 "maxSize",
309];
310
311impl std::fmt::Display for SetError {
312 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313 write!(f, "{}", self.error_type)?;
314 if let Some(ref desc) = self.description {
315 write!(f, ": {desc}")?;
316 }
317 Ok(())
318 }
319}
320
321/// Build the RFC 8620 §5.3 wire object for a [`SetError`] without going
322/// through `serde_json::to_value`, so `/set` handlers can fill
323/// `notCreated` / `notUpdated` / `notDestroyed` with no fallible step
324/// (bd:JMAP-ic0j.38).
325///
326/// The result equals the derived `Serialize` output: absent optional
327/// fields are omitted and each [`SetError::extra`] entry is merged at the
328/// top level.
329impl From<&SetError> for serde_json::Value {
330 fn from(e: &SetError) -> Self {
331 fn strings(v: &[impl AsRef<str>]) -> serde_json::Value {
332 serde_json::Value::Array(
333 v.iter()
334 .map(|s| serde_json::Value::String(s.as_ref().to_owned()))
335 .collect(),
336 )
337 }
338 let mut m = serde_json::Map::new();
339 m.insert("type".into(), e.error_type.as_str().into());
340 if let Some(d) = &e.description {
341 m.insert("description".into(), d.as_str().into());
342 }
343 if let Some(p) = &e.properties {
344 m.insert("properties".into(), strings(p));
345 }
346 if let Some(id) = &e.existing_id {
347 m.insert("existingId".into(), id.as_ref().into());
348 }
349 if let Some(n) = e.max_recipients {
350 m.insert("maxRecipients".into(), n.into());
351 }
352 if let Some(r) = &e.invalid_recipients {
353 m.insert("invalidRecipients".into(), strings(r));
354 }
355 if let Some(ids) = &e.not_found {
356 m.insert("notFound".into(), strings(ids));
357 }
358 if let Some(n) = e.max_size {
359 m.insert("maxSize".into(), n.into());
360 }
361 m.extend(e.extra.iter().map(|(k, v)| (k.clone(), v.clone())));
362 serde_json::Value::Object(m)
363 }
364}
365
366impl From<SetError> for serde_json::Value {
367 fn from(e: SetError) -> Self {
368 Self::from(&e)
369 }
370}
371
372/// The machine-readable type for a [`SetError`] (RFC 8620 §5.3 and RFC 8621).
373///
374/// # Variant policy
375///
376/// The variant set below carries:
377///
378/// - The 10 RFC 8620 §5.3 and §5.4 base error types
379/// (`Forbidden`, `OverQuota`, `TooLarge`, `RateLimit`, `NotFound`,
380/// `InvalidPatch`, `WillDestroy`, `InvalidProperties`, `Singleton`,
381/// `AlreadyExists`).
382/// - 13 RFC 8621 mail-specific error types
383/// (`MailboxHasChild`, `MailboxHasEmail`, `TooManyKeywords`,
384/// `TooManyMailboxes`, `BlobNotFound`, `ForbiddenFrom`,
385/// `InvalidEmail`, `TooManyRecipients`, `NoRecipients`,
386/// `InvalidRecipients`, `ForbiddenMailFrom`, `ForbiddenToSend`,
387/// `CannotUnsend`). They stay typed here so mail servers and mail
388/// clients share one typed match (bd:JMAP-wlip.35).
389/// - [`Self::Other`] for every other wire string.
390///
391/// **New extension errors MUST use [`Self::Other`]**, built with
392/// `SetErrorType::from("rateLimited")`, `SetError::new("invalidSieve")`
393/// and so on. Do not add further extension-specific variants here.
394///
395/// `From<&str>` and `Deserialize` share one mapping: a known wire string
396/// always builds its typed variant, never `Other` (bd:JMAP-wlip.22).
397#[non_exhaustive]
398#[derive(Debug, Clone, PartialEq, Eq, Hash)]
399pub enum SetErrorType {
400 /// The action would violate an ACL or other access control policy.
401 Forbidden,
402 /// Creating or modifying the object would exceed a server quota.
403 OverQuota,
404 /// The object is too large to be stored by the server.
405 TooLarge,
406 /// The server is rate-limiting this client.
407 RateLimit,
408 /// The object to be updated or destroyed does not exist.
409 NotFound,
410 /// The patch object is not a valid JSON Merge Patch or cannot be applied.
411 InvalidPatch,
412 /// The client requested destruction of an object that will be destroyed
413 /// implicitly when another object is destroyed.
414 WillDestroy,
415 /// One or more properties have invalid values.
416 InvalidProperties,
417 /// The object type is a singleton and cannot be created or destroyed.
418 Singleton,
419 /// RFC 8620 §5.4 — an object with the same unique key already exists.
420 AlreadyExists,
421 /// RFC 8621 §2.5 — Mailbox has child mailboxes and cannot be destroyed.
422 MailboxHasChild,
423 /// RFC 8621 §2.5 — Mailbox contains emails and `onDestroyRemoveEmails` is false.
424 MailboxHasEmail,
425 /// RFC 8621 §5.5 — Too many keywords on the Email.
426 TooManyKeywords,
427 /// RFC 8621 §5.5 — Email is in too many mailboxes.
428 TooManyMailboxes,
429 /// RFC 8621 §5.5 — A referenced blob was not found.
430 BlobNotFound,
431 /// RFC 8621 §6.3 — The `from` address is not permitted for this Identity.
432 ForbiddenFrom,
433 /// RFC 8621 §7.5 — The Email is invalid for submission.
434 InvalidEmail,
435 /// RFC 8621 §7.5 — Too many recipients.
436 TooManyRecipients,
437 /// RFC 8621 §7.5 — No recipients specified.
438 NoRecipients,
439 /// RFC 8621 §7.5 — One or more recipient addresses are invalid.
440 InvalidRecipients,
441 /// RFC 8621 §7.5 — The MAIL FROM address is not permitted.
442 ForbiddenMailFrom,
443 /// RFC 8621 §7.5 — The user does not have send permission.
444 ForbiddenToSend,
445 /// RFC 8621 §7.5 — The submission cannot be undone.
446 CannotUnsend,
447 /// An extension-defined error type not covered by the variants above.
448 /// Serializes as the inner string directly (e.g. `"mdnAlreadySent"`).
449 Other(String),
450}
451
452crate::impl_string_enum!(
453 SetErrorType,
454 "a JMAP SetError type string",
455 "forbidden" => Forbidden,
456 "overQuota" => OverQuota,
457 "tooLarge" => TooLarge,
458 "rateLimit" => RateLimit,
459 "notFound" => NotFound,
460 "invalidPatch" => InvalidPatch,
461 "willDestroy" => WillDestroy,
462 "invalidProperties" => InvalidProperties,
463 "singleton" => Singleton,
464 "alreadyExists" => AlreadyExists,
465 "mailboxHasChild" => MailboxHasChild,
466 "mailboxHasEmail" => MailboxHasEmail,
467 "tooManyKeywords" => TooManyKeywords,
468 "tooManyMailboxes" => TooManyMailboxes,
469 "blobNotFound" => BlobNotFound,
470 "forbiddenFrom" => ForbiddenFrom,
471 "invalidEmail" => InvalidEmail,
472 "tooManyRecipients" => TooManyRecipients,
473 "noRecipients" => NoRecipients,
474 "invalidRecipients" => InvalidRecipients,
475 "forbiddenMailFrom" => ForbiddenMailFrom,
476 "forbiddenToSend" => ForbiddenToSend,
477 "cannotUnsend" => CannotUnsend,
478);
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 /// Oracle (bd:JMAP-wlip.22): `SetErrorType::from("forbidden")` MUST
485 /// return `SetErrorType::Forbidden`, not `Other("forbidden")`. The
486 /// asymmetry where `from("forbidden") != Forbidden` was a real
487 /// foot-gun: handler code intending to emit the typed variant via
488 /// a string produced an `Other` that was wire-identical
489 /// but PartialEq-distinct, breaking test assertions that compared
490 /// the deserialised round-trip against the typed expected value.
491 ///
492 /// Test vector: every known typed variant name canonicalises to its
493 /// typed variant; an unknown name stays Other. The wire-name list
494 /// is hand-built from the same RFC source as the round-trip test
495 /// `set_error_type_all_known_variants_round_trip`.
496 #[test]
497 fn from_str_canonicalises_known_wire_names_to_typed_variants() {
498 // Spot-check a representative subset across the 23 known names.
499 // The exhaustive round-trip is exercised by
500 // set_error_type_all_known_variants_round_trip; this test focuses
501 // on the from() → typed-variant direction the bead was about.
502 let cases: &[(&str, SetErrorType)] = &[
503 ("forbidden", SetErrorType::Forbidden),
504 ("overQuota", SetErrorType::OverQuota),
505 ("invalidPatch", SetErrorType::InvalidPatch),
506 ("mailboxHasChild", SetErrorType::MailboxHasChild),
507 ("tooManyRecipients", SetErrorType::TooManyRecipients),
508 ("cannotUnsend", SetErrorType::CannotUnsend),
509 ];
510 for (name, expected) in cases {
511 let from_str = SetErrorType::from(*name);
512 assert_eq!(
513 &from_str, expected,
514 "from({name:?}) must canonicalise to the typed variant, not Other"
515 );
516 assert!(
517 !matches!(from_str, SetErrorType::Other(_)),
518 "from({name:?}) must NOT remain Other — known wire-name asymmetry"
519 );
520 }
521
522 // Unknown names stay Other — extension crates depend on this.
523 let unknown = SetErrorType::from("mdnAlreadySent");
524 assert!(
525 matches!(unknown, SetErrorType::Other(ref s) if s == "mdnAlreadySent"),
526 "from('mdnAlreadySent') must remain Other (not a known wire-name)"
527 );
528 }
529
530 /// Oracle: SetErrorType::Other("mdnAlreadySent") must serialize as the bare
531 /// string "mdnAlreadySent" and deserialize back to Other("mdnAlreadySent").
532 /// Extension crates depend on this round-trip to emit domain-specific errors.
533 #[test]
534 fn set_error_type_other_round_trips_as_bare_string() {
535 let original = SetErrorType::from("mdnAlreadySent");
536 let serialized = serde_json::to_string(&original).expect("serialize");
537 assert_eq!(
538 serialized, r#""mdnAlreadySent""#,
539 "Other must serialize as bare string"
540 );
541 let deserialized: SetErrorType = serde_json::from_str(&serialized).expect("deserialize");
542 assert_eq!(
543 deserialized, original,
544 "Other must deserialize back to Other"
545 );
546 }
547
548 /// Oracle (bd:JMAP-dha0): SetError gains an `extra` map that captures
549 /// extension-defined fields not covered by the typed `with_*` builders.
550 /// A handler that emits `rateLimited` with `serverRetryAfter` must
551 /// see the value round-trip through serialize / deserialize.
552 #[test]
553 fn set_error_extra_field_round_trips() {
554 let original = SetError::new(SetErrorType::from("rateLimited"))
555 .with_description("Slow mode is active")
556 .with_extra(
557 "serverRetryAfter",
558 serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
559 );
560
561 let wire = serde_json::to_value(&original).expect("serialize");
562 assert_eq!(wire["type"], "rateLimited");
563 assert_eq!(wire["description"], "Slow mode is active");
564 assert_eq!(
565 wire["serverRetryAfter"], "2025-12-31T23:59:59Z",
566 "extra field must flatten into the SetError wire shape"
567 );
568
569 let round: SetError = serde_json::from_value(wire).expect("deserialize");
570 assert_eq!(round.error_type, original.error_type);
571 assert_eq!(round.description, original.description);
572 assert_eq!(
573 round.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
574 Some("2025-12-31T23:59:59Z"),
575 "extra field must survive deserialize"
576 );
577 }
578
579 /// Oracle (bd:JMAP-dha0): a SetError with no extras serializes to a
580 /// wire shape byte-identical to the pre-extras layout. The
581 /// `skip_serializing_if` on `extra` collapses the empty map.
582 #[test]
583 fn set_error_empty_extra_is_invisible_on_the_wire() {
584 let err = SetError::new(SetErrorType::Forbidden);
585 let wire = serde_json::to_value(&err).expect("serialize");
586 let obj = wire.as_object().expect("object");
587 assert!(
588 !obj.contains_key("extra"),
589 "empty `extra` map must not appear on the wire (got {wire})"
590 );
591 // The only key on the wire for a bare SetError must be `type`.
592 assert_eq!(
593 obj.len(),
594 1,
595 "bare SetError must have exactly one key on the wire"
596 );
597 assert_eq!(obj["type"], "forbidden");
598 }
599
600 /// Oracle (bd:JMAP-dha0): unknown wire fields on a deserialized
601 /// SetError land in `extra`. This means a future spec adding
602 /// `someNewSetErrorField` will round-trip through current
603 /// versions of the kit losslessly.
604 #[test]
605 fn set_error_unknown_field_lands_in_extra() {
606 let wire = serde_json::json!({
607 "type": "forbidden",
608 "futureSpecField": "future-value",
609 "anotherOne": 42
610 });
611 let err: SetError = serde_json::from_value(wire).expect("deserialize");
612 assert_eq!(err.error_type, SetErrorType::Forbidden);
613 assert_eq!(
614 err.extra.get("futureSpecField").and_then(|v| v.as_str()),
615 Some("future-value")
616 );
617 assert_eq!(
618 err.extra.get("anotherOne").and_then(|v| v.as_u64()),
619 Some(42)
620 );
621 }
622
623 /// Oracle (bd:JMAP-wlip.3): [`SetError::with_extra`] panics in debug
624 /// builds when called with a reserved wire-name key. Catches the bug
625 /// at first test run rather than letting a malformed-on-the-wire
626 /// SetError ship through review. The assert is debug-only so release
627 /// builds pay no runtime cost on correctly-written callers.
628 ///
629 /// Iterates every wire-name in [`RESERVED_SET_ERROR_WIRE_NAMES`] so
630 /// adding a new typed field to SetError plus its rename to the
631 /// constant list keeps the negative tests in sync automatically.
632 #[test]
633 #[cfg(debug_assertions)]
634 fn with_extra_panics_on_reserved_wire_name() {
635 for &reserved in RESERVED_SET_ERROR_WIRE_NAMES {
636 let reserved_owned = reserved.to_owned();
637 let result = std::panic::catch_unwind(move || {
638 SetError::new(SetErrorType::Forbidden)
639 .with_extra(&reserved_owned, serde_json::Value::Null);
640 });
641 assert!(
642 result.is_err(),
643 "with_extra({reserved:?}, ...) must panic in debug builds; \
644 reserved wire-names collide with typed fields and would \
645 produce a malformed SetError on the wire"
646 );
647 }
648 }
649
650 /// Oracle (bd:JMAP-jfia.17): direct mutation of `SetError.extra`
651 /// bypasses the `with_extra` debug_assert and can plant a
652 /// reserved wire-name. [`SetError::validate_extras`] is the
653 /// deterministic, build-profile-independent gate for the same
654 /// invariant.
655 ///
656 /// Test vector: iterate every reserved wire-name, plant it
657 /// directly into `extra`, and assert `validate_extras` returns
658 /// `Err(ReservedExtrasKey { key: <name> })`.
659 #[test]
660 fn validate_extras_detects_reserved_key_planted_via_direct_mutation() {
661 for &reserved in RESERVED_SET_ERROR_WIRE_NAMES {
662 let mut err = SetError::new(SetErrorType::Forbidden);
663 // Bypass with_extra entirely — this is the pattern the
664 // `pub` field surface invites that the debug_assert cannot
665 // see (bd:JMAP-jfia.17).
666 err.extra
667 .insert(reserved.to_owned(), serde_json::Value::Null);
668 let collision = err
669 .validate_extras()
670 .expect_err("reserved-name extras key must be detected");
671 assert_eq!(
672 collision.key, reserved,
673 "validate_extras must report the colliding key verbatim"
674 );
675 }
676 }
677
678 /// Oracle (bd:JMAP-jfia.17): `validate_extras` returns `Ok(())` for
679 /// a SetError whose `extra` map contains only extension-namespace
680 /// keys. Positive control paired with the rejection test above.
681 #[test]
682 fn validate_extras_accepts_extension_namespace_keys() {
683 let mut err = SetError::new(SetErrorType::from("rateLimited"));
684 err.extra.insert(
685 "serverRetryAfter".to_owned(),
686 serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
687 );
688 err.extra
689 .insert("retryAttempt".to_owned(), serde_json::Value::from(3));
690 err.validate_extras()
691 .expect("extension-namespace keys must pass validation");
692 }
693
694 /// Oracle (bd:JMAP-wlip.3): a non-reserved key passes the
695 /// [`SetError::with_extra`] debug_assert and lands in the `extra`
696 /// map as before. Positive control paired with the panic test
697 /// above.
698 #[test]
699 fn with_extra_accepts_extension_namespace_key() {
700 // 'serverRetryAfter' is the JMAP Chat extension's rateLimited
701 // SetError field; not in RESERVED_SET_ERROR_WIRE_NAMES.
702 let err = SetError::new(SetErrorType::from("rateLimited")).with_extra(
703 "serverRetryAfter",
704 serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
705 );
706 assert_eq!(
707 err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
708 Some("2025-12-31T23:59:59Z"),
709 "extension-namespace key must land in the extra map"
710 );
711 }
712
713 /// Oracle (bd:JMAP-wlip.3): the reserved-name constant covers every
714 /// `#[serde(rename = ...)]` and camelCase-derived field name on the
715 /// public [`SetError`] surface. A future contributor that adds a
716 /// typed wire field without extending the constant is the failure
717 /// mode this test guards against. The oracle is hand-derived from
718 /// the SetError struct definition by reading off the wire-name of
719 /// each field.
720 #[test]
721 fn reserved_set_error_wire_names_matches_serialized_surface() {
722 // Build a SetError with every typed field populated, serialize,
723 // and check that every JSON key (other than the extension extras)
724 // appears in RESERVED_SET_ERROR_WIRE_NAMES.
725 let err = SetError::new(SetErrorType::Forbidden)
726 .with_description("desc")
727 .with_properties(["p1"])
728 .with_existing_id(Id::from("eid"))
729 .with_max_recipients(10)
730 .with_invalid_recipients(["bad@example"])
731 .with_not_found(vec![Id::from("nfid")])
732 .with_max_size(1024);
733
734 let wire = serde_json::to_value(&err).expect("serialize");
735 let obj = wire.as_object().expect("SetError must serialize as object");
736 for key in obj.keys() {
737 assert!(
738 RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()),
739 "wire-name {key:?} appears on the SetError surface but is \
740 not in RESERVED_SET_ERROR_WIRE_NAMES — adding a typed \
741 field to SetError requires extending the constant"
742 );
743 }
744 }
745
746 /// Oracle (bd:JMAP-wlip.29): the Display arm, the Deserialize visitor
747 /// match, and the round-trip behaviour MUST agree for every known
748 /// variant of [`SetErrorType`]. This table-driven
749 /// test iterates ALL 23 typed variants and asserts:
750 ///
751 /// - Display produces the expected camelCase wire string
752 /// - serde_json::to_string emits the same string
753 /// - serde_json::from_str rebuilds the same variant (NOT an Other)
754 ///
755 /// A drift between Display and Deserialize (e.g. adding "rateLimit"
756 /// to Display but forgetting the Deserialize arm) would fail step 3
757 /// at first test run because the wire string would round-trip into
758 /// `Other("rateLimit")` instead of `RateLimit`. This is the silent
759 /// contract drift filed as bd:JMAP-wlip.22.
760 ///
761 /// The table is hand-built from the RFC 8620 / RFC 8621 spec text,
762 /// not derived from the code under test (the workspace test-integrity
763 /// rule requires an independent oracle). Adding a new typed variant
764 /// requires extending this table — that is the intent.
765 #[test]
766 fn set_error_type_all_known_variants_round_trip() {
767 // (variant constructor, expected wire string).
768 // Source of truth: RFC 8620 §5.3 + RFC 8621 §2.5, §5.5, §6.3, §7.5.
769 let cases: &[(SetErrorType, &str)] = &[
770 (SetErrorType::Forbidden, "forbidden"),
771 (SetErrorType::OverQuota, "overQuota"),
772 (SetErrorType::TooLarge, "tooLarge"),
773 (SetErrorType::RateLimit, "rateLimit"),
774 (SetErrorType::NotFound, "notFound"),
775 (SetErrorType::InvalidPatch, "invalidPatch"),
776 (SetErrorType::WillDestroy, "willDestroy"),
777 (SetErrorType::InvalidProperties, "invalidProperties"),
778 (SetErrorType::Singleton, "singleton"),
779 (SetErrorType::AlreadyExists, "alreadyExists"),
780 (SetErrorType::MailboxHasChild, "mailboxHasChild"),
781 (SetErrorType::MailboxHasEmail, "mailboxHasEmail"),
782 (SetErrorType::TooManyKeywords, "tooManyKeywords"),
783 (SetErrorType::TooManyMailboxes, "tooManyMailboxes"),
784 (SetErrorType::BlobNotFound, "blobNotFound"),
785 (SetErrorType::ForbiddenFrom, "forbiddenFrom"),
786 (SetErrorType::InvalidEmail, "invalidEmail"),
787 (SetErrorType::TooManyRecipients, "tooManyRecipients"),
788 (SetErrorType::NoRecipients, "noRecipients"),
789 (SetErrorType::InvalidRecipients, "invalidRecipients"),
790 (SetErrorType::ForbiddenMailFrom, "forbiddenMailFrom"),
791 (SetErrorType::ForbiddenToSend, "forbiddenToSend"),
792 (SetErrorType::CannotUnsend, "cannotUnsend"),
793 ];
794
795 for (variant, expected_wire) in cases {
796 // Display
797 assert_eq!(
798 variant.to_string(),
799 *expected_wire,
800 "Display arm for {variant:?} produced wrong wire string"
801 );
802 // Serialize
803 let serialized = serde_json::to_string(variant).expect("serialize");
804 assert_eq!(
805 serialized,
806 format!("\"{expected_wire}\""),
807 "Serialize for {variant:?} did not produce \"{expected_wire}\""
808 );
809 // Deserialize back — MUST rebuild the typed variant, NOT Other.
810 let deserialized: SetErrorType =
811 serde_json::from_str(&serialized).expect("deserialize");
812 assert_eq!(
813 &deserialized, variant,
814 "Deserialize of {expected_wire:?} did not rebuild {variant:?} \
815 (likely fell through to Other — Display and Deserialize \
816 match arms have drifted)"
817 );
818 // Belt-and-braces: explicitly assert NOT Other.
819 assert!(
820 !matches!(deserialized, SetErrorType::Other(_)),
821 "Deserialize of {expected_wire:?} fell through to Other; \
822 Display has an arm but Deserialize visitor doesn't"
823 );
824 }
825 }
826
827 /// Oracle: RFC 8620 §5.3 — "invalidProperties: The record given is
828 /// invalid in some way. ... The SetError object SHOULD also have a
829 /// property called "properties" of type String[] that lists all the
830 /// properties that were invalid." Hand-written expected wire object;
831 /// absent optional fields must not appear.
832 #[test]
833 fn value_from_set_error_matches_rfc8620_invalid_properties() {
834 let err = SetError::new(SetErrorType::InvalidProperties)
835 .with_properties(["name", "parentId"])
836 .with_description("bad");
837 let expected = serde_json::json!({
838 "type": "invalidProperties",
839 "description": "bad",
840 "properties": ["name", "parentId"]
841 });
842 assert_eq!(serde_json::Value::from(&err), expected);
843 assert_eq!(serde_json::Value::from(err), expected);
844 }
845
846 /// Oracle: RFC 8621 §7.5 — "tooManyRecipients ... A "maxRecipients"
847 /// UnsignedInt property MUST also be present", "invalidRecipients ...
848 /// An "invalidRecipients" property MUST also be present ... a list of
849 /// the invalid addresses", "tooLarge ... A "maxSize" UnsignedInt
850 /// property MUST be present"; RFC 8620 §5.4 "alreadyExists ... an
851 /// "existingId" property"; RFC 8621 §4.6 blobNotFound "notFound"
852 /// property. Every typed field plus an extension field (JMAP Chat
853 /// `serverRetryAfter`) against a hand-written object.
854 #[test]
855 fn value_from_set_error_matches_rfc8621_fields_and_extras() {
856 let err = SetError::new("rateLimited")
857 .with_existing_id(Id::from("obj-7"))
858 .with_max_recipients(50)
859 .with_invalid_recipients(["bad@", "no@no"])
860 .with_not_found(vec![Id::from("blob-1"), Id::from("blob-2")])
861 .with_max_size(10_485_760)
862 .with_extra(
863 "serverRetryAfter",
864 serde_json::json!("2025-12-31T23:59:59Z"),
865 );
866 let expected = serde_json::json!({
867 "type": "rateLimited",
868 "existingId": "obj-7",
869 "maxRecipients": 50,
870 "invalidRecipients": ["bad@", "no@no"],
871 "notFound": ["blob-1", "blob-2"],
872 "maxSize": 10485760,
873 "serverRetryAfter": "2025-12-31T23:59:59Z"
874 });
875 assert_eq!(serde_json::Value::from(&err), expected);
876 }
877
878 /// Oracle: the derived `Serialize` impl, an independent path through
879 /// serde. The hand-built conversion must agree with it for a bare
880 /// error and for one carrying every field.
881 #[test]
882 fn value_from_set_error_agrees_with_serde_derive() {
883 let full = SetError::new(SetErrorType::TooManyRecipients)
884 .with_description("too many")
885 .with_properties(["to"])
886 .with_existing_id(Id::from("e1"))
887 .with_max_recipients(3)
888 .with_invalid_recipients(["x@"])
889 .with_not_found(vec![Id::from("b1")])
890 .with_max_size(1)
891 .with_extra("x-ext", serde_json::json!({"k": [1, 2]}));
892 for err in [SetError::new(SetErrorType::Forbidden), full] {
893 assert_eq!(
894 serde_json::Value::from(&err),
895 serde_json::to_value(&err).expect("serialize"),
896 "conversion drifted from derive(Serialize) for {err:?}"
897 );
898 }
899 }
900}