Report user friendly error message when urn:ietf:params:jmap:principals is not supported and no accountId is provided (closes #21)

This commit is contained in:
Maurus Decimus
2026-07-17 15:51:05 +02:00
parent 0f28e9b17e
commit c13435b8f7
3 changed files with 94 additions and 10 deletions
+1
View File
@@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. This projec
### Fixed ### Fixed
- WebDAV import materialised the account root collection as a directory named after the account displayname (#18). - WebDAV import materialised the account root collection as a directory named after the account displayname (#18).
- Report user friendly error message when `urn:ietf:params:jmap:principals` is not supported and no accountId is provided (#21).
## [1.0.6] - 2026-07-12 ## [1.0.6] - 2026-07-12
+56 -5
View File
@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 OR MIT * SPDX-License-Identifier: Apache-2.0 OR MIT
*/ */
use serde_json::{Value, json};
use crate::error::Error; use crate::error::Error;
use crate::jmap::error::JmapError;
use crate::jmap::http::HttpClient; use crate::jmap::http::HttpClient;
use crate::jmap::request::{Request, check_method_error}; use crate::jmap::request::{Request, check_method_error};
use crate::jmap::session::Session; use crate::jmap::session::Session;
use serde_json::{Value, json};
use std::fmt::Write;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AccountSelector { pub enum AccountSelector {
@@ -18,6 +19,7 @@ pub enum AccountSelector {
} }
const OWNER_URN: &str = "urn:ietf:params:jmap:principals:owner"; const OWNER_URN: &str = "urn:ietf:params:jmap:principals:owner";
const PRINCIPALS_URN: &str = "urn:ietf:params:jmap:principals";
pub fn resolve( pub fn resolve(
selector: &AccountSelector, selector: &AccountSelector,
@@ -42,6 +44,10 @@ fn resolve_via_principal(
session: &Session, session: &Session,
client: &HttpClient, client: &HttpClient,
) -> Result<String, Error> { ) -> Result<String, Error> {
if !session.capabilities.contains_key(PRINCIPALS_URN) {
return Err(unsupported_principals(name, session));
}
let mut req = Request::new(); let mut req = Request::new();
req.call( req.call(
"Principal/query", "Principal/query",
@@ -56,9 +62,17 @@ fn resolve_via_principal(
}), }),
"g", "g",
); );
let resp = req let resp = match req.send(client, &session.api_url) {
.send(client, &session.api_url) Ok(resp) => resp,
.map_err(|e| Error::Account(format!("principal resolution request failed: {e}")))?; Err(JmapError::HttpStatus { status: 400, body }) if body.contains("unknownCapability") => {
return Err(unsupported_principals(name, session));
}
Err(e) => {
return Err(Error::Account(format!(
"principal resolution request failed: {e}"
)));
}
};
let query = resp let query = resp
.by_call_id("q") .by_call_id("q")
@@ -103,6 +117,26 @@ fn resolve_via_principal(
} }
} }
fn unsupported_principals(name: &str, session: &Session) -> Error {
let mut available = String::new();
for (id, account) in &session.accounts {
if !available.is_empty() {
available.push_str(", ");
}
let _ = write!(available, "{} ({id})", account.name);
}
if available.is_empty() {
available.push_str("(none)");
}
Error::Account(format!(
"account name '{name}' is not enumerated in this session and the source server does not \
support '{PRINCIPALS_URN}', which is required to resolve an account by name from an \
administrator session. Accounts visible to these credentials: {available}. Pass \
--account-id <id> to use an account id verbatim (no principals lookup is performed), \
authenticate directly as the target user, or import over IMAP instead."
))
}
fn extract_account_id(principal: &Value, name: &str) -> Result<String, Error> { fn extract_account_id(principal: &Value, name: &str) -> Result<String, Error> {
if let Some(accounts) = principal.get("accounts").and_then(Value::as_object) { if let Some(accounts) = principal.get("accounts").and_then(Value::as_object) {
for data in accounts.values() { for data in accounts.values() {
@@ -197,6 +231,23 @@ mod tests {
assert_eq!(extract_account_id(matches[0], "alice").unwrap(), "w"); assert_eq!(extract_account_id(matches[0], "alice").unwrap(), "w");
} }
#[test]
fn missing_principals_capability_is_actionable_without_a_request() {
let s = session_with("[email protected]", "w");
let err = resolve(
&AccountSelector::Name("[email protected]".into()),
&s,
&client(),
)
.unwrap_err();
assert_eq!(err.exit_code(), 3);
let msg = err.to_string();
assert!(msg.contains(PRINCIPALS_URN));
assert!(msg.contains("--account-id"));
assert!(msg.contains("[email protected] (w)"));
assert!(msg.contains("[email protected]"));
}
#[test] #[test]
fn extract_falls_back_to_principal_id() { fn extract_falls_back_to_principal_id() {
let p = json!({ "id": "acc-7", "name": "bob", "accounts": {} }); let p = json!({ "id": "acc-7", "name": "bob", "accounts": {} });
+37 -5
View File
@@ -58,11 +58,14 @@ fn session_json(base: &str) -> String {
"apiUrl": format!("{base}/jmap/api"), "apiUrl": format!("{base}/jmap/api"),
"uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"), "uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"),
"downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"), "downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"),
"capabilities": { "urn:ietf:params:jmap:core": { "capabilities": {
"maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16, "urn:ietf:params:jmap:core": {
"maxConcurrentRequests": 4, "maxConcurrentUpload": 4, "maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16,
"maxSizeRequest": 10000000, "maxSizeUpload": 50000000 "maxConcurrentRequests": 4, "maxConcurrentUpload": 4,
} }, "maxSizeRequest": 10000000, "maxSizeUpload": 50000000
},
"urn:ietf:params:jmap:principals": {}
},
"accounts": { "w": { "name": "[email protected]", "accounts": { "w": { "name": "[email protected]",
"accountCapabilities": { "urn:ietf:params:jmap:mail": {} } } } "accountCapabilities": { "urn:ietf:params:jmap:mail": {} } } }
}) })
@@ -365,6 +368,35 @@ fn principal_ambiguous_exact_match_is_rejected() {
assert_eq!(err.exit_code(), 3); assert_eq!(err.exit_code(), 3);
} }
#[test]
fn principal_unknown_capability_400_is_actionable() {
let mut server = mockito::Server::new();
let base = server.url();
let session: Session = serde_json::from_str(&session_json(&base)).unwrap();
server
.mock("POST", "/jmap/api")
.with_status(400)
.with_body(
json!({
"type": "urn:ietf:params:jmap:error:unknownCapability",
"status": 400,
"detail": "The Request object used capability \
'urn:ietf:params:jmap:principals', which is not supported \
by this server."
})
.to_string(),
)
.create();
let err =
account::resolve(&AccountSelector::Name("ghost".into()), &session, &client(0)).unwrap_err();
assert_eq!(err.exit_code(), 3);
let msg = err.to_string();
assert!(msg.contains("urn:ietf:params:jmap:principals"));
assert!(msg.contains("--account-id"));
assert!(msg.contains("[email protected] (w)"));
}
#[test] #[test]
fn get_reports_not_found_ids() { fn get_reports_not_found_ids() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();