From 7fbcf5031e2ae8aa470c9ae383fb4c14ec083290 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 30 Sep 2026 10:09:10 -0700 Subject: [PATCH] Take the registry capability from the server's session Registry calls (x:Account, x:Domain and the rest of the x: types) were always sent under urn:stalwart:jmap. inbuxa advertises the same registry as urn:inbuxa:jmap:registry, so the capability now comes from the connected server: Session::registry_urn() picks urn:inbuxa:jmap:registry when the session advertises it, else urn:stalwart:jmap, so Stalwart servers still work as a source. A request carrying an x: call against a server that advertises neither fails with a MissingCapability error naming both, treated like any other connection-level failure, instead of sending a capability the server never offered. --- src/jmap/error.rs | 8 +++- src/jmap/request.rs | 104 ++++++++++++++++++++++++++++++++++++-------- src/jmap/session.rs | 42 ++++++++++++++++++ 3 files changed, 135 insertions(+), 19 deletions(-) diff --git a/src/jmap/error.rs b/src/jmap/error.rs index 9147c2e..48ff7cf 100644 --- a/src/jmap/error.rs +++ b/src/jmap/error.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -50,6 +51,9 @@ pub enum JmapError { #[error("malformed jmap response: {0}")] Malformed(String), + #[error("server lacks a required capability: {0}")] + MissingCapability(String), + #[error("json error: {0}")] Json(#[from] serde_json::Error), @@ -69,7 +73,9 @@ impl JmapError { impl From for Error { fn from(value: JmapError) -> Self { match value { - JmapError::Connect(m) | JmapError::Transport(m) => Error::Connection(m), + JmapError::Connect(m) | JmapError::Transport(m) | JmapError::MissingCapability(m) => { + Error::Connection(m) + } JmapError::Auth(m) => Error::Connection(format!("authentication rejected: {m}")), JmapError::Sqlite(e) => Error::Db(OpenError::Sqlite(e)), reached @ (JmapError::HttpStatus { .. } | JmapError::RetriesExhausted(_)) => { diff --git a/src/jmap/request.rs b/src/jmap/request.rs index 9cec161..c576530 100644 --- a/src/jmap/request.rs +++ b/src/jmap/request.rs @@ -1,5 +1,6 @@ /* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ @@ -17,9 +18,28 @@ use crate::logging::Logger; pub const URN_CORE: &str = "urn:ietf:params:jmap:core"; -pub fn using_urn(method: &str) -> &'static str { +/// The capability inbuxa advertises for its registry types (`x:Account`, +/// `x:Domain` and the rest of the `x:` objects). +pub const URN_INBUXA_REGISTRY: &str = "urn:inbuxa:jmap:registry"; + +/// The same registry as Stalwart advertises it, so a Stalwart server can be +/// migrated from. +pub const URN_STALWART_REGISTRY: &str = "urn:stalwart:jmap"; + +/// The capability a method needs. Registry (`x:`) methods have no fixed +/// capability: the connected server names it, and `registry` carries that +/// name (see `Session::registry_urn`). Without one they cannot be sent. +pub fn using_urn(method: &str, registry: Option<&'static str>) -> Result<&'static str, JmapError> { let prefix = method.split('/').next().unwrap_or(method); - match prefix { + if prefix.starts_with("x:") { + return registry.ok_or_else(|| { + JmapError::MissingCapability(format!( + "{method} needs the server's registry capability, but it advertises neither \ + {URN_INBUXA_REGISTRY} nor {URN_STALWART_REGISTRY}" + )) + }); + } + Ok(match prefix { "Mailbox" | "Email" => "urn:ietf:params:jmap:mail", "Identity" => "urn:ietf:params:jmap:submission", "SieveScript" => "urn:ietf:params:jmap:sieve", @@ -27,9 +47,8 @@ pub fn using_urn(method: &str) -> &'static str { "Calendar" | "CalendarEvent" | "ParticipantIdentity" => "urn:ietf:params:jmap:calendars", "FileNode" => "urn:ietf:params:jmap:filenode", "Principal" => "urn:ietf:params:jmap:principals", - "x:Account" | "x:Domain" => "urn:stalwart:jmap", _ => URN_CORE, - } + }) } #[derive(Debug, Clone)] @@ -42,11 +61,19 @@ pub struct MethodCall { #[derive(Debug, Clone, Default)] pub struct Request { calls: Vec, + registry: Option<&'static str>, } impl Request { pub fn new() -> Request { - Request { calls: Vec::new() } + Request::default() + } + + /// The registry capability to use for any `x:` call in this request, + /// normally `session.registry_urn()`. + pub fn registry(&mut self, urn: Option<&'static str>) -> &mut Request { + self.registry = urn; + self } pub fn call( @@ -71,29 +98,29 @@ impl Request { self.calls.is_empty() } - pub fn using(&self) -> Vec { + pub fn using(&self) -> Result, JmapError> { let mut set: IndexSet = IndexSet::new(); set.insert(URN_CORE.to_owned()); for c in &self.calls { - set.insert(using_urn(&c.name).to_owned()); + set.insert(using_urn(&c.name, self.registry)?.to_owned()); } - set.into_iter().collect() + Ok(set.into_iter().collect()) } - fn envelope(&self) -> Value { + fn envelope(&self) -> Result { let method_calls: Vec = self .calls .iter() .map(|c| json!([c.name, c.args, c.call_id])) .collect(); - json!({ "using": self.using(), "methodCalls": method_calls }) + Ok(json!({ "using": self.using()?, "methodCalls": method_calls })) } pub fn fits(&self, limits: &Limits) -> Result<(), JmapError> { if self.calls.len() as u64 > limits.max_calls_in_request { return Err(JmapError::RequestTooLarge); } - let size = serde_json::to_vec(&self.envelope())?.len() as u64; + let size = serde_json::to_vec(&self.envelope()?)?.len() as u64; if size > limits.max_size_request { return Err(JmapError::RequestTooLarge); } @@ -101,7 +128,7 @@ impl Request { } pub fn send(&self, client: &HttpClient, api_url: &str) -> Result { - let value = client.post_json(api_url, &self.envelope())?; + let value = client.post_json(api_url, &self.envelope()?)?; Response::parse(value) } } @@ -855,24 +882,65 @@ mod tests { let mut r = Request::new(); r.call("Mailbox/get", json!({}), "a"); r.call("Email/query", json!({}), "b"); - let u = r.using(); + let u = r.using().unwrap(); assert!(u.contains(&URN_CORE.to_owned())); assert!(u.contains(&"urn:ietf:params:jmap:mail".to_owned())); assert_eq!(u.iter().filter(|x| x.as_str() == URN_CORE).count(), 1); } #[test] - fn using_maps_principal_and_stalwart() { + fn using_maps_standard_types() { assert_eq!( - using_urn("Principal/query"), + using_urn("Principal/query", None).unwrap(), "urn:ietf:params:jmap:principals" ); - assert_eq!(using_urn("x:Account/set"), "urn:stalwart:jmap"); assert_eq!( - using_urn("CalendarEvent/set"), + using_urn("CalendarEvent/set", None).unwrap(), "urn:ietf:params:jmap:calendars" ); - assert_eq!(using_urn("Identity/get"), "urn:ietf:params:jmap:submission"); + assert_eq!( + using_urn("Identity/get", None).unwrap(), + "urn:ietf:params:jmap:submission" + ); + } + + #[test] + fn registry_calls_use_the_inbuxa_capability() { + let mut r = Request::new(); + r.registry(Some(URN_INBUXA_REGISTRY)); + r.call("x:Account/set", json!({}), "a"); + r.call("x:Domain/get", json!({}), "b"); + assert_eq!(r.using().unwrap(), vec![URN_CORE, URN_INBUXA_REGISTRY]); + } + + #[test] + fn registry_calls_use_the_stalwart_capability_from_a_stalwart_source() { + let mut r = Request::new(); + r.registry(Some(URN_STALWART_REGISTRY)); + r.call("x:Account/get", json!({}), "a"); + assert_eq!(r.using().unwrap(), vec![URN_CORE, URN_STALWART_REGISTRY]); + } + + #[test] + fn registry_calls_without_a_registry_capability_fail_clearly() { + let mut r = Request::new(); + r.call("Mailbox/get", json!({}), "a"); + r.call("x:Domain/set", json!({}), "b"); + let err = r.using().unwrap_err(); + let msg = err.to_string(); + assert!(matches!(err, JmapError::MissingCapability(_))); + assert!(msg.contains("x:Domain/set"), "{msg}"); + assert!( + msg.contains(URN_INBUXA_REGISTRY) && msg.contains(URN_STALWART_REGISTRY), + "{msg}" + ); + } + + #[test] + fn requests_without_registry_calls_need_no_registry() { + let mut r = Request::new(); + r.call("Email/get", json!({}), "a"); + assert!(r.using().is_ok()); } #[test] diff --git a/src/jmap/session.rs b/src/jmap/session.rs index 32b584a..f0098df 100644 --- a/src/jmap/session.rs +++ b/src/jmap/session.rs @@ -12,6 +12,7 @@ use serde_json::Value; use crate::error::Error; use crate::jmap::error::JmapError; use crate::jmap::http::HttpClient; +use crate::jmap::request::{URN_INBUXA_REGISTRY, URN_STALWART_REGISTRY}; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -234,6 +235,14 @@ impl Session { .map_err(|e| Error::Connection(format!("session core capability is malformed: {e}"))) } + /// The capability to use for registry (`x:`) calls on this server: + /// inbuxa's name when advertised, else Stalwart's, else none. + pub fn registry_urn(&self) -> Option<&'static str> { + [URN_INBUXA_REGISTRY, URN_STALWART_REGISTRY] + .into_iter() + .find(|urn| self.capabilities.contains_key(*urn)) + } + pub fn account(&self, account_id: &str) -> Option<&Account> { self.accounts.get(account_id) } @@ -316,6 +325,39 @@ mod tests { }"# } + fn session_with(extra_caps: &[&str]) -> Session { + let mut v: serde_json::Value = serde_json::from_str(raw_session()).unwrap(); + for urn in extra_caps { + v["capabilities"][*urn] = serde_json::json!({}); + } + serde_json::from_value(v).unwrap() + } + + #[test] + fn registry_urn_prefers_inbuxa() { + assert_eq!( + session_with(&[URN_INBUXA_REGISTRY]).registry_urn(), + Some(URN_INBUXA_REGISTRY) + ); + assert_eq!( + session_with(&[URN_STALWART_REGISTRY, URN_INBUXA_REGISTRY]).registry_urn(), + Some(URN_INBUXA_REGISTRY) + ); + } + + #[test] + fn registry_urn_falls_back_to_stalwart() { + assert_eq!( + session_with(&[URN_STALWART_REGISTRY]).registry_urn(), + Some(URN_STALWART_REGISTRY) + ); + } + + #[test] + fn registry_urn_is_none_without_either() { + assert_eq!(session_with(&[]).registry_urn(), None); + } + #[test] fn parses_a_session_and_reads_limits() { let session = parse_session(raw_session()).unwrap();