# jmap-server — Implementation Plan
A backend-agnostic JMAP server framework crate for Rust. Implements the
RFC 8620 wire protocol, dispatch machinery, and ResultReference resolution.
No opinion on authentication, method sets, or capability URIs.
## Crate Family
This crate is one piece of a planned family. Naming convention: `jmap-{extension}-{role}`.
```
jmap-types serde/serde_json only — shared wire types
├── jmap-server + tokio, http — dispatcher, parse, ResultReference
├── jmap-mail-types + RFC 8621 data types
│ └── jmap-mail-server
├── jmap-chat-types + Chat extension data types
│ ├── jmap-chat-server
│ └── jmap-chat-client
└── (future extensions)
```
Directory naming: `crate-{crate-name}` (e.g. `crate-jmap-server`, `crate-jmap-types`).
**Status:** `jmap-types` (`../crate-jmap-types`) exists. Wire types (`JmapError`,
`JmapRequest`, `JmapResponse`, `Invocation`, `ResultReference`, `Argument<T>`) live there
and are re-exported by this crate. This crate owns only the server-side layer: parsing,
ResultReference resolution, the dispatcher, and HTTP response helpers
(framework-agnostic via the `http` crate).
## What This Crate Is
A library that any JMAP server can use, regardless of what methods it exposes
or how it authenticates callers. Known consumers: `kith` (JMAP Chat) and
`stoa` (email/Usenet, possibly Chat too).
## What This Crate Is Not
- Not a full JMAP server
- Not opinionated about method names, capability URIs, or account models
- Not coupled to any auth system (Tailscale, bearer tokens, OIDC, etc.)
- Not a client library
## Source Material
All types and logic already exist and are tested. This crate is an extraction,
not a from-scratch implementation.
Types extracted by `jmap-types` (not this crate's job — depend on it):
| `JmapError` | `kith/crates/kith-core/src/error.rs` |
| `Invocation`, `JmapRequest`, `JmapResponse` | `kith/crates/kith-core/src/jmap.rs` |
| `ResultReference`, `Argument<T>` | `kith/crates/kith-core/src/resultref.rs` |
Logic extracted / adapted by this crate:
| `parse_request` | `kith/crates/kith-jmap/src/lib.rs` | Strip kith capability check; keep max_calls param |
| `resolve_args` / `ResultReferenceStore` | `crate-jmapchat-server/jmapchat-server/src/ref_store.rs` | Two-phase (atomic on failure); also has `json_pointer` helper and RFC 6901 tilde-escaping tests |
| `Dispatcher`, `JmapHandler` trait | `kith/crates/kith-jmap/src/lib.rs` | Replace `Role`+`Identity` with generic `CallerCtx`; merge `PeerJmapHandler` into single generic trait |
| HTTP handler pattern | `crate-jmapchat-server/jmapchat-server-axum/src/handlers/jmap.rs` | Shows `dispatch()` + `RequestError::into_response()` → `http::Response<String>` (axum accepts this directly) |
`stoa/crates/mail/src/jmap/dispatch.rs` is a synchronous `HandlerFn` type alias only — not useful here.
## Dependencies
```toml
jmap-types = { path = "../crate-jmap-types" }
tokio = { version = "1", features = ["rt"] }
http = "1"
```
`jmap-types` already pulls in serde, serde_json, and thiserror. No other dependencies.
No cloud SDKs, no auth crates, no application logic.
The `http` crate provides framework-agnostic `Response<T>`, `StatusCode`, and header types.
`RequestError::into_response()` returns `http::Response<String>`, which axum, hyper, and any
`http`-based framework accept directly.
## Public API
### Types (re-exported from `jmap-types`)
The following are defined in `jmap-types` and re-exported here for convenience.
Callers that need only types (no server machinery) should depend on `jmap-types` directly.
```rust
// RFC 8620 §7.1 — from jmap-types
pub use jmap_types::{JmapError, Invocation, JmapRequest, JmapResponse,
ResultReference, Argument, Id, UTCDate, State};
```
### Parse and validate
```rust
/// Validate a raw JMAP request body. Returns Err(JmapError) on:
/// empty `using`, too many method calls, or deserialization failure.
pub fn parse_request(body: serde_json::Value, max_calls: usize)
-> Result<JmapRequest, JmapError>
```
Note: `max_calls` is a parameter (not hardcoded) so each server sets its own limit.
### ResultReference resolution
```rust
/// Resolve all #-prefixed keys in `args` against `prior_responses` (RFC 8620 §3.7).
/// Modifies args in place. Returns Err(JmapError) on any resolution failure.
pub fn resolve_args(
args: &mut serde_json::Value,
prior_responses: &[Invocation], // Invocation = (method_name, args, call_id): (String, Value, String)
) -> Result<(), JmapError>
```
### HTTP helpers
```rust
pub fn error_invocation(call_id: &str, err: JmapError) -> Invocation
pub fn error_status(err: &JmapError) -> http::StatusCode
pub struct RequestError { /* private fields */ }
impl RequestError { pub fn into_response(self) -> http::Response<String> }
pub fn request_error(err: JmapError) -> RequestError
```
`RequestError::into_response()` returns an RFC 7807 Problem Details body.
No axum dependency — any HTTP framework that speaks `http::Response` can use this.
### Dispatcher
```rust
pub type HandlerFuture =
Pin<Box<dyn Future<Output = Result<serde_json::Value, JmapError>> + Send>>;
/// Implement this for each JMAP method handler.
/// CallerCtx is whatever your auth layer produces (Identity, session token, ()).
pub trait JmapHandler<CallerCtx>: Send + Sync {
fn call(&self, method: String, call_id: String,
args: serde_json::Value, caller: CallerCtx) -> HandlerFuture;
}
pub struct Dispatcher<CallerCtx> { ... }
impl<CallerCtx: Clone + Send + 'static> Dispatcher<CallerCtx> {
pub fn new() -> Self
pub fn register(&mut self, method: impl Into<String>,
handler: Arc<dyn JmapHandler<CallerCtx>>)
pub async fn dispatch(&self, request: JmapRequest,
caller: CallerCtx, session_state: State) -> JmapResponse
}
```
**Key design decisions:**
- Role checks are NOT in the dispatcher. Authorization is the caller's responsibility
(do it in your HTTP middleware before calling `dispatch`).
- Unknown method name (no registered handler) → `unknownMethod` error invocation.
- Each handler runs in `tokio::task::spawn` for panic isolation. A panicking handler
returns `serverFail`, not a crashed connection task.
- `createdIds` accumulation across `/set` calls in a batch is handled automatically
per RFC 8620 §3.4.
### JmapBackend supertrait
```rust
pub trait JmapBackend: Send + Sync + 'static {
type Error: std::error::Error + Send + Sync + 'static;
type CallerCtx: Clone + Send + Sync + 'static;
// ... read-side methods (account_exists, get_objects, get_state,
// get_changes, query_objects, query_changes) ...
/// The caller's stable identity within this account namespace.
/// Default impl returns `None`. Backends that honor identity-dependent
/// JMAP semantics override.
fn principal_id(caller: &Self::CallerCtx) -> Option<&jmap_types::Id> {
let _ = caller;
None
}
}
```
**`principal_id` is the foundation identity seam** (bd:JMAP-ga0q.1). It is
the ONE place the JMAP layer asks "who is the caller"; there is no
`caller_identity_blob()` escape hatch and no generic claims map.
- Returning `None` is a deliberate deployment signal: this server does not
honor identity-dependent JMAP semantics. Test fixtures and single-user
dev servers are fine on the default impl.
- Backends that need richer caller info (groups, claims, roles, device
class) add those to their OWN backend trait, NOT to `JmapBackend`.
Foundation gives the id; extensions own the meaning.
- **Permission enforcement is backend-canonical.** Handlers MUST NOT
permission-check. Defense-in-depth handler pre-checks are allowed but
the backend MUST re-verify atomically with the mutation.
- Federation does NOT bypass this seam — the federation handler maps
peer-signed identity to a local principal once, before invoking the
JMAP method.
First consumer: bd:JMAP-g7wu.2.4 (chat `Space/set` permission
enforcement). Planned downstream consumers: `jmap-mail-server` (RFC 8621
`$seen` on shared mailboxes), `jmap-calendars-server` (calendar ACLs),
`jmap-sharing-server` (RFC 9670 `myRights`), `jmap-metadata-server`
(`isPrivate` visibility scoping).
## Module Layout
```
src/
lib.rs re-exports from jmap-types; Dispatcher<CallerCtx>
parse.rs parse_request, resolve_args
response.rs error_invocation, error_status, RequestError, request_error
```
`types.rs` and `resultref.rs` do not exist here — those live in `jmap-types`.
## What Stays Out of This Crate
| `Role` (Owner/Peer) | kith — Tailscale auth concept |
| `Identity` (Tailscale WhoIs result) | kith |
| Method-role ACL table | kith-jmap |
| Session/capability structs | kith-jmap (kith chat caps), stoa (NNTP mail caps) |
| `MAX_*` sizing constants | each consumer sets their own |
| `AuthError`, `KithError` | kith-core |
## Migration Path (after publishing)
### kith
1. Add `jmap-types` to kith-core `Cargo.toml`; add `jmap-server` to kith-jmap `Cargo.toml`
2. Remove `JmapError`, `JmapRequest`, `JmapResponse`, `Invocation`, `ResultReference`,
`Argument` from kith-core — re-export from `jmap-types`
3. In kith-jmap:
- All handlers become `JmapHandler<Identity>` (owner handlers ignore the caller arg)
- `PeerJmapHandler` trait is deleted (merged into single generic trait)
- Role check moves into the HTTP handler before `dispatcher.dispatch(...)` is called
- `METHOD_ROLES` table stays — kith still enforces it, just not inside `Dispatcher`
- `MAX_CALLS_IN_REQUEST = 16` stays — passed as arg to `parse_request(body, 16)`
### stoa
1. Add `jmap-server` to `crates/mail/Cargo.toml`
2. Delete `crates/mail/src/jmap/types.rs` (replaced by crate types)
3. Rewrite `crates/mail/src/jmap/dispatch.rs` using `Dispatcher<StoaCallerCtx>`
4. Keep stoa's own `build_session` equivalent (advertises different capabilities)
## Test Strategy
All tests are extracted from existing sources — no new test logic needed.
| `parse_request` | `kith/crates/kith-jmap/src/lib.rs` lines 624–716 | 8 cases: valid, empty using, unknown cap, too many calls, malformed |
| `resolve_args` / ResultReference | `kith/crates/kith-jmap/src/lib.rs` lines 1180–1498 | 12+ cases: no refs, valid ref, unknown resultOf, bad path, multiple refs, name mismatch, key conflict |
| ResultReference atomic failure | `crate-jmapchat-server/jmapchat-server/src/ref_store.rs` | `resolve_is_atomic_on_error` — args unchanged on partial failure |
| `json_pointer` (RFC 6901) | `crate-jmapchat-server/jmapchat-server/src/ref_store.rs` | tilde-escaping (`~0`, `~1`), empty path, array index OOB |
| Dispatcher: error paths | `kith/crates/kith-jmap/src/lib.rs` lines 996–1154 | unknown method, no handler registered, handler error, session_state echo |
| Dispatcher: happy path | `kith/crates/kith-jmap/src/lib.rs` lines 1054–1077 | success invocation, batch mixing |
| Dispatcher: createdIds | `kith/crates/kith-jmap/src/lib.rs` lines 1662–1759 | accumulation across /set calls; absent when no /set |
| Dispatcher: panic isolation | `kith/crates/kith-jmap/src/lib.rs` lines 1762–1842 | panicking handler → serverFail, not crashed task |
| Dispatcher: ResultReference e2e | `kith/crates/kith-jmap/src/lib.rs` lines 1337–1407 | full dispatch path with #ids reference |
| Additional dispatch cases | `crate-jmapchat-server/jmapchat-server/tests/` | `dispatch_error_paths.rs`, `dispatch_happy_path.rs`, `result_reference_tests.rs` |
The CallerCtx substitution (replacing `Role`/`Identity` with `CallerCtx`) does not
require new tests — the same oracle values apply.