use anyhow::Context; const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0"; /// Build the user path segment: `/me` or `/users/{user_id}`. fn user_path(user_id: &str) -> String { if user_id == "me" { "/me".to_string() } else { format!("/users/{user_id}") } } /// List mail messages for a user. pub async fn mail_list( client: &reqwest::Client, token: &str, user_id: &str, folder: Option<&str>, top: u32, ) -> anyhow::Result { let base = user_path(user_id); let path = match folder { Some(f) => format!("{GRAPH_BASE}{base}/mailFolders/{f}/messages"), None => format!("{GRAPH_BASE}{base}/messages"), }; let resp = client .get(&path) .bearer_auth(token) .query(&[("$top", top.to_string())]) .send() .await .context("ms365: mail_list request failed")?; handle_json_response(resp, "mail_list").await } /// Send a mail message. pub async fn mail_send( client: &reqwest::Client, token: &str, user_id: &str, to: &[String], subject: &str, body: &str, ) -> anyhow::Result<()> { let base = user_path(user_id); let url = format!("{GRAPH_BASE}{base}/sendMail"); let to_recipients: Vec = to .iter() .map(|addr| { serde_json::json!({ "emailAddress": { "address": addr } }) }) .collect(); let payload = serde_json::json!({ "message": { "subject": subject, "body": { "contentType": "Text", "content": body }, "toRecipients": to_recipients } }); let resp = client .post(&url) .bearer_auth(token) .json(&payload) .send() .await .context("ms365: mail_send request failed")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!("ms365: mail_send failed ({status}): {body}"); } Ok(()) } /// List messages in a Teams channel. pub async fn teams_message_list( client: &reqwest::Client, token: &str, team_id: &str, channel_id: &str, top: u32, ) -> anyhow::Result { let url = format!( "{GRAPH_BASE}/teams/{team_id}/channels/{channel_id}/messages" ); let resp = client .get(&url) .bearer_auth(token) .query(&[("$top", top.to_string())]) .send() .await .context("ms365: teams_message_list request failed")?; handle_json_response(resp, "teams_message_list").await } /// Send a message to a Teams channel. pub async fn teams_message_send( client: &reqwest::Client, token: &str, team_id: &str, channel_id: &str, body: &str, ) -> anyhow::Result<()> { let url = format!( "{GRAPH_BASE}/teams/{team_id}/channels/{channel_id}/messages" ); let payload = serde_json::json!({ "body": { "content": body } }); let resp = client .post(&url) .bearer_auth(token) .json(&payload) .send() .await .context("ms365: teams_message_send request failed")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!("ms365: teams_message_send failed ({status}): {body}"); } Ok(()) } /// List calendar events in a date range. pub async fn calendar_events_list( client: &reqwest::Client, token: &str, user_id: &str, start: &str, end: &str, top: u32, ) -> anyhow::Result { let base = user_path(user_id); let url = format!("{GRAPH_BASE}{base}/calendarView"); let resp = client .get(&url) .bearer_auth(token) .query(&[ ("startDateTime", start.to_string()), ("endDateTime", end.to_string()), ("$top", top.to_string()), ]) .send() .await .context("ms365: calendar_events_list request failed")?; handle_json_response(resp, "calendar_events_list").await } /// Create a calendar event. pub async fn calendar_event_create( client: &reqwest::Client, token: &str, user_id: &str, subject: &str, start: &str, end: &str, attendees: &[String], body_text: Option<&str>, ) -> anyhow::Result { let base = user_path(user_id); let url = format!("{GRAPH_BASE}{base}/events"); let attendee_list: Vec = attendees .iter() .map(|email| { serde_json::json!({ "emailAddress": { "address": email }, "type": "required" }) }) .collect(); let mut payload = serde_json::json!({ "subject": subject, "start": { "dateTime": start, "timeZone": "UTC" }, "end": { "dateTime": end, "timeZone": "UTC" }, "attendees": attendee_list }); if let Some(text) = body_text { payload["body"] = serde_json::json!({ "contentType": "Text", "content": text }); } let resp = client .post(&url) .bearer_auth(token) .json(&payload) .send() .await .context("ms365: calendar_event_create request failed")?; let value = handle_json_response(resp, "calendar_event_create").await?; let event_id = value["id"] .as_str() .unwrap_or("unknown") .to_string(); Ok(event_id) } /// Delete a calendar event by ID. pub async fn calendar_event_delete( client: &reqwest::Client, token: &str, user_id: &str, event_id: &str, ) -> anyhow::Result<()> { let base = user_path(user_id); let url = format!("{GRAPH_BASE}{base}/events/{event_id}"); let resp = client .delete(&url) .bearer_auth(token) .send() .await .context("ms365: calendar_event_delete request failed")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!("ms365: calendar_event_delete failed ({status}): {body}"); } Ok(()) } /// List children of a OneDrive folder. pub async fn onedrive_list( client: &reqwest::Client, token: &str, user_id: &str, path: Option<&str>, ) -> anyhow::Result { let base = user_path(user_id); let url = match path { Some(p) if !p.is_empty() => { let encoded = urlencoding::encode(p); format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children") } _ => format!("{GRAPH_BASE}{base}/drive/root/children"), }; let resp = client .get(&url) .bearer_auth(token) .send() .await .context("ms365: onedrive_list request failed")?; handle_json_response(resp, "onedrive_list").await } /// Download a OneDrive item by ID, with a maximum size guard. pub async fn onedrive_download( client: &reqwest::Client, token: &str, user_id: &str, item_id: &str, max_size: usize, ) -> anyhow::Result> { let base = user_path(user_id); let url = format!("{GRAPH_BASE}{base}/drive/items/{item_id}/content"); let resp = client .get(&url) .bearer_auth(token) .send() .await .context("ms365: onedrive_download request failed")?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!("ms365: onedrive_download failed ({status}): {body}"); } let bytes = resp .bytes() .await .context("ms365: failed to read download body")?; if bytes.len() > max_size { anyhow::bail!( "ms365: downloaded file exceeds max_size ({} > {max_size})", bytes.len() ); } Ok(bytes.to_vec()) } /// Search SharePoint for documents matching a query. pub async fn sharepoint_search( client: &reqwest::Client, token: &str, query: &str, top: u32, ) -> anyhow::Result { let url = format!("{GRAPH_BASE}/search/query"); let payload = serde_json::json!({ "requests": [{ "entityTypes": ["driveItem", "listItem", "site"], "query": { "queryString": query }, "from": 0, "size": top }] }); let resp = client .post(&url) .bearer_auth(token) .json(&payload) .send() .await .context("ms365: sharepoint_search request failed")?; handle_json_response(resp, "sharepoint_search").await } /// Parse a JSON response body, returning an error on non-success status. async fn handle_json_response( resp: reqwest::Response, operation: &str, ) -> anyhow::Result { if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); anyhow::bail!("ms365: {operation} failed ({status}): {body}"); } resp.json() .await .with_context(|| format!("ms365: failed to parse {operation} response")) } #[cfg(test)] mod tests { use super::*; #[test] fn user_path_me() { assert_eq!(user_path("me"), "/me"); } #[test] fn user_path_specific_user() { assert_eq!( user_path("user@contoso.com"), "/users/user@contoso.com" ); } #[test] fn mail_list_url_no_folder() { let base = user_path("me"); let url = format!("{GRAPH_BASE}{base}/messages"); assert_eq!(url, "https://graph.microsoft.com/v1.0/me/messages"); } #[test] fn mail_list_url_with_folder() { let base = user_path("me"); let folder = "inbox"; let url = format!("{GRAPH_BASE}{base}/mailFolders/{folder}/messages"); assert_eq!( url, "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages" ); } #[test] fn calendar_view_url() { let base = user_path("user@example.com"); let url = format!("{GRAPH_BASE}{base}/calendarView"); assert_eq!( url, "https://graph.microsoft.com/v1.0/users/user@example.com/calendarView" ); } #[test] fn teams_message_url() { let url = format!( "{GRAPH_BASE}/teams/{}/channels/{}/messages", "team-123", "channel-456" ); assert_eq!( url, "https://graph.microsoft.com/v1.0/teams/team-123/channels/channel-456/messages" ); } #[test] fn onedrive_root_url() { let base = user_path("me"); let url = format!("{GRAPH_BASE}{base}/drive/root/children"); assert_eq!( url, "https://graph.microsoft.com/v1.0/me/drive/root/children" ); } #[test] fn onedrive_path_url() { let base = user_path("me"); let encoded = urlencoding::encode("Documents/Reports"); let url = format!("{GRAPH_BASE}{base}/drive/root:/{encoded}:/children"); assert_eq!( url, "https://graph.microsoft.com/v1.0/me/drive/root:/Documents%2FReports:/children" ); } #[test] fn sharepoint_search_url() { let url = format!("{GRAPH_BASE}/search/query"); assert_eq!( url, "https://graph.microsoft.com/v1.0/search/query" ); } }