Skip to main content

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}