use std::collections::HashMap;
use std::collections::HashSet;
use serde::Deserialize;
use jmap_types::{Invocation, JmapRequest, State};
use crate::error::ClientError;
#[derive(Debug)]
pub struct JmapRequestBuilder {
using: Vec<String>,
method_calls: Vec<Invocation>,
call_ids: HashSet<String>,
}
impl JmapRequestBuilder {
pub fn new(using: &[&str]) -> Self {
Self {
using: using.iter().map(|&s| s.to_owned()).collect(),
method_calls: Vec::new(),
call_ids: HashSet::new(),
}
}
pub fn add_call(
&mut self,
method: impl Into<String>,
args: serde_json::Value,
call_id: impl Into<String>,
) -> Result<&mut Self, ClientError> {
let call_id = call_id.into();
if !self.call_ids.insert(call_id.clone()) {
return Err(ClientError::InvalidArgument(format!(
"JmapRequestBuilder: duplicate call_id {call_id:?}"
)));
}
self.method_calls.push((method.into(), args, call_id));
Ok(self)
}
pub fn build(self) -> Result<JmapRequest, ClientError> {
if self.method_calls.is_empty() {
return Err(ClientError::InvalidArgument("no method calls added".into()));
}
Ok(JmapRequest::new(self.using, self.method_calls, None))
}
}
#[non_exhaustive]
#[derive(Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub capabilities: HashMap<String, serde_json::Value>,
pub accounts: HashMap<String, AccountInfo>,
pub primary_accounts: HashMap<String, String>,
pub username: String,
pub api_url: String,
pub download_url: String,
pub upload_url: String,
pub event_source_url: String,
pub state: State,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl Session {
pub fn primary_account_id(&self, capability: &str) -> Option<&str> {
self.primary_accounts.get(capability).map(String::as_str)
}
pub fn websocket_capability(&self) -> Result<Option<WebSocketCapability>, ClientError> {
let Some(raw) = self.capabilities.get("urn:ietf:params:jmap:websocket") else {
return Ok(None);
};
WebSocketCapability::deserialize(raw)
.map(Some)
.map_err(ClientError::Parse)
}
pub fn supports_cid(&self) -> bool {
self.capabilities.contains_key("urn:ietf:params:jmap:cid")
}
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session")
.field("capabilities", &self.capabilities)
.field("accounts", &self.accounts)
.field("primary_accounts", &self.primary_accounts)
.field("username", &"[REDACTED]")
.field("api_url", &self.api_url)
.field("download_url", &self.download_url)
.field("upload_url", &self.upload_url)
.field("event_source_url", &self.event_source_url)
.field("state", &"[opaque]")
.field("extra", &self.extra)
.finish()
}
}
#[non_exhaustive]
#[derive(Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountInfo {
pub name: String,
pub is_personal: bool,
pub is_read_only: bool,
pub account_capabilities: HashMap<String, serde_json::Value>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl std::fmt::Debug for AccountInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AccountInfo")
.field("name", &"[REDACTED]")
.field("is_personal", &self.is_personal)
.field("is_read_only", &self.is_read_only)
.field("account_capabilities", &self.account_capabilities)
.field("extra", &self.extra)
.finish()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WebSocketCapability {
pub url: String,
#[serde(default)]
pub supports_push: bool,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn builder_two_calls_serializes_correctly() {
let mut builder =
JmapRequestBuilder::new(&["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]);
builder
.add_call(
"Mailbox/get",
json!({"accountId": "A13824", "ids": null}),
"r1",
)
.expect("add_call r1 must succeed");
builder
.add_call(
"Email/get",
json!({"accountId": "A13824", "ids": ["e001"]}),
"r2",
)
.expect("add_call r2 must succeed");
let req = builder.build().expect("build must succeed with two calls");
let v = serde_json::to_value(&req).expect("serialize JmapRequest");
assert!(v.get("using").is_some(), "must have 'using' field");
let using = v["using"].as_array().expect("using must be array");
assert_eq!(using.len(), 2);
assert!(using.contains(&json!("urn:ietf:params:jmap:core")));
assert!(using.contains(&json!("urn:ietf:params:jmap:mail")));
let calls = v["methodCalls"]
.as_array()
.expect("methodCalls must be array");
assert_eq!(calls.len(), 2, "must have exactly 2 method calls");
assert_eq!(calls[0][0], json!("Mailbox/get"));
assert_eq!(calls[0][2], json!("r1"));
assert_eq!(calls[1][0], json!("Email/get"));
assert_eq!(calls[1][2], json!("r2"));
}
#[test]
fn builder_returns_err_on_empty_build() {
let result = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]).build();
assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"empty build must return Err(InvalidArgument), got {result:?}"
);
}
#[test]
fn builder_returns_err_on_duplicate_call_id() {
let mut builder = JmapRequestBuilder::new(&["urn:ietf:params:jmap:core"]);
builder
.add_call("Foo/get", json!({}), "r1")
.expect("first add_call must succeed");
let result = builder.add_call("Bar/get", json!({}), "r1"); assert!(
matches!(result, Err(ClientError::InvalidArgument(_))),
"duplicate call_id must return Err(InvalidArgument), got {result:?}"
);
}
#[test]
fn session_deserializes_rfc8620_example() {
let raw = r#"{
"capabilities": {
"urn:ietf:params:jmap:core": {
"maxSizeUpload": 50000000,
"maxConcurrentUpload": 8,
"maxSizeRequest": 10000000,
"maxConcurrentRequest": 8,
"maxCallsInRequest": 32,
"maxObjectsInGet": 256,
"maxObjectsInSet": 128,
"collationAlgorithms": [
"i;ascii-numeric",
"i;ascii-casemap",
"i;unicode-casemap"
]
},
"urn:ietf:params:jmap:mail": {},
"urn:ietf:params:jmap:contacts": {},
"https://example.com/apis/foobar": {
"maxFoosFinangled": 42
}
},
"accounts": {
"A13824": {
"name": "john@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": null,
"maxMailboxDepth": 10
},
"urn:ietf:params:jmap:contacts": {}
}
},
"A97813": {
"name": "jane@example.com",
"isPersonal": false,
"isReadOnly": true,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": 1,
"maxMailboxDepth": 10
}
}
}
},
"primaryAccounts": {
"urn:ietf:params:jmap:mail": "A13824",
"urn:ietf:params:jmap:contacts": "A13824"
},
"username": "john@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/upload/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "75128aab4b1b"
}"#;
let session: Session =
serde_json::from_str(raw).expect("RFC 8620 §2.1 example must deserialize");
assert_eq!(session.username, "john@example.com");
assert_eq!(session.api_url, "https://jmap.example.com/api/");
assert_eq!(
session.upload_url,
"https://jmap.example.com/upload/{accountId}/"
);
assert_eq!(
session.download_url,
"https://jmap.example.com/download/{accountId}/{blobId}/{name}?accept={type}"
);
assert_eq!(
session.event_source_url,
"https://jmap.example.com/eventsource/?types={types}&closeafter={closeafter}&ping={ping}"
);
assert_eq!(session.state, "75128aab4b1b");
assert!(
session
.capabilities
.contains_key("urn:ietf:params:jmap:core"),
"must have core capability"
);
assert!(
session
.capabilities
.contains_key("urn:ietf:params:jmap:mail"),
"must have mail capability"
);
assert!(
session
.capabilities
.contains_key("https://example.com/apis/foobar"),
"must have vendor capability"
);
assert!(
session.accounts.contains_key("A13824"),
"must have account A13824"
);
assert!(
session.accounts.contains_key("A97813"),
"must have account A97813"
);
assert_eq!(
session.primary_account_id("urn:ietf:params:jmap:mail"),
Some("A13824")
);
assert_eq!(
session.primary_account_id("urn:ietf:params:jmap:contacts"),
Some("A13824")
);
assert_eq!(
session.primary_account_id("urn:ietf:params:jmap:core"),
None
);
}
#[test]
fn account_info_deserializes_rfc8620_example() {
let raw = r#"{
"name": "john@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": null,
"maxMailboxDepth": 10
},
"urn:ietf:params:jmap:contacts": {}
}
}"#;
let account: AccountInfo =
serde_json::from_str(raw).expect("RFC 8620 §2.1 AccountInfo must deserialize");
assert_eq!(account.name, "john@example.com");
assert!(account.is_personal, "isPersonal must be true");
assert!(!account.is_read_only, "isReadOnly must be false");
assert!(
account
.account_capabilities
.contains_key("urn:ietf:params:jmap:mail"),
"must have mail capability"
);
assert!(
account
.account_capabilities
.contains_key("urn:ietf:params:jmap:contacts"),
"must have contacts capability"
);
let raw2 = r#"{
"name": "jane@example.com",
"isPersonal": false,
"isReadOnly": true,
"accountCapabilities": {
"urn:ietf:params:jmap:mail": {
"maxMailboxesPerEmail": 1,
"maxMailboxDepth": 10
}
}
}"#;
let account2: AccountInfo = serde_json::from_str(raw2)
.expect("RFC 8620 §2.1 read-only AccountInfo must deserialize");
assert_eq!(account2.name, "jane@example.com");
assert!(!account2.is_personal, "isPersonal must be false");
assert!(account2.is_read_only, "isReadOnly must be true");
}
#[test]
fn websocket_capability_deserializes() {
let raw = r#"{"url": "wss://jmap.example.com/ws", "supportsPush": true}"#;
let cap: WebSocketCapability =
serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
assert_eq!(cap.url, "wss://jmap.example.com/ws");
assert!(cap.supports_push);
}
#[test]
fn websocket_capability_supports_push_defaults_false() {
let raw = r#"{"url": "wss://jmap.example.com/ws"}"#;
let cap: WebSocketCapability =
serde_json::from_str(raw).expect("WebSocketCapability must deserialize");
assert_eq!(cap.url, "wss://jmap.example.com/ws");
assert!(!cap.supports_push, "supportsPush must default to false");
}
#[test]
fn session_websocket_capability_absent_returns_ok_none() {
let raw = r#"{
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "u@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/ul/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "s1"
}"#;
let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
let result = session.websocket_capability();
assert!(
matches!(result, Ok(None)),
"expected Ok(None), got {result:?}"
);
}
#[test]
fn session_websocket_capability_present_and_valid() {
let raw = r#"{
"capabilities": {
"urn:ietf:params:jmap:websocket": {
"url": "wss://jmap.example.com/ws",
"supportsPush": true
}
},
"accounts": {},
"primaryAccounts": {},
"username": "u@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/ul/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "s1"
}"#;
let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
let ws = session
.websocket_capability()
.expect("must not error")
.expect("websocket capability must be present");
assert_eq!(ws.url, "wss://jmap.example.com/ws");
assert!(ws.supports_push);
}
#[test]
fn supports_cid_returns_false_when_capability_absent() {
let raw = r#"{
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "u@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/ul/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "s1"
}"#;
let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
assert!(!session.supports_cid());
}
#[test]
fn supports_cid_returns_true_when_capability_present_empty_value() {
let raw = r#"{
"capabilities": {
"urn:ietf:params:jmap:cid": {}
},
"accounts": {},
"primaryAccounts": {},
"username": "u@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/ul/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "s1"
}"#;
let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
assert!(session.supports_cid());
}
#[test]
fn supports_cid_returns_true_when_capability_present_with_extra_fields() {
let raw = r#"{
"capabilities": {
"urn:ietf:params:jmap:cid": {
"x-vendor-flag": "future-shape"
}
},
"accounts": {},
"primaryAccounts": {},
"username": "u@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/ul/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "s1"
}"#;
let session: Session = serde_json::from_str(raw).expect("Session must deserialize");
assert!(session.supports_cid());
}
#[test]
fn session_debug_does_not_leak_username_or_state() {
const CANARY_USER: &str = "CANARY-USERNAME-DO-NOT-LEAK@example.com";
const CANARY_STATE: &str = "CANARY-STATE-TOKEN-DO-NOT-LEAK";
let raw = format!(
r#"{{
"capabilities": {{}},
"accounts": {{
"a1": {{
"name": "{CANARY_USER}",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {{}}
}}
}},
"primaryAccounts": {{}},
"username": "{CANARY_USER}",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/dl/{{accountId}}/",
"uploadUrl": "https://jmap.example.com/ul/{{accountId}}/",
"eventSourceUrl": "https://jmap.example.com/sse/",
"state": "{CANARY_STATE}"
}}"#
);
let session: Session = serde_json::from_str(&raw).expect("Session must deserialize");
let account = session
.accounts
.get("a1")
.expect("accounts['a1'] must deserialize");
assert_eq!(account.name, CANARY_USER);
let dbg = format!("{session:?}");
assert!(
!dbg.contains(CANARY_USER),
"Session Debug must not contain the raw username or AccountInfo.name; got: {dbg}"
);
assert!(
!dbg.contains(CANARY_STATE),
"Session Debug must not contain the raw state token; got: {dbg}"
);
}
#[test]
fn account_info_debug_does_not_leak_name() {
const CANARY_NAME: &str = "CANARY-ACCOUNT-NAME-DO-NOT-LEAK@example.com";
let raw = format!(
r#"{{
"name": "{CANARY_NAME}",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {{}}
}}"#
);
let account: AccountInfo =
serde_json::from_str(&raw).expect("AccountInfo must deserialize");
assert_eq!(account.name, CANARY_NAME);
let dbg = format!("{account:?}");
assert!(
!dbg.contains(CANARY_NAME),
"AccountInfo Debug must not contain the raw name; got: {dbg}"
);
}
#[test]
fn session_preserves_vendor_extras() {
let raw = json!({
"capabilities": {},
"accounts": {},
"primaryAccounts": {},
"username": "u@example.com",
"apiUrl": "https://jmap.example.com/api/",
"downloadUrl": "https://jmap.example.com/dl/{accountId}/{blobId}/{name}?accept={type}",
"uploadUrl": "https://jmap.example.com/ul/{accountId}/",
"eventSourceUrl": "https://jmap.example.com/sse/?types={types}&closeafter={closeafter}&ping={ping}",
"state": "s1",
"acmeCorpDeployment": "prod-eu-west-1"
});
let obj: Session = serde_json::from_value(raw).expect("Session must deserialize");
assert_eq!(
obj.extra.get("acmeCorpDeployment").and_then(|v| v.as_str()),
Some("prod-eu-west-1")
);
}
#[test]
fn account_info_preserves_vendor_extras() {
let raw = json!({
"name": "u@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {},
"acmeCorpQuotaTier": "gold"
});
let obj: AccountInfo = serde_json::from_value(raw).expect("AccountInfo must deserialize");
assert_eq!(
obj.extra.get("acmeCorpQuotaTier").and_then(|v| v.as_str()),
Some("gold")
);
}
#[test]
fn websocket_capability_preserves_vendor_extras() {
let raw = json!({
"url": "wss://jmap.example.com/ws",
"supportsPush": true,
"acmeCorpHeartbeatMs": 30000
});
let obj: WebSocketCapability =
serde_json::from_value(raw).expect("WebSocketCapability must deserialize");
assert_eq!(
obj.extra
.get("acmeCorpHeartbeatMs")
.and_then(|v| v.as_u64()),
Some(30000)
);
}
}