Skip to main content

max / goingson

8.9 KB · 260 lines History Blame Raw
1 //! JMAP session discovery and management.
2 //!
3 //! The session endpoint provides account information and API URLs.
4
5 use serde::Deserialize;
6 use std::collections::HashMap;
7
8 /// JMAP session response from the session endpoint.
9 #[derive(Debug, Clone, Deserialize)]
10 #[serde(rename_all = "camelCase")]
11 pub struct JmapSession {
12 /// Capabilities and their configurations
13 pub capabilities: HashMap<String, serde_json::Value>,
14 /// Accounts accessible to this user
15 pub accounts: HashMap<String, SessionAccount>,
16 /// Primary accounts for each data type
17 pub primary_accounts: HashMap<String, String>,
18 /// Username (email address)
19 pub username: String,
20 /// API URL for method calls
21 pub api_url: String,
22 /// Download URL template for blobs
23 pub download_url: String,
24 /// Upload URL for blobs
25 pub upload_url: String,
26 /// Event source URL for push notifications
27 pub event_source_url: Option<String>,
28 /// Session state for change detection
29 pub state: String,
30 }
31
32 impl JmapSession {
33 /// Gets the primary account ID for email.
34 pub fn primary_email_account(&self) -> Option<&str> {
35 self.primary_accounts
36 .get("urn:ietf:params:jmap:mail")
37 .map(|s| s.as_str())
38 }
39
40 /// Gets the API URL for making JMAP method calls.
41 pub fn api_url(&self) -> &str {
42 &self.api_url
43 }
44 }
45
46 /// Account information in a session.
47 #[derive(Debug, Clone, Deserialize)]
48 #[serde(rename_all = "camelCase")]
49 pub struct SessionAccount {
50 /// Account display name
51 pub name: String,
52 /// Whether this is a personal account
53 pub is_personal: bool,
54 /// Whether this is read-only
55 pub is_read_only: bool,
56 /// Capabilities this account has
57 pub account_capabilities: HashMap<String, serde_json::Value>,
58 }
59
60 /// Discovers the JMAP session for an account.
61 pub async fn discover_session(
62 session_url: &str,
63 access_token: &str,
64 ) -> Result<JmapSession, String> {
65 let client = reqwest::Client::builder()
66 .timeout(std::time::Duration::from_secs(30))
67 .connect_timeout(std::time::Duration::from_secs(10))
68 .build()
69 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
70 let response = client
71 .get(session_url)
72 .bearer_auth(access_token)
73 .send()
74 .await
75 .map_err(|e| format!("Session request failed: {}", e))?;
76
77 if !response.status().is_success() {
78 let status = response.status();
79 let body = response.text().await.unwrap_or_default();
80 return Err(format!("Session request failed ({}): {}", status, body));
81 }
82
83 let session: JmapSession = response
84 .json()
85 .await
86 .map_err(|e| format!("Failed to parse session response: {}", e))?;
87
88 Ok(session)
89 }
90
91 #[cfg(test)]
92 mod tests {
93 use super::*;
94 use serde_json::json;
95
96 fn sample_session_json() -> serde_json::Value {
97 json!({
98 "capabilities": {
99 "urn:ietf:params:jmap:core": {
100 "maxSizeUpload": 50000000,
101 "maxConcurrentUpload": 8,
102 "maxSizeRequest": 10000000,
103 "maxConcurrentRequests": 8,
104 "maxCallsInRequest": 16,
105 "maxObjectsInGet": 4096,
106 "maxObjectsInSet": 4096,
107 "collationAlgorithms": ["i;ascii-numeric", "i;ascii-casemap"]
108 },
109 "urn:ietf:params:jmap:mail": {},
110 "urn:ietf:params:jmap:submission": {}
111 },
112 "accounts": {
113 "acc1": {
114 "name": "user@fastmail.com",
115 "isPersonal": true,
116 "isReadOnly": false,
117 "accountCapabilities": {
118 "urn:ietf:params:jmap:mail": {},
119 "urn:ietf:params:jmap:submission": {}
120 }
121 }
122 },
123 "primaryAccounts": {
124 "urn:ietf:params:jmap:mail": "acc1",
125 "urn:ietf:params:jmap:submission": "acc1"
126 },
127 "username": "user@fastmail.com",
128 "apiUrl": "https://api.fastmail.com/jmap/api/",
129 "downloadUrl": "https://api.fastmail.com/jmap/download/{accountId}/{blobId}/{name}?accept={type}",
130 "uploadUrl": "https://api.fastmail.com/jmap/upload/{accountId}/",
131 "eventSourceUrl": "https://api.fastmail.com/jmap/eventsource/?types={types}&closeafter=state&ping=30",
132 "state": "session_state_abc"
133 })
134 }
135
136 #[test]
137 fn session_deserializes_full_response() {
138 let session: JmapSession = serde_json::from_value(sample_session_json()).unwrap();
139 assert_eq!(session.username, "user@fastmail.com");
140 assert_eq!(session.api_url, "https://api.fastmail.com/jmap/api/");
141 assert!(session.download_url.contains("{blobId}"));
142 assert!(session.upload_url.contains("{accountId}"));
143 assert_eq!(session.state, "session_state_abc");
144 assert!(session.event_source_url.is_some());
145 assert_eq!(session.accounts.len(), 1);
146 assert_eq!(session.capabilities.len(), 3);
147 }
148
149 #[test]
150 fn session_primary_email_account() {
151 let session: JmapSession = serde_json::from_value(sample_session_json()).unwrap();
152 assert_eq!(session.primary_email_account(), Some("acc1"));
153 }
154
155 #[test]
156 fn session_primary_email_account_missing() {
157 let raw = json!({
158 "capabilities": {},
159 "accounts": {},
160 "primaryAccounts": {},
161 "username": "test@example.com",
162 "apiUrl": "https://api.example.com/jmap/api/",
163 "downloadUrl": "https://api.example.com/download/",
164 "uploadUrl": "https://api.example.com/upload/",
165 "state": "s1"
166 });
167 let session: JmapSession = serde_json::from_value(raw).unwrap();
168 assert_eq!(session.primary_email_account(), None);
169 }
170
171 #[test]
172 fn session_api_url_method() {
173 let session: JmapSession = serde_json::from_value(sample_session_json()).unwrap();
174 assert_eq!(session.api_url(), "https://api.fastmail.com/jmap/api/");
175 }
176
177 #[test]
178 fn session_without_event_source() {
179 let raw = json!({
180 "capabilities": {},
181 "accounts": {},
182 "primaryAccounts": {},
183 "username": "test@example.com",
184 "apiUrl": "https://api.example.com/jmap/api/",
185 "downloadUrl": "https://api.example.com/download/",
186 "uploadUrl": "https://api.example.com/upload/",
187 "state": "s1"
188 });
189 let session: JmapSession = serde_json::from_value(raw).unwrap();
190 assert!(session.event_source_url.is_none());
191 }
192
193 #[test]
194 fn session_account_deserializes() {
195 let raw = json!({
196 "name": "Personal",
197 "isPersonal": true,
198 "isReadOnly": false,
199 "accountCapabilities": {
200 "urn:ietf:params:jmap:mail": {},
201 "urn:ietf:params:jmap:submission": {}
202 }
203 });
204 let account: SessionAccount = serde_json::from_value(raw).unwrap();
205 assert_eq!(account.name, "Personal");
206 assert!(account.is_personal);
207 assert!(!account.is_read_only);
208 assert_eq!(account.account_capabilities.len(), 2);
209 }
210
211 #[test]
212 fn session_read_only_shared_account() {
213 let raw = json!({
214 "name": "Shared Mailbox",
215 "isPersonal": false,
216 "isReadOnly": true,
217 "accountCapabilities": {
218 "urn:ietf:params:jmap:mail": {}
219 }
220 });
221 let account: SessionAccount = serde_json::from_value(raw).unwrap();
222 assert!(!account.is_personal);
223 assert!(account.is_read_only);
224 }
225
226 #[test]
227 fn session_multiple_accounts() {
228 let raw = json!({
229 "capabilities": {"urn:ietf:params:jmap:core": {}},
230 "accounts": {
231 "personal": {
232 "name": "Personal",
233 "isPersonal": true,
234 "isReadOnly": false,
235 "accountCapabilities": {}
236 },
237 "shared": {
238 "name": "Team",
239 "isPersonal": false,
240 "isReadOnly": true,
241 "accountCapabilities": {}
242 }
243 },
244 "primaryAccounts": {
245 "urn:ietf:params:jmap:mail": "personal"
246 },
247 "username": "user@example.com",
248 "apiUrl": "https://api.example.com/jmap/",
249 "downloadUrl": "https://api.example.com/download/",
250 "uploadUrl": "https://api.example.com/upload/",
251 "state": "s2"
252 });
253 let session: JmapSession = serde_json::from_value(raw).unwrap();
254 assert_eq!(session.accounts.len(), 2);
255 assert!(session.accounts.contains_key("personal"));
256 assert!(session.accounts.contains_key("shared"));
257 assert_eq!(session.primary_email_account(), Some("personal"));
258 }
259 }
260