Skip to main content

rusmes_jmap/
api.rs

1//! JMAP API server
2//!
3//! This module implements the JMAP (JSON Meta Application Protocol) API server
4//! as defined in RFC 8620. It provides comprehensive request validation including:
5//!
6//! - Request structure validation (using, methodCalls)
7//! - Capability validation (ensuring declared capabilities are supported)
8//! - Method call validation (structure, limits, arguments)
9//! - Error responses per RFC 8620 Section 3.6:
10//!   - `unknownCapability`: Capability not recognized
11//!   - `notRequest`: Request doesn't match JMAP structure
12//!   - `limit`: Server limit exceeded (e.g., maxCallsInRequest)
13//!   - `unknownMethod`: Method not recognized
14//!   - `invalidArguments`: Invalid method arguments
15//!   - Other error types for account/server issues
16//!
17//! ## Authentication
18//!
19//! Real authentication is handled by [`crate::auth::require_auth`] which
20//! attaches a [`Principal`] to the request extensions. Construct the router
21//! via [`JmapServer::routes_with_auth`] in production. The legacy
22//! [`JmapServer::routes`] returns a router that rejects every request with
23//! 401 — the previous implementation that fabricated a hardcoded principal
24//! was a development-only fallback and has been removed.
25//!
26//! For production deployments that include blob storage and EventSource push,
27//! use [`JmapServer::routes_with_auth_and_state`] which additionally mounts:
28//! - `POST /upload/:account_id` — blob upload (RFC 8620 §6.2)
29//! - `GET /download/:account_id/:blob_id/:name` — blob download (RFC 8620 §6.2)
30//! - `GET /eventsource` — Server-Sent Events push channel (RFC 8620 §7.3)
31
32use crate::auth::{require_auth, SharedAuth};
33use crate::back_reference;
34use crate::blob::{self, BlobStorage};
35use crate::eventsource::{self, EventSourceManager};
36use crate::session::Session;
37use crate::types::{
38    derive_account_id, JmapError, JmapErrorType, JmapMethodCall, JmapRequest, JmapResponse,
39    Principal,
40};
41use axum::{
42    extract::{Extension, Json, Request},
43    http::StatusCode,
44    middleware::{self, Next},
45    response::{IntoResponse, Response},
46    routing::{get, post},
47    Router,
48};
49
50/// JMAP server.
51///
52/// Construct routes with [`Self::routes_with_auth`] to wire a real
53/// authentication backend. The bare [`Self::routes`] constructor returns a
54/// router that 401s every request and exists primarily for tests that only
55/// exercise the routing surface.
56pub struct JmapServer;
57
58impl JmapServer {
59    /// Build the JMAP HTTP routes with a real [`AuthBackend`](rusmes_auth::AuthBackend).
60    ///
61    /// Every route is wrapped by the authentication middleware so handlers
62    /// receive a guaranteed-present [`Principal`] in their extensions, and by the
63    /// connection-tracking middleware that maintains
64    /// `rusmes_active_connections{protocol="jmap"}` plus the TLS counter.
65    ///
66    /// This mounts only the core JMAP API endpoints (session + method dispatch).
67    /// For blob and EventSource routes, use [`Self::routes_with_auth_and_state`].
68    pub fn routes_with_auth(auth: SharedAuth) -> Router {
69        Router::new()
70            .route("/.well-known/jmap", get(session_endpoint))
71            .route("/jmap", post(api_endpoint))
72            .layer(middleware::from_fn_with_state(auth.clone(), require_auth))
73            .layer(middleware::from_fn(metrics_middleware))
74            .with_state(auth)
75    }
76
77    /// Build the full JMAP HTTP routes including blob storage and EventSource push.
78    ///
79    /// In addition to the core JMAP routes from [`Self::routes_with_auth`], this
80    /// mounts the following endpoints behind the same `require_auth` middleware:
81    ///
82    /// - `POST /upload/:account_id` — blob upload (RFC 8620 §6.2)
83    /// - `GET /download/:account_id/:blob_id/:name` — blob download (RFC 8620 §6.2)
84    /// - `GET /eventsource` — Server-Sent Events push channel (RFC 8620 §7.3)
85    ///
86    /// All routes enforce authentication via `require_auth` before dispatching
87    /// to their respective handlers.
88    pub fn routes_with_auth_and_state(
89        auth: SharedAuth,
90        blob_storage: BlobStorage,
91        event_manager: EventSourceManager,
92    ) -> Router {
93        let blob_r = blob::blob_routes().with_state(blob_storage);
94        let es_r = eventsource::eventsource_routes().with_state(event_manager);
95        Router::new()
96            .route("/.well-known/jmap", get(session_endpoint))
97            .route("/jmap", post(api_endpoint))
98            .merge(blob_r)
99            .merge(es_r)
100            .layer(middleware::from_fn_with_state(auth.clone(), require_auth))
101            .layer(middleware::from_fn(metrics_middleware))
102            .with_state(auth)
103    }
104
105    /// Build the JMAP HTTP routes WITHOUT an auth backend.
106    ///
107    /// Every request is rejected with 401. This exists so the public API
108    /// remains call-compatible during the transition window — callers that
109    /// previously relied on the unauthenticated dev path should migrate to
110    /// [`Self::routes_with_auth`].
111    pub fn routes() -> Router {
112        Router::new()
113            .route("/.well-known/jmap", get(reject_unauthenticated))
114            .route("/jmap", post(reject_unauthenticated))
115            .layer(middleware::from_fn(metrics_middleware))
116    }
117}
118
119/// Axum middleware that records each JMAP HTTP request as a "session" in the metrics.
120///
121/// JMAP is request/response over HTTP — there is no long-lived TCP session in the same
122/// sense as SMTP/IMAP, so we treat every request as one session for the purposes of the
123/// active-connections gauge and TLS counter. The active gauge therefore tracks
124/// concurrently-in-flight requests, which is the operationally useful number.
125///
126/// TLS labelling: at this layer we cannot tell whether the request arrived over TLS
127/// (the listener handles termination upstream). We unconditionally record `no` — when a
128/// future change wraps the JMAP listener with rustls-axum, this should be flipped to
129/// inspect the request's `extensions()` for a `ConnectInfo<TlsConnectionInfo>` marker.
130async fn metrics_middleware(request: Request, next: Next) -> Response {
131    let metrics = rusmes_metrics::global_metrics();
132    let _conn_guard = metrics.connection_guard("jmap");
133    metrics.inc_tls_session(rusmes_metrics::tls_label::NO);
134    next.run(request).await
135}
136
137/// Hard-fail handler used by the auth-less constructor — every JMAP route
138/// answers 401 without an `AuthBackend`.
139async fn reject_unauthenticated() -> Response {
140    let body = JmapError::new(JmapErrorType::ServerFail)
141        .with_status(401)
142        .with_detail(
143            "JMAP server constructed without an authentication backend; \
144             use JmapServer::routes_with_auth in production",
145        );
146    (StatusCode::UNAUTHORIZED, Json(body)).into_response()
147}
148
149/// Session discovery endpoint (RFC 8620 Section 2)
150///
151/// Returns a Session object describing the server's capabilities, accounts,
152/// and API endpoints for the authenticated [`Principal`].
153async fn session_endpoint(Extension(principal): Extension<Principal>) -> Json<Session> {
154    let base_url = "https://jmap.example.com".to_string();
155    let session = Session::new(
156        principal.username.clone(),
157        principal.account_id.clone(),
158        base_url,
159    );
160    Json(session)
161}
162
163/// Main JMAP API endpoint
164async fn api_endpoint(
165    Extension(principal): Extension<Principal>,
166    Json(request): Json<JmapRequest>,
167) -> Response {
168    tracing::debug!(
169        "API_ENDPOINT: Received JMAP request from {} with {} method calls",
170        principal.username,
171        request.method_calls.len()
172    );
173    // Validate the request structure (RFC 8620 Section 3.3)
174    if let Some(error_response) = validate_request(&request) {
175        tracing::debug!("API_ENDPOINT: Request validation failed");
176        return error_response;
177    }
178    tracing::debug!("API_ENDPOINT: Request validated successfully");
179
180    let mut response = JmapResponse {
181        method_responses: Vec::new(),
182        session_state: Some("state1".to_string()),
183        created_ids: request.created_ids.clone(),
184    };
185
186    // Track completed calls so that later method calls in the same batch can
187    // reference their results via RFC 8620 §3.7 ResultReferences.
188    // Each entry is (call_id, method_name, response_body).
189    let mut completed: Vec<(String, String, serde_json::Value)> = Vec::new();
190
191    // Process each method call
192    for method_call in request.method_calls {
193        let call_id = method_call.2.clone();
194        let method_name = method_call.0.clone();
195
196        // RFC 8620 §3.7 — resolve any ResultReferences in the call's arguments
197        // before dispatching.
198        let method_call = match resolve_back_refs_in_call(method_call, &completed) {
199            Ok(resolved) => resolved,
200            Err(e) => {
201                // Resolution failure → invalidArguments error for this call.
202                tracing::debug!("Back-reference resolution failed for {}: {}", call_id, e);
203                let err_value = serde_json::to_value(
204                    JmapError::new(JmapErrorType::InvalidArguments).with_detail(e.to_string()),
205                )
206                .unwrap_or(serde_json::Value::Null);
207                response
208                    .method_responses
209                    .push(crate::types::JmapMethodResponse(
210                        "error".to_string(),
211                        err_value,
212                        call_id,
213                    ));
214                // Continue to the next call — RFC 8620 §3.7 says failure
215                // yields an error for that call and execution continues.
216                continue;
217            }
218        };
219
220        match crate::methods::dispatch_method(method_call, &request.using, &principal).await {
221            Ok(method_response) => {
222                // Record this completed call so subsequent calls can reference it.
223                completed.push((call_id, method_name, method_response.1.clone()));
224                response.method_responses.push(method_response);
225            }
226            Err(e) => {
227                tracing::error!("JMAP method error: {}", e);
228                let err_value = serde_json::to_value(
229                    JmapError::new(JmapErrorType::ServerFail).with_detail(e.to_string()),
230                )
231                .unwrap_or(serde_json::Value::Null);
232                // Record the error response body too, so that a reference to this
233                // call's result correctly returns ResultWasError.
234                completed.push((call_id.clone(), method_name, err_value.clone()));
235                response
236                    .method_responses
237                    .push(crate::types::JmapMethodResponse(
238                        "error".to_string(),
239                        err_value,
240                        call_id,
241                    ));
242            }
243        }
244    }
245
246    (StatusCode::OK, Json(response)).into_response()
247}
248
249/// Apply RFC 8620 §3.7 back-reference resolution to a single method call.
250///
251/// Mutates the call's argument object in place, replacing every `#key`
252/// ResultReference with the value it points to in `completed`.  Returns the
253/// (possibly mutated) call on success, or a [`back_reference::BackRefError`]
254/// on the first resolution failure.
255fn resolve_back_refs_in_call(
256    mut call: JmapMethodCall,
257    completed: &[(String, String, serde_json::Value)],
258) -> Result<JmapMethodCall, back_reference::BackRefError> {
259    if let Some(obj) = call.1.as_object_mut() {
260        back_reference::resolve_back_references(obj, completed)?;
261    }
262    Ok(call)
263}
264
265/// Validate JMAP request structure and capabilities (RFC 8620 Section 3.3)
266fn validate_request(request: &JmapRequest) -> Option<Response> {
267    // Validate "using" capabilities
268    if request.using.is_empty() {
269        let error = JmapError::new(JmapErrorType::UnknownCapability)
270            .with_status(400)
271            .with_detail("The 'using' property must contain at least one capability");
272        return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
273    }
274
275    // Check for supported capabilities
276    let supported_capabilities = get_supported_capabilities();
277    for capability in &request.using {
278        if !supported_capabilities.contains(&capability.as_str()) {
279            let error = JmapError::new(JmapErrorType::UnknownCapability)
280                .with_status(400)
281                .with_detail(format!("Unsupported capability: {}", capability));
282            return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
283        }
284    }
285
286    // Validate methodCalls structure
287    if request.method_calls.is_empty() {
288        let error = JmapError::new(JmapErrorType::NotRequest)
289            .with_status(400)
290            .with_detail("The 'methodCalls' property must contain at least one method call");
291        return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
292    }
293
294    // Check for method call limit (RFC 8620 Section 3.4)
295    const MAX_CALLS_IN_REQUEST: usize = 16;
296    if request.method_calls.len() > MAX_CALLS_IN_REQUEST {
297        let error = JmapError::new(JmapErrorType::Limit)
298            .with_status(400)
299            .with_detail(format!(
300                "Too many method calls. Maximum allowed: {}",
301                MAX_CALLS_IN_REQUEST
302            ))
303            .with_limit("maxCallsInRequest");
304        return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
305    }
306
307    // Validate each method call structure
308    for (idx, method_call) in request.method_calls.iter().enumerate() {
309        let method_name = &method_call.0;
310        let call_id = &method_call.2;
311
312        // Validate method name is not empty
313        if method_name.is_empty() {
314            let error = JmapError::new(JmapErrorType::NotRequest)
315                .with_status(400)
316                .with_detail(format!("Method call {} has empty method name", idx));
317            return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
318        }
319
320        // Validate call ID is not empty
321        if call_id.is_empty() {
322            let error = JmapError::new(JmapErrorType::NotRequest)
323                .with_status(400)
324                .with_detail(format!("Method call {} has empty call ID", idx));
325            return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
326        }
327
328        // Validate arguments is an object
329        if !method_call.1.is_object() {
330            let error = JmapError::new(JmapErrorType::InvalidArguments)
331                .with_status(400)
332                .with_detail(format!(
333                    "Method call {} ('{}') has invalid arguments - must be an object",
334                    idx, method_name
335                ));
336            return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
337        }
338    }
339
340    None
341}
342
343/// Get the list of supported JMAP capabilities
344fn get_supported_capabilities() -> Vec<&'static str> {
345    vec![
346        "urn:ietf:params:jmap:core",
347        "urn:ietf:params:jmap:mail",
348        "urn:ietf:params:jmap:submission",
349        "urn:ietf:params:jmap:vacationresponse",
350    ]
351}
352
353/// Helper exposed for callers that need the canonical username → account-id
354/// mapping (kept here so external crates have a single entry point).
355pub fn account_id_for(username: &str) -> String {
356    derive_account_id(username)
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::auth::SharedAuth;
363    use async_trait::async_trait;
364    use rusmes_auth::AuthBackend;
365    use rusmes_proto::Username;
366    use std::sync::Arc;
367
368    struct DenyAll;
369
370    #[async_trait]
371    impl AuthBackend for DenyAll {
372        async fn authenticate(&self, _u: &Username, _p: &str) -> anyhow::Result<bool> {
373            Ok(false)
374        }
375        async fn verify_identity(&self, _u: &Username) -> anyhow::Result<bool> {
376            Ok(false)
377        }
378        async fn list_users(&self) -> anyhow::Result<Vec<Username>> {
379            Ok(vec![])
380        }
381        async fn create_user(&self, _u: &Username, _p: &str) -> anyhow::Result<()> {
382            Ok(())
383        }
384        async fn delete_user(&self, _u: &Username) -> anyhow::Result<()> {
385            Ok(())
386        }
387        async fn change_password(&self, _u: &Username, _p: &str) -> anyhow::Result<()> {
388            Ok(())
389        }
390    }
391
392    #[test]
393    fn test_jmap_server_routes() {
394        let _router = JmapServer::routes();
395        // Router created successfully
396    }
397
398    #[test]
399    fn test_jmap_server_routes_with_auth() {
400        let auth: SharedAuth = Arc::new(DenyAll);
401        let _router = JmapServer::routes_with_auth(auth);
402    }
403
404    #[test]
405    fn test_account_id_helper() {
406        assert_eq!(
407            account_id_for("alice@example.com"),
408            "account-alice-example.com"
409        );
410    }
411}