Skip to main content

max / goingson

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