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 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
70 pub extra: serde_json::Map<String, serde_json::Value>,
71}
72
73impl SetError {
74 /// Construct a [`SetError`] with the given type and all optional fields `None`.
75 pub fn new(error_type: SetErrorType) -> Self {
76 Self {
77 error_type,
78 description: None,
79 properties: None,
80 existing_id: None,
81 max_recipients: None,
82 invalid_recipients: None,
83 not_found: None,
84 max_size: None,
85 extra: serde_json::Map::new(),
86 }
87 }
88
89 /// Set the human-readable description.
90 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
91 self.description = Some(desc.into());
92 self
93 }
94
95 /// Set the list of property names that caused the error.
96 pub fn with_properties<I, S>(mut self, props: I) -> Self
97 where
98 I: IntoIterator<Item = S>,
99 S: Into<String>,
100 {
101 self.properties = Some(props.into_iter().map(|s| s.into()).collect());
102 self
103 }
104
105 /// Set the existing object id (used with `alreadyExists`).
106 pub fn with_existing_id(mut self, id: jmap_types::Id) -> Self {
107 self.existing_id = Some(id);
108 self
109 }
110
111 /// Set the maximum recipients (used with `tooManyRecipients` — RFC 8621 §7.5).
112 pub fn with_max_recipients(mut self, n: u64) -> Self {
113 self.max_recipients = Some(n);
114 self
115 }
116
117 /// Set the invalid recipient addresses (used with `invalidRecipients` — RFC 8621 §7.5).
118 pub fn with_invalid_recipients<I, S>(mut self, addrs: I) -> Self
119 where
120 I: IntoIterator<Item = S>,
121 S: Into<String>,
122 {
123 self.invalid_recipients = Some(addrs.into_iter().map(|s| s.into()).collect());
124 self
125 }
126
127 /// Set the missing blob IDs (used with `blobNotFound` — RFC 8621 §5.5).
128 pub fn with_not_found(mut self, ids: Vec<jmap_types::Id>) -> Self {
129 self.not_found = Some(ids);
130 self
131 }
132
133 /// Set the maximum message size in octets (used with `tooLarge` on EmailSubmission — RFC 8621 §7.5).
134 pub fn with_max_size(mut self, n: u64) -> Self {
135 self.max_size = Some(n);
136 self
137 }
138
139 /// Insert an extension-defined field into [`Self::extra`].
140 ///
141 /// Used by handlers to attach typed wire fields that no `with_*`
142 /// builder covers — for example JMAP Chat's `rateLimited` SetError
143 /// must carry a `serverRetryAfter` UTCDate:
144 ///
145 /// ```ignore
146 /// SetError::new(SetErrorType::custom("rateLimited"))
147 /// .with_description("Slow mode is active for this chat")
148 /// .with_extra("serverRetryAfter", serde_json::json!(retry_after_str))
149 /// ```
150 ///
151 /// The serialized wire shape merges `key`/`value` at the same
152 /// level as the typed fields (via `#[serde(flatten)]` on
153 /// [`Self::extra`]). Calling `with_extra("type", ...)`,
154 /// `with_extra("properties", ...)`, or any other reserved
155 /// wire-name will produce a malformed SetError on the wire —
156 /// callers are responsible for choosing extension-namespace keys
157 /// that do not collide with the typed-field wire names.
158 pub fn with_extra(mut self, key: &str, value: serde_json::Value) -> Self {
159 self.extra.insert(key.to_owned(), value);
160 self
161 }
162}
163
164impl std::fmt::Display for SetError {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 write!(f, "{}", self.error_type)?;
167 if let Some(ref desc) = self.description {
168 write!(f, ": {desc}")?;
169 }
170 Ok(())
171 }
172}
173
174/// The machine-readable type for a [`SetError`] (RFC 8620 §5.3 and RFC 8621).
175///
176/// Extension crates define their own error strings via [`SetErrorType::custom`]
177/// rather than adding variants here. This keeps the base crate stable as new
178/// JMAP extension crates (calendar, contacts, etc.) are added.
179#[non_exhaustive]
180#[derive(Debug, Clone, PartialEq)]
181pub enum SetErrorType {
182 /// The action would violate an ACL or other access control policy.
183 Forbidden,
184 /// Creating or modifying the object would exceed a server quota.
185 OverQuota,
186 /// The object is too large to be stored by the server.
187 TooLarge,
188 /// The server is rate-limiting this client.
189 RateLimit,
190 /// The object to be updated or destroyed does not exist.
191 NotFound,
192 /// The patch object is not a valid JSON Merge Patch or cannot be applied.
193 InvalidPatch,
194 /// The client requested destruction of an object that will be destroyed
195 /// implicitly when another object is destroyed.
196 WillDestroy,
197 /// One or more properties have invalid values.
198 InvalidProperties,
199 /// The object type is a singleton and cannot be created or destroyed.
200 Singleton,
201 /// An object with the same unique key already exists.
202 AlreadyExists,
203 /// RFC 8621 §2.5 — Mailbox has child mailboxes and cannot be destroyed.
204 MailboxHasChild,
205 /// RFC 8621 §2.5 — Mailbox contains emails and `onDestroyRemoveEmails` is false.
206 MailboxHasEmail,
207 /// RFC 8621 §5.5 — Too many keywords on the Email.
208 TooManyKeywords,
209 /// RFC 8621 §5.5 — Email is in too many mailboxes.
210 TooManyMailboxes,
211 /// RFC 8621 §5.5 — A referenced blob was not found.
212 BlobNotFound,
213 /// RFC 8621 §6.3 — The `from` address is not permitted for this Identity.
214 ForbiddenFrom,
215 /// RFC 8621 §7.5 — The Email is invalid for submission.
216 InvalidEmail,
217 /// RFC 8621 §7.5 — Too many recipients.
218 TooManyRecipients,
219 /// RFC 8621 §7.5 — No recipients specified.
220 NoRecipients,
221 /// RFC 8621 §7.5 — One or more recipient addresses are invalid.
222 InvalidRecipients,
223 /// RFC 8621 §7.5 — The MAIL FROM address is not permitted.
224 ForbiddenMailFrom,
225 /// RFC 8621 §7.5 — The user does not have send permission.
226 ForbiddenToSend,
227 /// RFC 8621 §7.5 — The submission cannot be undone.
228 CannotUnsend,
229 /// An extension-defined error type not covered by the variants above.
230 /// Serializes as the inner string directly (e.g. `"mdnAlreadySent"`).
231 Custom(String),
232}
233
234impl SetErrorType {
235 /// Construct a [`SetErrorType::Custom`] from any string.
236 ///
237 /// Use this in extension crates to emit domain-specific error types
238 /// without adding variants to this enum.
239 pub fn custom(s: impl Into<String>) -> Self {
240 Self::Custom(s.into())
241 }
242}
243
244impl std::fmt::Display for SetErrorType {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 let s: &str = match self {
247 Self::Forbidden => "forbidden",
248 Self::OverQuota => "overQuota",
249 Self::TooLarge => "tooLarge",
250 Self::RateLimit => "rateLimit",
251 Self::NotFound => "notFound",
252 Self::InvalidPatch => "invalidPatch",
253 Self::WillDestroy => "willDestroy",
254 Self::InvalidProperties => "invalidProperties",
255 Self::Singleton => "singleton",
256 Self::AlreadyExists => "alreadyExists",
257 Self::MailboxHasChild => "mailboxHasChild",
258 Self::MailboxHasEmail => "mailboxHasEmail",
259 Self::TooManyKeywords => "tooManyKeywords",
260 Self::TooManyMailboxes => "tooManyMailboxes",
261 Self::BlobNotFound => "blobNotFound",
262 Self::ForbiddenFrom => "forbiddenFrom",
263 Self::InvalidEmail => "invalidEmail",
264 Self::TooManyRecipients => "tooManyRecipients",
265 Self::NoRecipients => "noRecipients",
266 Self::InvalidRecipients => "invalidRecipients",
267 Self::ForbiddenMailFrom => "forbiddenMailFrom",
268 Self::ForbiddenToSend => "forbiddenToSend",
269 Self::CannotUnsend => "cannotUnsend",
270 Self::Custom(s) => s.as_str(),
271 };
272 f.write_str(s)
273 }
274}
275
276impl serde::Serialize for SetErrorType {
277 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
278 s.serialize_str(&self.to_string())
279 }
280}
281
282impl<'de> serde::Deserialize<'de> for SetErrorType {
283 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
284 struct Visitor;
285 impl serde::de::Visitor<'_> for Visitor {
286 type Value = SetErrorType;
287 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288 f.write_str("a JMAP SetError type string")
289 }
290 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
291 Ok(match v {
292 "forbidden" => SetErrorType::Forbidden,
293 "overQuota" => SetErrorType::OverQuota,
294 "tooLarge" => SetErrorType::TooLarge,
295 "rateLimit" => SetErrorType::RateLimit,
296 "notFound" => SetErrorType::NotFound,
297 "invalidPatch" => SetErrorType::InvalidPatch,
298 "willDestroy" => SetErrorType::WillDestroy,
299 "invalidProperties" => SetErrorType::InvalidProperties,
300 "singleton" => SetErrorType::Singleton,
301 "alreadyExists" => SetErrorType::AlreadyExists,
302 "mailboxHasChild" => SetErrorType::MailboxHasChild,
303 "mailboxHasEmail" => SetErrorType::MailboxHasEmail,
304 "tooManyKeywords" => SetErrorType::TooManyKeywords,
305 "tooManyMailboxes" => SetErrorType::TooManyMailboxes,
306 "blobNotFound" => SetErrorType::BlobNotFound,
307 "forbiddenFrom" => SetErrorType::ForbiddenFrom,
308 "invalidEmail" => SetErrorType::InvalidEmail,
309 "tooManyRecipients" => SetErrorType::TooManyRecipients,
310 "noRecipients" => SetErrorType::NoRecipients,
311 "invalidRecipients" => SetErrorType::InvalidRecipients,
312 "forbiddenMailFrom" => SetErrorType::ForbiddenMailFrom,
313 "forbiddenToSend" => SetErrorType::ForbiddenToSend,
314 "cannotUnsend" => SetErrorType::CannotUnsend,
315 other => SetErrorType::Custom(other.to_owned()),
316 })
317 }
318 }
319 d.deserialize_str(Visitor)
320 }
321}
322
323/// Error type returned by create/update/destroy backend methods.
324#[non_exhaustive]
325#[derive(Debug)]
326pub enum BackendSetError<E> {
327 /// A well-typed JMAP [`SetError`] to place verbatim in the
328 /// `notCreated`/`notUpdated`/`notDestroyed` map.
329 SetError(SetError),
330 /// An unexpected storage-layer error.
331 Other(E),
332}
333
334impl<E: std::fmt::Display> std::fmt::Display for BackendSetError<E> {
335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336 match self {
337 Self::SetError(se) => write!(f, "set error: {se}"),
338 Self::Other(e) => write!(f, "{e}"),
339 }
340 }
341}
342
343impl<E: std::error::Error + 'static> std::error::Error for BackendSetError<E> {
344 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
345 match self {
346 Self::Other(e) => Some(e),
347 _ => None,
348 }
349 }
350}
351
352impl<E> From<SetError> for BackendSetError<E> {
353 fn from(e: SetError) -> Self {
354 Self::SetError(e)
355 }
356}
357
358// ---------------------------------------------------------------------------
359// Backend error envelopes
360// ---------------------------------------------------------------------------
361
362/// Error type returned by [`JmapBackend::get_changes`] and
363/// [`JmapBackend::query_changes`].
364#[non_exhaustive]
365#[derive(Debug)]
366pub enum BackendChangesError<E> {
367 /// The server cannot supply incremental changes for the given `sinceState`.
368 ///
369 /// Two sub-cases share this variant:
370 ///
371 /// - **`limit > 0`** — maps to `tooManyChanges` in the JMAP response, with
372 /// `limit` as the suggested maximum. The client may retry with a smaller
373 /// window.
374 ///
375 /// - **`limit: 0`** — maps to `cannotCalculateChanges`. This signals a
376 /// **full state reset**: the client MUST discard ALL locally cached
377 /// objects for the affected type, reset its local state token to the
378 /// empty string, and perform a full resync (`/get` with `ids: null`).
379 /// Partial recovery is not permitted — the server has no usable
380 /// change log for this state window. (Source: RFC 8620 §5.6; authoritative
381 /// behavior documented in jmapio/jmap-js `mail-model.js`.)
382 TooManyChanges {
383 /// Maximum window size the server can supply in a single
384 /// `/changes` response. A value of `0` signals a full state
385 /// reset is required per RFC 8620 §5.6; any non-zero value is
386 /// the suggested maximum the client may retry with.
387 limit: u64,
388 },
389 /// An unexpected storage-layer error.
390 Other(E),
391}
392
393impl<E: std::fmt::Display> std::fmt::Display for BackendChangesError<E> {
394 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 match self {
396 Self::TooManyChanges { limit: 0 } => write!(f, "cannot calculate changes"),
397 Self::TooManyChanges { limit } => write!(f, "too many changes (limit: {limit})"),
398 Self::Other(e) => write!(f, "{e}"),
399 }
400 }
401}
402
403impl<E: std::error::Error + 'static> std::error::Error for BackendChangesError<E> {
404 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
405 match self {
406 Self::Other(e) => Some(e),
407 _ => None,
408 }
409 }
410}
411
412impl<E> From<E> for BackendChangesError<E> {
413 fn from(e: E) -> Self {
414 Self::Other(e)
415 }
416}
417
418impl<E: std::error::Error> From<BackendChangesError<E>> for jmap_types::JmapError {
419 fn from(e: BackendChangesError<E>) -> Self {
420 match e {
421 BackendChangesError::TooManyChanges { limit: 0 } => {
422 jmap_types::JmapError::cannot_calculate_changes()
423 }
424 BackendChangesError::TooManyChanges { limit } => {
425 jmap_types::JmapError::too_many_changes_with_limit(limit)
426 }
427 BackendChangesError::Other(inner) => {
428 jmap_types::JmapError::server_fail(inner.to_string())
429 }
430 }
431 }
432}
433
434// ---------------------------------------------------------------------------
435// Result types
436// ---------------------------------------------------------------------------
437
438/// Result of a `/changes` call (RFC 8620 §5.2).
439#[derive(Debug)]
440#[non_exhaustive]
441pub struct ChangesResult {
442 /// Ids of objects that were created since `sinceState`.
443 pub created: Vec<jmap_types::Id>,
444 /// Ids of objects that were updated since `sinceState`.
445 pub updated: Vec<jmap_types::Id>,
446 /// Ids of objects that were destroyed since `sinceState`.
447 pub destroyed: Vec<jmap_types::Id>,
448 /// `true` if there are more changes beyond this batch.
449 pub has_more_changes: bool,
450 /// The current state token after applying all reported changes.
451 pub new_state: jmap_types::State,
452}
453
454impl ChangesResult {
455 /// Construct a [`ChangesResult`].
456 pub fn new(
457 created: Vec<jmap_types::Id>,
458 updated: Vec<jmap_types::Id>,
459 destroyed: Vec<jmap_types::Id>,
460 has_more_changes: bool,
461 new_state: jmap_types::State,
462 ) -> Self {
463 Self {
464 created,
465 updated,
466 destroyed,
467 has_more_changes,
468 new_state,
469 }
470 }
471}
472
473/// Result of a `/query` call (RFC 8620 §5.5).
474#[derive(Debug)]
475#[non_exhaustive]
476pub struct QueryResult {
477 /// The ordered list of matching object ids.
478 pub ids: Vec<jmap_types::Id>,
479 /// The 0-based index of the first returned id in the complete result list.
480 pub position: i64,
481 /// Total number of results, if the backend can calculate it.
482 pub total: Option<u64>,
483 /// Opaque query state token for subsequent `/queryChanges` calls.
484 pub query_state: jmap_types::State,
485 /// Whether the backend supports `/queryChanges` for this query.
486 pub can_calculate_changes: bool,
487}
488
489impl QueryResult {
490 /// Construct a [`QueryResult`].
491 pub fn new(
492 ids: Vec<jmap_types::Id>,
493 position: i64,
494 total: Option<u64>,
495 query_state: jmap_types::State,
496 can_calculate_changes: bool,
497 ) -> Self {
498 Self {
499 ids,
500 position,
501 total,
502 query_state,
503 can_calculate_changes,
504 }
505 }
506}
507
508/// One entry in the `added` list of a `/queryChanges` response (RFC 8620 §5.6).
509#[derive(Debug)]
510#[non_exhaustive]
511pub struct AddedItem {
512 /// The id of the newly-added object.
513 pub id: jmap_types::Id,
514 /// Its 0-based position in the result list after applying all changes.
515 pub index: u64,
516}
517
518impl AddedItem {
519 /// Construct an [`AddedItem`].
520 pub fn new(id: jmap_types::Id, index: u64) -> Self {
521 Self { id, index }
522 }
523}
524
525/// Result of a `/queryChanges` call (RFC 8620 §5.6).
526#[derive(Debug)]
527#[non_exhaustive]
528pub struct QueryChangesResult {
529 /// The query state token supplied by the client in `sinceQueryState`.
530 pub old_query_state: jmap_types::State,
531 /// The current query state token.
532 pub new_query_state: jmap_types::State,
533 /// Total number of results in the new query, if the backend can calculate it.
534 pub total: Option<u64>,
535 /// Ids removed from the result set since `oldQueryState`.
536 pub removed: Vec<jmap_types::Id>,
537 /// Ids added to the result set since `oldQueryState`, with their positions.
538 pub added: Vec<AddedItem>,
539}
540
541impl QueryChangesResult {
542 /// Construct a [`QueryChangesResult`].
543 pub fn new(
544 old_query_state: jmap_types::State,
545 new_query_state: jmap_types::State,
546 total: Option<u64>,
547 removed: Vec<jmap_types::Id>,
548 added: Vec<AddedItem>,
549 ) -> Self {
550 Self {
551 old_query_state,
552 new_query_state,
553 total,
554 removed,
555 added,
556 }
557 }
558}
559
560// ---------------------------------------------------------------------------
561// JmapBackend — the read-side supertrait
562// ---------------------------------------------------------------------------
563
564/// Read-side backend supertrait shared by all JMAP server crates.
565///
566/// Domain-specific backend traits (`MailBackend`, `ChatBackend`, etc.) require
567/// this trait as a supertrait and add write-side methods on top.
568///
569/// Only the read operations that have an identical signature across all JMAP
570/// object types belong here. Write operations (`create_object`, `update_object`,
571/// `destroy_object`) and domain-specific operations remain in the domain crate.
572///
573/// The `collapse_threads` parameter on `query_changes` is included for
574/// `Email/queryChanges` (RFC 8621 §4.5). Non-mail backends should pass `false`
575/// and may ignore the parameter.
576///
577/// This trait is not object-safe by design (generic methods). Use
578/// `Arc<impl JmapBackend>` when sharing across tasks.
579///
580/// # CallerCtx
581///
582/// Every backend method takes a `caller: &Self::CallerCtx` parameter as the
583/// first argument after `&self`. This is the per-request authentication /
584/// authorisation context produced by the caller's auth layer and forwarded
585/// unchanged through [`crate::Dispatcher::dispatch`] → [`crate::JmapHandler`]
586/// → the registered closure → the backend.
587///
588/// Implementations that do not need an auth identity can use the unit type:
589///
590/// ```rust,ignore
591/// impl JmapBackend for MyBackend {
592/// type Error = MyError;
593/// type CallerCtx = ();
594/// // ...
595/// }
596/// ```
597///
598/// Implementations that do need to differentiate behaviour per caller (e.g.
599/// applying per-user visibility rules, or rejecting reads with
600/// `forbidden` when the caller is not the owner of the account) read the
601/// `caller` parameter to decide.
602///
603/// The trait bound `Clone + Send + 'static` is what [`crate::Dispatcher`]
604/// requires; the bound is repeated here so the supertrait can stand on its
605/// own without depending on the dispatcher.
606pub trait JmapBackend: Send + Sync + 'static {
607 /// The error type returned by storage operations.
608 ///
609 /// # Security
610 ///
611 /// The `Display` impl of this type is surfaced through
612 /// [`BackendSetError::Other`]'s and [`BackendChangesError::Other`]'s
613 /// own `Display` impls, which in turn flow into
614 /// [`crate::request_error`]'s `RequestError::Display` output. When a
615 /// downstream consumer wires tracing-style logging on top, the
616 /// formatted error text lands in operator logs verbatim.
617 ///
618 /// Implementations MUST NOT include any of the following in this
619 /// type's `Display` output:
620 ///
621 /// - **Credential material** — auth tokens, passwords, push
622 /// verification codes, invite codes, session cookies, or anything
623 /// derived byte-for-byte from an `Authorization`-header value.
624 /// - **Blob content** — email bodies, sieve scripts, file contents,
625 /// or any user-supplied opaque payload. An error like
626 /// `"sieve parse error at line 42: <script excerpt>"` violates
627 /// this — emit the line number and a short type-only summary
628 /// ("sieve parse error at line 42: unexpected token") and let the
629 /// server log the full script body separately under a redacted
630 /// path.
631 /// - **PII shaped like an email address** in any code path that an
632 /// unauthenticated caller can trigger. Wrapping a downstream
633 /// service error that interpolates the caller's email is the
634 /// common foot-gun.
635 ///
636 /// Errors that wrap a downstream-service failure should sanitize
637 /// the downstream error text — or strip it entirely and replace it
638 /// with a static summary — before constructing the `Display`
639 /// string. The same rule applies to every extension `*Backend`
640 /// trait that inherits this associated type by transitivity:
641 /// `MailBackend::Error`, `ChatBackend::Error`,
642 /// `CalendarsBackend::Error`, `TasksBackend::Error`,
643 /// `ContactsBackend::Error`, `FileNodeBackend::Error`, and
644 /// `SharingBackend::Error` are all the same `JmapBackend::Error`
645 /// associated type — the contract here governs all of them.
646 ///
647 /// Precedent: bd:JMAP-sc1b.79 redacted `BearerAuth` and `BasicAuth`
648 /// at the type-derive level; bd:JMAP-sc1b.100 documents the
649 /// equivalent contract at the trait-associated-type level.
650 type Error: std::error::Error + Send + Sync + 'static;
651
652 /// The per-request caller context type produced by the auth layer and
653 /// forwarded by [`crate::Dispatcher::dispatch`] into every method call.
654 ///
655 /// Use `()` when no auth context is needed.
656 ///
657 /// The bound is `Clone + Send + Sync + 'static`:
658 /// - `Clone` because [`crate::Dispatcher`] clones the value once per
659 /// method call in the batch.
660 /// - `Send + 'static` because each method call is spawned on a
661 /// [`tokio::task`].
662 /// - `Sync` because handler method bodies take `&Self::CallerCtx`
663 /// and hold that reference across `.await` boundaries inside a
664 /// `Send` future (a `&T` is `Send` iff `T: Sync`).
665 type CallerCtx: Clone + Send + Sync + 'static;
666
667 /// Return `true` if the given account exists in this backend.
668 ///
669 /// Handlers call this at the start of each method to return
670 /// `accountNotFound` (RFC 8620 §3.6.2) rather than surfacing
671 /// the wrong error when `accountId` is unknown.
672 fn account_exists(
673 &self,
674 caller: &Self::CallerCtx,
675 account_id: &jmap_types::Id,
676 ) -> impl std::future::Future<Output = Result<bool, Self::Error>> + Send;
677
678 /// Fetch objects by id (or all objects when `ids` is `None`).
679 ///
680 /// `properties` is the list of property names requested by the client
681 /// (RFC 8620 §5.1). `None` means the client did not send a `properties`
682 /// field; the backend should return all properties. When `Some`, the backend
683 /// MAY filter the response to only the named properties, but is not required
684 /// to — implementations that always return all properties are correct.
685 ///
686 /// Returns `(found, not_found)` — objects that exist and ids that do not.
687 fn get_objects<O: GetObject + Send + Sync>(
688 &self,
689 caller: &Self::CallerCtx,
690 account_id: &jmap_types::Id,
691 ids: Option<&[jmap_types::Id]>,
692 properties: Option<&[String]>,
693 ) -> impl std::future::Future<Output = Result<(Vec<O>, Vec<jmap_types::Id>), Self::Error>> + Send;
694
695 /// Return the current state token for an object type in the given account.
696 fn get_state<O: JmapObject + Send + Sync>(
697 &self,
698 caller: &Self::CallerCtx,
699 account_id: &jmap_types::Id,
700 ) -> impl std::future::Future<Output = Result<jmap_types::State, Self::Error>> + Send;
701
702 /// Return changes since `since_state`, up to `max_changes` entries.
703 fn get_changes<O: JmapObject + Send + Sync>(
704 &self,
705 caller: &Self::CallerCtx,
706 account_id: &jmap_types::Id,
707 since_state: &jmap_types::State,
708 max_changes: Option<u64>,
709 ) -> impl std::future::Future<Output = Result<ChangesResult, BackendChangesError<Self::Error>>> + Send;
710
711 /// Execute a `/query` and return a page of matching ids.
712 ///
713 /// `position` may be negative — negative values are relative to the end of
714 /// the result set per RFC 8620 §5.5 (e.g. -1 means the last result).
715 ///
716 /// # Filter and sort handling
717 ///
718 /// Implementations MUST honour the supplied `filter` and `sort` arguments
719 /// efficiently — typically by pushing both into the indexed storage layer
720 /// (database WHERE / ORDER BY, search index, etc.). Returning every
721 /// matching id and relying on the caller to paginate after the fact
722 /// degenerates to O(n) per page for IMAP-migration accounts.
723 ///
724 /// Handler implementations in `jmap-*-server` crates SHOULD NOT
725 /// post-filter or post-sort the backend's result; doing so re-introduces
726 /// the O(n) cost this method exists to avoid. The Mailbox handler in
727 /// `jmap-mail-server` is the canonical example of pushing filter/sort
728 /// fully into the backend.
729 #[allow(clippy::too_many_arguments)]
730 fn query_objects<O: QueryObject + Send + Sync>(
731 &self,
732 caller: &Self::CallerCtx,
733 account_id: &jmap_types::Id,
734 filter: Option<&O::Filter>,
735 sort: Option<&[O::Comparator]>,
736 limit: Option<u64>,
737 position: i64,
738 ) -> impl std::future::Future<Output = Result<QueryResult, Self::Error>> + Send;
739
740 /// Execute a `/queryChanges` and return deltas since `since_query_state`.
741 ///
742 /// `collapse_threads` is only meaningful for `Email/queryChanges`
743 /// (RFC 8621 §4.5). Pass `false` for all other object types.
744 #[allow(clippy::too_many_arguments)]
745 fn query_changes<O: QueryObject + Send + Sync>(
746 &self,
747 caller: &Self::CallerCtx,
748 account_id: &jmap_types::Id,
749 since_query_state: &jmap_types::State,
750 filter: Option<&O::Filter>,
751 sort: Option<&[O::Comparator]>,
752 max_changes: Option<u64>,
753 up_to_id: Option<&jmap_types::Id>,
754 collapse_threads: bool,
755 ) -> impl std::future::Future<
756 Output = Result<QueryChangesResult, BackendChangesError<Self::Error>>,
757 > + Send;
758
759 /// The caller's stable identity within this account namespace.
760 ///
761 /// Returns `None` for deployments that have not wired identity
762 /// (test fixtures, single-user dev servers). A `None`-returning
763 /// backend CANNOT honor JMAP semantics that depend on caller
764 /// identity — chat role-hierarchy, calendar ACLs, sharing/myRights,
765 /// per-user $seen on shared mailboxes, metadata isPrivate
766 /// visibility scoping, etc. Authentication is still the HTTP
767 /// layer's job; this method exposes the result of that
768 /// authentication to the JMAP layer for in-method semantics.
769 ///
770 /// Implementations MUST NOT mint identity — they MUST read it
771 /// from the `CallerCtx` populated by the HTTP/auth middleware
772 /// before `dispatch()` was called.
773 ///
774 /// Backends that honor identity-dependent semantics MUST override
775 /// this method. Handlers and downstream backend traits MAY rely on
776 /// it being correct when it returns `Some`.
777 fn principal_id(caller: &Self::CallerCtx) -> Option<&jmap_types::Id> {
778 let _ = caller;
779 None
780 }
781}
782
783// ---------------------------------------------------------------------------
784// Tests
785// ---------------------------------------------------------------------------
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 /// Oracle: BackendChangesError::TooManyChanges { limit: 0 } must map to
792 /// cannotCalculateChanges (RFC 8620 §5.6), not tooManyChanges with limit 0.
793 ///
794 /// limit=0 is the convention for "cannot calculate".
795 #[test]
796 fn backend_changes_error_limit_zero_maps_to_cannot_calculate() {
797 let err = jmap_types::JmapError::from(
798 BackendChangesError::<std::convert::Infallible>::TooManyChanges { limit: 0 },
799 );
800 assert_eq!(
801 err.error_type.as_str(),
802 "cannotCalculateChanges",
803 "limit=0 must produce cannotCalculateChanges; got: {:?}",
804 err.error_type
805 );
806 }
807
808 /// Oracle: BackendChangesError::TooManyChanges { limit: N } (N > 0) maps to
809 /// tooManyChanges with the suggested limit.
810 #[test]
811 fn backend_changes_error_nonzero_limit_maps_to_too_many_changes() {
812 let err = jmap_types::JmapError::from(
813 BackendChangesError::<std::convert::Infallible>::TooManyChanges { limit: 50 },
814 );
815 assert_eq!(
816 err.error_type.as_str(),
817 "tooManyChanges",
818 "limit=50 must produce tooManyChanges; got: {:?}",
819 err.error_type
820 );
821 }
822
823 /// Oracle: SetErrorType::Custom("mdnAlreadySent") must serialize as the bare
824 /// string "mdnAlreadySent" and deserialize back to Custom("mdnAlreadySent").
825 /// Extension crates depend on this round-trip to emit domain-specific errors.
826 #[test]
827 fn set_error_type_custom_round_trips_as_bare_string() {
828 let original = SetErrorType::custom("mdnAlreadySent");
829 let serialized = serde_json::to_string(&original).expect("serialize");
830 assert_eq!(
831 serialized, r#""mdnAlreadySent""#,
832 "Custom must serialize as bare string"
833 );
834 let deserialized: SetErrorType = serde_json::from_str(&serialized).expect("deserialize");
835 assert_eq!(
836 deserialized, original,
837 "Custom must deserialize back to Custom"
838 );
839 }
840
841 /// Oracle (bd:JMAP-dha0): SetError gains an `extra` map that captures
842 /// extension-defined fields not covered by the typed `with_*` builders.
843 /// A handler that emits `rateLimited` with `serverRetryAfter` must
844 /// see the value round-trip through serialize / deserialize.
845 #[test]
846 fn set_error_extra_field_round_trips() {
847 let original = SetError::new(SetErrorType::custom("rateLimited"))
848 .with_description("Slow mode is active")
849 .with_extra(
850 "serverRetryAfter",
851 serde_json::Value::String("2025-12-31T23:59:59Z".to_owned()),
852 );
853
854 let wire = serde_json::to_value(&original).expect("serialize");
855 assert_eq!(wire["type"], "rateLimited");
856 assert_eq!(wire["description"], "Slow mode is active");
857 assert_eq!(
858 wire["serverRetryAfter"], "2025-12-31T23:59:59Z",
859 "extra field must flatten into the SetError wire shape"
860 );
861
862 let round: SetError = serde_json::from_value(wire).expect("deserialize");
863 assert_eq!(round.error_type, original.error_type);
864 assert_eq!(round.description, original.description);
865 assert_eq!(
866 round.extra.get("serverRetryAfter").and_then(|v| v.as_str()),
867 Some("2025-12-31T23:59:59Z"),
868 "extra field must survive deserialize"
869 );
870 }
871
872 /// Oracle (bd:JMAP-dha0): a SetError with no extras serializes to a
873 /// wire shape byte-identical to the pre-extras layout. The
874 /// `skip_serializing_if` on `extra` collapses the empty map.
875 #[test]
876 fn set_error_empty_extra_is_invisible_on_the_wire() {
877 let err = SetError::new(SetErrorType::Forbidden);
878 let wire = serde_json::to_value(&err).expect("serialize");
879 let obj = wire.as_object().expect("object");
880 assert!(
881 !obj.contains_key("extra"),
882 "empty `extra` map must not appear on the wire (got {wire})"
883 );
884 // The only key on the wire for a bare SetError must be `type`.
885 assert_eq!(
886 obj.len(),
887 1,
888 "bare SetError must have exactly one key on the wire"
889 );
890 assert_eq!(obj["type"], "forbidden");
891 }
892
893 /// Oracle (bd:JMAP-dha0): unknown wire fields on a deserialized
894 /// SetError land in `extra`. This means a future spec adding
895 /// `someNewSetErrorField` will round-trip through current
896 /// versions of the kit losslessly.
897 #[test]
898 fn set_error_unknown_field_lands_in_extra() {
899 let wire = serde_json::json!({
900 "type": "forbidden",
901 "futureSpecField": "future-value",
902 "anotherOne": 42
903 });
904 let err: SetError = serde_json::from_value(wire).expect("deserialize");
905 assert_eq!(err.error_type, SetErrorType::Forbidden);
906 assert_eq!(
907 err.extra.get("futureSpecField").and_then(|v| v.as_str()),
908 Some("future-value")
909 );
910 assert_eq!(
911 err.extra.get("anotherOne").and_then(|v| v.as_u64()),
912 Some(42)
913 );
914 }
915
916 /// Oracle: known SetErrorType variants (e.g. Singleton) must still
917 /// serialize as their camelCase wire strings and deserialize back correctly.
918 #[test]
919 fn set_error_type_known_variant_round_trips() {
920 let original = SetErrorType::Singleton;
921 let serialized = serde_json::to_string(&original).expect("serialize");
922 assert_eq!(
923 serialized, r#""singleton""#,
924 "Singleton must serialize as \"singleton\""
925 );
926 let deserialized: SetErrorType = serde_json::from_str(&serialized).expect("deserialize");
927 assert_eq!(
928 deserialized, original,
929 "Singleton must deserialize back to Singleton"
930 );
931 }
932
933 /// Oracle (bd:JMAP-ga0q.1): `JmapBackend::principal_id` has a default impl
934 /// that returns `None`. A backend whose `CallerCtx = ()` and that does NOT
935 /// override `principal_id` inherits that default and signals "identity not
936 /// wired" to callers. JMAP semantics that depend on caller identity must
937 /// treat `None` as a hard "cannot honor".
938 #[test]
939 fn principal_id_default_impl_returns_none_for_unit_caller_ctx() {
940 // Minimal stub backend exercising only the default impl. All other
941 // trait methods are stubbed with `unreachable!()` and never invoked.
942 struct StubBackend;
943
944 #[derive(Debug)]
945 struct StubError;
946
947 impl std::fmt::Display for StubError {
948 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
949 f.write_str("stub")
950 }
951 }
952 impl std::error::Error for StubError {}
953
954 impl JmapBackend for StubBackend {
955 type Error = StubError;
956 type CallerCtx = ();
957
958 async fn account_exists(
959 &self,
960 _caller: &(),
961 _account_id: &jmap_types::Id,
962 ) -> Result<bool, Self::Error> {
963 unreachable!("only principal_id is exercised in this test")
964 }
965
966 async fn get_objects<O: GetObject + Send + Sync>(
967 &self,
968 _caller: &(),
969 _account_id: &jmap_types::Id,
970 _ids: Option<&[jmap_types::Id]>,
971 _properties: Option<&[String]>,
972 ) -> Result<(Vec<O>, Vec<jmap_types::Id>), Self::Error> {
973 unreachable!("only principal_id is exercised in this test")
974 }
975
976 async fn get_state<O: JmapObject + Send + Sync>(
977 &self,
978 _caller: &(),
979 _account_id: &jmap_types::Id,
980 ) -> Result<jmap_types::State, Self::Error> {
981 unreachable!("only principal_id is exercised in this test")
982 }
983
984 async fn get_changes<O: JmapObject + Send + Sync>(
985 &self,
986 _caller: &(),
987 _account_id: &jmap_types::Id,
988 _since_state: &jmap_types::State,
989 _max_changes: Option<u64>,
990 ) -> Result<ChangesResult, BackendChangesError<Self::Error>> {
991 unreachable!("only principal_id is exercised in this test")
992 }
993
994 async fn query_objects<O: QueryObject + Send + Sync>(
995 &self,
996 _caller: &(),
997 _account_id: &jmap_types::Id,
998 _filter: Option<&O::Filter>,
999 _sort: Option<&[O::Comparator]>,
1000 _limit: Option<u64>,
1001 _position: i64,
1002 ) -> Result<QueryResult, Self::Error> {
1003 unreachable!("only principal_id is exercised in this test")
1004 }
1005
1006 async fn query_changes<O: QueryObject + Send + Sync>(
1007 &self,
1008 _caller: &(),
1009 _account_id: &jmap_types::Id,
1010 _since_query_state: &jmap_types::State,
1011 _filter: Option<&O::Filter>,
1012 _sort: Option<&[O::Comparator]>,
1013 _max_changes: Option<u64>,
1014 _up_to_id: Option<&jmap_types::Id>,
1015 _collapse_threads: bool,
1016 ) -> Result<QueryChangesResult, BackendChangesError<Self::Error>> {
1017 unreachable!("only principal_id is exercised in this test")
1018 }
1019 }
1020
1021 let caller: <StubBackend as JmapBackend>::CallerCtx = ();
1022 let id = <StubBackend as JmapBackend>::principal_id(&caller);
1023 assert!(
1024 id.is_none(),
1025 "default principal_id impl must return None; got Some({:?})",
1026 id
1027 );
1028 }
1029}