//! JMAP (RFC 8620) HTTP server. //! //! JSON-based replacement for IMAP/SMTP. Serves the same `Store` that IMAP //! uses, so both protocols can run simultaneously. use std::net::SocketAddr; use std::sync::Arc; use axum::extract::{DefaultBodyLimit, State}; use axum::http::HeaderValue; use axum::middleware; use axum::response::IntoResponse; use axum::routing::post; use axum::{Json, Router}; use base64::Engine; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::store::Store; // ── Config ───────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub struct JmapServer { cfg: crate::config::JmapConfig, store: Arc, } impl JmapServer { pub fn new(cfg: crate::config::JmapConfig, store: Arc) -> Self { Self { cfg, store } } /// Bind and start serving. Returns the bound address immediately; /// the serve loop runs on a spawned task, mirroring the IMAP/SMTP /// `listen` contract so `main` keeps running (the job dispatcher /// must still start). pub async fn listen(&self) -> anyhow::Result { let listener = tokio::net::TcpListener::bind((self.cfg.host, self.cfg.port)).await?; let addr = listener.local_addr()?; let app = Router::new() .route("/jmap", post(jmap_handler)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(middleware::from_fn_with_state(self.store.clone(), auth_middleware)) .with_state(self.store.clone()); tracing::info!(%addr, "JMAP server listening"); tokio::spawn(async move { if let Err(e) = axum::serve(listener, app).await { tracing::error!(error = %e, "JMAP server stopped"); } }); Ok(addr) } } // ── Auth middleware ──────────────────────────────────────────────── /// Extracted user email stored in request extensions. #[derive(Debug, Clone)] struct AuthUser(String); async fn auth_middleware( State(store): State>, mut req: axum::http::Request, next: middleware::Next, ) -> axum::http::Response { let Some(creds) = extract_basic_auth(req.headers().get(axum::http::header::AUTHORIZATION)) else { return unauthorized_response("Missing Authorization header"); }; // Same rules as IMAP LOGIN: user is trimmed/lowercased and the // password token must match (src/imap/mod.rs `auth`). let Some(user) = store.find_user_by_email(&creds.email).await else { return unauthorized_response("Invalid credentials"); }; if user.password != creds.password { return unauthorized_response("Invalid credentials"); } req.extensions_mut().insert(AuthUser(user.email)); next.run(req).await } struct BasicCreds { email: String, password: String, } /// Parse an HTTP Basic header into (email, password). The user part is /// trimmed and lowercased to match IMAP LOGIN; the password keeps /// everything after the first ':' so passwords may contain ':'. fn extract_basic_auth(header: Option<&HeaderValue>) -> Option { let header = header?.to_str().ok()?; let without_prefix = header.strip_prefix("Basic ")?; let decoded = base64::engine::general_purpose::STANDARD.decode(without_prefix).ok()?; let creds = String::from_utf8(decoded).ok()?; let (email, password) = creds.split_once(':')?; let email = email.trim().to_lowercase(); if email.is_empty() { return None; } Some(BasicCreds { email, password: password.trim().to_string(), }) } fn unauthorized_response(msg: &str) -> axum::http::Response { axum::http::Response::builder() .status(401) .header(axum::http::header::WWW_AUTHENTICATE, "Basic realm=\"JMAP\"") .header(axum::http::header::CONTENT_TYPE, "application/json") .body(axum::body::Body::from( serde_json::to_string(&json!({ "error": msg, "errorDescription": msg, })) .unwrap_or_default(), )) .unwrap() } // ── JMAP wire types ──────────────────────────────────────────────── #[derive(Debug, Deserialize)] struct JmapRequest { #[serde(default)] #[allow(dead_code)] using: Vec, /// Server-wide cap applied when a method call omits its own `limit`. #[serde(default)] limit: Option, #[serde(default, rename = "methodCalls")] method_calls: Vec, } #[derive(Debug, Deserialize)] struct MethodCall { method: String, #[serde(default)] args: Value, /// Client-chosen id, echoed back in the method response. /// RFC 8620 uses `id` — not `methodId`. #[serde(default)] id: Option, } #[derive(Debug, Serialize)] struct JmapResponse { #[serde(rename = "methodResponses")] method_responses: Vec, #[serde(rename = "sessionState")] session_state: String, #[serde(rename = "paginationLimit", skip_serializing_if = "Option::is_none")] pagination_limit: Option, } /// RFC 8620 method response: `id`, `method`, `args`, plus `acb` / /// `error` / `errorDescription` where applicable. Errors use JMAP /// string codes (e.g. `"unknownMethod"`), not HTTP numbers. #[derive(Debug)] struct MethodResponse { id: Option, method: Option, args: Option, acb: Option, acb_too_big: Option, error: Option, error_description: Option, } impl Serialize for MethodResponse { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(None)?; if let Some(ref id) = self.id { map.serialize_entry("id", id)?; } if let Some(ref method) = self.method { map.serialize_entry("method", method)?; } if let Some(ref args) = self.args { map.serialize_entry("args", args)?; } if let Some(ref acb) = self.acb { map.serialize_entry("acb", acb)?; } if let Some(true) = self.acb_too_big { map.serialize_entry("acbTooBig", &true)?; } if let Some(ref err) = self.error { map.serialize_entry("error", err)?; } if let Some(ref desc) = self.error_description { map.serialize_entry("errorDescription", desc)?; } map.end() } } // ── Handler ──────────────────────────────────────────────────────── async fn jmap_handler( State(store): State>, axum::extract::Extension(AuthUser(user_email)): axum::extract::Extension, Json(req): Json, ) -> impl IntoResponse { Json(build_response(store, user_email, req).await) } async fn build_response( store: Arc, user_email: String, req: JmapRequest, ) -> JmapResponse { let default_limit = req.limit.unwrap_or(1000); let mut responses = Vec::new(); for call in req.method_calls { responses.push(dispatch_method(store.clone(), user_email.clone(), default_limit, call).await); } let final_state = store.get_jmap_state(&user_email).await; JmapResponse { method_responses: responses, session_state: final_state.to_string(), pagination_limit: Some(1000), } } async fn dispatch_method( store: Arc, user_email: String, default_limit: u32, call: MethodCall, ) -> MethodResponse { let id = call.id.clone(); let parts: Vec<&str> = call.method.split('/').collect(); if parts.len() < 2 { return error_response(id.as_deref(), "invalidMethod", "Invalid method name"); } // A single account: the user's own email is its account id. match parts[0] { "Core" => match parts[1] { "getAllAccountIds" => core_get_all_account_ids(id.as_deref(), &user_email), _ => error_response(id.as_deref(), "unknownMethod", "Unknown Core method"), }, "Account" => match parts[1] { "get" => account_get(id.as_deref(), &user_email), _ => error_response(id.as_deref(), "unknownMethod", "Unknown Account method"), }, "Email" => email_dispatch(store, user_email, default_limit, call, id).await, "EmailState" => email_state_dispatch(store, user_email, call, id).await, _ => error_response(id.as_deref(), "unknownMethod", &format!("Unknown method: {}", call.method)), } } fn ok_response(id: Option<&str>, method: &str, args: Value) -> MethodResponse { MethodResponse { id: id.map(String::from), method: Some(method.to_string()), args: Some(args), acb: None, acb_too_big: None, error: None, error_description: None, } } fn error_response(id: Option<&str>, error: &str, description: &str) -> MethodResponse { MethodResponse { id: id.map(String::from), method: None, args: None, acb: None, acb_too_big: None, error: Some(error.to_string()), error_description: Some(description.to_string()), } } // ── Core ─────────────────────────────────────────────────────────── fn core_get_all_account_ids(id: Option<&str>, user_email: &str) -> MethodResponse { ok_response( id, "Core/getAllAccountIds", json!({ "ids": [user_email], "accountId": user_email, }), ) } // ── Account ──────────────────────────────────────────────────────── fn account_get(id: Option<&str>, user_email: &str) -> MethodResponse { ok_response( id, "Account/get", json!({ "accountId": user_email, "list": [ { "id": user_email, "name": "Primary Account", "email": user_email, "primaryEmail": user_email, "emailSortOrder": user_email, "nameSortOrder": "Primary Account", "canUpload": true, "emailReaderSupported": true, "emailWriterSupported": true, "emailMaxSize": 25 * 1024 * 1024, "emailQuerySortOptions": { "isSeen": {"ascending": true, "descending": true}, "date": {"ascending": true, "descending": true}, "dateInternal": {"ascending": true, "descending": true}, "from": {"ascending": true, "descending": true}, "subject": {"ascending": true, "descending": true}, }, } ], }), ) } // ── Email ────────────────────────────────────────────────────────── async fn email_dispatch( store: Arc, user_email: String, default_limit: u32, call: MethodCall, id: Option, ) -> MethodResponse { let parts: Vec<&str> = call.method.split('/').collect(); match parts[1] { "get" => email_get(store, user_email, default_limit, call, id.as_deref()).await, "query" => email_query(store, user_email, default_limit, call, id.as_deref()).await, "set" => email_set(store, user_email, call, id.as_deref()).await, _ => error_response(id.as_deref(), "unknownMethod", "Unknown Email method"), } } /// Email/get — fetch email objects by ID with property filtering. /// `position`/`limit` window the delivery-ordered id list; requested ids /// outside the window land in `notFound`, per RFC 8620. async fn email_get( store: Arc, user_email: String, default_limit: u32, call: MethodCall, id: Option<&str>, ) -> MethodResponse { let ids = call.args.get("ids").and_then(|v| v.as_array()).map(|a| { a.iter() .filter_map(|v| v.as_str().map(String::from)) .collect::>() }); let properties: Vec = call .args .get("properties") .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(); let Some(ids) = ids else { return error_response(id, "invalidArguments", "ids is required"); }; let msgs = match store.list_user_messages(&user_email) { Ok(m) => m, Err(e) => { return error_response(id, "serverFail", &format!("Failed to list messages: {e}")); } }; let start = parse_position(call.args.get("position")); let limit = call .args .get("limit") .and_then(|v| v.as_u64()) .unwrap_or(default_limit as u64) as usize; let end = start.saturating_add(limit); let mut list = Vec::new(); let mut remaining: std::collections::HashSet = ids.into_iter().collect(); for (i, msg) in msgs.iter().enumerate() { if i < start || i >= end { continue; } if remaining.remove(&msg.message_id) { list.push(email_to_jmap(msg, &properties)); } } // HashSet iteration order is nondeterministic — sort for stable output. let mut not_found: Vec = remaining.into_iter().collect(); not_found.sort(); ok_response( id, "Email/get", json!({ "accountId": user_email, "list": list, "notFound": not_found, }), ) } /// Email/query — search/filter emails. `sort` is accepted but not /// applied: results always come back in delivery order. async fn email_query( store: Arc, user_email: String, default_limit: u32, call: MethodCall, id: Option<&str>, ) -> MethodResponse { let filter = call.args.get("filter"); let _sort = call.args.get("sort"); let msgs = match store.list_user_messages(&user_email) { Ok(m) => m, Err(e) => { return error_response(id, "serverFail", &format!("Failed to list messages: {e}")); } }; let matched: Vec = msgs .iter() .filter(|msg| matches_email_filter(msg, filter)) .map(|msg| msg.message_id.clone()) .collect(); let start = parse_position(call.args.get("position")); let limit = call .args .get("limit") .and_then(|v| v.as_u64()) .unwrap_or(default_limit as u64) as usize; let ids: Vec = matched .iter() .skip(start) .take(limit) .map(|s| json!(s)) .collect(); let state = store.get_jmap_state(&user_email).await; ok_response( id, "Email/query", json!({ "accountId": user_email, "queryState": state.to_string(), "ids": ids, }), ) } /// Email/set — update and destroy emails. /// /// Only `isSeen` is writable, and only in the set direction: unsetting /// (mark unread) is rejected with an acb error rather than silently /// dropped, since the maildir has no unmark path. Unknown properties /// are likewise rejected instead of pretending to succeed. async fn email_set( store: Arc, user_email: String, call: MethodCall, id: Option<&str>, ) -> MethodResponse { let updates = call.args.get("update").and_then(|v| v.as_object()); let destroy = call.args.get("destroy").and_then(|v| v.as_array()); let mut update_acb: Map = Map::new(); let mut destroy_acb: Map = Map::new(); // One mailbox scan shared by both operations. let (msgs, list_err) = if updates.is_none() && destroy.is_none() { (Vec::new(), None) } else { match store.list_user_messages(&user_email) { Ok(m) => (m, None), Err(e) => (Vec::new(), Some(e.to_string())), } }; if let Some(err) = &list_err { if let Some(updates) = updates { for key in updates.keys() { update_acb.insert(key.clone(), server_fail(err)); } } if let Some(destroy) = destroy { for v in destroy { if let Some(s) = v.as_str() { destroy_acb.insert(s.to_string(), server_fail(err)); } } } } else { if let Some(updates) = updates { for (msg_id, update_obj) in updates { match update_obj.as_object() { Some(props) => { if let Some(err) = apply_update(&store, &user_email, msg_id, props, &msgs).await { update_acb.insert(msg_id.clone(), err); } } None => { update_acb.insert( msg_id.clone(), json!({"type": "invalidArguments", "description": "update value must be an object"}), ); } } } } if let Some(destroy_list) = destroy { for v in destroy_list { let Some(msg_id) = v.as_str() else { continue; }; match msgs.iter().find(|m| m.message_id == msg_id) { Some(msg) => match tokio::fs::remove_file(&msg.path).await { Ok(()) => { destroy_acb.insert(msg_id.to_string(), json!("true")); store.increment_jmap_state(&user_email).await; } // Concurrent delete (e.g. IMAP EXPUNGE) — idempotent success. Err(e) if e.kind() == std::io::ErrorKind::NotFound => { destroy_acb.insert(msg_id.to_string(), json!("true")); } Err(e) => { destroy_acb.insert( msg_id.to_string(), server_fail(&format!("delete: {e}")), ); } }, None => { destroy_acb.insert(msg_id.to_string(), invalid_id(msg_id)); } } } } } let mut acb: Map = Map::new(); if updates.is_some() { acb.insert( "update".to_string(), if update_acb.is_empty() { Value::Null } else { Value::Object(update_acb) }, ); } if destroy.is_some() { acb.insert( "destroy".to_string(), if destroy_acb.is_empty() { Value::Null } else { Value::Object(destroy_acb) }, ); } MethodResponse { id: id.map(String::from), method: Some("Email/set".to_string()), args: Some(json!({ "accountId": user_email, })), acb: if acb.is_empty() { None } else { Some(Value::Object(acb)) }, acb_too_big: None, error: None, error_description: None, } } /// Apply one update object to one message. Returns an acb error object /// when something was rejected or failed; `None` on success or no-op. async fn apply_update( store: &Arc, user_email: &str, msg_id: &str, props: &Map, msgs: &[crate::store::StoredMessage], ) -> Option { if props.is_empty() { return None; } for key in props.keys() { if key != "isSeen" { return Some(json!({ "type": "invalidProperties", "description": format!("unsupported property: {key}"), })); } } match props.get("isSeen").and_then(|v| v.as_bool()) { Some(true) => {} Some(false) => { return Some(json!({ "type": "notAllowed", "description": "unsetting isSeen is not supported", })); } None => { return Some(json!({ "type": "invalidArguments", "description": "isSeen must be a boolean", })); } } let Some(msg) = msgs.iter().find(|m| m.message_id == msg_id) else { return Some(invalid_id(msg_id)); }; if msg.seen { return None; // no-op } if store.maildir().mark_seen(&msg.path).is_err() { return Some(server_fail("mark_seen failed")); } store.increment_jmap_state(user_email).await; None } fn invalid_id(msg_id: &str) -> Value { json!({"type": "invalidId", "description": format!("no such message: {msg_id}")}) } fn server_fail(description: &str) -> Value { json!({"type": "serverFail", "description": description}) } // ── EmailState ───────────────────────────────────────────────────── async fn email_state_dispatch( store: Arc, user_email: String, call: MethodCall, id: Option, ) -> MethodResponse { let parts: Vec<&str> = call.method.split('/').collect(); match parts[1] { "get" => { let state = store.get_jmap_state(&user_email).await; email_state_get(id.as_deref(), &user_email, state) } _ => error_response(id.as_deref(), "unknownMethod", "Unknown EmailState method"), } } fn email_state_get(id: Option<&str>, user_email: &str, state: u64) -> MethodResponse { ok_response( id, "EmailState/get", json!({ "accountId": user_email, "id": "emailStateId", "state": state.to_string(), }), ) } // ── Helpers ──────────────────────────────────────────────────────── /// JMAP positions are strings of the form `"N"` or `"N/"`. fn parse_position(v: Option<&Value>) -> usize { v.and_then(|v| v.as_str()) .and_then(|s| s.split('/').next()) .and_then(|s| s.parse().ok()) .unwrap_or(0) } /// Convert a StoredMessage to a JMAP Email object with property filtering. fn email_to_jmap(msg: &crate::store::StoredMessage, properties: &[String]) -> Value { let include_prop = |name: &str| properties.is_empty() || properties.iter().any(|p| p == name); let mut map = Map::new(); map.insert("id".to_string(), json!(msg.message_id)); if include_prop("name") { map.insert("name".to_string(), json!(msg.subject)); } if include_prop("isSeen") { map.insert("isSeen".to_string(), json!(msg.seen)); } if include_prop("isDraft") { map.insert("isDraft".to_string(), json!(false)); } if include_prop("isSubmitted") { map.insert("isSubmitted".to_string(), json!(false)); } if include_prop("isTrashed") { map.insert("isTrashed".to_string(), json!(false)); } if include_prop("location") { map.insert("location".to_string(), json!("inbox")); } if include_prop("keywords") { map.insert("keywords".to_string(), json!({})); } if include_prop("notBefore") { map.insert("notBefore".to_string(), Value::Null); } if include_prop("priority") { map.insert("priority".to_string(), json!("normal")); } if include_prop("version") { map.insert("version".to_string(), json!(msg.uid.to_string())); } if include_prop("from") { map.insert( "from".to_string(), json!([{ "name": "", "email": extract_address(&msg.from_addr), }]), ); } if include_prop("to") { let addrs = parse_email_list(&msg.to_addrs); map.insert("to".to_string(), json!(addrs)); } if include_prop("cc") { map.insert("cc".to_string(), json!([])); } if include_prop("bcc") { map.insert("bcc".to_string(), json!([])); } if include_prop("replyTo") { map.insert("replyTo".to_string(), json!([])); } if include_prop("subject") { map.insert("subject".to_string(), json!(msg.subject)); } // No Date header is retained in StoredMessage yet, so the // date properties stay null until that lands. if include_prop("date") { map.insert("date".to_string(), Value::Null); } if include_prop("dateInternal") { map.insert("dateInternal".to_string(), Value::Null); } if include_prop("size") { let size = msg .path .metadata() .map(|m| m.len()) .unwrap_or(msg.body_text.len() as u64); map.insert("size".to_string(), json!(size)); } if include_prop("headerCount") { map.insert("headerCount".to_string(), json!(0)); } if include_prop("attachmentCount") { map.insert("attachmentCount".to_string(), json!(0)); } if include_prop("blobIds") { map.insert("blobIds".to_string(), json!([])); } if include_prop("blobIdToLocation") { map.insert("blobIdToLocation".to_string(), json!({})); } if include_prop("bodyValues") { map.insert("bodyValues".to_string(), json!({})); } if include_prop("bodyStructure") { map.insert("bodyStructure".to_string(), Value::Null); } if include_prop("preview") { let preview = msg.body_text.chars().take(500).collect::(); map.insert("preview".to_string(), json!(preview)); } if include_prop("references") { map.insert( "references".to_string(), json!(msg.references.as_deref().unwrap_or("")), ); } if include_prop("inReplyTo") { map.insert( "inReplyTo".to_string(), json!(msg.in_reply_to.as_deref().unwrap_or("")), ); } Value::Object(map) } /// Pull the bare address out of a `Name ` (or bare `addr`) string. fn extract_address(s: &str) -> &str { match (s.rfind('<'), s.find('>')) { (Some(lt), Some(gt)) if gt > lt => &s[lt + 1..gt], _ => s.trim(), } } /// Parse a comma-separated email address list into JMAP address objects. fn parse_email_list(s: &str) -> Vec { s.split(',') .filter_map(|seg| { let email = extract_address(seg).trim().to_string(); if email.is_empty() { return None; } Some(json!({ "name": "", "email": email, })) }) .collect() } /// Check if a message matches a JMAP filter. fn matches_email_filter(msg: &crate::store::StoredMessage, filter: Option<&Value>) -> bool { let filter = match filter { Some(f) => f, None => return true, }; // Handle "and" filter if let Some(and_list) = filter.get("and") { if let Some(list) = and_list.as_array() { return list.iter().all(|f| matches_email_filter(msg, Some(f))); } } // Handle "or" filter if let Some(or_list) = filter.get("or") { if let Some(list) = or_list.as_array() { return list.iter().any(|f| matches_email_filter(msg, Some(f))); } } // Handle "not" filter if let Some(not_filter) = filter.get("not") { return !matches_email_filter(msg, Some(not_filter)); } // Simple property filters if let Some(is_seen) = filter.get("isSeen").and_then(|v| v.as_bool()) { return msg.seen == is_seen; } if let Some(subject) = filter.get("subject").and_then(|v| v.as_str()) { return msg.subject.to_lowercase().contains(&subject.to_lowercase()); } if let Some(from) = filter.get("from").and_then(|v| v.as_str()) { return msg.from_addr.to_lowercase().contains(&from.to_lowercase()); } if let Some(to) = filter.get("to").and_then(|v| v.as_str()) { return msg.to_addrs.to_lowercase().contains(&to.to_lowercase()); } true }