Skip to main content

zeroclaw_tools/microsoft365/
graph_client.rs

1use anyhow::Context;
2
3const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
4
5/// Build the user path segment: `/me` or `/users/{user_id}`.
6/// The user_id is percent-encoded to prevent path-traversal attacks.
7fn user_path(user_id: &str) -> String {
8    if user_id == "me" {
9        "/me".to_string()
10    } else {
11        format!("/users/{}", urlencoding::encode(user_id))
12    }
13}
14
15/// Percent-encode a single path segment to prevent path-traversal attacks.
16fn encode_path_segment(segment: &str) -> String {
17    urlencoding::encode(segment).into_owned()
18}
19
20/// List mail messages for a user.
21pub async fn mail_list(
22    client: &reqwest::Client,
23    token: &str,
24    user_id: &str,
25    folder: Option<&str>,
26    top: u32,
27) -> anyhow::Result<serde_json::Value> {
28    let base = user_path(user_id);
29    let path = match folder {
30        Some(f) => format!(
31            "{GRAPH_BASE}{base}/mailFolders/{}/messages",
32            encode_path_segment(f)
33        ),
34        None => format!("{GRAPH_BASE}{base}/messages"),
35    };
36
37    let resp = client
38        .get(&path)
39        .bearer_auth(token)
40        .query(&[("$top", top.to_string())])
41        .send()
42        .await
43        .context("ms365: mail_list request failed")?;
44
45    handle_json_response(resp, "mail_list").await
46}
47
48/// Send a mail message.
49pub async fn mail_send(
50    client: &reqwest::Client,
51    token: &str,
52    user_id: &str,
53    to: &[String],
54    subject: &str,
55    body: &str,
56) -> anyhow::Result<()> {
57    let base = user_path(user_id);
58    let url = format!("{GRAPH_BASE}{base}/sendMail");
59
60    let to_recipients: Vec<serde_json::Value> = to
61        .iter()
62        .map(|addr| {
63            serde_json::json!({
64                "emailAddress": { "address": addr }
65            })
66        })
67        .collect();
68
69    let payload = serde_json::json!({
70        "message": {
71            "subject": subject,
72            "body": {
73                "contentType": "Text",
74                "content": body
75            },
76            "toRecipients": to_recipients
77        }
78    });
79
80    let resp = client
81        .post(&url)
82        .bearer_auth(token)
83        .json(&payload)
84        .send()
85        .await
86        .context("ms365: mail_send request failed")?;
87
88    if !resp.status().is_success() {
89        let status = resp.status();
90        let body = resp.text().await.unwrap_or_default();
91        let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
92        ::zeroclaw_log::record!(
93            DEBUG,
94            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
95                .with_attrs(::serde_json::json!({"body": body})),
96            "ms365: mail_send raw error body"
97        );
98        anyhow::bail!("ms365: mail_send failed ({status}, code={code})");
99    }
100
101    Ok(())
102}
103
104/// List messages in a Teams channel.
105pub async fn teams_message_list(
106    client: &reqwest::Client,
107    token: &str,
108    team_id: &str,
109    channel_id: &str,
110    top: u32,
111) -> anyhow::Result<serde_json::Value> {
112    let url = format!(
113        "{GRAPH_BASE}/teams/{}/channels/{}/messages",
114        encode_path_segment(team_id),
115        encode_path_segment(channel_id)
116    );
117
118    let resp = client
119        .get(&url)
120        .bearer_auth(token)
121        .query(&[("$top", top.to_string())])
122        .send()
123        .await
124        .context("ms365: teams_message_list request failed")?;
125
126    handle_json_response(resp, "teams_message_list").await
127}
128
129/// Send a message to a Teams channel.
130pub async fn teams_message_send(
131    client: &reqwest::Client,
132    token: &str,
133    team_id: &str,
134    channel_id: &str,
135    body: &str,
136) -> anyhow::Result<()> {
137    let url = format!(
138        "{GRAPH_BASE}/teams/{}/channels/{}/messages",
139        encode_path_segment(team_id),
140        encode_path_segment(channel_id)
141    );
142
143    let payload = serde_json::json!({
144        "body": {
145            "content": body
146        }
147    });
148
149    let resp = client
150        .post(&url)
151        .bearer_auth(token)
152        .json(&payload)
153        .send()
154        .await
155        .context("ms365: teams_message_send request failed")?;
156
157    if !resp.status().is_success() {
158        let status = resp.status();
159        let body = resp.text().await.unwrap_or_default();
160        let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
161        ::zeroclaw_log::record!(
162            DEBUG,
163            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
164                .with_attrs(::serde_json::json!({"body": body})),
165            "ms365: teams_message_send raw error body"
166        );
167        anyhow::bail!("ms365: teams_message_send failed ({status}, code={code})");
168    }
169
170    Ok(())
171}
172
173/// List calendar events in a date range.
174pub async fn calendar_events_list(
175    client: &reqwest::Client,
176    token: &str,
177    user_id: &str,
178    start: &str,
179    end: &str,
180    top: u32,
181) -> anyhow::Result<serde_json::Value> {
182    let base = user_path(user_id);
183    let url = format!("{GRAPH_BASE}{base}/calendarView");
184
185    let resp = client
186        .get(&url)
187        .bearer_auth(token)
188        .query(&[
189            ("startDateTime", start.to_string()),
190            ("endDateTime", end.to_string()),
191            ("$top", top.to_string()),
192        ])
193        .send()
194        .await
195        .context("ms365: calendar_events_list request failed")?;
196
197    handle_json_response(resp, "calendar_events_list").await
198}
199
200/// Create a calendar event.
201pub async fn calendar_event_create(
202    client: &reqwest::Client,
203    token: &str,
204    user_id: &str,
205    subject: &str,
206    start: &str,
207    end: &str,
208    attendees: &[String],
209    body_text: Option<&str>,
210) -> anyhow::Result<String> {
211    let base = user_path(user_id);
212    let url = format!("{GRAPH_BASE}{base}/events");
213
214    let attendee_list: Vec<serde_json::Value> = attendees
215        .iter()
216        .map(|email| {
217            serde_json::json!({
218                "emailAddress": { "address": email },
219                "type": "required"
220            })
221        })
222        .collect();
223
224    let mut payload = serde_json::json!({
225        "subject": subject,
226        "start": {
227            "dateTime": start,
228            "timeZone": "UTC"
229        },
230        "end": {
231            "dateTime": end,
232            "timeZone": "UTC"
233        },
234        "attendees": attendee_list
235    });
236
237    if let Some(text) = body_text {
238        payload["body"] = serde_json::json!({
239            "contentType": "Text",
240            "content": text
241        });
242    }
243
244    let resp = client
245        .post(&url)
246        .bearer_auth(token)
247        .json(&payload)
248        .send()
249        .await
250        .context("ms365: calendar_event_create request failed")?;
251
252    let value = handle_json_response(resp, "calendar_event_create").await?;
253    let event_id = value["id"].as_str().unwrap_or("unknown").to_string();
254    Ok(event_id)
255}
256
257/// Delete a calendar event by ID.
258pub async fn calendar_event_delete(
259    client: &reqwest::Client,
260    token: &str,
261    user_id: &str,
262    event_id: &str,
263) -> anyhow::Result<()> {
264    let base = user_path(user_id);
265    let url = format!(
266        "{GRAPH_BASE}{base}/events/{}",
267        encode_path_segment(event_id)
268    );
269
270    let resp = client
271        .delete(&url)
272        .bearer_auth(token)
273        .send()
274        .await
275        .context("ms365: calendar_event_delete request failed")?;
276
277    if !resp.status().is_success() {
278        let status = resp.status();
279        let body = resp.text().await.unwrap_or_default();
280        let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
281        ::zeroclaw_log::record!(
282            DEBUG,
283            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
284                .with_attrs(::serde_json::json!({"body": body})),
285            "ms365: calendar_event_delete raw error body"
286        );
287        anyhow::bail!("ms365: calendar_event_delete failed ({status}, code={code})");
288    }
289
290    Ok(())
291}
292
293/// List children of a OneDrive folder.
294pub async fn onedrive_list(
295    client: &reqwest::Client,
296    token: &str,
297    user_id: &str,
298    path: Option<&str>,
299) -> anyhow::Result<serde_json::Value> {
300    let base = user_path(user_id);
301    let url = match path {
302        Some(p) if !p.is_empty() => {
303            let encoded = urlencoding::encode(p);
304            format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children")
305        }
306        _ => format!("{GRAPH_BASE}{base}/drive/root/children"),
307    };
308
309    let resp = client
310        .get(&url)
311        .bearer_auth(token)
312        .send()
313        .await
314        .context("ms365: onedrive_list request failed")?;
315
316    handle_json_response(resp, "onedrive_list").await
317}
318
319/// Download a OneDrive item by ID, with a maximum size guard.
320pub async fn onedrive_download(
321    client: &reqwest::Client,
322    token: &str,
323    user_id: &str,
324    item_id: &str,
325    max_size: usize,
326) -> anyhow::Result<Vec<u8>> {
327    let base = user_path(user_id);
328    let url = format!(
329        "{GRAPH_BASE}{base}/drive/items/{}/content",
330        encode_path_segment(item_id)
331    );
332
333    let resp = client
334        .get(&url)
335        .bearer_auth(token)
336        .send()
337        .await
338        .context("ms365: onedrive_download request failed")?;
339
340    if !resp.status().is_success() {
341        let status = resp.status();
342        let body = resp.text().await.unwrap_or_default();
343        let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
344        ::zeroclaw_log::record!(
345            DEBUG,
346            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
347                .with_attrs(::serde_json::json!({"body": body})),
348            "ms365: onedrive_download raw error body"
349        );
350        anyhow::bail!("ms365: onedrive_download failed ({status}, code={code})");
351    }
352
353    let bytes = resp
354        .bytes()
355        .await
356        .context("ms365: failed to read download body")?;
357    if bytes.len() > max_size {
358        anyhow::bail!(
359            "ms365: downloaded file exceeds max_size ({} > {max_size})",
360            bytes.len()
361        );
362    }
363
364    Ok(bytes.to_vec())
365}
366
367/// Search SharePoint for documents matching a query.
368pub async fn sharepoint_search(
369    client: &reqwest::Client,
370    token: &str,
371    query: &str,
372    top: u32,
373) -> anyhow::Result<serde_json::Value> {
374    let url = format!("{GRAPH_BASE}/search/query");
375
376    let payload = serde_json::json!({
377        "requests": [{
378            "entityTypes": ["driveItem", "listItem", "site"],
379            "query": {
380                "queryString": query
381            },
382            "from": 0,
383            "size": top
384        }]
385    });
386
387    let resp = client
388        .post(&url)
389        .bearer_auth(token)
390        .json(&payload)
391        .send()
392        .await
393        .context("ms365: sharepoint_search request failed")?;
394
395    handle_json_response(resp, "sharepoint_search").await
396}
397
398/// Extract a short, safe error code from a Graph API JSON error body.
399/// Returns `None` when the body is not a recognised Graph error envelope.
400fn extract_graph_error_code(body: &str) -> Option<String> {
401    let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
402    parsed
403        .get("error")
404        .and_then(|e| e.get("code"))
405        .and_then(|c| c.as_str())
406        .map(|s| s.to_string())
407}
408
409/// Parse a JSON response body, returning an error on non-success status.
410/// Raw Graph API error bodies are not propagated; only the HTTP status and a
411/// short error code (when available) are surfaced to avoid leaking internal
412/// API details.
413async fn handle_json_response(
414    resp: reqwest::Response,
415    operation: &str,
416) -> anyhow::Result<serde_json::Value> {
417    if !resp.status().is_success() {
418        let status = resp.status();
419        let body = resp.text().await.unwrap_or_default();
420        let code = extract_graph_error_code(&body).unwrap_or_else(|| "unknown".to_string());
421        ::zeroclaw_log::record!(
422            DEBUG,
423            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
424                .with_attrs(::serde_json::json!({"operation": operation, "body": body})),
425            "ms365: raw error body"
426        );
427        anyhow::bail!("ms365: {operation} failed ({status}, code={code})");
428    }
429
430    resp.json()
431        .await
432        .with_context(|| format!("ms365: failed to parse {operation} response"))
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn user_path_me() {
441        assert_eq!(user_path("me"), "/me");
442    }
443
444    #[test]
445    fn user_path_specific_user() {
446        assert_eq!(user_path("user@contoso.com"), "/users/user%40contoso.com");
447    }
448
449    #[test]
450    fn mail_list_url_no_folder() {
451        let base = user_path("me");
452        let url = format!("{GRAPH_BASE}{base}/messages");
453        assert_eq!(url, "https://graph.microsoft.com/v1.0/me/messages");
454    }
455
456    #[test]
457    fn mail_list_url_with_folder() {
458        let base = user_path("me");
459        let folder = "inbox";
460        let url = format!(
461            "{GRAPH_BASE}{base}/mailFolders/{}/messages",
462            encode_path_segment(folder)
463        );
464        assert_eq!(
465            url,
466            "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
467        );
468    }
469
470    #[test]
471    fn calendar_view_url() {
472        let base = user_path("user@example.com");
473        let url = format!("{GRAPH_BASE}{base}/calendarView");
474        assert_eq!(
475            url,
476            "https://graph.microsoft.com/v1.0/users/user%40example.com/calendarView"
477        );
478    }
479
480    #[test]
481    fn teams_message_url() {
482        let url = format!(
483            "{GRAPH_BASE}/teams/{}/channels/{}/messages",
484            encode_path_segment("team-123"),
485            encode_path_segment("channel-456")
486        );
487        assert_eq!(
488            url,
489            "https://graph.microsoft.com/v1.0/teams/team-123/channels/channel-456/messages"
490        );
491    }
492
493    #[test]
494    fn onedrive_root_url() {
495        let base = user_path("me");
496        let url = format!("{GRAPH_BASE}{base}/drive/root/children");
497        assert_eq!(
498            url,
499            "https://graph.microsoft.com/v1.0/me/drive/root/children"
500        );
501    }
502
503    #[test]
504    fn onedrive_path_url() {
505        let base = user_path("me");
506        let encoded = urlencoding::encode("Documents/Reports");
507        let url = format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children");
508        assert_eq!(
509            url,
510            "https://graph.microsoft.com/v1.0/me/drive/root:/Documents%2FReports:/children"
511        );
512    }
513
514    #[test]
515    fn sharepoint_search_url() {
516        let url = format!("{GRAPH_BASE}/search/query");
517        assert_eq!(url, "https://graph.microsoft.com/v1.0/search/query");
518    }
519}