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}