Skip to main content

max / goingson

5.7 KB · 168 lines History Blame Raw
1 //! JMAP HTTP client for making method calls.
2
3 use super::session::{discover_session, JmapSession};
4 use super::types::{JmapRequest, JmapResponse, MethodResponse};
5 use std::time::Duration;
6 use tracing::instrument;
7
8 /// JMAP client for making API calls.
9 pub struct JmapClient {
10 /// HTTP client
11 client: reqwest::Client,
12 /// Access token
13 access_token: String,
14 /// Cached session
15 session: Option<JmapSession>,
16 /// Session discovery URL
17 session_url: String,
18 }
19
20 impl JmapClient {
21 /// Creates a new JMAP client.
22 ///
23 /// # Arguments
24 /// * `session_url` - The session discovery URL (e.g., https://api.fastmail.com/jmap/session)
25 /// * `access_token` - OAuth2 access token
26 pub fn new(session_url: impl Into<String>, access_token: impl Into<String>) -> Result<Self, String> {
27 let client = reqwest::Client::builder()
28 .timeout(Duration::from_secs(30))
29 .connect_timeout(Duration::from_secs(10))
30 .build()
31 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
32 Ok(Self {
33 client,
34 access_token: access_token.into(),
35 session: None,
36 session_url: session_url.into(),
37 })
38 }
39
40 /// Creates a client with a pre-fetched session.
41 pub fn with_session(
42 session: JmapSession,
43 access_token: impl Into<String>,
44 session_url: impl Into<String>,
45 ) -> Result<Self, String> {
46 let client = reqwest::Client::builder()
47 .timeout(Duration::from_secs(30))
48 .connect_timeout(Duration::from_secs(10))
49 .build()
50 .map_err(|e| format!("Failed to build HTTP client: {}", e))?;
51 Ok(Self {
52 client,
53 access_token: access_token.into(),
54 session: Some(session),
55 session_url: session_url.into(),
56 })
57 }
58
59 /// Gets or discovers the JMAP session.
60 #[instrument(skip_all)]
61 pub async fn session(&mut self) -> Result<&JmapSession, String> {
62 if self.session.is_none() {
63 let session = discover_session(&self.session_url, &self.access_token).await?;
64 self.session = Some(session);
65 }
66 self.session
67 .as_ref()
68 .ok_or_else(|| "Failed to populate JMAP session".to_string())
69 }
70
71 /// Forces a session refresh.
72 #[instrument(skip_all)]
73 pub async fn refresh_session(&mut self) -> Result<&JmapSession, String> {
74 let session = discover_session(&self.session_url, &self.access_token).await?;
75 self.session = Some(session);
76 self.session
77 .as_ref()
78 .ok_or_else(|| "Failed to populate JMAP session".to_string())
79 }
80
81 /// Gets the primary email account ID.
82 pub async fn account_id(&mut self) -> Result<String, String> {
83 let session = self.session().await?;
84 session
85 .primary_email_account()
86 .map(|s| s.to_string())
87 .ok_or_else(|| "No primary email account found".to_string())
88 }
89
90 /// Gets the API URL for method calls.
91 pub async fn api_url(&mut self) -> Result<String, String> {
92 let session = self.session().await?;
93 Ok(session.api_url().to_string())
94 }
95
96 /// Gets the username (email address) from the session.
97 pub async fn username(&mut self) -> Result<String, String> {
98 let session = self.session().await?;
99 Ok(session.username.clone())
100 }
101
102 /// Executes a JMAP request and returns the response.
103 #[instrument(skip_all)]
104 pub async fn execute(&mut self, request: JmapRequest) -> Result<JmapResponse, String> {
105 let api_url = self.api_url().await?;
106 // `apiUrl` is server-supplied; never POST the bearer token to a
107 // cleartext endpoint.
108 super::session::require_https(&api_url)?;
109
110 let response = self
111 .client
112 .post(&api_url)
113 .bearer_auth(&self.access_token)
114 .json(&request)
115 .send()
116 .await
117 .map_err(|e| format!("JMAP request failed: {}", e))?;
118
119 if !response.status().is_success() {
120 let status = response.status();
121 let body = response.text().await.unwrap_or_default();
122 return Err(format!("JMAP request failed ({}): {}", status, body));
123 }
124
125 let jmap_response: JmapResponse = response
126 .json()
127 .await
128 .map_err(|e| format!("Failed to parse JMAP response: {}", e))?;
129
130 // Check for method-level errors
131 for method_response in &jmap_response.method_responses {
132 if method_response.method == "error" {
133 let error_type = method_response.data["type"].as_str().unwrap_or("unknown");
134 let description = method_response.data["description"]
135 .as_str()
136 .unwrap_or("Unknown error");
137 return Err(format!("JMAP error ({}): {}", error_type, description));
138 }
139 }
140
141 Ok(jmap_response)
142 }
143
144 /// Executes a single method call and returns its response.
145 #[instrument(skip_all, fields(jmap.method = %method))]
146 pub async fn call(
147 &mut self,
148 method: &str,
149 args: serde_json::Value,
150 ) -> Result<MethodResponse, String> {
151 let mut request = JmapRequest::new();
152 request.add_call(method, args, "c0");
153
154 let response = self.execute(request).await?;
155
156 response
157 .method_responses
158 .into_iter()
159 .next()
160 .ok_or_else(|| "No response from JMAP method".to_string())
161 }
162
163 /// Updates the access token (after a token refresh).
164 pub fn update_token(&mut self, access_token: impl Into<String>) {
165 self.access_token = access_token.into();
166 }
167 }
168