use serde::Deserialize;
#[non_exhaustive]
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(default)]
pub struct ChatCapability {
pub max_body_bytes: u64,
pub max_attachment_bytes: u64,
pub max_attachments_per_message: u64,
pub supports_threads: bool,
#[serde(default)]
pub supported_body_types: Vec<String>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ChatPushCapability {
pub max_snippet_bytes: u64,
pub supported_urgency_values: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_messages_per_push: Option<u64>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
pub trait ChatSessionExt {
fn chat_account_id(&self) -> Option<&str>;
fn chat_capability(
&self,
account_id: &str,
) -> Result<Option<ChatCapability>, jmap_base_client::ClientError>;
fn chat_push_capability(
&self,
account_id: &str,
) -> Result<Option<ChatPushCapability>, jmap_base_client::ClientError>;
fn supports_chat_websocket(&self) -> bool;
fn vapid_public_key(&self) -> Option<&str>;
fn supports_refplus(&self) -> bool;
fn supports_quotas(&self) -> bool;
}
impl ChatSessionExt for jmap_base_client::Session {
fn chat_account_id(&self) -> Option<&str> {
self.primary_account_id("urn:ietf:params:jmap:chat")
}
fn chat_capability(
&self,
account_id: &str,
) -> Result<Option<ChatCapability>, jmap_base_client::ClientError> {
let Some(account) = self.accounts.get(account_id) else {
return Ok(None);
};
let Some(raw) = account
.account_capabilities
.get("urn:ietf:params:jmap:chat")
else {
return Ok(None);
};
ChatCapability::deserialize(raw)
.map(Some)
.map_err(jmap_base_client::ClientError::Parse)
}
fn chat_push_capability(
&self,
account_id: &str,
) -> Result<Option<ChatPushCapability>, jmap_base_client::ClientError> {
let Some(account) = self.accounts.get(account_id) else {
return Ok(None);
};
let Some(raw) = account
.account_capabilities
.get("urn:ietf:params:jmap:chat:push")
else {
return Ok(None);
};
ChatPushCapability::deserialize(raw)
.map(Some)
.map_err(jmap_base_client::ClientError::Parse)
}
fn supports_chat_websocket(&self) -> bool {
self.capabilities
.contains_key("urn:ietf:params:jmap:chat:websocket")
}
fn vapid_public_key(&self) -> Option<&str> {
self.capabilities
.get("urn:ietf:params:jmap:webpush-vapid")?
.get("vapidPublicKey")?
.as_str()
}
fn supports_refplus(&self) -> bool {
self.capabilities
.contains_key("urn:ietf:params:jmap:refplus")
}
fn supports_quotas(&self) -> bool {
self.capabilities.contains_key("urn:ietf:params:jmap:quota")
}
}
#[cfg(test)]
mod tests {
use super::*;
use jmap_base_client::Session;
use serde_json::json;
fn make_session(
capabilities: serde_json::Value,
accounts: serde_json::Value,
primary_accounts: serde_json::Value,
) -> Session {
let raw = json!({
"capabilities": capabilities,
"accounts": accounts,
"primaryAccounts": primary_accounts,
"username": "test@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"
});
serde_json::from_value(raw).expect("make_session: malformed test JSON")
}
#[test]
fn chat_account_id_present() {
let session = make_session(
json!({}),
json!({}),
json!({"urn:ietf:params:jmap:chat": "acct1"}),
);
assert_eq!(session.chat_account_id(), Some("acct1"));
}
#[test]
fn chat_account_id_absent() {
let session = make_session(json!({}), json!({}), json!({}));
assert!(
session.chat_account_id().is_none(),
"expected None for missing primaryAccounts entry"
);
}
#[test]
fn chat_capability_parses() {
let session = make_session(
json!({}),
json!({
"acct1": {
"name": "test@example.com",
"isPersonal": true,
"isReadOnly": false,
"accountCapabilities": {
"urn:ietf:params:jmap:chat": {
"maxBodyBytes": 65536,
"maxAttachmentBytes": 10485760,
"maxAttachmentsPerMessage": 10,
"supportsThreads": true
}
}
}
}),
json!({"urn:ietf:params:jmap:chat": "acct1"}),
);
let cap = session
.chat_capability("acct1")
.expect("chat_capability must not return Err")
.expect("acct1 must have chat capability");
assert_eq!(cap.max_body_bytes, 65536);
assert_eq!(cap.max_attachment_bytes, 10485760);
assert_eq!(cap.max_attachments_per_message, 10);
assert!(cap.supports_threads);
}
#[test]
fn supports_chat_websocket_true() {
let session = make_session(
json!({"urn:ietf:params:jmap:chat:websocket": {}}),
json!({}),
json!({}),
);
assert!(
session.supports_chat_websocket(),
"expected true when capability key is present"
);
}
#[test]
fn supports_chat_websocket_false() {
let session = make_session(json!({}), json!({}), json!({}));
assert!(
!session.supports_chat_websocket(),
"expected false when capability key is absent"
);
}
#[test]
fn chat_capability_supported_body_types_round_trips() {
let raw = json!({
"maxBodyBytes": 65536,
"maxAttachmentBytes": 10485760,
"maxAttachmentsPerMessage": 10,
"supportsThreads": true,
"supportedBodyTypes": [
"text/plain",
"text/markdown",
"application/jmap-chat-rich"
]
});
let cap: ChatCapability =
serde_json::from_value(raw).expect("ChatCapability must deserialize");
assert_eq!(
cap.supported_body_types,
vec![
"text/plain".to_owned(),
"text/markdown".to_owned(),
"application/jmap-chat-rich".to_owned(),
],
"supported_body_types must preserve wire order"
);
}
#[test]
fn chat_capability_supported_body_types_absent_defaults_empty() {
let raw = json!({
"maxBodyBytes": 65536,
"maxAttachmentBytes": 10485760,
"maxAttachmentsPerMessage": 10,
"supportsThreads": true
});
let cap: ChatCapability =
serde_json::from_value(raw).expect("ChatCapability must deserialize");
assert!(
cap.supported_body_types.is_empty(),
"missing supportedBodyTypes must default to an empty Vec"
);
}
#[test]
fn chat_capability_preserves_vendor_extras() {
let raw = json!({
"maxBodyBytes": 65536,
"maxAttachmentBytes": 10485760,
"maxAttachmentsPerMessage": 10,
"supportsThreads": true,
"acmeCorpFeatureFlag": "beta"
});
let obj: ChatCapability =
serde_json::from_value(raw).expect("ChatCapability must deserialize");
assert_eq!(
obj.extra
.get("acmeCorpFeatureFlag")
.and_then(|v| v.as_str()),
Some("beta")
);
}
#[test]
fn chat_push_capability_preserves_vendor_extras() {
let raw = json!({
"maxSnippetBytes": 256,
"supportedUrgencyValues": ["normal", "high"],
"maxMessagesPerPush": 10,
"acmeCorpPushTier": "gold"
});
let obj: ChatPushCapability =
serde_json::from_value(raw).expect("ChatPushCapability must deserialize");
assert_eq!(
obj.extra.get("acmeCorpPushTier").and_then(|v| v.as_str()),
Some("gold")
);
}
}