//! JMAP session discovery and management. //! //! The session endpoint provides account information and API URLs. use serde::Deserialize; use std::collections::HashMap; /// JMAP session response from the session endpoint. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct JmapSession { /// Capabilities and their configurations pub capabilities: HashMap, /// Accounts accessible to this user pub accounts: HashMap, /// Primary accounts for each data type pub primary_accounts: HashMap, /// Username (email address) pub username: String, /// API URL for method calls pub api_url: String, /// Download URL template for blobs pub download_url: String, /// Upload URL for blobs pub upload_url: String, /// Event source URL for push notifications pub event_source_url: Option, /// Session state for change detection pub state: String, } impl JmapSession { /// Gets the primary account ID for email. pub fn primary_email_account(&self) -> Option<&str> { self.primary_accounts .get("urn:ietf:params:jmap:mail") .map(|s| s.as_str()) } /// Gets the API URL for making JMAP method calls. pub fn api_url(&self) -> &str { &self.api_url } } /// Account information in a session. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionAccount { /// Account display name pub name: String, /// Whether this is a personal account pub is_personal: bool, /// Whether this is read-only pub is_read_only: bool, /// Capabilities this account has pub account_capabilities: HashMap, } /// Discovers the JMAP session for an account. pub async fn discover_session( session_url: &str, access_token: &str, ) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .connect_timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| format!("Failed to build HTTP client: {}", e))?; let response = client .get(session_url) .bearer_auth(access_token) .send() .await .map_err(|e| format!("Session request failed: {}", e))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("Session request failed ({}): {}", status, body)); } let session: JmapSession = response .json() .await .map_err(|e| format!("Failed to parse session response: {}", e))?; Ok(session) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn sample_session_json() -> serde_json::Value { json!({ "capabilities": { "urn:ietf:params:jmap:core": { "maxSizeUpload": 50000000, "maxConcurrentUpload": 8, "maxSizeRequest": 10000000, "maxConcurrentRequests": 8, "maxCallsInRequest": 16, "maxObjectsInGet": 4096, "maxObjectsInSet": 4096, "collationAlgorithms": ["i;ascii-numeric", "i;ascii-casemap"] }, "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {} }, "accounts": { "acc1": { "name": "user@fastmail.com", "isPersonal": true, "isReadOnly": false, "accountCapabilities": { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {} } } }, "primaryAccounts": { "urn:ietf:params:jmap:mail": "acc1", "urn:ietf:params:jmap:submission": "acc1" }, "username": "user@fastmail.com", "apiUrl": "https://api.fastmail.com/jmap/api/", "downloadUrl": "https://api.fastmail.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}", "uploadUrl": "https://api.fastmail.com/jmap/upload/{accountId}/", "eventSourceUrl": "https://api.fastmail.com/jmap/eventsource/?types={types}&closeafter=state&ping=30", "state": "session_state_abc" }) } #[test] fn session_deserializes_full_response() { let session: JmapSession = serde_json::from_value(sample_session_json()).unwrap(); assert_eq!(session.username, "user@fastmail.com"); assert_eq!(session.api_url, "https://api.fastmail.com/jmap/api/"); assert!(session.download_url.contains("{blobId}")); assert!(session.upload_url.contains("{accountId}")); assert_eq!(session.state, "session_state_abc"); assert!(session.event_source_url.is_some()); assert_eq!(session.accounts.len(), 1); assert_eq!(session.capabilities.len(), 3); } #[test] fn session_primary_email_account() { let session: JmapSession = serde_json::from_value(sample_session_json()).unwrap(); assert_eq!(session.primary_email_account(), Some("acc1")); } #[test] fn session_primary_email_account_missing() { let raw = json!({ "capabilities": {}, "accounts": {}, "primaryAccounts": {}, "username": "test@example.com", "apiUrl": "https://api.example.com/jmap/api/", "downloadUrl": "https://api.example.com/download/", "uploadUrl": "https://api.example.com/upload/", "state": "s1" }); let session: JmapSession = serde_json::from_value(raw).unwrap(); assert_eq!(session.primary_email_account(), None); } #[test] fn session_api_url_method() { let session: JmapSession = serde_json::from_value(sample_session_json()).unwrap(); assert_eq!(session.api_url(), "https://api.fastmail.com/jmap/api/"); } #[test] fn session_without_event_source() { let raw = json!({ "capabilities": {}, "accounts": {}, "primaryAccounts": {}, "username": "test@example.com", "apiUrl": "https://api.example.com/jmap/api/", "downloadUrl": "https://api.example.com/download/", "uploadUrl": "https://api.example.com/upload/", "state": "s1" }); let session: JmapSession = serde_json::from_value(raw).unwrap(); assert!(session.event_source_url.is_none()); } #[test] fn session_account_deserializes() { let raw = json!({ "name": "Personal", "isPersonal": true, "isReadOnly": false, "accountCapabilities": { "urn:ietf:params:jmap:mail": {}, "urn:ietf:params:jmap:submission": {} } }); let account: SessionAccount = serde_json::from_value(raw).unwrap(); assert_eq!(account.name, "Personal"); assert!(account.is_personal); assert!(!account.is_read_only); assert_eq!(account.account_capabilities.len(), 2); } #[test] fn session_read_only_shared_account() { let raw = json!({ "name": "Shared Mailbox", "isPersonal": false, "isReadOnly": true, "accountCapabilities": { "urn:ietf:params:jmap:mail": {} } }); let account: SessionAccount = serde_json::from_value(raw).unwrap(); assert!(!account.is_personal); assert!(account.is_read_only); } #[test] fn session_multiple_accounts() { let raw = json!({ "capabilities": {"urn:ietf:params:jmap:core": {}}, "accounts": { "personal": { "name": "Personal", "isPersonal": true, "isReadOnly": false, "accountCapabilities": {} }, "shared": { "name": "Team", "isPersonal": false, "isReadOnly": true, "accountCapabilities": {} } }, "primaryAccounts": { "urn:ietf:params:jmap:mail": "personal" }, "username": "user@example.com", "apiUrl": "https://api.example.com/jmap/", "downloadUrl": "https://api.example.com/download/", "uploadUrl": "https://api.example.com/upload/", "state": "s2" }); let session: JmapSession = serde_json::from_value(raw).unwrap(); assert_eq!(session.accounts.len(), 2); assert!(session.accounts.contains_key("personal")); assert!(session.accounts.contains_key("shared")); assert_eq!(session.primary_email_account(), Some("personal")); } }