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.
This commit is contained in:
+7
-1
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||||
*/
|
*/
|
||||||
@@ -50,6 +51,9 @@ pub enum JmapError {
|
|||||||
#[error("malformed jmap response: {0}")]
|
#[error("malformed jmap response: {0}")]
|
||||||
Malformed(String),
|
Malformed(String),
|
||||||
|
|
||||||
|
#[error("server lacks a required capability: {0}")]
|
||||||
|
MissingCapability(String),
|
||||||
|
|
||||||
#[error("json error: {0}")]
|
#[error("json error: {0}")]
|
||||||
Json(#[from] serde_json::Error),
|
Json(#[from] serde_json::Error),
|
||||||
|
|
||||||
@@ -69,7 +73,9 @@ impl JmapError {
|
|||||||
impl From<JmapError> for Error {
|
impl From<JmapError> for Error {
|
||||||
fn from(value: JmapError) -> Self {
|
fn from(value: JmapError) -> Self {
|
||||||
match value {
|
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::Auth(m) => Error::Connection(format!("authentication rejected: {m}")),
|
||||||
JmapError::Sqlite(e) => Error::Db(OpenError::Sqlite(e)),
|
JmapError::Sqlite(e) => Error::Db(OpenError::Sqlite(e)),
|
||||||
reached @ (JmapError::HttpStatus { .. } | JmapError::RetriesExhausted(_)) => {
|
reached @ (JmapError::HttpStatus { .. } | JmapError::RetriesExhausted(_)) => {
|
||||||
|
|||||||
+86
-18
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
|
* SPDX-FileCopyrightText: 2026 John Coffey <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
* 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 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);
|
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",
|
"Mailbox" | "Email" => "urn:ietf:params:jmap:mail",
|
||||||
"Identity" => "urn:ietf:params:jmap:submission",
|
"Identity" => "urn:ietf:params:jmap:submission",
|
||||||
"SieveScript" => "urn:ietf:params:jmap:sieve",
|
"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",
|
"Calendar" | "CalendarEvent" | "ParticipantIdentity" => "urn:ietf:params:jmap:calendars",
|
||||||
"FileNode" => "urn:ietf:params:jmap:filenode",
|
"FileNode" => "urn:ietf:params:jmap:filenode",
|
||||||
"Principal" => "urn:ietf:params:jmap:principals",
|
"Principal" => "urn:ietf:params:jmap:principals",
|
||||||
"x:Account" | "x:Domain" => "urn:stalwart:jmap",
|
|
||||||
_ => URN_CORE,
|
_ => URN_CORE,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -42,11 +61,19 @@ pub struct MethodCall {
|
|||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Request {
|
pub struct Request {
|
||||||
calls: Vec<MethodCall>,
|
calls: Vec<MethodCall>,
|
||||||
|
registry: Option<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Request {
|
impl Request {
|
||||||
pub fn new() -> 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(
|
pub fn call(
|
||||||
@@ -71,29 +98,29 @@ impl Request {
|
|||||||
self.calls.is_empty()
|
self.calls.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn using(&self) -> Vec<String> {
|
pub fn using(&self) -> Result<Vec<String>, JmapError> {
|
||||||
let mut set: IndexSet<String> = IndexSet::new();
|
let mut set: IndexSet<String> = IndexSet::new();
|
||||||
set.insert(URN_CORE.to_owned());
|
set.insert(URN_CORE.to_owned());
|
||||||
for c in &self.calls {
|
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<Value, JmapError> {
|
||||||
let method_calls: Vec<Value> = self
|
let method_calls: Vec<Value> = self
|
||||||
.calls
|
.calls
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| json!([c.name, c.args, c.call_id]))
|
.map(|c| json!([c.name, c.args, c.call_id]))
|
||||||
.collect();
|
.collect();
|
||||||
json!({ "using": self.using(), "methodCalls": method_calls })
|
Ok(json!({ "using": self.using()?, "methodCalls": method_calls }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fits(&self, limits: &Limits) -> Result<(), JmapError> {
|
pub fn fits(&self, limits: &Limits) -> Result<(), JmapError> {
|
||||||
if self.calls.len() as u64 > limits.max_calls_in_request {
|
if self.calls.len() as u64 > limits.max_calls_in_request {
|
||||||
return Err(JmapError::RequestTooLarge);
|
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 {
|
if size > limits.max_size_request {
|
||||||
return Err(JmapError::RequestTooLarge);
|
return Err(JmapError::RequestTooLarge);
|
||||||
}
|
}
|
||||||
@@ -101,7 +128,7 @@ impl Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn send(&self, client: &HttpClient, api_url: &str) -> Result<Response, JmapError> {
|
pub fn send(&self, client: &HttpClient, api_url: &str) -> Result<Response, JmapError> {
|
||||||
let value = client.post_json(api_url, &self.envelope())?;
|
let value = client.post_json(api_url, &self.envelope()?)?;
|
||||||
Response::parse(value)
|
Response::parse(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -855,24 +882,65 @@ mod tests {
|
|||||||
let mut r = Request::new();
|
let mut r = Request::new();
|
||||||
r.call("Mailbox/get", json!({}), "a");
|
r.call("Mailbox/get", json!({}), "a");
|
||||||
r.call("Email/query", json!({}), "b");
|
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_CORE.to_owned()));
|
||||||
assert!(u.contains(&"urn:ietf:params:jmap:mail".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);
|
assert_eq!(u.iter().filter(|x| x.as_str() == URN_CORE).count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn using_maps_principal_and_stalwart() {
|
fn using_maps_standard_types() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
using_urn("Principal/query"),
|
using_urn("Principal/query", None).unwrap(),
|
||||||
"urn:ietf:params:jmap:principals"
|
"urn:ietf:params:jmap:principals"
|
||||||
);
|
);
|
||||||
assert_eq!(using_urn("x:Account/set"), "urn:stalwart:jmap");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
using_urn("CalendarEvent/set"),
|
using_urn("CalendarEvent/set", None).unwrap(),
|
||||||
"urn:ietf:params:jmap:calendars"
|
"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]
|
#[test]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use serde_json::Value;
|
|||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::jmap::error::JmapError;
|
use crate::jmap::error::JmapError;
|
||||||
use crate::jmap::http::HttpClient;
|
use crate::jmap::http::HttpClient;
|
||||||
|
use crate::jmap::request::{URN_INBUXA_REGISTRY, URN_STALWART_REGISTRY};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -234,6 +235,14 @@ impl Session {
|
|||||||
.map_err(|e| Error::Connection(format!("session core capability is malformed: {e}")))
|
.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> {
|
pub fn account(&self, account_id: &str) -> Option<&Account> {
|
||||||
self.accounts.get(account_id)
|
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]
|
#[test]
|
||||||
fn parses_a_session_and_reads_limits() {
|
fn parses_a_session_and_reads_limits() {
|
||||||
let session = parse_session(raw_session()).unwrap();
|
let session = parse_session(raw_session()).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user