use crate::auth::{require_auth, SharedAuth};
use crate::back_reference;
use crate::blob::{self, BlobStorage};
use crate::eventsource::{self, EventSourceManager};
use crate::session::Session;
use crate::types::{
derive_account_id, JmapError, JmapErrorType, JmapMethodCall, JmapRequest, JmapResponse,
Principal,
};
use axum::{
extract::{Extension, Json, Request},
http::StatusCode,
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
pub struct JmapServer;
impl JmapServer {
pub fn routes_with_auth(auth: SharedAuth) -> Router {
Router::new()
.route("/.well-known/jmap", get(session_endpoint))
.route("/jmap", post(api_endpoint))
.layer(middleware::from_fn_with_state(auth.clone(), require_auth))
.layer(middleware::from_fn(metrics_middleware))
.with_state(auth)
}
pub fn routes_with_auth_and_state(
auth: SharedAuth,
blob_storage: BlobStorage,
event_manager: EventSourceManager,
) -> Router {
let blob_r = blob::blob_routes().with_state(blob_storage);
let es_r = eventsource::eventsource_routes().with_state(event_manager);
Router::new()
.route("/.well-known/jmap", get(session_endpoint))
.route("/jmap", post(api_endpoint))
.merge(blob_r)
.merge(es_r)
.layer(middleware::from_fn_with_state(auth.clone(), require_auth))
.layer(middleware::from_fn(metrics_middleware))
.with_state(auth)
}
pub fn routes() -> Router {
Router::new()
.route("/.well-known/jmap", get(reject_unauthenticated))
.route("/jmap", post(reject_unauthenticated))
.layer(middleware::from_fn(metrics_middleware))
}
}
async fn metrics_middleware(request: Request, next: Next) -> Response {
let metrics = rusmes_metrics::global_metrics();
let _conn_guard = metrics.connection_guard("jmap");
metrics.inc_tls_session(rusmes_metrics::tls_label::NO);
next.run(request).await
}
async fn reject_unauthenticated() -> Response {
let body = JmapError::new(JmapErrorType::ServerFail)
.with_status(401)
.with_detail(
"JMAP server constructed without an authentication backend; \
use JmapServer::routes_with_auth in production",
);
(StatusCode::UNAUTHORIZED, Json(body)).into_response()
}
async fn session_endpoint(Extension(principal): Extension<Principal>) -> Json<Session> {
let base_url = "https://jmap.example.com".to_string();
let session = Session::new(
principal.username.clone(),
principal.account_id.clone(),
base_url,
);
Json(session)
}
async fn api_endpoint(
Extension(principal): Extension<Principal>,
Json(request): Json<JmapRequest>,
) -> Response {
tracing::debug!(
"API_ENDPOINT: Received JMAP request from {} with {} method calls",
principal.username,
request.method_calls.len()
);
if let Some(error_response) = validate_request(&request) {
tracing::debug!("API_ENDPOINT: Request validation failed");
return error_response;
}
tracing::debug!("API_ENDPOINT: Request validated successfully");
let mut response = JmapResponse {
method_responses: Vec::new(),
session_state: Some("state1".to_string()),
created_ids: request.created_ids.clone(),
};
let mut completed: Vec<(String, String, serde_json::Value)> = Vec::new();
for method_call in request.method_calls {
let call_id = method_call.2.clone();
let method_name = method_call.0.clone();
let method_call = match resolve_back_refs_in_call(method_call, &completed) {
Ok(resolved) => resolved,
Err(e) => {
tracing::debug!("Back-reference resolution failed for {}: {}", call_id, e);
let err_value = serde_json::to_value(
JmapError::new(JmapErrorType::InvalidArguments).with_detail(e.to_string()),
)
.unwrap_or(serde_json::Value::Null);
response
.method_responses
.push(crate::types::JmapMethodResponse(
"error".to_string(),
err_value,
call_id,
));
continue;
}
};
match crate::methods::dispatch_method(method_call, &request.using, &principal).await {
Ok(method_response) => {
completed.push((call_id, method_name, method_response.1.clone()));
response.method_responses.push(method_response);
}
Err(e) => {
tracing::error!("JMAP method error: {}", e);
let err_value = serde_json::to_value(
JmapError::new(JmapErrorType::ServerFail).with_detail(e.to_string()),
)
.unwrap_or(serde_json::Value::Null);
completed.push((call_id.clone(), method_name, err_value.clone()));
response
.method_responses
.push(crate::types::JmapMethodResponse(
"error".to_string(),
err_value,
call_id,
));
}
}
}
(StatusCode::OK, Json(response)).into_response()
}
fn resolve_back_refs_in_call(
mut call: JmapMethodCall,
completed: &[(String, String, serde_json::Value)],
) -> Result<JmapMethodCall, back_reference::BackRefError> {
if let Some(obj) = call.1.as_object_mut() {
back_reference::resolve_back_references(obj, completed)?;
}
Ok(call)
}
fn validate_request(request: &JmapRequest) -> Option<Response> {
if request.using.is_empty() {
let error = JmapError::new(JmapErrorType::UnknownCapability)
.with_status(400)
.with_detail("The 'using' property must contain at least one capability");
return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
}
let supported_capabilities = get_supported_capabilities();
for capability in &request.using {
if !supported_capabilities.contains(&capability.as_str()) {
let error = JmapError::new(JmapErrorType::UnknownCapability)
.with_status(400)
.with_detail(format!("Unsupported capability: {}", capability));
return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
}
}
if request.method_calls.is_empty() {
let error = JmapError::new(JmapErrorType::NotRequest)
.with_status(400)
.with_detail("The 'methodCalls' property must contain at least one method call");
return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
}
const MAX_CALLS_IN_REQUEST: usize = 16;
if request.method_calls.len() > MAX_CALLS_IN_REQUEST {
let error = JmapError::new(JmapErrorType::Limit)
.with_status(400)
.with_detail(format!(
"Too many method calls. Maximum allowed: {}",
MAX_CALLS_IN_REQUEST
))
.with_limit("maxCallsInRequest");
return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
}
for (idx, method_call) in request.method_calls.iter().enumerate() {
let method_name = &method_call.0;
let call_id = &method_call.2;
if method_name.is_empty() {
let error = JmapError::new(JmapErrorType::NotRequest)
.with_status(400)
.with_detail(format!("Method call {} has empty method name", idx));
return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
}
if call_id.is_empty() {
let error = JmapError::new(JmapErrorType::NotRequest)
.with_status(400)
.with_detail(format!("Method call {} has empty call ID", idx));
return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
}
if !method_call.1.is_object() {
let error = JmapError::new(JmapErrorType::InvalidArguments)
.with_status(400)
.with_detail(format!(
"Method call {} ('{}') has invalid arguments - must be an object",
idx, method_name
));
return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
}
}
None
}
fn get_supported_capabilities() -> Vec<&'static str> {
vec![
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
"urn:ietf:params:jmap:vacationresponse",
]
}
pub fn account_id_for(username: &str) -> String {
derive_account_id(username)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::SharedAuth;
use async_trait::async_trait;
use rusmes_auth::AuthBackend;
use rusmes_proto::Username;
use std::sync::Arc;
struct DenyAll;
#[async_trait]
impl AuthBackend for DenyAll {
async fn authenticate(&self, _u: &Username, _p: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn verify_identity(&self, _u: &Username) -> anyhow::Result<bool> {
Ok(false)
}
async fn list_users(&self) -> anyhow::Result<Vec<Username>> {
Ok(vec![])
}
async fn create_user(&self, _u: &Username, _p: &str) -> anyhow::Result<()> {
Ok(())
}
async fn delete_user(&self, _u: &Username) -> anyhow::Result<()> {
Ok(())
}
async fn change_password(&self, _u: &Username, _p: &str) -> anyhow::Result<()> {
Ok(())
}
}
#[test]
fn test_jmap_server_routes() {
let _router = JmapServer::routes();
}
#[test]
fn test_jmap_server_routes_with_auth() {
let auth: SharedAuth = Arc::new(DenyAll);
let _router = JmapServer::routes_with_auth(auth);
}
#[test]
fn test_account_id_helper() {
assert_eq!(
account_id_for("alice@example.com"),
"account-alice-example.com"
);
}
}