diff --git a/CHANGELOG.md b/CHANGELOG.md index 502df3f..c8157e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. This projec ### Fixed - 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 diff --git a/src/jmap/account.rs b/src/jmap/account.rs index 12e2cd8..e2ff271 100644 --- a/src/jmap/account.rs +++ b/src/jmap/account.rs @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 OR MIT */ -use serde_json::{Value, json}; - use crate::error::Error; +use crate::jmap::error::JmapError; use crate::jmap::http::HttpClient; use crate::jmap::request::{Request, check_method_error}; use crate::jmap::session::Session; +use serde_json::{Value, json}; +use std::fmt::Write; #[derive(Debug, Clone)] pub enum AccountSelector { @@ -18,6 +19,7 @@ pub enum AccountSelector { } const OWNER_URN: &str = "urn:ietf:params:jmap:principals:owner"; +const PRINCIPALS_URN: &str = "urn:ietf:params:jmap:principals"; pub fn resolve( selector: &AccountSelector, @@ -42,6 +44,10 @@ fn resolve_via_principal( session: &Session, client: &HttpClient, ) -> Result { + if !session.capabilities.contains_key(PRINCIPALS_URN) { + return Err(unsupported_principals(name, session)); + } + let mut req = Request::new(); req.call( "Principal/query", @@ -56,9 +62,17 @@ fn resolve_via_principal( }), "g", ); - let resp = req - .send(client, &session.api_url) - .map_err(|e| Error::Account(format!("principal resolution request failed: {e}")))?; + let resp = match req.send(client, &session.api_url) { + Ok(resp) => resp, + 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 .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 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 { if let Some(accounts) = principal.get("accounts").and_then(Value::as_object) { for data in accounts.values() { @@ -197,6 +231,23 @@ mod tests { assert_eq!(extract_account_id(matches[0], "alice").unwrap(), "w"); } + #[test] + fn missing_principals_capability_is_actionable_without_a_request() { + let s = session_with("alice@example.org", "w"); + let err = resolve( + &AccountSelector::Name("bob@example.org".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("alice@example.org (w)")); + assert!(msg.contains("bob@example.org")); + } + #[test] fn extract_falls_back_to_principal_id() { let p = json!({ "id": "acc-7", "name": "bob", "accounts": {} }); diff --git a/tests/mock_jmap.rs b/tests/mock_jmap.rs index 4fd0f6f..ee801c5 100644 --- a/tests/mock_jmap.rs +++ b/tests/mock_jmap.rs @@ -58,11 +58,14 @@ fn session_json(base: &str) -> String { "apiUrl": format!("{base}/jmap/api"), "uploadUrl": format!("{base}/jmap/upload/{{accountId}}/"), "downloadUrl": format!("{base}/jmap/dl/{{accountId}}/{{blobId}}/{{type}}/{{name}}"), - "capabilities": { "urn:ietf:params:jmap:core": { - "maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16, - "maxConcurrentRequests": 4, "maxConcurrentUpload": 4, - "maxSizeRequest": 10000000, "maxSizeUpload": 50000000 - } }, + "capabilities": { + "urn:ietf:params:jmap:core": { + "maxObjectsInGet": 500, "maxObjectsInSet": 500, "maxCallsInRequest": 16, + "maxConcurrentRequests": 4, "maxConcurrentUpload": 4, + "maxSizeRequest": 10000000, "maxSizeUpload": 50000000 + }, + "urn:ietf:params:jmap:principals": {} + }, "accounts": { "w": { "name": "alice@example.org", "accountCapabilities": { "urn:ietf:params:jmap:mail": {} } } } }) @@ -365,6 +368,35 @@ fn principal_ambiguous_exact_match_is_rejected() { 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("alice@example.org (w)")); +} + #[test] fn get_reports_not_found_ids() { let mut server = mockito::Server::new();