Skip to main content

jmap_server/
lib.rs

1//! Backend-agnostic JMAP server framework (RFC 8620).
2//!
3//! Provides request parsing, ResultReference resolution, HTTP response helpers,
4//! the [`Dispatcher`] machinery, shared backend infrastructure, and generic
5//! JMAP method handlers.
6
7#![forbid(unsafe_code)]
8
9pub use jmap_types::{
10    Argument, Id, Invocation, JmapError, JmapRequest, JmapResponse, ResultReference, State, UTCDate,
11};
12
13pub mod backend;
14pub mod handlers;
15pub(crate) mod helpers;
16
17pub use backend::{
18    AddedItem, BackendChangesError, ChangesResult, GetObject, JmapBackend, JmapObject,
19    QueryChangesResult, QueryObject, QueryResult, SetObject,
20};
21pub use handlers::{handle_changes, handle_get, handle_query, handle_query_changes};
22pub use helpers::{extract_account_id, not_found_json, now_utc_string, ser};
23
24mod parse;
25mod response;
26
27pub use parse::{parse_request, resolve_args};
28pub use response::{error_invocation, error_status, request_error, RequestError};
29
30use std::{collections::HashMap, fmt, future::Future, pin::Pin, sync::Arc};
31
32use serde_json::Value;
33use tokio::task;
34
35/// The return type for all [`JmapHandler`] implementations.
36///
37/// Handlers must return a `Send` future.  The concrete type is a heap-allocated
38/// trait object so the trait itself remains object-safe.
39///
40/// The `Vec<Invocation>` holds zero or more additional entries to append to
41/// `methodResponses` immediately after the primary response (in order).  Most
42/// handlers return an empty `Vec`.  RFC 8621 §7.5 `EmailSubmission/set` uses
43/// this to append the implicit `Email/set` invocation for `onSuccessUpdateEmail`.
44pub type HandlerFuture =
45    Pin<Box<dyn Future<Output = Result<(Value, Vec<Invocation>), JmapError>> + Send>>;
46
47/// Implement this for each JMAP method handler.
48///
49/// `CallerCtx` is whatever your auth layer produces — an `Identity`, a session
50/// token, `()`, etc. The dispatcher passes it through unchanged.
51///
52/// # /set response contract
53///
54/// Handlers for `/set` methods (RFC 8620 §5.3) that create objects MUST include
55/// an `"id"` field (type string) in each entry of the `"created"` map.  The
56/// dispatcher reads this field to accumulate `createdIds` in the response.
57/// Entries without an `"id"` field are silently skipped — the dispatcher cannot
58/// retroactively error a method call that already returned success.
59pub trait JmapHandler<CallerCtx>: Send + Sync {
60    /// `method` is the registered method name for this call.  A single handler
61    /// instance may be registered under multiple names (e.g. both `"Foo/get"` and
62    /// `"Bar/get"`); this parameter lets the handler distinguish between them.
63    ///
64    /// `call_id` is the client-supplied identifier for this invocation (RFC 8620 §3.3).
65    /// Handlers may use it for logging or correlation but need not echo it —
66    /// the dispatcher echoes it in the response automatically.
67    fn call(
68        &self,
69        method: String,
70        call_id: String,
71        args: Value,
72        caller: CallerCtx,
73    ) -> HandlerFuture;
74}
75
76/// Dispatches a [`JmapRequest`] to registered method handlers.
77///
78/// Register handlers with [`Dispatcher::register`], then call
79/// [`Dispatcher::dispatch`] per request.  `CallerCtx` is cloned for each
80/// method call in the batch, so it must be `Clone`.
81///
82/// `CallerCtx` must also be `'static` because each handler call is spawned as
83/// a [`tokio::task`].  To share non-static data (e.g. a database connection),
84/// wrap it in `Arc<T>` — `Arc` is `Clone + Send + 'static` when `T: Send + Sync`.
85///
86/// # Thread safety
87///
88/// `Dispatcher` is both `Send` and `Sync`.  Register handlers on one thread,
89/// then wrap in `Arc` and share across tasks — `dispatch` takes `&self` and is
90/// safe to call concurrently.
91pub struct Dispatcher<CallerCtx> {
92    handlers: HashMap<String, Arc<dyn JmapHandler<CallerCtx>>>,
93}
94
95impl<CallerCtx: Clone + Send + 'static> Dispatcher<CallerCtx> {
96    /// Create an empty dispatcher with no registered handlers.
97    pub fn new() -> Self {
98        Self {
99            handlers: HashMap::new(),
100        }
101    }
102
103    /// Register a handler for the given method name.
104    ///
105    /// Registering the same name twice replaces the earlier handler.
106    ///
107    /// Using `Arc` rather than `Box` allows the same handler instance to be
108    /// shared across multiple method name registrations (via `Arc::clone`).
109    pub fn register(
110        &mut self,
111        method: impl Into<String>,
112        handler: Arc<dyn JmapHandler<CallerCtx>>,
113    ) {
114        self.handlers.insert(method.into(), handler);
115    }
116
117    /// Process a validated [`JmapRequest`] and return a [`JmapResponse`].
118    ///
119    /// Method calls are processed sequentially per RFC 8620 §3.3.  Each
120    /// handler runs in a `tokio::task::spawn` for panic isolation: a panicking
121    /// handler returns a `serverFail` invocation rather than crashing the
122    /// connection task.
123    ///
124    /// `CallerCtx` must be `Clone + Send + 'static`; see the struct-level doc.
125    ///
126    /// # Cancellation
127    ///
128    /// If this future is dropped while a handler task is running (e.g., the
129    /// HTTP connection closes), the spawned task runs to completion — tokio
130    /// does not cancel tasks when their `JoinHandle` is dropped.  The handler
131    /// result is discarded.  Callers that need strict cancellation should
132    /// implement it at the handler level (e.g., `tokio::select!` with a
133    /// shutdown signal).
134    pub async fn dispatch(
135        &self,
136        request: JmapRequest,
137        caller: CallerCtx,
138        session_state: State,
139    ) -> JmapResponse {
140        let mut method_responses: Vec<Invocation> = Vec::with_capacity(request.method_calls.len());
141        let client_sent_created_ids = request.created_ids.is_some();
142        let mut created_ids: HashMap<Id, Id> = request.created_ids.unwrap_or_default();
143
144        // Invocation layout: (method_name, args, call_id) — RFC 8620 §3.3.
145        for (method, mut args, call_id) in request.method_calls {
146            // Resolve ResultReferences from prior responses.
147            if let Err(e) = resolve_args(&mut args, &method_responses) {
148                method_responses.push(error_invocation(&call_id, e));
149                continue;
150            }
151
152            // Look up the handler.
153            let handler = match self.handlers.get(&method) {
154                Some(h) => Arc::clone(h),
155                None => {
156                    method_responses.push(error_invocation(&call_id, JmapError::unknown_method()));
157                    continue;
158                }
159            };
160
161            let caller_clone = caller.clone();
162            let method_clone = method.clone();
163            let call_id_clone = call_id.clone();
164
165            // Run in a spawned task for panic isolation.
166            let result: Result<
167                Result<(Value, Vec<Invocation>), JmapError>,
168                tokio::task::JoinError,
169            > = task::spawn(async move {
170                handler
171                    .call(method_clone, call_id_clone, args, caller_clone)
172                    .await
173            })
174            .await;
175
176            match result {
177                Ok(Ok((primary_value, extra_invocations))) => {
178                    // Accumulate createdIds from /set responses (RFC 8620 §3.4).
179                    // Only when the client sent createdIds; otherwise the field
180                    // is omitted from the response.
181                    if client_sent_created_ids {
182                        if let Some(map) = primary_value.get("created").and_then(|v| v.as_object())
183                        {
184                            for (client_id, created_obj) in map {
185                                // RFC 8620 §5.3 requires each created object to contain
186                                // an "id" field.  If the handler violates this contract
187                                // (no "id" key or non-string value), the entry is silently
188                                // skipped — the dispatcher cannot produce an error for a
189                                // method call that already succeeded.
190                                if let Some(id_val) = created_obj.get("id").and_then(|v| v.as_str())
191                                {
192                                    created_ids.insert(client_id.as_str().into(), id_val.into());
193                                }
194                            }
195                        }
196                    }
197                    // Push the primary response first, then any extra invocations
198                    // appended by the handler (e.g. onSuccessUpdateEmail from
199                    // EmailSubmission/set, RFC 8621 §7.5).  Order is preserved.
200                    method_responses.push((method, primary_value, call_id));
201                    method_responses.extend(extra_invocations);
202                }
203                Ok(Err(e)) => {
204                    method_responses.push(error_invocation(&call_id, e));
205                }
206                Err(join_err) => {
207                    // Panics and cancellations both map to serverFail, but with
208                    // distinct descriptions to aid server-side diagnostics.
209                    let desc = if join_err.is_cancelled() {
210                        "task cancelled"
211                    } else {
212                        "internal error"
213                    };
214                    method_responses.push(error_invocation(&call_id, JmapError::server_fail(desc)));
215                }
216            }
217        }
218
219        let created_ids = client_sent_created_ids.then_some(created_ids);
220
221        JmapResponse::new(method_responses, session_state, created_ids)
222    }
223}
224
225impl<CallerCtx: Clone + Send + 'static> Default for Dispatcher<CallerCtx> {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231impl<CallerCtx> fmt::Debug for Dispatcher<CallerCtx> {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        f.debug_struct("Dispatcher")
234            .field("methods", &self.handlers.keys())
235            .finish()
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use serde_json::{json, Value};
243    use std::sync::{Arc, Mutex};
244
245    // Compile-time: Dispatcher must be Send + Sync so it can be wrapped in Arc
246    // and shared across tokio tasks.  This assertion catches future regressions
247    // that would silently break thread-safety (e.g., adding a Cell or Rc field).
248    #[allow(dead_code)]
249    fn assert_dispatcher_send_sync() {
250        fn check<T: Send + Sync>() {}
251        check::<Dispatcher<String>>();
252        check::<Dispatcher<()>>();
253    }
254
255    // -----------------------------------------------------------------------
256    // Test handler implementations
257    // -----------------------------------------------------------------------
258
259    /// Returns a fixed Value regardless of inputs.
260    struct EchoHandler(Value);
261
262    impl<C: Clone + Send + 'static> JmapHandler<C> for EchoHandler {
263        fn call(
264            &self,
265            _method: String,
266            _call_id: String,
267            _args: Value,
268            _caller: C,
269        ) -> HandlerFuture {
270            let v = self.0.clone();
271            Box::pin(async move { Ok((v, vec![])) })
272        }
273    }
274
275    /// Returns a fixed error.
276    struct ErrorHandler(JmapError);
277
278    impl JmapHandler<String> for ErrorHandler {
279        fn call(
280            &self,
281            _method: String,
282            _call_id: String,
283            _args: Value,
284            _caller: String,
285        ) -> HandlerFuture {
286            let e = self.0.clone();
287            Box::pin(async move { Err(e) })
288        }
289    }
290
291    /// Captures the resolved args it was called with.
292    struct CaptureArgsHandler(Arc<Mutex<Option<Value>>>);
293
294    impl JmapHandler<String> for CaptureArgsHandler {
295        fn call(
296            &self,
297            _method: String,
298            _call_id: String,
299            args: Value,
300            _caller: String,
301        ) -> HandlerFuture {
302            let slot = self.0.clone();
303            Box::pin(async move {
304                *slot.lock().expect("test: mutex poisoned") = Some(args);
305                Ok((json!({}), vec![]))
306            })
307        }
308    }
309
310    /// Captures the caller value it was called with.
311    struct CaptureCallerHandler(Arc<Mutex<Option<String>>>);
312
313    impl JmapHandler<String> for CaptureCallerHandler {
314        fn call(
315            &self,
316            _method: String,
317            _call_id: String,
318            _args: Value,
319            caller: String,
320        ) -> HandlerFuture {
321            let slot = self.0.clone();
322            Box::pin(async move {
323                *slot.lock().expect("test: mutex poisoned") = Some(caller);
324                Ok((json!({}), vec![]))
325            })
326        }
327    }
328
329    /// Panics unconditionally.
330    struct PanicHandler;
331
332    impl JmapHandler<String> for PanicHandler {
333        fn call(
334            &self,
335            _method: String,
336            _call_id: String,
337            _args: Value,
338            _caller: String,
339        ) -> HandlerFuture {
340            Box::pin(async move { panic!("deliberate test panic") })
341        }
342    }
343
344    // -----------------------------------------------------------------------
345    // Helper: build a minimal JmapRequest with a single method call.
346    // -----------------------------------------------------------------------
347
348    fn single_call(method: &str, args: Value, call_id: &str) -> JmapRequest {
349        JmapRequest::new(
350            vec!["urn:ietf:params:jmap:core".into()],
351            vec![(method.into(), args, call_id.into())],
352            None,
353        )
354    }
355
356    // -----------------------------------------------------------------------
357    // Basic dispatch
358    // -----------------------------------------------------------------------
359
360    /// Oracle: RFC 8620 §7.1 — unknownMethod when no handler is registered.
361    #[tokio::test]
362    async fn unknown_method_returns_error_invocation() {
363        let d: Dispatcher<String> = Dispatcher::new();
364        let req = single_call("Foo/get", json!({}), "c0");
365        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
366        assert_eq!(resp.method_responses.len(), 1);
367        let (_, args, call_id) = &resp.method_responses[0];
368        assert_eq!(call_id, "c0");
369        assert_eq!(args["type"], "unknownMethod");
370    }
371
372    /// Oracle: RFC 8620 §3.5 — successful call appears in methodResponses.
373    #[tokio::test]
374    async fn known_method_success() {
375        let mut d: Dispatcher<String> = Dispatcher::new();
376        d.register("Foo/get", Arc::new(EchoHandler(json!({"list": []}))));
377        let req = single_call("Foo/get", json!({}), "c1");
378        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
379        assert_eq!(resp.method_responses.len(), 1);
380        let (method, args, call_id) = &resp.method_responses[0];
381        assert_eq!(method, "Foo/get");
382        assert_eq!(call_id, "c1");
383        assert_eq!(args["list"], json!([]));
384    }
385
386    /// Oracle: RFC 8620 §3.6.2 — method-level errors appear in methodResponses.
387    #[tokio::test]
388    async fn handler_returns_error() {
389        let mut d: Dispatcher<String> = Dispatcher::new();
390        d.register("Foo/get", Arc::new(ErrorHandler(JmapError::not_found())));
391        let req = single_call("Foo/get", json!({}), "c2");
392        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
393        assert_eq!(resp.method_responses.len(), 1);
394        let (_, args, _) = &resp.method_responses[0];
395        assert_eq!(args["type"], "notFound");
396    }
397
398    /// Oracle: RFC 8620 §3.4 — sessionState in response matches what dispatcher was given.
399    #[tokio::test]
400    async fn session_state_echoed() {
401        let d: Dispatcher<String> = Dispatcher::new();
402        let req = JmapRequest::new(vec!["urn:ietf:params:jmap:core".into()], vec![], None);
403        let resp = d.dispatch(req, "alice".into(), "my-state-123".into()).await;
404        assert_eq!(resp.session_state.as_ref(), "my-state-123");
405    }
406
407    // -----------------------------------------------------------------------
408    // Batch
409    // -----------------------------------------------------------------------
410
411    /// Oracle: RFC 8620 §3.3 — methodCalls processed in order, all responses present.
412    /// Also covers: error in one method does not abort the batch (RFC 8620 §3.6.2).
413    #[tokio::test]
414    async fn mixed_batch_all_responses_in_order() {
415        let mut d: Dispatcher<String> = Dispatcher::new();
416        d.register("M/a", Arc::new(EchoHandler(json!({"ok": true}))));
417        // "M/b" is NOT registered → unknownMethod
418        let req = JmapRequest::new(
419            vec!["urn:ietf:params:jmap:core".into()],
420            vec![
421                ("M/a".into(), json!({}), "c0".into()),
422                ("M/b".into(), json!({}), "c1".into()),
423                ("M/a".into(), json!({}), "c2".into()),
424            ],
425            None,
426        );
427        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
428        assert_eq!(
429            resp.method_responses.len(),
430            3,
431            "all three calls must produce a response"
432        );
433        // responses[0]: M/a success
434        assert_eq!(resp.method_responses[0].2, "c0");
435        assert!(
436            resp.method_responses[0].1.get("type").is_none(),
437            "c0 must not be an error"
438        );
439        // responses[1]: M/b unknownMethod
440        assert_eq!(resp.method_responses[1].2, "c1");
441        assert_eq!(resp.method_responses[1].1["type"], "unknownMethod");
442        // responses[2]: M/a success (error in [1] did not abort the batch)
443        assert_eq!(resp.method_responses[2].2, "c2");
444        assert!(
445            resp.method_responses[2].1.get("type").is_none(),
446            "c2 must not be an error"
447        );
448    }
449
450    /// Oracle: RFC 8620 §3.6.2 — error in one method does not abort subsequent calls.
451    #[tokio::test]
452    async fn error_does_not_abort_subsequent_calls() {
453        let mut d: Dispatcher<String> = Dispatcher::new();
454        d.register("M/ok", Arc::new(EchoHandler(json!({"ok": true}))));
455        d.register("M/err", Arc::new(ErrorHandler(JmapError::forbidden())));
456        let req = JmapRequest::new(
457            vec!["urn:ietf:params:jmap:core".into()],
458            vec![
459                ("M/err".into(), json!({}), "c0".into()),
460                ("M/ok".into(), json!({}), "c1".into()),
461            ],
462            None,
463        );
464        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
465        assert_eq!(resp.method_responses.len(), 2);
466        assert_eq!(resp.method_responses[0].1["type"], "forbidden");
467        assert!(
468            resp.method_responses[1].1.get("type").is_none(),
469            "second call must succeed"
470        );
471    }
472
473    // -----------------------------------------------------------------------
474    // Panic isolation
475    // -----------------------------------------------------------------------
476
477    /// Oracle: RFC 8620 §7.1 serverFail; PLAN.md panic isolation design decision.
478    #[tokio::test]
479    async fn panicking_handler_returns_server_fail() {
480        let mut d: Dispatcher<String> = Dispatcher::new();
481        d.register("Panic/now", Arc::new(PanicHandler));
482        let req = single_call("Panic/now", json!({}), "c0");
483        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
484        assert_eq!(resp.method_responses.len(), 1);
485        let (_, args, _) = &resp.method_responses[0];
486        assert_eq!(
487            args["type"], "serverFail",
488            "panicking handler must produce serverFail"
489        );
490    }
491
492    /// Oracle: security invariant — panic payloads may contain secrets, must not be leaked.
493    #[tokio::test]
494    async fn panic_message_not_in_response() {
495        let mut d: Dispatcher<String> = Dispatcher::new();
496        d.register("Panic/now", Arc::new(PanicHandler));
497        let req = single_call("Panic/now", json!({}), "c0");
498        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
499        let (_, args, _) = &resp.method_responses[0];
500        if let Some(desc) = args["description"].as_str() {
501            assert!(
502                !desc.contains("deliberate test panic"),
503                "panic message must not leak into response description"
504            );
505        }
506    }
507
508    // -----------------------------------------------------------------------
509    // ResultReference end-to-end
510    // -----------------------------------------------------------------------
511
512    /// Oracle: RFC 8620 §3.7 — #-prefixed args resolved from prior responses before handler call.
513    #[tokio::test]
514    async fn result_reference_resolved_before_dispatch() {
515        let captured = Arc::new(Mutex::new(None::<Value>));
516        let mut d: Dispatcher<String> = Dispatcher::new();
517        d.register(
518            "Foo/get",
519            Arc::new(EchoHandler(json!({"list": [{"id": "item-1"}]}))),
520        );
521        d.register(
522            "Bar/query",
523            Arc::new(CaptureArgsHandler(Arc::clone(&captured))),
524        );
525        let req = JmapRequest::new(
526            vec!["urn:ietf:params:jmap:core".into()],
527            vec![
528                ("Foo/get".into(), json!({}), "c0".into()),
529                (
530                    "Bar/query".into(),
531                    json!({"#ids": {"resultOf": "c0", "name": "Foo/get", "path": "/list/0/id"}}),
532                    "c1".into(),
533                ),
534            ],
535            None,
536        );
537        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
538        assert_eq!(resp.method_responses.len(), 2);
539        // c1 must succeed, not be an error
540        assert!(
541            resp.method_responses[1].1.get("type").is_none(),
542            "Bar/query must succeed after ResultReference resolution"
543        );
544        // Handler must have received the resolved value, not the original #ids object
545        let got = captured
546            .lock()
547            .unwrap()
548            .clone()
549            .expect("CaptureArgsHandler was not called");
550        assert_eq!(
551            got["ids"],
552            json!("item-1"),
553            "resolved value must be the string item-1"
554        );
555        assert!(
556            got.get("#ids").is_none(),
557            "#ids key must have been replaced"
558        );
559    }
560
561    /// Oracle: RFC 8620 §3.7 — resolution failure → error for that call, batch continues.
562    #[tokio::test]
563    async fn result_reference_failure_stops_that_call() {
564        let d: Dispatcher<String> = Dispatcher::new();
565        let req = single_call(
566            "Foo/get",
567            json!({"#ids": {"resultOf": "nonexistent", "name": "Foo/get", "path": "/x"}}),
568            "c0",
569        );
570        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
571        assert_eq!(resp.method_responses.len(), 1);
572        let (_, args, _) = &resp.method_responses[0];
573        assert!(
574            args.get("type").is_some(),
575            "failed ResultReference must produce an error invocation"
576        );
577    }
578
579    // -----------------------------------------------------------------------
580    // createdIds
581    // -----------------------------------------------------------------------
582
583    /// Oracle: RFC 8620 §3.3 createdIds — server-assigned IDs returned from /set
584    /// responses are accumulated into resp.created_ids when client sent createdIds.
585    #[tokio::test]
586    async fn created_ids_accumulated_from_set_response() {
587        let mut d: Dispatcher<String> = Dispatcher::new();
588        d.register(
589            "Foo/set",
590            Arc::new(EchoHandler(
591                json!({"created": {"client-1": {"id": "server-abc"}}}),
592            )),
593        );
594        // Client sends createdIds (empty map) to signal it wants the response field.
595        let req = JmapRequest::new(
596            vec!["urn:ietf:params:jmap:core".into()],
597            vec![("Foo/set".into(), json!({}), "c0".into())],
598            Some(std::collections::HashMap::new()),
599        );
600        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
601        let ids = resp
602            .created_ids
603            .as_ref()
604            .expect("created_ids must be Some when client sent createdIds");
605        assert_eq!(
606            ids.get(&Id::from("client-1")),
607            Some(&Id::from("server-abc")),
608            "client-1 must map to server-abc"
609        );
610    }
611
612    /// Oracle: RFC 8620 §3.4 — createdIds omitted when no objects were created.
613    #[tokio::test]
614    async fn created_ids_absent_when_no_set() {
615        let mut d: Dispatcher<String> = Dispatcher::new();
616        d.register("Foo/get", Arc::new(EchoHandler(json!({"list": []}))));
617        let req = single_call("Foo/get", json!({}), "c0");
618        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
619        assert!(
620            resp.created_ids.is_none(),
621            "created_ids must be None when no /set call created objects"
622        );
623    }
624
625    /// Oracle: RFC 8620 §3.3 — createdIds accumulates across ALL /set calls in the batch.
626    #[tokio::test]
627    async fn created_ids_accumulated_across_multiple_set_calls() {
628        let mut d: Dispatcher<String> = Dispatcher::new();
629        d.register(
630            "A/set",
631            Arc::new(EchoHandler(json!({"created": {"cA": {"id": "sA"}}}))),
632        );
633        d.register(
634            "B/set",
635            Arc::new(EchoHandler(json!({"created": {"cB": {"id": "sB"}}}))),
636        );
637        // Client sends createdIds to signal it wants the response field.
638        let req = JmapRequest::new(
639            vec!["urn:ietf:params:jmap:core".into()],
640            vec![
641                ("A/set".into(), json!({}), "c0".into()),
642                ("B/set".into(), json!({}), "c1".into()),
643            ],
644            Some(std::collections::HashMap::new()),
645        );
646        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
647        let ids = resp
648            .created_ids
649            .as_ref()
650            .expect("created_ids must be Some when client sent createdIds");
651        assert_eq!(
652            ids.get(&Id::from("cA")),
653            Some(&Id::from("sA")),
654            "cA must be present"
655        );
656        assert_eq!(
657            ids.get(&Id::from("cB")),
658            Some(&Id::from("sB")),
659            "cB must be present"
660        );
661    }
662
663    /// Oracle: RFC 8620 §3.4 — pre-populated client createdIds are preserved and
664    /// new /set entries are merged in alongside them.
665    #[tokio::test]
666    async fn created_ids_merges_with_pre_populated_map() {
667        let mut d: Dispatcher<String> = Dispatcher::new();
668        d.register(
669            "Foo/set",
670            Arc::new(EchoHandler(
671                json!({"created": {"client-new": {"id": "server-new"}}}),
672            )),
673        );
674        // Client sends a pre-populated createdIds map.
675        let mut initial = std::collections::HashMap::new();
676        initial.insert(Id::from("client-old"), Id::from("server-old"));
677        let req = JmapRequest::new(
678            vec!["urn:ietf:params:jmap:core".into()],
679            vec![("Foo/set".into(), json!({}), "c0".into())],
680            Some(initial),
681        );
682        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
683        let ids = resp
684            .created_ids
685            .as_ref()
686            .expect("created_ids must be Some when client sent createdIds");
687        assert_eq!(
688            ids.get(&Id::from("client-old")),
689            Some(&Id::from("server-old")),
690            "pre-populated entry must be preserved"
691        );
692        assert_eq!(
693            ids.get(&Id::from("client-new")),
694            Some(&Id::from("server-new")),
695            "new /set entry must be merged in"
696        );
697    }
698
699    // -----------------------------------------------------------------------
700    // CallerCtx
701    // -----------------------------------------------------------------------
702
703    /// Oracle: PLAN.md CallerCtx design — caller value passed through to handler unchanged.
704    #[tokio::test]
705    async fn caller_ctx_passed_to_handler() {
706        let captured = Arc::new(Mutex::new(None::<String>));
707        let mut d: Dispatcher<String> = Dispatcher::new();
708        d.register(
709            "Foo/get",
710            Arc::new(CaptureCallerHandler(Arc::clone(&captured))),
711        );
712        let req = single_call("Foo/get", json!({}), "c0");
713        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
714        assert!(
715            resp.method_responses[0].1.get("type").is_none(),
716            "must succeed"
717        );
718        let got = captured
719            .lock()
720            .unwrap()
721            .clone()
722            .expect("handler was not called");
723        assert_eq!(got, "alice", "caller must be passed through unchanged");
724    }
725
726    /// Oracle: PLAN.md — CallerCtx = () must work (unit type as auth context).
727    #[tokio::test]
728    async fn unit_caller_ctx_works() {
729        let mut d: Dispatcher<()> = Dispatcher::new();
730        d.register("Foo/get", Arc::new(EchoHandler(json!({"ok": true}))));
731        let req = single_call("Foo/get", json!({}), "c0");
732        let resp = d.dispatch(req, (), "s0".into()).await;
733        assert_eq!(resp.method_responses.len(), 1);
734        assert!(
735            resp.method_responses[0].1.get("type").is_none(),
736            "must succeed with () caller"
737        );
738    }
739
740    // -----------------------------------------------------------------------
741    // Extra invocations
742    // -----------------------------------------------------------------------
743
744    /// A handler that returns both a primary response and one extra invocation.
745    ///
746    /// Models RFC 8621 §7.5 EmailSubmission/set with onSuccessUpdateEmail: the
747    /// submission response is primary; the implied Email/set call is extra.
748    struct ExtraInvocationHandler;
749
750    impl JmapHandler<String> for ExtraInvocationHandler {
751        fn call(
752            &self,
753            _method: String,
754            _call_id: String,
755            _args: Value,
756            _caller: String,
757        ) -> HandlerFuture {
758            Box::pin(async move {
759                let primary = json!({"type": "primary"});
760                let extra: Vec<Invocation> = vec![(
761                    "Extra/call".to_owned(),
762                    json!({"type": "extra"}),
763                    "x0".to_owned(),
764                )];
765                Ok((primary, extra))
766            })
767        }
768    }
769
770    /// Oracle: handler returning extra invocations → both primary and extra appear in
771    /// methodResponses in order (primary first, then extra).
772    #[tokio::test]
773    async fn extra_invocations_appended_after_primary() {
774        let mut d: Dispatcher<String> = Dispatcher::new();
775        d.register("Sub/set", Arc::new(ExtraInvocationHandler));
776        let req = single_call("Sub/set", json!({}), "c0");
777        let resp = d.dispatch(req, "alice".into(), "s0".into()).await;
778
779        assert_eq!(
780            resp.method_responses.len(),
781            2,
782            "primary + 1 extra = 2 total invocations"
783        );
784        // First: the primary Sub/set response.
785        assert_eq!(resp.method_responses[0].0, "Sub/set");
786        assert_eq!(resp.method_responses[0].2, "c0");
787        assert_eq!(resp.method_responses[0].1["type"], "primary");
788        // Second: the appended extra invocation.
789        assert_eq!(resp.method_responses[1].0, "Extra/call");
790        assert_eq!(resp.method_responses[1].2, "x0");
791        assert_eq!(resp.method_responses[1].1["type"], "extra");
792    }
793}