jmap_server/handlers.rs
1//! Generic JMAP method handlers shared across all server crates.
2//!
3//! Each function handles one RFC 8620 operation type for any object type `O`
4//! and any backend `B: JmapBackend`. Domain crates call these for types that
5//! have no domain-specific logic beyond the standard wire protocol.
6//!
7//! # Backend-error leak policy (bd:JMAP-wlip.2)
8//!
9//! Every handler in this module that maps a [`JmapBackend::Error`] to a
10//! wire-format [`JmapError::server_fail`] MUST use the static description
11//! [`SERVER_FAIL_INTERNAL_DESC`] rather than interpolating the backend
12//! error's [`Display`](std::fmt::Display) output. The backend-error
13//! contract on [`JmapBackend::Error`] (`crate::backend::JmapBackend`'s
14//! associated-type doc comment) forbids credential / blob / PII in
15//! `Display`, but a single accidental violation by a backend implementor
16//! would land the leaked text in `serverFail.description` on every
17//! affected response. Stripping the description at the handler layer
18//! changes that from a wire-format security incident into a server-side
19//! diagnostic gap that the operator can close with its own structured
20//! logger wrapping the backend call.
21//!
22//! Extension `*-server` crates with their own per-method handlers
23//! SHOULD follow the same pattern; the helper [`server_fail_from_backend`]
24//! exists so each call site is one line and reviewable at a glance.
25
26use jmap_types::{Id, Invocation, JmapError, State};
27use serde_json::{json, Value};
28
29use crate::backend::{GetObject, JmapBackend, JmapObject, QueryObject};
30use crate::helpers::{extract_account_id, not_found_json, optional_arg, serialize_value};
31
32/// Static description used for every `serverFail` invocation that wraps a
33/// [`JmapBackend::Error`] (bd:JMAP-wlip.2).
34///
35/// RFC 8620 §3.6.2 explicitly permits omitting the description; a static
36/// "internal error" is RFC-compliant and forecloses the backend-error
37/// Display leak path documented on `JmapBackend::Error`.
38pub const SERVER_FAIL_INTERNAL_DESC: &str = "internal error";
39
40/// Construct a [`JmapError::server_fail`] for a backend-originated error
41/// without echoing the backend error's [`Display`](std::fmt::Display) output
42/// onto the wire (bd:JMAP-wlip.2).
43///
44/// **The `err` parameter is intentionally discarded** (bd:JMAP-jfia.22).
45/// It exists only to keep the call site ergonomic
46/// (`.map_err(|e| server_fail_from_backend(&e))`) — the function never
47/// reads it, logs it, or stashes it. Callers that want their backend
48/// error visible in operator logs MUST log it explicitly at the call
49/// site before invoking this helper; no logging happens here. The
50/// crate's sealed dep set (workspace AGENTS.md) excludes `tracing`,
51/// so a built-in log line is not on the table.
52///
53/// The backend error parameter is accepted by reference (and discarded) so
54/// callers retain it for their own structured logging if they wire one. The
55/// returned `JmapError` always carries the static
56/// [`SERVER_FAIL_INTERNAL_DESC`] description; no caller-controlled text
57/// reaches the wire from this helper.
58///
59/// The function is generic over any `Display` (not just
60/// `JmapBackend::Error`) so the extension `*-server` crates' own per-method
61/// handlers — which mix [`JmapBackend::Error`], domain-specific error
62/// envelopes (`BackendSetError::Other`, `BackendChangesError::Other`), and
63/// trait-method errors — can call it uniformly.
64///
65/// # Use at every site that maps a backend error to `serverFail`
66///
67/// Replace:
68///
69/// ```ignore
70/// .map_err(|e| JmapError::server_fail(e.to_string()))
71/// ```
72///
73/// with:
74///
75/// ```ignore
76/// .map_err(|e| server_fail_from_backend(&e))
77/// ```
78pub fn server_fail_from_backend<E: std::fmt::Display + ?Sized>(_err: &E) -> JmapError {
79 JmapError::server_fail(SERVER_FAIL_INTERNAL_DESC)
80}
81
82/// Construct the per-id `serverFail` [`Value`] used inside the
83/// `notCreated`/`notUpdated`/`notDestroyed` maps of `/set` responses
84/// (bd:JMAP-ic0j.68), without echoing the backend error's
85/// [`Display`](std::fmt::Display) output onto the wire.
86///
87/// This is the [`Value`]-shaped sibling of [`server_fail_from_backend`].
88/// The handler-layer helper returns a [`JmapError`] which is only useful
89/// where the entire method invocation fails. Per-id `/set` failures are
90/// expressed as a [`serde_json::Value`] keyed under each failing id in
91/// `notCreated` / `notUpdated` / `notDestroyed`; the existing
92/// `JmapError`-shaped helper does not fit those sites, so each crate
93/// previously hand-rolled
94/// `json!({ "type": "serverFail", "description": e.to_string() })` —
95/// every one of which leaks the backend error's `Display` onto the wire,
96/// in direct violation of the [`JmapBackend::Error`](crate::JmapBackend)
97/// Display MUST-NOT contract.
98///
99/// **The `err` parameter is intentionally discarded** (bd:JMAP-jfia.22),
100/// matching the contract on [`server_fail_from_backend`]. It exists only
101/// to keep the call site ergonomic — the function never reads it, logs
102/// it, or stashes it. Callers that want their backend error visible in
103/// operator logs MUST log it explicitly at the call site before invoking
104/// this helper.
105///
106/// The function is generic over any [`Display`](std::fmt::Display) so it
107/// applies uniformly to [`JmapBackend::Error`](crate::JmapBackend),
108/// [`BackendSetError::Other`](crate::BackendSetError),
109/// [`BackendChangesError::Other`](crate::BackendChangesError), and any
110/// extension-trait-specific error envelope. It also accepts the
111/// `&String` shape produced by some legacy helpers that flattened a
112/// backend error to `String` before propagating.
113///
114/// # Use at every per-id /set serverFail site
115///
116/// Replace:
117///
118/// ```ignore
119/// json!({ "type": "serverFail", "description": e.to_string() })
120/// ```
121///
122/// with:
123///
124/// ```ignore
125/// server_fail_value_from_backend(&e)
126/// ```
127pub fn server_fail_value_from_backend<E: std::fmt::Display + ?Sized>(_err: &E) -> Value {
128 json!({
129 "type": "serverFail",
130 "description": SERVER_FAIL_INTERNAL_DESC,
131 })
132}
133
134// ---------------------------------------------------------------------------
135// handle_get
136// ---------------------------------------------------------------------------
137
138/// Generic `*/get` handler (RFC 8620 §5.1).
139///
140/// Fetches objects by id (or all objects when `ids` is absent or `null`) and
141/// returns the standard `get` response shape.
142pub async fn handle_get<O: GetObject, B: JmapBackend>(
143 backend: &B,
144 caller: &B::CallerCtx,
145 args: Value,
146) -> Result<(Value, Vec<Invocation>), JmapError> {
147 let (account_id, mut args) = extract_account_id(args)?;
148 if !backend
149 .account_exists(caller, &account_id)
150 .await
151 .map_err(|e| server_fail_from_backend(&e))?
152 {
153 return Err(JmapError::account_not_found());
154 }
155
156 let ids: Option<Vec<Id>> = optional_arg(&mut args, "ids", || {
157 JmapError::invalid_arguments("ids must be an Id array")
158 })?;
159
160 let properties: Option<Vec<String>> = optional_arg(&mut args, "properties", || {
161 JmapError::invalid_arguments("properties must be a string array")
162 })?;
163
164 let ids_slice = ids.as_deref();
165 let (list, not_found) = backend
166 .get_objects::<O>(caller, &account_id, ids_slice, properties.as_deref())
167 .await
168 .map_err(|e| server_fail_from_backend(&e))?;
169
170 let state = backend
171 .get_state::<O>(caller, &account_id)
172 .await
173 .map_err(|e| server_fail_from_backend(&e))?;
174
175 // bd:JMAP-jfia.10 — batch-serialize the entire Vec<O> rather than
176 // calling to_value per element. One serializer construction
177 // instead of N. For Mailbox/get / Email/get over large accounts
178 // (~100k+ objects) the saving is measurable. serde_json::to_value
179 // on a Vec<O> always produces Value::Array(Vec<Value>) so the
180 // wire shape is identical.
181 let list_json = serialize_value(&list)?;
182
183 Ok((
184 json!({
185 "accountId": account_id.as_ref(),
186 "state": state.as_ref(),
187 "list": list_json,
188 "notFound": not_found_json(¬_found),
189 }),
190 vec![],
191 ))
192}
193
194// ---------------------------------------------------------------------------
195// handle_changes
196// ---------------------------------------------------------------------------
197
198/// Generic `*/changes` handler (RFC 8620 §5.2).
199///
200/// This implementation always returns `updatedProperties: null` (see RFC 8620
201/// §5.2 for the field's semantics). For types with frequently-updated
202/// server-computed counts (e.g. Mailbox `totalEmails`, `unreadEmails`), a
203/// production backend MAY override or post-process the response to set
204/// `updatedProperties` to the list of count fields when only those changed.
205/// When non-null, compliant clients skip re-fetching non-count properties,
206/// reducing traffic on large inboxes. Backends that do not track per-property
207/// change detail MUST leave it null — returning an empty array would be
208/// incorrect (that means "nothing about the listed objects actually changed").
209pub async fn handle_changes<O: JmapObject, B: JmapBackend>(
210 backend: &B,
211 caller: &B::CallerCtx,
212 args: Value,
213) -> Result<(Value, Vec<Invocation>), JmapError> {
214 let (account_id, args) = extract_account_id(args)?;
215 if !backend
216 .account_exists(caller, &account_id)
217 .await
218 .map_err(|e| server_fail_from_backend(&e))?
219 {
220 return Err(JmapError::account_not_found());
221 }
222
223 let since_state: State = match args.get("sinceState").and_then(|v| v.as_str()) {
224 Some(s) => State::from(s),
225 None => return Err(JmapError::invalid_arguments("sinceState is required")),
226 };
227
228 let max_changes: Option<u64> = match args.get("maxChanges") {
229 None | Some(Value::Null) => None,
230 Some(v) => Some(v.as_u64().filter(|&n| n > 0).ok_or_else(|| {
231 JmapError::invalid_arguments("maxChanges must be a positive integer")
232 })?),
233 };
234
235 let result = backend
236 .get_changes::<O>(caller, &account_id, &since_state, max_changes)
237 .await
238 .map_err(JmapError::from)?;
239
240 Ok((
241 json!({
242 "accountId": account_id.as_ref(),
243 "oldState": since_state.as_ref(),
244 "newState": result.new_state.as_ref(),
245 "hasMoreChanges": result.has_more_changes,
246 "updatedProperties": Value::Null,
247 // bd:JMAP-wlip.28 — Vec<Id> serializes directly via Id's
248 // #[serde(transparent)] impl; no intermediate &str Vec needed.
249 "created": result.created,
250 "updated": result.updated,
251 "destroyed": result.destroyed,
252 }),
253 vec![],
254 ))
255}
256
257// ---------------------------------------------------------------------------
258// handle_query
259// ---------------------------------------------------------------------------
260
261/// Generic `*/query` handler (RFC 8620 §5.5).
262///
263/// Parses filter and sort from args as `O::Filter` and `O::Comparator`, then
264/// delegates to [`JmapBackend::query_objects`].
265pub async fn handle_query<O: QueryObject, B: JmapBackend>(
266 backend: &B,
267 caller: &B::CallerCtx,
268 args: Value,
269) -> Result<(Value, Vec<Invocation>), JmapError> {
270 let (account_id, mut args) = extract_account_id(args)?;
271 if !backend
272 .account_exists(caller, &account_id)
273 .await
274 .map_err(|e| server_fail_from_backend(&e))?
275 {
276 return Err(JmapError::account_not_found());
277 }
278
279 let calculate_total: bool = args
280 .get("calculateTotal")
281 .and_then(|v| v.as_bool())
282 .unwrap_or(false);
283
284 let limit: Option<u64> = match args.get("limit") {
285 None | Some(Value::Null) => None,
286 Some(v) => match v.as_u64() {
287 Some(n) => Some(n),
288 None => {
289 return Err(JmapError::invalid_arguments(format!(
290 "limit: expected a non-negative integer, got {v}"
291 )))
292 }
293 },
294 };
295
296 let position: i64 = match args.get("position") {
297 None | Some(Value::Null) => 0,
298 Some(v) => v.as_i64().ok_or_else(|| {
299 JmapError::invalid_arguments(format!("position: expected an integer, got {v}"))
300 })?,
301 };
302
303 let filter: Option<O::Filter> =
304 optional_arg(&mut args, "filter", JmapError::unsupported_filter)?;
305
306 let sort: Option<Vec<O::Comparator>> = optional_arg(&mut args, "sort", || {
307 JmapError::invalid_arguments("sort must be an array")
308 })?;
309
310 let result = backend
311 .query_objects::<O>(
312 caller,
313 &account_id,
314 filter.as_ref(),
315 sort.as_deref(),
316 limit,
317 position,
318 )
319 .await
320 .map_err(|e| server_fail_from_backend(&e))?;
321
322 let mut resp = json!({
323 "accountId": account_id.as_ref(),
324 "queryState": result.query_state.as_ref(),
325 "canCalculateChanges": result.can_calculate_changes,
326 "position": result.position,
327 // bd:JMAP-wlip.28 — Vec<Id> serializes directly via Id's
328 // #[serde(transparent)] impl.
329 "ids": result.ids,
330 });
331 if calculate_total {
332 if let Some(t) = result.total {
333 resp["total"] = json!(t);
334 }
335 }
336
337 Ok((resp, vec![]))
338}
339
340// ---------------------------------------------------------------------------
341// handle_query_changes
342// ---------------------------------------------------------------------------
343
344/// Generic `*/queryChanges` handler (RFC 8620 §5.6).
345///
346/// Parses filter and sort from args, then delegates to
347/// [`JmapBackend::query_changes`] with `collapse_threads: false`. For
348/// `Email/queryChanges` (which may need `collapseThreads: true`), use the
349/// domain-specific handler in jmap-mail-server instead.
350pub async fn handle_query_changes<O: QueryObject, B: JmapBackend>(
351 backend: &B,
352 caller: &B::CallerCtx,
353 args: Value,
354) -> Result<(Value, Vec<Invocation>), JmapError> {
355 let (account_id, mut args) = extract_account_id(args)?;
356 if !backend
357 .account_exists(caller, &account_id)
358 .await
359 .map_err(|e| server_fail_from_backend(&e))?
360 {
361 return Err(JmapError::account_not_found());
362 }
363
364 let since_query_state: State = match args.get("sinceQueryState").and_then(|v| v.as_str()) {
365 Some(s) => State::from(s),
366 None => return Err(JmapError::invalid_arguments("sinceQueryState is required")),
367 };
368
369 let max_changes: Option<u64> = match args.get("maxChanges") {
370 None | Some(Value::Null) => None,
371 Some(v) => Some(v.as_u64().filter(|&n| n > 0).ok_or_else(|| {
372 JmapError::invalid_arguments("maxChanges must be a positive integer")
373 })?),
374 };
375
376 let up_to_id: Option<Id> = match args.get("upToId") {
377 None | Some(Value::Null) => None,
378 Some(Value::String(s)) => Some(Id::from(s.as_str())),
379 Some(_) => {
380 return Err(JmapError::invalid_arguments(
381 "upToId must be a string Id or null",
382 ))
383 }
384 };
385
386 let calculate_total: bool = args
387 .get("calculateTotal")
388 .and_then(|v| v.as_bool())
389 .unwrap_or(false);
390
391 let filter: Option<O::Filter> =
392 optional_arg(&mut args, "filter", JmapError::unsupported_filter)?;
393
394 let sort: Option<Vec<O::Comparator>> = optional_arg(&mut args, "sort", || {
395 JmapError::invalid_arguments("sort must be an array")
396 })?;
397
398 let result = backend
399 .query_changes::<O>(
400 caller,
401 &account_id,
402 &since_query_state,
403 filter.as_ref(),
404 sort.as_deref(),
405 max_changes,
406 up_to_id.as_ref(),
407 false, // collapse_threads: only meaningful for Email/queryChanges
408 )
409 .await
410 .map_err(JmapError::from)?;
411
412 let added: Vec<Value> = result
413 .added
414 .iter()
415 .map(|item| {
416 json!({
417 "id": item.id.as_ref(),
418 "index": item.index,
419 })
420 })
421 .collect();
422
423 let mut resp = json!({
424 "accountId": account_id.as_ref(),
425 "oldQueryState": result.old_query_state.as_ref(),
426 "newQueryState": result.new_query_state.as_ref(),
427 // bd:JMAP-wlip.28 — Vec<Id> serializes directly.
428 "removed": result.removed,
429 "added": added,
430 });
431 if calculate_total {
432 if let Some(t) = result.total {
433 resp["total"] = json!(t);
434 }
435 }
436
437 Ok((resp, vec![]))
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 /// Oracle (bd:JMAP-wlip.2): [`server_fail_from_backend`] MUST NOT echo
445 /// the backend error's `Display` text into the resulting JmapError's
446 /// description. The defence-in-depth contract is that even if a
447 /// backend implementor accidentally violates the
448 /// [`JmapBackend::Error`](crate::JmapBackend) Display MUST-NOT
449 /// (credential / blob / PII), the leaked text never reaches the wire.
450 ///
451 /// Test vector: an error whose Display contains a canary string
452 /// resembling a credential leak. The canary literal is hand-built and
453 /// not derived from any production type's behaviour.
454 #[test]
455 fn server_fail_from_backend_drops_display_text() {
456 #[derive(Debug)]
457 struct LeakyError(&'static str);
458 impl std::fmt::Display for LeakyError {
459 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460 f.write_str(self.0)
461 }
462 }
463 impl std::error::Error for LeakyError {}
464
465 const CANARY: &str = "TOKEN-DO-NOT-LEAK-c0ffee";
466 let err = LeakyError(CANARY);
467
468 let jmap_err = server_fail_from_backend(&err);
469
470 // Serialize to wire shape and assert the canary is absent from
471 // every value in the resulting JSON. The error_invocation wraps
472 // a JmapError as { "type": "serverFail", "description": "..." }
473 // — both fields are wire-visible.
474 let wire = serde_json::to_value(&jmap_err).expect("JmapError must serialize");
475 let wire_str = wire.to_string();
476 assert!(
477 !wire_str.contains(CANARY),
478 "server_fail_from_backend must not echo backend error Display \
479 onto the wire; got {wire_str}"
480 );
481 // The description MUST be exactly SERVER_FAIL_INTERNAL_DESC.
482 assert_eq!(
483 wire["description"], SERVER_FAIL_INTERNAL_DESC,
484 "description must be the static 'internal error' string"
485 );
486 assert_eq!(wire["type"], "serverFail");
487 }
488
489 /// Oracle: the helper accepts any `Display` — not just
490 /// [`JmapBackend::Error`](crate::JmapBackend) — so the extension
491 /// `*-server` crates' per-method handlers can use the same call
492 /// site for `BackendSetError`, `BackendChangesError`, and any
493 /// trait-method-specific error envelope.
494 #[test]
495 fn server_fail_from_backend_accepts_generic_display() {
496 // String, &str, and a custom Display all compile-check that the
497 // bound is `Display + ?Sized`.
498 let _ = server_fail_from_backend("a string");
499 let _ = server_fail_from_backend(&"&str".to_owned());
500 let _ = server_fail_from_backend(&42_u64);
501 }
502
503 /// Oracle (bd:JMAP-ic0j.68): [`server_fail_value_from_backend`] MUST
504 /// NOT echo the backend error's `Display` text into the per-id
505 /// `serverFail` Value used inside `/set`'s `notCreated`/`notUpdated`/
506 /// `notDestroyed` maps. Mirrors the
507 /// [`server_fail_from_backend_drops_display_text`] oracle for the
508 /// `JmapError`-shaped sibling helper.
509 ///
510 /// Test vector: an error whose `Display` contains a canary string
511 /// resembling a credential leak. The canary literal is hand-built and
512 /// not derived from any production type's behaviour.
513 #[test]
514 fn server_fail_value_from_backend_drops_display_text() {
515 #[derive(Debug)]
516 struct LeakyError(&'static str);
517 impl std::fmt::Display for LeakyError {
518 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 f.write_str(self.0)
520 }
521 }
522 impl std::error::Error for LeakyError {}
523
524 const CANARY: &str = "TOKEN-DO-NOT-LEAK-d00d";
525 let err = LeakyError(CANARY);
526
527 let wire = server_fail_value_from_backend(&err);
528
529 // The canary MUST NOT appear anywhere in the serialized wire shape.
530 let wire_str = wire.to_string();
531 assert!(
532 !wire_str.contains(CANARY),
533 "server_fail_value_from_backend must not echo backend error \
534 Display onto the wire; got {wire_str}"
535 );
536 // Wire shape: { "type": "serverFail", "description": "internal error" }.
537 assert_eq!(wire["type"], "serverFail");
538 assert_eq!(
539 wire["description"], SERVER_FAIL_INTERNAL_DESC,
540 "description must be the static 'internal error' string"
541 );
542 }
543
544 /// Oracle: the helper accepts any `Display` — not just
545 /// [`JmapBackend::Error`](crate::JmapBackend) — so the extension
546 /// `*-server` crates' per-method handlers can use the same call
547 /// site for `BackendSetError::Other`, `BackendChangesError::Other`,
548 /// and the `&String` shape produced by some legacy helpers.
549 #[test]
550 fn server_fail_value_from_backend_accepts_generic_display() {
551 // String, &str, &String, and a custom Display all compile-check
552 // that the bound is `Display + ?Sized`.
553 let _ = server_fail_value_from_backend("a string");
554 let _ = server_fail_value_from_backend(&"owned-String".to_owned());
555 let owned: String = "owned".to_string();
556 let _ = server_fail_value_from_backend(&owned);
557 let _ = server_fail_value_from_backend(&42_u64);
558 }
559}