pub use jmap_chat_types::{ChatCapability, ChatPushCapability};
pub trait ChatSessionExt: sealed::Sealed {
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;
}
mod sealed {
pub trait Sealed {}
impl Sealed for ::jmap_base_client::Session {}
}
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);
};
account.account_extension_capability::<ChatCapability>("urn:ietf:params:jmap:chat")
}
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);
};
account.account_extension_capability::<ChatPushCapability>("urn:ietf:params:jmap:chat:push")
}
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() {
use jmap_chat_types::BodyType;
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![BodyType::Plain, BodyType::Markdown, BodyType::Rich],
"supported_body_types must preserve wire order and map canonical strings to typed variants"
);
}
#[test]
fn chat_capability_supported_body_types_unknown_variant_round_trips() {
use jmap_chat_types::BodyType;
let raw = json!({
"maxBodyBytes": 65536,
"maxAttachmentBytes": 10485760,
"maxAttachmentsPerMessage": 10,
"supportsThreads": true,
"supportedBodyTypes": [
"text/plain",
"application/mls-ciphertext"
]
});
let cap: ChatCapability =
serde_json::from_value(raw).expect("ChatCapability must deserialize");
assert_eq!(
cap.supported_body_types,
vec![
BodyType::Plain,
BodyType::Other("application/mls-ciphertext".to_owned()),
],
"unknown wire strings must land in BodyType::Other preserving the original string"
);
}
#[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.clone()).expect("ChatCapability must deserialize");
assert_eq!(
obj.extra
.get("acmeCorpFeatureFlag")
.and_then(|v| v.as_str()),
Some("beta")
);
let reserialized = serde_json::to_value(&obj).expect("ChatCapability must serialize");
assert_eq!(
reserialized
.get("acmeCorpFeatureFlag")
.and_then(|v| v.as_str()),
Some("beta"),
"vendor field must survive deserialize -> serialize"
);
assert_eq!(
reserialized.get("maxBodyBytes").and_then(|v| v.as_u64()),
Some(65536),
"typed field must round-trip with its typed value"
);
assert!(
obj.extra.get("maxBodyBytes").is_none(),
"typed field maxBodyBytes must NOT be duplicated into extra"
);
assert!(
obj.extra.get("supportsThreads").is_none(),
"typed field supportsThreads must NOT be duplicated into extra"
);
}
#[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.clone()).expect("ChatPushCapability must deserialize");
assert_eq!(
obj.extra.get("acmeCorpPushTier").and_then(|v| v.as_str()),
Some("gold")
);
let reserialized = serde_json::to_value(&obj).expect("ChatPushCapability must serialize");
assert_eq!(
reserialized
.get("acmeCorpPushTier")
.and_then(|v| v.as_str()),
Some("gold"),
"vendor field must survive deserialize -> serialize"
);
assert_eq!(
reserialized.get("maxSnippetBytes").and_then(|v| v.as_u64()),
Some(256),
"typed field must round-trip with its typed value"
);
assert!(
obj.extra.get("maxSnippetBytes").is_none(),
"typed field maxSnippetBytes must NOT be duplicated into extra"
);
assert!(
obj.extra.get("supportedUrgencyValues").is_none(),
"typed field supportedUrgencyValues must NOT be duplicated into extra"
);
}
#[test]
fn chat_capability_empty_object_rejected() {
let raw = json!({});
let result: Result<ChatCapability, _> = serde_json::from_value(raw);
assert!(
result.is_err(),
"ChatCapability {{}} must fail deserialize (missing required fields); got Ok"
);
}
#[test]
fn chat_capability_missing_max_body_bytes_rejected() {
let raw = json!({
"maxAttachmentBytes": 10485760,
"maxAttachmentsPerMessage": 10,
"supportsThreads": true
});
let result: Result<ChatCapability, _> = serde_json::from_value(raw);
assert!(
result.is_err(),
"ChatCapability without maxBodyBytes must fail deserialize; got Ok"
);
}
#[test]
fn chat_push_capability_empty_object_rejected() {
let raw = json!({});
let result: Result<ChatPushCapability, _> = serde_json::from_value(raw);
assert!(
result.is_err(),
"ChatPushCapability {{}} must fail deserialize (missing required fields); got Ok"
);
}
#[test]
fn chat_push_capability_missing_supported_urgency_values_rejected() {
let raw = json!({
"maxSnippetBytes": 256
});
let result: Result<ChatPushCapability, _> = serde_json::from_value(raw);
assert!(
result.is_err(),
"ChatPushCapability without supportedUrgencyValues must fail deserialize; got Ok"
);
}
#[test]
fn chat_push_capability_optional_max_messages_per_push_absent_succeeds() {
let raw = json!({
"maxSnippetBytes": 256,
"supportedUrgencyValues": ["normal", "high"]
});
let cap: ChatPushCapability = serde_json::from_value(raw)
.expect("ChatPushCapability without maxMessagesPerPush must deserialize");
assert_eq!(cap.max_snippet_bytes, 256);
assert_eq!(
cap.supported_urgency_values,
vec![
jmap_chat_types::UrgencyLevel::Normal,
jmap_chat_types::UrgencyLevel::High,
]
);
assert!(
cap.max_messages_per_push.is_none(),
"maxMessagesPerPush optional must default to None when absent"
);
}
}