Skip to main content

jmap_server/
backend.rs

1//! Shared backend infrastructure for all JMAP server crates.
2//!
3//! Re-exports the marker traits from `jmap-types` and adds the result types,
4//! `BackendChangesError`, and [`JmapBackend`] supertrait. Domain crates add
5//! their write-side methods and domain-specific error variants on top.
6
7pub use jmap_types::{GetObject, JmapObject, QueryObject, SetObject};
8
9// ---------------------------------------------------------------------------
10// SetError — RFC 8620 §5.3 per-object set-method error
11// ---------------------------------------------------------------------------
12
13/// A per-item error in a `/set` response (`notCreated`, `notUpdated`,
14/// `notDestroyed` maps) (RFC 8620 §5.3).
15///
16/// Construct with [`SetError::new`] and chain the builder methods as needed.
17#[non_exhaustive]
18#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct SetError {
21    /// The machine-readable error type.
22    #[serde(rename = "type")]
23    pub error_type: SetErrorType,
24    /// Optional human-readable description of the error.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub description: Option<String>,
27    /// Property names that caused the error (for `invalidProperties`).
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub properties: Option<Vec<String>>,
30    /// The existing object id (for `alreadyExists` — RFC 8621 §5.7).
31    #[serde(rename = "existingId", skip_serializing_if = "Option::is_none")]
32    pub existing_id: Option<jmap_types::Id>,
33    /// Maximum recipients allowed (for `tooManyRecipients` — RFC 8621 §7.5).
34    #[serde(rename = "maxRecipients", skip_serializing_if = "Option::is_none")]
35    pub max_recipients: Option<u64>,
36    /// Invalid recipient addresses (for `invalidRecipients` — RFC 8621 §7.5).
37    #[serde(rename = "invalidRecipients", skip_serializing_if = "Option::is_none")]
38    pub invalid_recipients: Option<Vec<String>>,
39    /// Missing blob IDs (for `blobNotFound` — RFC 8621 §5.5).
40    #[serde(rename = "notFound", skip_serializing_if = "Option::is_none")]
41    pub not_found: Option<Vec<jmap_types::Id>>,
42    /// Maximum message size in octets (for `tooLarge` on EmailSubmission — RFC 8621 §7.5).
43    #[serde(rename = "maxSize", skip_serializing_if = "Option::is_none")]
44    pub max_size: Option<u64>,
45    /// Catch-all for extension-defined SetError fields not covered by
46    /// the typed members above.
47    ///
48    /// JMAP extensions sometimes ship error variants whose wire shape
49    /// includes additional structured fields beyond the RFC 8620 §5.3
50    /// base set — e.g. JMAP Chat's `rateLimited` SetError carries a
51    /// `serverRetryAfter` UTCDate telling the client when it may
52    /// retry, and `mdnAlreadySent` (RFC 8621 §7.7) is a typed
53    /// extension error variant. This map preserves any such field
54    /// across serialize / deserialize round-trip, mirroring the
55    /// extras-preservation policy on the client-side
56    /// [`jmap_types::SetError`] type.
57    ///
58    /// Use [`SetError::with_extra`] to populate from handler code:
59    ///
60    /// ```ignore
61    /// SetError::new(SetErrorType::custom("rateLimited"))
62    ///     .with_description("Slow mode is active for this chat")
63    ///     .with_extra("serverRetryAfter", json!(retry_after_str))
64    /// ```
65    ///
66    /// Per workspace AGENTS.md "Extras-preservation policy" — wire
67    /// format is byte-identical to a pre-extras SetError when the
68    /// map is empty (the `skip_serializing_if` collapses it).
69    ///
70    /// # Reserved-name invariant (bd:JMAP-jfia.17)
71    ///
72    /// Keys in [`RESERVED_SET_ERROR_WIRE_NAMES`] MUST NOT appear in
73    /// this map. The typed fields above serialize to those names, so
74    /// a colliding extras key produces a JSON object with two keys at
75    /// the same level — RFC 8259 §4 permits duplicate keys but the
76    /// behaviour is implementation-defined and the resulting SetError
77    /// is malformed in practice.
78    ///
79    /// [`SetError::with_extra`] enforces this in debug builds via
80    /// `debug_assert!`; direct field mutation (this field is `pub` per
81    /// the workspace extras-preservation policy) bypasses that guard.
82    /// Test and audit code SHOULD call [`SetError::validate_extras`]
83    /// to detect collisions deterministically across build profiles.
84    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
85    pub extra: serde_json::Map<String, serde_json::Value>,
86}
87
88impl SetError {
89    /// Construct a [`SetError`] with the given type and all optional fields `None`.
90    pub fn new(error_type: SetErrorType) -> Self {
91        Self {
92            error_type,
93            description: None,
94            properties: None,
95            existing_id: None,
96            max_recipients: None,
97            invalid_recipients: None,
98            not_found: None,
99            max_size: None,
100            extra: serde_json::Map::new(),
101        }
102    }
103
104    /// Set the human-readable description.
105    ///
106    /// # Security
107    ///
108    /// `SetError.description` is serialized verbatim into the JMAP wire
109    /// response (RFC 8620 §5.3 `notCreated` / `notUpdated` /
110    /// `notDestroyed` maps) and is visible to any client that can
111    /// dispatch the failing `/set` call. The MUST-NOT rules that apply
112    /// to [`JmapBackend::Error`]'s [`Display`](std::fmt::Display) output
113    /// also apply to this string:
114    ///
115    /// - **Credential material** — auth tokens, passwords, push
116    ///   verification codes, invite codes, session cookies, or anything
117    ///   derived byte-for-byte from an `Authorization`-header value.
118    /// - **Blob content** — email bodies, sieve scripts, file
119    ///   contents, or any user-supplied opaque payload.
120    /// - **PII shaped like an email address** in any code path that
121    ///   an unauthenticated caller can trigger.
122    ///
123    /// Wrap downstream errors with [`crate::server_fail_from_backend`]
124    /// (which always emits the static "internal error" description)
125    /// rather than interpolating them into a SetError description.
126    ///
127    /// `SetError` paths are MORE leak-prone than `serverFail` because
128    /// adversarial clients can probe for descriptions by sending
129    /// crafted `/set` arguments — the typed-error contract guarantees
130    /// the response includes a `SetError` for every failing target.
131    /// Static, caller-meaningful descriptions ("rate limit exceeded —
132    /// retry in N seconds", "patch nesting exceeds server limit") are
133    /// fine; backend-error interpolations are not.
134    ///
135    /// Precedent: the parallel contract on
136    /// [`JmapBackend::Error`] (bd:JMAP-sc1b.100) and the matching
137    /// handler-side leak path closed in bd:JMAP-wlip.2. This warning
138    /// added in bd:JMAP-wlip.26.
139    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
140        self.description = Some(desc.into());
141        self
142    }
143
144    /// Set the list of property names that caused the error.
145    pub fn with_properties<I, S>(mut self, props: I) -> Self
146    where
147        I: IntoIterator<Item = S>,
148        S: Into<String>,
149    {
150        self.properties = Some(props.into_iter().map(|s| s.into()).collect());
151        self
152    }
153
154    /// Set the existing object id (used with `alreadyExists`).
155    pub fn with_existing_id(mut self, id: jmap_types::Id) -> Self {
156        self.existing_id = Some(id);
157        self
158    }
159
160    /// Set the maximum recipients (used with `tooManyRecipients` — RFC 8621 §7.5).
161    pub fn with_max_recipients(mut self, n: u64) -> Self {
162        self.max_recipients = Some(n);
163        self
164    }
165
166    /// Set the invalid recipient addresses (used with `invalidRecipients` — RFC 8621 §7.5).
167    pub fn with_invalid_recipients<I, S>(mut self, addrs: I) -> Self
168    where
169        I: IntoIterator<Item = S>,
170        S: Into<String>,
171    {
172        self.invalid_recipients = Some(addrs.into_iter().map(|s| s.into()).collect());
173        self
174    }
175
176    /// Set the missing blob IDs (used with `blobNotFound` — RFC 8621 §5.5).
177    pub fn with_not_found(mut self, ids: Vec<jmap_types::Id>) -> Self {
178        self.not_found = Some(ids);
179        self
180    }
181
182    /// Set the maximum message size in octets (used with `tooLarge` on EmailSubmission — RFC 8621 §7.5).
183    pub fn with_max_size(mut self, n: u64) -> Self {
184        self.max_size = Some(n);
185        self
186    }
187
188    /// Insert an extension-defined field into [`Self::extra`].
189    ///
190    /// Used by handlers to attach typed wire fields that no `with_*`
191    /// builder covers — for example JMAP Chat's `rateLimited` SetError
192    /// must carry a `serverRetryAfter` UTCDate:
193    ///
194    /// ```ignore
195    /// SetError::new(SetErrorType::custom("rateLimited"))
196    ///     .with_description("Slow mode is active for this chat")
197    ///     .with_extra("serverRetryAfter", serde_json::json!(retry_after_str))
198    /// ```
199    ///
200    /// The serialized wire shape merges `key`/`value` at the same
201    /// level as the typed fields (via `#[serde(flatten)]` on
202    /// [`Self::extra`]). Calling `with_extra("type", ...)`,
203    /// `with_extra("properties", ...)`, or any other reserved
204    /// wire-name will produce a malformed SetError on the wire —
205    /// callers are responsible for choosing extension-namespace keys
206    /// that do not collide with the typed-field wire names.
207    ///
208    /// In debug builds, a `with_extra(key, ...)` call where `key` is in
209    /// the reserved set [`RESERVED_SET_ERROR_WIRE_NAMES`] panics via
210    /// `debug_assert!` to catch the bug at first test run
211    /// (bd:JMAP-wlip.3). Release builds preserve the current
212    /// no-validation behaviour to avoid silent runtime cost on a
213    /// correctly-written caller.
214    pub fn with_extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
215        // bd:JMAP-jfia.32 — accept impl Into<String> to match the
216        // sibling builders on the same type (with_description,
217        // with_properties, with_invalid_recipients). Existing
218        // &str-passing call sites compile unchanged via
219        // impl From<&str> for String.
220        let key: String = key.into();
221        debug_assert!(
222            !RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()),
223            "SetError::with_extra called with reserved wire-name key {key:?} \
224             — would produce a malformed JSON SetError on the wire. \
225             Choose an extension-namespace key that does not collide \
226             with the typed-field wire names \
227             ({RESERVED_SET_ERROR_WIRE_NAMES:?})."
228        );
229        self.extra.insert(key, value);
230        self
231    }
232
233    /// Validate that [`Self::extra`] does not contain any key in
234    /// [`RESERVED_SET_ERROR_WIRE_NAMES`] (bd:JMAP-jfia.17).
235    ///
236    /// [`Self::with_extra`] enforces the same invariant in debug builds
237    /// via `debug_assert!`, but direct field mutation (e.g.
238    /// `err.extra.insert("type", json!("evil"))`) bypasses that guard.
239    /// This method is the deterministic, build-profile-independent
240    /// gate: callers and tests that construct SetError values
241    /// programmatically should run it before serializing, to catch
242    /// the collision case that would produce a malformed wire shape
243    /// with two keys at the same name.
244    ///
245    /// Returns the first colliding key on `Err`; check `validate_extras`
246    /// in a loop if you need to surface all collisions.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`ReservedExtrasKey`] with the first reserved key
251    /// encountered in [`Self::extra`].
252    pub fn validate_extras(&self) -> Result<(), ReservedExtrasKey> {
253        for key in self.extra.keys() {
254            if RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()) {
255                return Err(ReservedExtrasKey { key: key.clone() });
256            }
257        }
258        Ok(())
259    }
260}
261
262/// Returned by [`SetError::validate_extras`] when [`SetError::extra`]
263/// contains a key that collides with a typed-field wire-name in
264/// [`RESERVED_SET_ERROR_WIRE_NAMES`] (bd:JMAP-jfia.17).
265#[non_exhaustive]
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ReservedExtrasKey {
268    /// The first reserved wire-name found in `SetError.extra`.
269    pub key: String,
270}
271
272impl std::fmt::Display for ReservedExtrasKey {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        write!(
275            f,
276            "SetError.extra contains reserved wire-name key {:?} — would \
277             produce a malformed JSON SetError on the wire",
278            self.key
279        )
280    }
281}
282
283impl std::error::Error for ReservedExtrasKey {}
284
285/// Reserved wire-name keys that [`SetError::with_extra`] MUST NOT receive.
286///
287/// These are the JSON keys emitted by the typed `#[serde(rename)]` /
288/// `#[serde(rename_all = "camelCase")]` fields on [`SetError`]. Passing
289/// any of these as the `key` argument to `with_extra` would produce a
290/// JSON object with two keys at the same name — technically RFC 8259
291/// §4 permits duplicate keys but the behaviour is implementation-defined
292/// and the resulting SetError on the wire is malformed in practice.
293///
294/// Kept here as a `pub const` rather than inline in the assert message
295/// so consumers can reference the same list — e.g. a future contract
296/// test, or a wire-format conformance check.
297pub const RESERVED_SET_ERROR_WIRE_NAMES: &[&str] = &[
298    "type",
299    "description",
300    "properties",
301    "existingId",
302    "maxRecipients",
303    "invalidRecipients",
304    "notFound",
305    "maxSize",
306];
307
308impl std::fmt::Display for SetError {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        write!(f, "{}", self.error_type)?;
311        if let Some(ref desc) = self.description {
312            write!(f, ": {desc}")?;
313        }
314        Ok(())
315    }
316}
317
318/// The machine-readable type for a [`SetError`] (RFC 8620 §5.3 and RFC 8621).
319///
320/// # Variant policy
321///
322/// The variant set below carries:
323///
324/// - The 10 RFC 8620 §5.3 base error types
325///   (`Forbidden`, `OverQuota`, `TooLarge`, `RateLimit`, `NotFound`,
326///   `InvalidPatch`, `WillDestroy`, `InvalidProperties`, `Singleton`,
327///   `AlreadyExists`).
328/// - 13 RFC 8621 mail-specific error types
329///   (`MailboxHasChild`, `MailboxHasEmail`, `TooManyKeywords`,
330///   `TooManyMailboxes`, `BlobNotFound`, `ForbiddenFrom`,
331///   `InvalidEmail`, `TooManyRecipients`, `NoRecipients`,
332///   `InvalidRecipients`, `ForbiddenMailFrom`, `ForbiddenToSend`,
333///   `CannotUnsend`). These predate the canonical-template extraction
334///   and ship in the foundation for back-compat with existing
335///   `jmap-mail-server` callers (bd:JMAP-wlip.19).
336/// - [`Self::Custom`] for everything else.
337///
338/// **New extension errors MUST use [`Self::custom`].** Other JMAP
339/// extensions (chat, calendars, tasks, contacts, filenode, sharing,
340/// metadata) ship their error strings via `custom("rateLimited")`,
341/// `custom("addressBookHasContents")`, `custom("invalidSieve")`, etc.
342/// The known wire-name table inside the private `from_wire_str` helper
343/// is the authoritative list of typed variants — any wire-name outside
344/// that list round-trips as `Custom(s)`.
345///
346/// The mail-variants asymmetry is documented but not yet reshaped.
347/// Moving the 13 mail variants to `jmap-mail-types` is a breaking
348/// change that requires a workspace-wide major version bump and
349/// propagation across every `*-server` extension crate; it is tracked
350/// separately rather than performed silently. Until that bump, do not
351/// add further extension-specific variants here — even mail-style
352/// extensions like Calendars / Tasks / Contacts use [`Self::custom`].
353#[non_exhaustive]
354#[derive(Debug, Clone, PartialEq)]
355pub enum SetErrorType {
356    /// The action would violate an ACL or other access control policy.
357    Forbidden,
358    /// Creating or modifying the object would exceed a server quota.
359    OverQuota,
360    /// The object is too large to be stored by the server.
361    TooLarge,
362    /// The server is rate-limiting this client.
363    RateLimit,
364    /// The object to be updated or destroyed does not exist.
365    NotFound,
366    /// The patch object is not a valid JSON Merge Patch or cannot be applied.
367    InvalidPatch,
368    /// The client requested destruction of an object that will be destroyed
369    /// implicitly when another object is destroyed.
370    WillDestroy,
371    /// One or more properties have invalid values.
372    InvalidProperties,
373    /// The object type is a singleton and cannot be created or destroyed.
374    Singleton,
375    /// An object with the same unique key already exists.
376    AlreadyExists,
377    /// RFC 8621 §2.5 — Mailbox has child mailboxes and cannot be destroyed.
378    MailboxHasChild,
379    /// RFC 8621 §2.5 — Mailbox contains emails and `onDestroyRemoveEmails` is false.
380    MailboxHasEmail,
381    /// RFC 8621 §5.5 — Too many keywords on the Email.
382    TooManyKeywords,
383    /// RFC 8621 §5.5 — Email is in too many mailboxes.
384    TooManyMailboxes,
385    /// RFC 8621 §5.5 — A referenced blob was not found.
386    BlobNotFound,
387    /// RFC 8621 §6.3 — The `from` address is not permitted for this Identity.
388    ForbiddenFrom,
389    /// RFC 8621 §7.5 — The Email is invalid for submission.
390    InvalidEmail,
391    /// RFC 8621 §7.5 — Too many recipients.
392    TooManyRecipients,
393    /// RFC 8621 §7.5 — No recipients specified.
394    NoRecipients,
395    /// RFC 8621 §7.5 — One or more recipient addresses are invalid.
396    InvalidRecipients,
397    /// RFC 8621 §7.5 — The MAIL FROM address is not permitted.
398    ForbiddenMailFrom,
399    /// RFC 8621 §7.5 — The user does not have send permission.
400    ForbiddenToSend,
401    /// RFC 8621 §7.5 — The submission cannot be undone.
402    CannotUnsend,
403    /// An extension-defined error type not covered by the variants above.
404    /// Serializes as the inner string directly (e.g. `"mdnAlreadySent"`).
405    Custom(String),
406}
407
408impl SetErrorType {
409    /// Construct a [`SetErrorType`] from any string, canonicalising
410    /// known wire-names back to their typed variant.
411    ///
412    /// `custom("forbidden")` returns [`SetErrorType::Forbidden`], NOT
413    /// `Custom("forbidden")`. Only strings that do not match any known
414    /// JMAP wire-name produce [`SetErrorType::Custom`]. This makes
415    /// round-trip symmetric — `custom(s)` equals the result of
416    /// deserialising `"s"` for every `s`, eliminating the silent
417    /// contract drift filed as bd:JMAP-wlip.22.
418    ///
419    /// Use this in extension crates to emit domain-specific error
420    /// types without adding variants to this enum; if your extension's
421    /// chosen name later becomes a typed variant in this crate, the
422    /// call site keeps working — `custom("mdnAlreadySent")` returns
423    /// `Custom("mdnAlreadySent")` today and would return the typed
424    /// variant when that variant is added.
425    pub fn custom(s: impl Into<String>) -> Self {
426        let s: String = s.into();
427        Self::from_wire_str(&s).unwrap_or(Self::Custom(s))
428    }
429
430    /// Map a JMAP wire-name string to its typed variant, returning
431    /// `None` for any string not in the known-name set.
432    ///
433    /// Single source of truth used by both [`Self::custom`] and the
434    /// [`serde::Deserialize`] visitor (bd:JMAP-wlip.22). Adding a new
435    /// typed variant requires extending this match arm AND the
436    /// `Display` impl; the table-driven round-trip test
437    /// `set_error_type_all_known_variants_round_trip` (bd:JMAP-wlip.29)
438    /// catches any drift between them.
439    fn from_wire_str(s: &str) -> Option<Self> {
440        Some(match s {
441            "forbidden" => Self::Forbidden,
442            "overQuota" => Self::OverQuota,
443            "tooLarge" => Self::TooLarge,
444            "rateLimit" => Self::RateLimit,
445            "notFound" => Self::NotFound,
446            "invalidPatch" => Self::InvalidPatch,
447            "willDestroy" => Self::WillDestroy,
448            "invalidProperties" => Self::InvalidProperties,
449            "singleton" => Self::Singleton,
450            "alreadyExists" => Self::AlreadyExists,
451            "mailboxHasChild" => Self::MailboxHasChild,
452            "mailboxHasEmail" => Self::MailboxHasEmail,
453            "tooManyKeywords" => Self::TooManyKeywords,
454            "tooManyMailboxes" => Self::TooManyMailboxes,
455            "blobNotFound" => Self::BlobNotFound,
456            "forbiddenFrom" => Self::ForbiddenFrom,
457            "invalidEmail" => Self::InvalidEmail,
458            "tooManyRecipients" => Self::TooManyRecipients,
459            "noRecipients" => Self::NoRecipients,
460            "invalidRecipients" => Self::InvalidRecipients,
461            "forbiddenMailFrom" => Self::ForbiddenMailFrom,
462            "forbiddenToSend" => Self::ForbiddenToSend,
463            "cannotUnsend" => Self::CannotUnsend,
464            _ => return None,
465        })
466    }
467}
468
469impl std::fmt::Display for SetErrorType {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        let s: &str = match self {
472            Self::Forbidden => "forbidden",
473            Self::OverQuota => "overQuota",
474            Self::TooLarge => "tooLarge",
475            Self::RateLimit => "rateLimit",
476            Self::NotFound => "notFound",
477            Self::InvalidPatch => "invalidPatch",
478            Self::WillDestroy => "willDestroy",
479            Self::InvalidProperties => "invalidProperties",
480            Self::Singleton => "singleton",
481            Self::AlreadyExists => "alreadyExists",
482            Self::MailboxHasChild => "mailboxHasChild",
483            Self::MailboxHasEmail => "mailboxHasEmail",
484            Self::TooManyKeywords => "tooManyKeywords",
485            Self::TooManyMailboxes => "tooManyMailboxes",
486            Self::BlobNotFound => "blobNotFound",
487            Self::ForbiddenFrom => "forbiddenFrom",
488            Self::InvalidEmail => "invalidEmail",
489            Self::TooManyRecipients => "tooManyRecipients",
490            Self::NoRecipients => "noRecipients",
491            Self::InvalidRecipients => "invalidRecipients",
492            Self::ForbiddenMailFrom => "forbiddenMailFrom",
493            Self::ForbiddenToSend => "forbiddenToSend",
494            Self::CannotUnsend => "cannotUnsend",
495            Self::Custom(s) => s.as_str(),
496        };
497        f.write_str(s)
498    }
499}
500
501impl serde::Serialize for SetErrorType {
502    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
503        // bd:JMAP-jfia.33 — collect_str avoids the per-call String
504        // allocation that `s.serialize_str(&self.to_string())` does.
505        // serde_json's collect_str uses a stack buffer for short
506        // strings; every SetErrorType variant's Display is short
507        // enough to fit. The round-trip oracle
508        // set_error_type_all_known_variants_round_trip pins
509        // wire-format identity.
510        s.collect_str(self)
511    }
512}
513
514impl<'de> serde::Deserialize<'de> for SetErrorType {
515    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
516        struct Visitor;
517        impl serde::de::Visitor<'_> for Visitor {
518            type Value = SetErrorType;
519            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520                f.write_str("a JMAP SetError type string")
521            }
522            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
523                // Single source of truth shared with [`SetErrorType::custom`]
524                // (bd:JMAP-wlip.22). An unknown wire-name falls through to
525                // Custom; a known wire-name canonicalises to its typed
526                // variant so that round-trip is symmetric.
527                Ok(SetErrorType::from_wire_str(v)
528                    .unwrap_or_else(|| SetErrorType::Custom(v.to_owned())))
529            }
530        }
531        d.deserialize_str(Visitor)
532    }
533}
534
535/// Error type returned by create/update/destroy backend methods.
536#[non_exhaustive]
537#[derive(Debug)]
538pub enum BackendSetError<E> {
539    /// A well-typed JMAP [`SetError`] to place verbatim in the
540    /// `notCreated`/`notUpdated`/`notDestroyed` map.
541    SetError(SetError),
542    /// An unexpected storage-layer error.
543    Other(E),
544}
545
546impl<E: std::fmt::Display> std::fmt::Display for BackendSetError<E> {
547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548        match self {
549            Self::SetError(se) => write!(f, "set error: {se}"),
550            Self::Other(e) => write!(f, "{e}"),
551        }
552    }
553}
554
555impl<E: std::error::Error + 'static> std::error::Error for BackendSetError<E> {
556    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
557        match self {
558            Self::Other(e) => Some(e),
559            _ => None,
560        }
561    }
562}
563
564impl<E> From<SetError> for BackendSetError<E> {
565    fn from(e: SetError) -> Self {
566        Self::SetError(e)
567    }
568}
569
570// ---------------------------------------------------------------------------
571// Backend error envelopes
572// ---------------------------------------------------------------------------
573
574/// Error type returned by [`JmapBackend::get_changes`] and
575/// [`JmapBackend::query_changes`].
576///
577/// # `CannotCalculate` vs `TooManyChanges`
578///
579/// The two non-`Other` variants map to two distinct JMAP wire errors
580/// (RFC 8620 §5.6). Previously a single `TooManyChanges { limit: 0 }`
581/// variant overloaded both meanings via a magic-zero sentinel; the
582/// `CannotCalculate` variant was added in bd:JMAP-jfia.31 to surface
583/// the distinction at the type level. `TooManyChanges { limit: 0 }`
584/// is preserved as a **permanent** legacy alias — it still maps to
585/// `cannotCalculateChanges` via the `From` and `Display` impls — but
586/// new backends SHOULD construct `CannotCalculate` directly. See the
587/// `TooManyChanges` variant docs for why the alias cannot be
588/// `#[deprecated]` at the type level (bd:JMAP-jfia.37).
589#[non_exhaustive]
590#[derive(Debug)]
591pub enum BackendChangesError<E> {
592    /// The server has no usable change log for the given `sinceState`
593    /// and cannot supply incremental changes — the client MUST
594    /// discard ALL locally cached objects for the affected type,
595    /// reset its local state token to the empty string, and perform a
596    /// full resync (`/get` with `ids: null`). Partial recovery is not
597    /// permitted. Maps to `cannotCalculateChanges` (RFC 8620 §5.6;
598    /// authoritative behavior documented in jmapio/jmap-js
599    /// `mail-model.js`).
600    ///
601    /// Added in bd:JMAP-jfia.31 to replace the
602    /// `TooManyChanges { limit: 0 }` magic-zero overload. New backends
603    /// SHOULD construct `CannotCalculate` directly; legacy backends
604    /// that emit `TooManyChanges { limit: 0 }` still map to the same
605    /// wire error via the permanent legacy alias (bd:JMAP-jfia.37).
606    CannotCalculate,
607    /// The change window exceeds what the server can supply in a
608    /// single `/changes` response. Maps to `tooManyChanges` with the
609    /// `limit` as the suggested maximum — the client may retry with
610    /// a smaller window.
611    ///
612    /// **Legacy sub-case (bd:JMAP-jfia.31, bd:JMAP-jfia.37)**: a
613    /// `limit` of `0` historically meant "full state reset
614    /// required" and is preserved as an alias for the new
615    /// [`Self::CannotCalculate`] variant. New code SHOULD use
616    /// `CannotCalculate` directly. The alias is **permanent** at
617    /// the type level — Rust cannot `#[deprecated]` a single
618    /// discriminator value of an enum variant without deprecating
619    /// the variant itself, and removing the alias would silently
620    /// break any backend still emitting `TooManyChanges { limit: 0 }`
621    /// (their returns would stop mapping to `cannotCalculateChanges`
622    /// and start emitting a malformed RFC 8620 §5.6 `tooManyChanges`
623    /// with `limit: 0`). The `From` and `Display` impls below pin
624    /// the alias semantics permanently. Match arms in extension
625    /// servers SHOULD treat `TooManyChanges { limit: 0 }` and
626    /// `CannotCalculate` as a single "full-resync required" case
627    /// rather than branching them apart.
628    TooManyChanges {
629        /// Maximum window size the server can supply in a single
630        /// `/changes` response. A value of `0` is the permanent
631        /// legacy alias for [`Self::CannotCalculate`]; any non-zero
632        /// value is the suggested maximum the client may retry with.
633        limit: u64,
634    },
635    /// An unexpected storage-layer error.
636    Other(E),
637}
638
639impl<E: std::fmt::Display> std::fmt::Display for BackendChangesError<E> {
640    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
641        match self {
642            Self::CannotCalculate => write!(f, "cannot calculate changes"),
643            // Permanent legacy alias (bd:JMAP-jfia.31, bd:JMAP-jfia.37).
644            Self::TooManyChanges { limit: 0 } => write!(f, "cannot calculate changes"),
645            Self::TooManyChanges { limit } => write!(f, "too many changes (limit: {limit})"),
646            Self::Other(e) => write!(f, "{e}"),
647        }
648    }
649}
650
651impl<E: std::error::Error + 'static> std::error::Error for BackendChangesError<E> {
652    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
653        match self {
654            Self::Other(e) => Some(e),
655            _ => None,
656        }
657    }
658}
659
660impl<E> From<E> for BackendChangesError<E> {
661    fn from(e: E) -> Self {
662        Self::Other(e)
663    }
664}
665
666impl<E: std::error::Error> From<BackendChangesError<E>> for jmap_types::JmapError {
667    fn from(e: BackendChangesError<E>) -> Self {
668        match e {
669            BackendChangesError::CannotCalculate => {
670                jmap_types::JmapError::cannot_calculate_changes()
671            }
672            // Permanent legacy alias for CannotCalculate
673            // (bd:JMAP-jfia.31, bd:JMAP-jfia.37). Preserved so
674            // backends that emit TooManyChanges { limit: 0 } produce
675            // the correct wire error. The variant docs explain why
676            // this cannot be #[deprecated] at the type level.
677            BackendChangesError::TooManyChanges { limit: 0 } => {
678                jmap_types::JmapError::cannot_calculate_changes()
679            }
680            BackendChangesError::TooManyChanges { limit } => {
681                jmap_types::JmapError::too_many_changes_with_limit(limit)
682            }
683            // bd:JMAP-jfia.1 / bd:JMAP-wlip.2 — the `Other` arm wraps a
684            // backend `Error` whose `Display` impl is contractually
685            // forbidden from carrying credential/blob/PII text but which
686            // we still treat as untrusted at the wire boundary. Use the
687            // same static [`SERVER_FAIL_INTERNAL_DESC`] string that the
688            // [`server_fail_from_backend`] handler-layer helper uses so
689            // the defence-in-depth chain (backend Display contract →
690            // handler helper → this From impl) cannot be bypassed by
691            // handlers that take the ergonomic `.map_err(JmapError::from)?`
692            // path on `BackendChangesError`.
693            //
694            // [`SERVER_FAIL_INTERNAL_DESC`]: crate::handlers::SERVER_FAIL_INTERNAL_DESC
695            // [`server_fail_from_backend`]: crate::handlers::server_fail_from_backend
696            BackendChangesError::Other(_inner) => {
697                jmap_types::JmapError::server_fail(crate::handlers::SERVER_FAIL_INTERNAL_DESC)
698            }
699        }
700    }
701}
702
703// ---------------------------------------------------------------------------
704// Result types
705// ---------------------------------------------------------------------------
706
707/// Result of a `/changes` call (RFC 8620 §5.2).
708#[derive(Debug)]
709#[non_exhaustive]
710pub struct ChangesResult {
711    /// Ids of objects that were created since `sinceState`.
712    pub created: Vec<jmap_types::Id>,
713    /// Ids of objects that were updated since `sinceState`.
714    pub updated: Vec<jmap_types::Id>,
715    /// Ids of objects that were destroyed since `sinceState`.
716    pub destroyed: Vec<jmap_types::Id>,
717    /// `true` if there are more changes beyond this batch.
718    pub has_more_changes: bool,
719    /// The current state token after applying all reported changes.
720    pub new_state: jmap_types::State,
721}
722
723impl ChangesResult {
724    /// Construct a [`ChangesResult`].
725    pub fn new(
726        created: Vec<jmap_types::Id>,
727        updated: Vec<jmap_types::Id>,
728        destroyed: Vec<jmap_types::Id>,
729        has_more_changes: bool,
730        new_state: jmap_types::State,
731    ) -> Self {
732        Self {
733            created,
734            updated,
735            destroyed,
736            has_more_changes,
737            new_state,
738        }
739    }
740}
741
742/// Result of a `/query` call (RFC 8620 §5.5).
743#[derive(Debug)]
744#[non_exhaustive]
745pub struct QueryResult {
746    /// The ordered list of matching object ids.
747    pub ids: Vec<jmap_types::Id>,
748    /// The 0-based index of the first returned id in the complete result list.
749    ///
750    /// RFC 8620 §5.5 specifies this as `UnsignedInt` in the response —
751    /// a non-negative integer (bd:JMAP-wlip.25). The request-side
752    /// position parameter accepts negative values as end-relative
753    /// offsets, but the response position cannot validly be negative.
754    /// Backends that derive `position` from a request-side `i64`
755    /// offset MUST clamp / normalize to `u64` before constructing this
756    /// struct.
757    pub position: u64,
758    /// Total number of results, if the backend can calculate it.
759    pub total: Option<u64>,
760    /// Opaque query state token for subsequent `/queryChanges` calls.
761    pub query_state: jmap_types::State,
762    /// Whether the backend supports `/queryChanges` for this query.
763    pub can_calculate_changes: bool,
764}
765
766impl QueryResult {
767    /// Construct a [`QueryResult`].
768    pub fn new(
769        ids: Vec<jmap_types::Id>,
770        position: u64,
771        total: Option<u64>,
772        query_state: jmap_types::State,
773        can_calculate_changes: bool,
774    ) -> Self {
775        Self {
776            ids,
777            position,
778            total,
779            query_state,
780            can_calculate_changes,
781        }
782    }
783
784    /// Construct a [`QueryResult`] from a signed request-side position,
785    /// clamping negatives to `0` and normalizing to `u64`
786    /// (bd:JMAP-jfia.25).
787    ///
788    /// RFC 8620 §5.5 specifies the response `position` as `UnsignedInt`,
789    /// but the request-side `position` parameter accepts negative
790    /// values as end-relative offsets. Backends typically receive the
791    /// signed value, walk the offset into the result list, and need
792    /// to surface the resulting 0-based index. This constructor takes
793    /// the resolved-offset value and clamps to `u64`, matching the
794    /// spec contract.
795    ///
796    /// Negative inputs clamp to `0` (representing "the start of the
797    /// result list"). Backends that have already done the
798    /// offset-to-index resolution and have a `u64` already SHOULD use
799    /// the plain [`Self::new`] constructor instead.
800    pub fn new_clamped(
801        ids: Vec<jmap_types::Id>,
802        position_signed: i64,
803        total: Option<u64>,
804        query_state: jmap_types::State,
805        can_calculate_changes: bool,
806    ) -> Self {
807        // i64::max(_, 0) then cast to u64 — both fit in u64 because
808        // i64 >= 0 has range [0, i64::MAX], which is a strict subset
809        // of u64's range.
810        let position = u64::try_from(position_signed.max(0)).unwrap_or(0);
811        Self::new(ids, position, total, query_state, can_calculate_changes)
812    }
813}
814
815/// One entry in the `added` list of a `/queryChanges` response (RFC 8620 §5.6).
816#[derive(Debug)]
817#[non_exhaustive]
818pub struct AddedItem {
819    /// The id of the newly-added object.
820    pub id: jmap_types::Id,
821    /// Its 0-based position in the result list after applying all changes.
822    pub index: u64,
823}
824
825impl AddedItem {
826    /// Construct an [`AddedItem`].
827    pub fn new(id: jmap_types::Id, index: u64) -> Self {
828        Self { id, index }
829    }
830}
831
832/// Result of a `/queryChanges` call (RFC 8620 §5.6).
833#[derive(Debug)]
834#[non_exhaustive]
835pub struct QueryChangesResult {
836    /// The query state token supplied by the client in `sinceQueryState`.
837    pub old_query_state: jmap_types::State,
838    /// The current query state token.
839    pub new_query_state: jmap_types::State,
840    /// Total number of results in the new query, if the backend can calculate it.
841    pub total: Option<u64>,
842    /// Ids removed from the result set since `oldQueryState`.
843    pub removed: Vec<jmap_types::Id>,
844    /// Ids added to the result set since `oldQueryState`, with their positions.
845    pub added: Vec<AddedItem>,
846}
847
848impl QueryChangesResult {
849    /// Construct a [`QueryChangesResult`].
850    pub fn new(
851        old_query_state: jmap_types::State,
852        new_query_state: jmap_types::State,
853        total: Option<u64>,
854        removed: Vec<jmap_types::Id>,
855        added: Vec<AddedItem>,
856    ) -> Self {
857        Self {
858            old_query_state,
859            new_query_state,
860            total,
861            removed,
862            added,
863        }
864    }
865}
866
867// ---------------------------------------------------------------------------
868// JmapBackend — the read-side supertrait
869// ---------------------------------------------------------------------------
870
871/// Read-side backend supertrait shared by all JMAP server crates.
872///
873/// Domain-specific backend traits (`MailBackend`, `ChatBackend`, etc.) require
874/// this trait as a supertrait and add write-side methods on top.
875///
876/// Only the read operations that have an identical signature across all JMAP
877/// object types belong here. Write operations (`create_object`, `update_object`,
878/// `destroy_object`) and domain-specific operations remain in the domain crate.
879///
880/// The `collapse_threads` parameter on `query_changes` is included for
881/// `Email/queryChanges` (RFC 8621 §4.5). Non-mail backends should pass `false`
882/// and may ignore the parameter.
883///
884/// This trait is not object-safe by design (generic methods). Use
885/// `Arc<impl JmapBackend>` when sharing across tasks.
886///
887/// # CallerCtx
888///
889/// Every backend method takes a `caller: &Self::CallerCtx` parameter as the
890/// first argument after `&self`. This is the per-request authentication /
891/// authorisation context produced by the caller's auth layer and forwarded
892/// unchanged through [`crate::Dispatcher::dispatch`] → [`crate::JmapHandler`]
893/// → the registered closure → the backend.
894///
895/// Implementations that do not need an auth identity can use the unit type:
896///
897/// ```rust,ignore
898/// impl JmapBackend for MyBackend {
899///     type Error = MyError;
900///     type CallerCtx = ();
901///     // ...
902/// }
903/// ```
904///
905/// Implementations that do need to differentiate behaviour per caller (e.g.
906/// applying per-user visibility rules, or rejecting reads with
907/// `forbidden` when the caller is not the owner of the account) read the
908/// `caller` parameter to decide.
909///
910/// The trait bound `Clone + Send + 'static` is what [`crate::Dispatcher`]
911/// requires; the bound is repeated here so the supertrait can stand on its
912/// own without depending on the dispatcher.
913pub trait JmapBackend: Send + Sync + 'static {
914    /// The error type returned by storage operations.
915    ///
916    /// # Security
917    ///
918    /// The `Display` impl of this type is surfaced through
919    /// [`BackendSetError::Other`]'s and [`BackendChangesError::Other`]'s
920    /// own `Display` impls, which in turn flow into
921    /// [`crate::request_error`]'s `RequestError::Display` output. When a
922    /// downstream consumer wires tracing-style logging on top, the
923    /// formatted error text lands in operator logs verbatim.
924    ///
925    /// Implementations MUST NOT include any of the following in this
926    /// type's `Display` output:
927    ///
928    /// - **Credential material** — auth tokens, passwords, push
929    ///   verification codes, invite codes, session cookies, or anything
930    ///   derived byte-for-byte from an `Authorization`-header value.
931    /// - **Blob content** — email bodies, sieve scripts, file contents,
932    ///   or any user-supplied opaque payload. An error like
933    ///   `"sieve parse error at line 42: <script excerpt>"` violates
934    ///   this — emit the line number and a short type-only summary
935    ///   ("sieve parse error at line 42: unexpected token") and let the
936    ///   server log the full script body separately under a redacted
937    ///   path.
938    /// - **PII shaped like an email address** in any code path that an
939    ///   unauthenticated caller can trigger. Wrapping a downstream
940    ///   service error that interpolates the caller's email is the
941    ///   common foot-gun.
942    ///
943    /// Errors that wrap a downstream-service failure should sanitize
944    /// the downstream error text — or strip it entirely and replace it
945    /// with a static summary — before constructing the `Display`
946    /// string. The same rule applies to every extension `*Backend`
947    /// trait that inherits this associated type by transitivity:
948    /// `MailBackend::Error`, `ChatBackend::Error`,
949    /// `CalendarsBackend::Error`, `TasksBackend::Error`,
950    /// `ContactsBackend::Error`, `FileNodeBackend::Error`, and
951    /// `SharingBackend::Error` are all the same `JmapBackend::Error`
952    /// associated type — the contract here governs all of them.
953    ///
954    /// Precedent: bd:JMAP-sc1b.79 redacted `BearerAuth` and `BasicAuth`
955    /// at the type-derive level; bd:JMAP-sc1b.100 documents the
956    /// equivalent contract at the trait-associated-type level.
957    type Error: std::error::Error + Send + Sync + 'static;
958
959    /// The per-request caller context type produced by the auth layer and
960    /// forwarded by [`crate::Dispatcher::dispatch`] into every method call.
961    ///
962    /// Use `()` when no auth context is needed.
963    ///
964    /// The bound is `Clone + Send + Sync + 'static`:
965    /// - `Clone` because [`crate::Dispatcher`] clones the value once per
966    ///   method call in the batch.
967    /// - `Send + 'static` because each method call is spawned on a
968    ///   [`tokio::task`].
969    /// - `Sync` because handler method bodies take `&Self::CallerCtx`
970    ///   and hold that reference across `.await` boundaries inside a
971    ///   `Send` future (a `&T` is `Send` iff `T: Sync`).
972    type CallerCtx: Clone + Send + Sync + 'static;
973
974    /// Return `true` if the given account exists in this backend.
975    ///
976    /// Handlers call this at the start of each method to return
977    /// `accountNotFound` (RFC 8620 §3.6.2) rather than surfacing
978    /// the wrong error when `accountId` is unknown.
979    ///
980    /// # Performance contract (bd:JMAP-jfia.27)
981    ///
982    /// Implementations SHOULD return in sub-millisecond time. The
983    /// JMAP standard handlers (`handle_get`, `handle_changes`,
984    /// `handle_query`, `handle_query_changes`) each call
985    /// `account_exists` at the START of the method, BEFORE delegating
986    /// to the per-domain backend. A typical JMAP request batch is
987    /// 4–16 method calls; a naive remote round-trip per call would
988    /// add 4–16× the network latency to every batch.
989    ///
990    /// Acceptable backing strategies: in-memory cache (the
991    /// reference `MemoryBackend` impls all use this); an indexed
992    /// primary-key lookup against a local database; a bloom filter
993    /// for negative lookups paired with a cache for positives. A
994    /// naive `SELECT 1 FROM accounts WHERE id = ?` against a remote
995    /// database is INCORRECT for this method even though it would
996    /// return the right value — the round-trip cost is the bug.
997    ///
998    /// The dispatcher does not cache this call across the method
999    /// calls in a single batch (workspace-architectural decision —
1000    /// the dispatcher is intentionally stateless across the batch
1001    /// loop). Caching is the backend implementor's responsibility.
1002    fn account_exists(
1003        &self,
1004        caller: &Self::CallerCtx,
1005        account_id: &jmap_types::Id,
1006    ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
1007
1008    /// Fetch objects by id (or all objects when `ids` is `None`).
1009    ///
1010    /// `properties` is the list of property names requested by the client
1011    /// (RFC 8620 §5.1). `None` means the client did not send a `properties`
1012    /// field; the backend should return all properties. When `Some`, the backend
1013    /// MAY filter the response to only the named properties, but is not required
1014    /// to — implementations that always return all properties are correct.
1015    ///
1016    /// Returns `(found, not_found)` — objects that exist and ids that do not.
1017    fn get_objects<O: GetObject + Send + Sync>(
1018        &self,
1019        caller: &Self::CallerCtx,
1020        account_id: &jmap_types::Id,
1021        ids: Option<&[jmap_types::Id]>,
1022        properties: Option<&[String]>,
1023    ) -> impl std::future::Future<Output = Result<(Vec<O>, Vec<jmap_types::Id>), Self::Error>> + Send;
1024
1025    /// Return the current state token for an object type in the given account.
1026    fn get_state<O: JmapObject + Send + Sync>(
1027        &self,
1028        caller: &Self::CallerCtx,
1029        account_id: &jmap_types::Id,
1030    ) -> impl std::future::Future<Output = Result<jmap_types::State, Self::Error>> + Send;
1031
1032    /// Return changes since `since_state`, up to `max_changes` entries.
1033    fn get_changes<O: JmapObject + Send + Sync>(
1034        &self,
1035        caller: &Self::CallerCtx,
1036        account_id: &jmap_types::Id,
1037        since_state: &jmap_types::State,
1038        max_changes: Option<u64>,
1039    ) -> impl std::future::Future<Output = Result<ChangesResult, BackendChangesError<Self::Error>>> + Send;
1040
1041    /// Execute a `/query` and return a page of matching ids.
1042    ///
1043    /// `position` may be negative — negative values are relative to the end of
1044    /// the result set per RFC 8620 §5.5 (e.g. -1 means the last result).
1045    ///
1046    /// # Filter and sort handling
1047    ///
1048    /// Implementations MUST honour the supplied `filter` and `sort` arguments
1049    /// efficiently — typically by pushing both into the indexed storage layer
1050    /// (database WHERE / ORDER BY, search index, etc.). Returning every
1051    /// matching id and relying on the caller to paginate after the fact
1052    /// degenerates to O(n) per page for IMAP-migration accounts.
1053    ///
1054    /// Handler implementations in `jmap-*-server` crates SHOULD NOT
1055    /// post-filter or post-sort the backend's result; doing so re-introduces
1056    /// the O(n) cost this method exists to avoid. The Mailbox handler in
1057    /// `jmap-mail-server` is the canonical example of pushing filter/sort
1058    /// fully into the backend.
1059    #[allow(clippy::too_many_arguments)]
1060    fn query_objects<O: QueryObject + Send + Sync>(
1061        &self,
1062        caller: &Self::CallerCtx,
1063        account_id: &jmap_types::Id,
1064        filter: Option<&O::Filter>,
1065        sort: Option<&[O::Comparator]>,
1066        limit: Option<u64>,
1067        position: i64,
1068    ) -> impl std::future::Future<Output = Result<QueryResult, Self::Error>> + Send;
1069
1070    /// Execute a `/queryChanges` and return deltas since `since_query_state`.
1071    ///
1072    /// `collapse_threads` is only meaningful for `Email/queryChanges`
1073    /// (RFC 8621 §4.5). Pass `false` for all other object types.
1074    #[allow(clippy::too_many_arguments)]
1075    fn query_changes<O: QueryObject + Send + Sync>(
1076        &self,
1077        caller: &Self::CallerCtx,
1078        account_id: &jmap_types::Id,
1079        since_query_state: &jmap_types::State,
1080        filter: Option<&O::Filter>,
1081        sort: Option<&[O::Comparator]>,
1082        max_changes: Option<u64>,
1083        up_to_id: Option<&jmap_types::Id>,
1084        collapse_threads: bool,
1085    ) -> impl std::future::Future<
1086        Output = Result<QueryChangesResult, BackendChangesError<Self::Error>>,
1087    > + Send;
1088
1089    /// The caller's stable identity within this account namespace.
1090    ///
1091    /// Returns `None` for deployments that have not wired identity
1092    /// (test fixtures, single-user dev servers). A `None`-returning
1093    /// backend CANNOT honor JMAP semantics that depend on caller
1094    /// identity — chat role-hierarchy, calendar ACLs, sharing/myRights,
1095    /// per-user $seen on shared mailboxes, metadata isPrivate
1096    /// visibility scoping, etc. Authentication is still the HTTP
1097    /// layer's job; this method exposes the result of that
1098    /// authentication to the JMAP layer for in-method semantics.
1099    ///
1100    /// Implementations MUST NOT mint identity — they MUST read it
1101    /// from the `CallerCtx` populated by the HTTP/auth middleware
1102    /// before `dispatch()` was called.
1103    ///
1104    /// Backends that honor identity-dependent semantics MUST override
1105    /// this method. Handlers and downstream backend traits MAY rely on
1106    /// it being correct when it returns `Some`.
1107    ///
1108    /// # Why an associated function and not a method (bd:JMAP-wlip.6 / bd:JMAP-jfia.13)
1109    ///
1110    /// The signature deliberately takes `caller: &Self::CallerCtx`
1111    /// without a `&self` receiver. Backends therefore have no access
1112    /// to their own storage state from inside `principal_id`. The
1113    /// auth-layer middleware MUST pre-resolve the principal (e.g. map
1114    /// a JWT `sub` claim to a local `Id` via an internal lookup) and
1115    /// stash the result inside `CallerCtx` *before* it calls
1116    /// `dispatch`. The JMAP layer reads the pre-resolved value here;
1117    /// no JIT lookup is possible.
1118    ///
1119    /// This is a structural enforcement of the "identity is not the
1120    /// JMAP layer's job to mint" rule. A consumer that wants
1121    /// JIT-resolved identity (e.g. database-backed JWT → principal
1122    /// mapping) wires that mapping into the HTTP layer's `CallerCtx`
1123    /// construction step instead of trying to fit it inside the
1124    /// backend's `principal_id` impl.
1125    ///
1126    /// **Decision record (bd:JMAP-jfia.13)**: a future reviewer or AI
1127    /// tool will reasonably suggest "this is an oversight, surely the
1128    /// backend wants `&self` access to its identity store" and propose
1129    /// `fn principal_id(&self, caller: ...) -> ...`. That suggestion
1130    /// is **WRONG** and must be rejected: the function-vs-method
1131    /// distinction is the structural enforcement of the no-JIT-lookup
1132    /// policy. Comments alone could be ignored; the type system makes
1133    /// the wrong thing impossible. Defending this shape protects the
1134    /// workspace identity-seam policy from drift.
1135    fn principal_id(caller: &Self::CallerCtx) -> Option<&jmap_types::Id> {
1136        let _ = caller;
1137        None
1138    }
1139
1140    /// Maximum number of objects this account permits in a single
1141    /// `/set` call (RFC 8620 §5.3 `maxObjectsInSet`) (bd:JMAP-ayoz.41.1).
1142    ///
1143    /// Counts the sum of `create` + `update` + `destroy` entries in the
1144    /// wire arguments. Handlers MUST enforce this at the top of every
1145    /// `handle_*_set` via [`crate::helpers::enforce_max_objects_in_set`]
1146    /// so a single batched request cannot drive O(M·N) work against
1147    /// the storage layer.
1148    ///
1149    /// The default value `500` mirrors the workspace's testjig Session
1150    /// JSON advertised cap and matches common Fastmail / Cyrus IMAP
1151    /// server defaults. The cap is a *floor* on permissiveness, not a
1152    /// floor on capability — backends MAY override per account (Free vs
1153    /// Pro tier, multi-tenant SaaS, etc.). The `caller` and `account_id`
1154    /// arguments are passed even though the default impl ignores them
1155    /// so production backends can vary without an API break.
1156    ///
1157    /// Returning `0` makes every `/set` call fail with `limit
1158    /// maxObjectsInSet` (defensive read-only mode). Returning
1159    /// `u64::MAX` effectively disables the cap (NOT recommended — the
1160    /// cap is a DoS defence and disabling it forfeits that defence).
1161    ///
1162    /// # Why on `JmapBackend` and not a per-extension `XxxLimits` struct
1163    ///
1164    /// `maxObjectsInSet` is RFC 8620 §5.3 base-protocol scope, not an
1165    /// extension concept. Putting it on the foundation supertrait
1166    /// covers all 8 extension server crates with one default impl;
1167    /// per-extension `XxxLimits` structs are the right shape for
1168    /// extension-specific caps (per-Space content limits, per-Mailbox
1169    /// message size, etc. per workspace AGENTS.md "Backend caps and
1170    /// limits") but are out of scope here.
1171    fn max_objects_in_set(&self, caller: &Self::CallerCtx, account_id: &jmap_types::Id) -> u64 {
1172        let _ = (caller, account_id);
1173        500
1174    }
1175}
1176
1177// ---------------------------------------------------------------------------
1178// Tests
1179// ---------------------------------------------------------------------------
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184
1185    /// Oracle: BackendChangesError::TooManyChanges { limit: 0 } must map to
1186    /// cannotCalculateChanges (RFC 8620 §5.6), not tooManyChanges with limit 0.
1187    ///
1188    /// limit=0 is the convention for "cannot calculate".
1189    #[test]
1190    fn backend_changes_error_limit_zero_maps_to_cannot_calculate() {
1191        let err = jmap_types::JmapError::from(
1192            BackendChangesError::<std::convert::Infallible>::TooManyChanges { limit: 0 },
1193        );
1194        assert_eq!(
1195            err.error_type.as_str(),
1196            "cannotCalculateChanges",
1197            "limit=0 must produce cannotCalculateChanges; got: {:?}",
1198            err.error_type
1199        );
1200    }
1201
1202    /// Oracle (bd:JMAP-jfia.31, bd:JMAP-jfia.37): the new
1203    /// `CannotCalculate` variant maps to `cannotCalculateChanges`
1204    /// on the wire, matching the permanent legacy
1205    /// `TooManyChanges { limit: 0 }` alias. New backends SHOULD
1206    /// emit `CannotCalculate` directly.
1207    #[test]
1208    fn backend_changes_error_cannot_calculate_maps_to_cannot_calculate_changes() {
1209        let err = jmap_types::JmapError::from(
1210            BackendChangesError::<std::convert::Infallible>::CannotCalculate,
1211        );
1212        assert_eq!(
1213            err.error_type.as_str(),
1214            "cannotCalculateChanges",
1215            "CannotCalculate must produce cannotCalculateChanges; got: {:?}",
1216            err.error_type
1217        );
1218
1219        // Display agrees with the permanent legacy-alias Display arm.
1220        let s = BackendChangesError::<std::convert::Infallible>::CannotCalculate.to_string();
1221        assert_eq!(
1222            s, "cannot calculate changes",
1223            "Display must produce the same string as TooManyChanges {{ limit: 0 }}"
1224        );
1225    }
1226
1227    /// Oracle: BackendChangesError::TooManyChanges { limit: N } (N > 0) maps to
1228    /// tooManyChanges with the suggested limit.
1229    #[test]
1230    fn backend_changes_error_nonzero_limit_maps_to_too_many_changes() {
1231        let err = jmap_types::JmapError::from(
1232            BackendChangesError::<std::convert::Infallible>::TooManyChanges { limit: 50 },
1233        );
1234        assert_eq!(
1235            err.error_type.as_str(),
1236            "tooManyChanges",
1237            "limit=50 must produce tooManyChanges; got: {:?}",
1238            err.error_type
1239        );
1240    }
1241
1242    /// Oracle (bd:JMAP-jfia.1 / bd:JMAP-wlip.2): the
1243    /// `From<BackendChangesError<E>> for JmapError` impl MUST NOT echo
1244    /// the wrapped backend error's `Display` text into the resulting
1245    /// `JmapError`'s description. The defence-in-depth contract is that
1246    /// even if a backend implementor accidentally violates the
1247    /// [`JmapBackend::Error`] Display MUST-NOT (credential / blob /
1248    /// PII), the leaked text never reaches the wire — and the
1249    /// ergonomic `.map_err(JmapError::from)?` path that
1250    /// `handle_changes` / `handle_query_changes` take on
1251    /// `BackendChangesError` must redact identically to the explicit
1252    /// `server_fail_from_backend(&e)` helper used elsewhere.
1253    ///
1254    /// Test vector: an `Other` variant whose Display contains a canary
1255    /// string resembling a credential leak. The canary literal is
1256    /// hand-built and not derived from any production type's
1257    /// behaviour. Mirrors
1258    /// `server_fail_from_backend_drops_display_text` in
1259    /// `handlers.rs`.
1260    #[test]
1261    fn backend_changes_error_other_drops_display_text() {
1262        #[derive(Debug)]
1263        struct LeakyError(&'static str);
1264        impl std::fmt::Display for LeakyError {
1265            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1266                f.write_str(self.0)
1267            }
1268        }
1269        impl std::error::Error for LeakyError {}
1270
1271        const CANARY: &str = "TOKEN-DO-NOT-LEAK-c0ffee";
1272        let err: BackendChangesError<LeakyError> = BackendChangesError::Other(LeakyError(CANARY));
1273
1274        let jmap_err = jmap_types::JmapError::from(err);
1275
1276        // Serialize to wire shape and assert the canary is absent from
1277        // the resulting JSON. The error_invocation wraps a JmapError as
1278        // { "type": "serverFail", "description": "..." } — both fields
1279        // are wire-visible.
1280        let wire = serde_json::to_value(&jmap_err).expect("JmapError must serialize");
1281        let wire_str = wire.to_string();
1282        assert!(
1283            !wire_str.contains(CANARY),
1284            "From<BackendChangesError<E>> for JmapError must not echo \
1285             backend error Display onto the wire; got {wire_str}"
1286        );
1287        // The description MUST be exactly SERVER_FAIL_INTERNAL_DESC.
1288        assert_eq!(
1289            wire["description"],
1290            crate::handlers::SERVER_FAIL_INTERNAL_DESC,
1291            "description must be the static 'internal error' string"
1292        );
1293        assert_eq!(wire["type"], "serverFail");
1294    }
1295
1296    /// Oracle (bd:JMAP-wlip.22): `SetErrorType::custom("forbidden")` MUST
1297    /// return `SetErrorType::Forbidden`, not `Custom("forbidden")`. The
1298    /// asymmetry where `custom("forbidden") != Forbidden` was a real
1299    /// foot-gun: handler code intending to emit the typed variant via
1300    /// the `custom` builder produced a Custom that was wire-identical
1301    /// but PartialEq-distinct, breaking test assertions that compared
1302    /// the deserialised round-trip against the typed expected value.
1303    ///
1304    /// Test vector: every known typed variant name canonicalises to its
1305    /// typed variant; an unknown name stays Custom. The wire-name list
1306    /// is hand-built from the same RFC source as the round-trip test
1307    /// `set_error_type_all_known_variants_round_trip`.
1308    #[test]
1309    fn custom_canonicalises_known_wire_names_to_typed_variants() {
1310        // Spot-check a representative subset across the 23 known names.
1311        // The exhaustive round-trip from from_wire_str is exercised by
1312        // set_error_type_all_known_variants_round_trip; this test focuses
1313        // on the custom() → typed-variant direction the bead was about.
1314        let cases: &[(&str, SetErrorType)] = &[
1315            ("forbidden", SetErrorType::Forbidden),
1316            ("overQuota", SetErrorType::OverQuota),
1317            ("invalidPatch", SetErrorType::InvalidPatch),
1318            ("mailboxHasChild", SetErrorType::MailboxHasChild),
1319            ("tooManyRecipients", SetErrorType::TooManyRecipients),
1320            ("cannotUnsend", SetErrorType::CannotUnsend),
1321        ];
1322        for (name, expected) in cases {
1323            let from_custom = SetErrorType::custom(*name);
1324            assert_eq!(
1325                &from_custom, expected,
1326                "custom({name:?}) must canonicalise to the typed variant, not Custom"
1327            );
1328            assert!(
1329                !matches!(from_custom, SetErrorType::Custom(_)),
1330                "custom({name:?}) must NOT remain Custom — known wire-name asymmetry"
1331            );
1332        }
1333
1334        // Unknown names stay Custom — extension crates depend on this.
1335        let unknown = SetErrorType::custom("mdnAlreadySent");
1336        assert!(
1337            matches!(unknown, SetErrorType::Custom(ref s) if s == "mdnAlreadySent"),
1338            "custom('mdnAlreadySent') must remain Custom (not a known wire-name)"
1339        );
1340    }
1341
1342    /// Oracle: SetErrorType::Custom("mdnAlreadySent") must serialize as the bare
1343    /// string "mdnAlreadySent" and deserialize back to Custom("mdnAlreadySent").
1344    /// Extension crates depend on this round-trip to emit domain-specific errors.
1345    #[test]
1346    fn set_error_type_custom_round_trips_as_bare_string() {
1347        let original = SetErrorType::custom("mdnAlreadySent");
1348        let serialized = serde_json::to_string(&original).expect("serialize");
1349        assert_eq!(
1350            serialized, r#""mdnAlreadySent""#,
1351            "Custom must serialize as bare string"
1352        );
1353        let deserialized: SetErrorType = serde_json::from_str(&serialized).expect("deserialize");
1354        assert_eq!(
1355            deserialized, original,
1356            "Custom must deserialize back to Custom"
1357        );
1358    }
1359
1360    /// Oracle (bd:JMAP-dha0): SetError gains an `extra` map that captures
1361    /// extension-defined fields not covered by the typed `with_*` builders.
1362    /// A handler that emits `rateLimited` with `serverRetryAfter` must
1363    /// see the value round-trip through serialize / deserialize.
1364    #[test]
1365    fn set_error_extra_field_round_trips() {
1366        let original = SetError::new(SetErrorType::custom("rateLimited"))
1367            .with_description("Slow mode is active")
1368            .with_extra(
1369                "serverRetryAfter",
1370                serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
1371            );
1372
1373        let wire = serde_json::to_value(&original).expect("serialize");
1374        assert_eq!(wire["type"], "rateLimited");
1375        assert_eq!(wire["description"], "Slow mode is active");
1376        assert_eq!(
1377            wire["serverRetryAfter"], "2025-12-31T23:59:59Z",
1378            "extra field must flatten into the SetError wire shape"
1379        );
1380
1381        let round: SetError = serde_json::from_value(wire).expect("deserialize");
1382        assert_eq!(round.error_type, original.error_type);
1383        assert_eq!(round.description, original.description);
1384        assert_eq!(
1385            round.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
1386            Some("2025-12-31T23:59:59Z"),
1387            "extra field must survive deserialize"
1388        );
1389    }
1390
1391    /// Oracle (bd:JMAP-dha0): a SetError with no extras serializes to a
1392    /// wire shape byte-identical to the pre-extras layout. The
1393    /// `skip_serializing_if` on `extra` collapses the empty map.
1394    #[test]
1395    fn set_error_empty_extra_is_invisible_on_the_wire() {
1396        let err = SetError::new(SetErrorType::Forbidden);
1397        let wire = serde_json::to_value(&err).expect("serialize");
1398        let obj = wire.as_object().expect("object");
1399        assert!(
1400            !obj.contains_key("extra"),
1401            "empty `extra` map must not appear on the wire (got {wire})"
1402        );
1403        // The only key on the wire for a bare SetError must be `type`.
1404        assert_eq!(
1405            obj.len(),
1406            1,
1407            "bare SetError must have exactly one key on the wire"
1408        );
1409        assert_eq!(obj["type"], "forbidden");
1410    }
1411
1412    /// Oracle (bd:JMAP-dha0): unknown wire fields on a deserialized
1413    /// SetError land in `extra`. This means a future spec adding
1414    /// `someNewSetErrorField` will round-trip through current
1415    /// versions of the kit losslessly.
1416    #[test]
1417    fn set_error_unknown_field_lands_in_extra() {
1418        let wire = serde_json::json!({
1419            "type": "forbidden",
1420            "futureSpecField": "future-value",
1421            "anotherOne": 42
1422        });
1423        let err: SetError = serde_json::from_value(wire).expect("deserialize");
1424        assert_eq!(err.error_type, SetErrorType::Forbidden);
1425        assert_eq!(
1426            err.extra.get("futureSpecField").and_then(|v| v.as_str()),
1427            Some("future-value")
1428        );
1429        assert_eq!(
1430            err.extra.get("anotherOne").and_then(|v| v.as_u64()),
1431            Some(42)
1432        );
1433    }
1434
1435    /// Oracle (bd:JMAP-wlip.3): [`SetError::with_extra`] panics in debug
1436    /// builds when called with a reserved wire-name key. Catches the bug
1437    /// at first test run rather than letting a malformed-on-the-wire
1438    /// SetError ship through review. The assert is debug-only so release
1439    /// builds pay no runtime cost on correctly-written callers.
1440    ///
1441    /// Iterates every wire-name in [`RESERVED_SET_ERROR_WIRE_NAMES`] so
1442    /// adding a new typed field to SetError plus its rename to the
1443    /// constant list keeps the negative tests in sync automatically.
1444    #[test]
1445    #[cfg(debug_assertions)]
1446    fn with_extra_panics_on_reserved_wire_name() {
1447        for &reserved in RESERVED_SET_ERROR_WIRE_NAMES {
1448            let reserved_owned = reserved.to_owned();
1449            let result = std::panic::catch_unwind(move || {
1450                SetError::new(SetErrorType::Forbidden)
1451                    .with_extra(&reserved_owned, serde_json::Value::Null);
1452            });
1453            assert!(
1454                result.is_err(),
1455                "with_extra({reserved:?}, ...) must panic in debug builds; \
1456                 reserved wire-names collide with typed fields and would \
1457                 produce a malformed SetError on the wire"
1458            );
1459        }
1460    }
1461
1462    /// Oracle (bd:JMAP-jfia.17): direct mutation of `SetError.extra`
1463    /// bypasses the `with_extra` debug_assert and can plant a
1464    /// reserved wire-name. [`SetError::validate_extras`] is the
1465    /// deterministic, build-profile-independent gate for the same
1466    /// invariant.
1467    ///
1468    /// Test vector: iterate every reserved wire-name, plant it
1469    /// directly into `extra`, and assert `validate_extras` returns
1470    /// `Err(ReservedExtrasKey { key: <name> })`.
1471    #[test]
1472    fn validate_extras_detects_reserved_key_planted_via_direct_mutation() {
1473        for &reserved in RESERVED_SET_ERROR_WIRE_NAMES {
1474            let mut err = SetError::new(SetErrorType::Forbidden);
1475            // Bypass with_extra entirely — this is the pattern the
1476            // `pub` field surface invites that the debug_assert cannot
1477            // see (bd:JMAP-jfia.17).
1478            err.extra
1479                .insert(reserved.to_owned(), serde_json::Value::Null);
1480            let collision = err
1481                .validate_extras()
1482                .expect_err("reserved-name extras key must be detected");
1483            assert_eq!(
1484                collision.key, reserved,
1485                "validate_extras must report the colliding key verbatim"
1486            );
1487        }
1488    }
1489
1490    /// Oracle (bd:JMAP-jfia.17): `validate_extras` returns `Ok(())` for
1491    /// a SetError whose `extra` map contains only extension-namespace
1492    /// keys. Positive control paired with the rejection test above.
1493    #[test]
1494    fn validate_extras_accepts_extension_namespace_keys() {
1495        let mut err = SetError::new(SetErrorType::custom("rateLimited"));
1496        err.extra.insert(
1497            "serverRetryAfter".to_owned(),
1498            serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
1499        );
1500        err.extra
1501            .insert("retryAttempt".to_owned(), serde_json::Value::from(3));
1502        err.validate_extras()
1503            .expect("extension-namespace keys must pass validation");
1504    }
1505
1506    /// Oracle (bd:JMAP-wlip.3): a non-reserved key passes the
1507    /// [`SetError::with_extra`] debug_assert and lands in the `extra`
1508    /// map as before. Positive control paired with the panic test
1509    /// above.
1510    #[test]
1511    fn with_extra_accepts_extension_namespace_key() {
1512        // 'serverRetryAfter' is the JMAP Chat extension's rateLimited
1513        // SetError field; not in RESERVED_SET_ERROR_WIRE_NAMES.
1514        let err = SetError::new(SetErrorType::custom("rateLimited")).with_extra(
1515            "serverRetryAfter",
1516            serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
1517        );
1518        assert_eq!(
1519            err.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
1520            Some("2025-12-31T23:59:59Z"),
1521            "extension-namespace key must land in the extra map"
1522        );
1523    }
1524
1525    /// Oracle (bd:JMAP-wlip.3): the reserved-name constant covers every
1526    /// `#[serde(rename = ...)]` and camelCase-derived field name on the
1527    /// public [`SetError`] surface. A future contributor that adds a
1528    /// typed wire field without extending the constant is the failure
1529    /// mode this test guards against. The oracle is hand-derived from
1530    /// the SetError struct definition by reading off the wire-name of
1531    /// each field.
1532    #[test]
1533    fn reserved_set_error_wire_names_matches_serialized_surface() {
1534        // Build a SetError with every typed field populated, serialize,
1535        // and check that every JSON key (other than the extension extras)
1536        // appears in RESERVED_SET_ERROR_WIRE_NAMES.
1537        let err = SetError::new(SetErrorType::Forbidden)
1538            .with_description("desc")
1539            .with_properties(["p1"])
1540            .with_existing_id(jmap_types::Id::from("eid"))
1541            .with_max_recipients(10)
1542            .with_invalid_recipients(["bad@example"])
1543            .with_not_found(vec![jmap_types::Id::from("nfid")])
1544            .with_max_size(1024);
1545
1546        let wire = serde_json::to_value(&err).expect("serialize");
1547        let obj = wire.as_object().expect("SetError must serialize as object");
1548        for key in obj.keys() {
1549            assert!(
1550                RESERVED_SET_ERROR_WIRE_NAMES.contains(&key.as_str()),
1551                "wire-name {key:?} appears on the SetError surface but is \
1552                 not in RESERVED_SET_ERROR_WIRE_NAMES — adding a typed \
1553                 field to SetError requires extending the constant"
1554            );
1555        }
1556    }
1557
1558    /// Oracle (bd:JMAP-wlip.29): the Display arm, the Deserialize visitor
1559    /// match, and the round-trip behaviour MUST agree for every known
1560    /// variant of [`SetErrorType`]. The mapping is duplicated across three
1561    /// places (Display, Serialize via Display, Deserialize visitor); the
1562    /// workspace dep allowlist forbids strum / serde_with that would
1563    /// derive the round-trip from a single source. This table-driven
1564    /// test iterates ALL 23 typed variants and asserts:
1565    ///
1566    ///   - Display produces the expected camelCase wire string
1567    ///   - serde_json::to_string emits the same string
1568    ///   - serde_json::from_str rebuilds the same variant (NOT a Custom)
1569    ///
1570    /// A drift between Display and Deserialize (e.g. adding "rateLimit"
1571    /// to Display but forgetting the Deserialize arm) would fail step 3
1572    /// at first test run because the wire string would round-trip into
1573    /// `Custom("rateLimit")` instead of `RateLimit`. This is the silent
1574    /// contract drift filed as bd:JMAP-wlip.22.
1575    ///
1576    /// The table is hand-built from the RFC 8620 / RFC 8621 spec text,
1577    /// not derived from the code under test (the workspace test-integrity
1578    /// rule requires an independent oracle). Adding a new typed variant
1579    /// requires extending this table — that is the intent.
1580    #[test]
1581    fn set_error_type_all_known_variants_round_trip() {
1582        // (variant constructor, expected wire string).
1583        // Source of truth: RFC 8620 §5.3 + RFC 8621 §2.5, §5.5, §6.3, §7.5.
1584        let cases: &[(SetErrorType, &str)] = &[
1585            (SetErrorType::Forbidden, "forbidden"),
1586            (SetErrorType::OverQuota, "overQuota"),
1587            (SetErrorType::TooLarge, "tooLarge"),
1588            (SetErrorType::RateLimit, "rateLimit"),
1589            (SetErrorType::NotFound, "notFound"),
1590            (SetErrorType::InvalidPatch, "invalidPatch"),
1591            (SetErrorType::WillDestroy, "willDestroy"),
1592            (SetErrorType::InvalidProperties, "invalidProperties"),
1593            (SetErrorType::Singleton, "singleton"),
1594            (SetErrorType::AlreadyExists, "alreadyExists"),
1595            (SetErrorType::MailboxHasChild, "mailboxHasChild"),
1596            (SetErrorType::MailboxHasEmail, "mailboxHasEmail"),
1597            (SetErrorType::TooManyKeywords, "tooManyKeywords"),
1598            (SetErrorType::TooManyMailboxes, "tooManyMailboxes"),
1599            (SetErrorType::BlobNotFound, "blobNotFound"),
1600            (SetErrorType::ForbiddenFrom, "forbiddenFrom"),
1601            (SetErrorType::InvalidEmail, "invalidEmail"),
1602            (SetErrorType::TooManyRecipients, "tooManyRecipients"),
1603            (SetErrorType::NoRecipients, "noRecipients"),
1604            (SetErrorType::InvalidRecipients, "invalidRecipients"),
1605            (SetErrorType::ForbiddenMailFrom, "forbiddenMailFrom"),
1606            (SetErrorType::ForbiddenToSend, "forbiddenToSend"),
1607            (SetErrorType::CannotUnsend, "cannotUnsend"),
1608        ];
1609
1610        for (variant, expected_wire) in cases {
1611            // Display
1612            assert_eq!(
1613                variant.to_string(),
1614                *expected_wire,
1615                "Display arm for {variant:?} produced wrong wire string"
1616            );
1617            // Serialize (delegates to Display)
1618            let serialized = serde_json::to_string(variant).expect("serialize");
1619            assert_eq!(
1620                serialized,
1621                format!("\"{expected_wire}\""),
1622                "Serialize for {variant:?} did not produce \"{expected_wire}\""
1623            );
1624            // Deserialize back — MUST rebuild the typed variant, NOT Custom.
1625            let deserialized: SetErrorType =
1626                serde_json::from_str(&serialized).expect("deserialize");
1627            assert_eq!(
1628                &deserialized, variant,
1629                "Deserialize of {expected_wire:?} did not rebuild {variant:?} \
1630                 (likely fell through to Custom — Display and Deserialize \
1631                 match arms have drifted)"
1632            );
1633            // Belt-and-braces: explicitly assert NOT Custom.
1634            assert!(
1635                !matches!(deserialized, SetErrorType::Custom(_)),
1636                "Deserialize of {expected_wire:?} fell through to Custom; \
1637                 Display has an arm but Deserialize visitor doesn't"
1638            );
1639        }
1640    }
1641
1642    /// Oracle (bd:JMAP-ga0q.1): `JmapBackend::principal_id` has a default impl
1643    /// that returns `None`. A backend whose `CallerCtx = ()` and that does NOT
1644    /// override `principal_id` inherits that default and signals "identity not
1645    /// wired" to callers. JMAP semantics that depend on caller identity must
1646    /// treat `None` as a hard "cannot honor".
1647    #[test]
1648    fn principal_id_default_impl_returns_none_for_unit_caller_ctx() {
1649        // Minimal stub backend exercising only the default impl. All other
1650        // trait methods are stubbed with `unreachable!()` and never invoked.
1651        struct StubBackend;
1652
1653        #[derive(Debug)]
1654        struct StubError;
1655
1656        impl std::fmt::Display for StubError {
1657            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1658                f.write_str("stub")
1659            }
1660        }
1661        impl std::error::Error for StubError {}
1662
1663        impl JmapBackend for StubBackend {
1664            type Error = StubError;
1665            type CallerCtx = ();
1666
1667            async fn account_exists(
1668                &self,
1669                _caller: &(),
1670                _account_id: &jmap_types::Id,
1671            ) -> Result<bool, Self::Error> {
1672                unreachable!("only principal_id is exercised in this test")
1673            }
1674
1675            async fn get_objects<O: GetObject + Send + Sync>(
1676                &self,
1677                _caller: &(),
1678                _account_id: &jmap_types::Id,
1679                _ids: Option<&[jmap_types::Id]>,
1680                _properties: Option<&[String]>,
1681            ) -> Result<(Vec<O>, Vec<jmap_types::Id>), Self::Error> {
1682                unreachable!("only principal_id is exercised in this test")
1683            }
1684
1685            async fn get_state<O: JmapObject + Send + Sync>(
1686                &self,
1687                _caller: &(),
1688                _account_id: &jmap_types::Id,
1689            ) -> Result<jmap_types::State, Self::Error> {
1690                unreachable!("only principal_id is exercised in this test")
1691            }
1692
1693            async fn get_changes<O: JmapObject + Send + Sync>(
1694                &self,
1695                _caller: &(),
1696                _account_id: &jmap_types::Id,
1697                _since_state: &jmap_types::State,
1698                _max_changes: Option<u64>,
1699            ) -> Result<ChangesResult, BackendChangesError<Self::Error>> {
1700                unreachable!("only principal_id is exercised in this test")
1701            }
1702
1703            async fn query_objects<O: QueryObject + Send + Sync>(
1704                &self,
1705                _caller: &(),
1706                _account_id: &jmap_types::Id,
1707                _filter: Option<&O::Filter>,
1708                _sort: Option<&[O::Comparator]>,
1709                _limit: Option<u64>,
1710                _position: i64,
1711            ) -> Result<QueryResult, Self::Error> {
1712                unreachable!("only principal_id is exercised in this test")
1713            }
1714
1715            async fn query_changes<O: QueryObject + Send + Sync>(
1716                &self,
1717                _caller: &(),
1718                _account_id: &jmap_types::Id,
1719                _since_query_state: &jmap_types::State,
1720                _filter: Option<&O::Filter>,
1721                _sort: Option<&[O::Comparator]>,
1722                _max_changes: Option<u64>,
1723                _up_to_id: Option<&jmap_types::Id>,
1724                _collapse_threads: bool,
1725            ) -> Result<QueryChangesResult, BackendChangesError<Self::Error>> {
1726                unreachable!("only principal_id is exercised in this test")
1727            }
1728        }
1729
1730        let caller: <StubBackend as JmapBackend>::CallerCtx = ();
1731        let id = <StubBackend as JmapBackend>::principal_id(&caller);
1732        assert!(
1733            id.is_none(),
1734            "default principal_id impl must return None; got Some({:?})",
1735            id
1736        );
1737    }
1738
1739    /// Oracle (bd:JMAP-ayoz.41.1): `JmapBackend::max_objects_in_set`
1740    /// has a default impl that returns `500`. The constant is the
1741    /// workspace's testjig Session JSON advertised cap; a backend that
1742    /// does not override the method inherits a sane DoS-defence
1743    /// default rather than silently disabling the cap.
1744    #[test]
1745    fn max_objects_in_set_default_impl_returns_500() {
1746        struct StubBackend;
1747
1748        #[derive(Debug)]
1749        struct StubError;
1750
1751        impl std::fmt::Display for StubError {
1752            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1753                f.write_str("stub")
1754            }
1755        }
1756        impl std::error::Error for StubError {}
1757
1758        impl JmapBackend for StubBackend {
1759            type Error = StubError;
1760            type CallerCtx = ();
1761
1762            async fn account_exists(
1763                &self,
1764                _caller: &(),
1765                _account_id: &jmap_types::Id,
1766            ) -> Result<bool, Self::Error> {
1767                unreachable!("only max_objects_in_set is exercised in this test")
1768            }
1769
1770            async fn get_objects<O: GetObject + Send + Sync>(
1771                &self,
1772                _caller: &(),
1773                _account_id: &jmap_types::Id,
1774                _ids: Option<&[jmap_types::Id]>,
1775                _properties: Option<&[String]>,
1776            ) -> Result<(Vec<O>, Vec<jmap_types::Id>), Self::Error> {
1777                unreachable!("only max_objects_in_set is exercised in this test")
1778            }
1779
1780            async fn get_state<O: JmapObject + Send + Sync>(
1781                &self,
1782                _caller: &(),
1783                _account_id: &jmap_types::Id,
1784            ) -> Result<jmap_types::State, Self::Error> {
1785                unreachable!("only max_objects_in_set is exercised in this test")
1786            }
1787
1788            async fn get_changes<O: JmapObject + Send + Sync>(
1789                &self,
1790                _caller: &(),
1791                _account_id: &jmap_types::Id,
1792                _since_state: &jmap_types::State,
1793                _max_changes: Option<u64>,
1794            ) -> Result<ChangesResult, BackendChangesError<Self::Error>> {
1795                unreachable!("only max_objects_in_set is exercised in this test")
1796            }
1797
1798            async fn query_objects<O: QueryObject + Send + Sync>(
1799                &self,
1800                _caller: &(),
1801                _account_id: &jmap_types::Id,
1802                _filter: Option<&O::Filter>,
1803                _sort: Option<&[O::Comparator]>,
1804                _limit: Option<u64>,
1805                _position: i64,
1806            ) -> Result<QueryResult, Self::Error> {
1807                unreachable!("only max_objects_in_set is exercised in this test")
1808            }
1809
1810            async fn query_changes<O: QueryObject + Send + Sync>(
1811                &self,
1812                _caller: &(),
1813                _account_id: &jmap_types::Id,
1814                _since_query_state: &jmap_types::State,
1815                _filter: Option<&O::Filter>,
1816                _sort: Option<&[O::Comparator]>,
1817                _max_changes: Option<u64>,
1818                _up_to_id: Option<&jmap_types::Id>,
1819                _collapse_threads: bool,
1820            ) -> Result<QueryChangesResult, BackendChangesError<Self::Error>> {
1821                unreachable!("only max_objects_in_set is exercised in this test")
1822            }
1823        }
1824
1825        let backend = StubBackend;
1826        let caller: <StubBackend as JmapBackend>::CallerCtx = ();
1827        let id = jmap_types::Id::from("any-account");
1828        assert_eq!(
1829            backend.max_objects_in_set(&caller, &id),
1830            500,
1831            "default max_objects_in_set must return 500"
1832        );
1833    }
1834}