Skip to main content

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(&not_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}