zeroclaw/src/tools/microsoft365/graph_client.rs
Giulio V 2dbe4e4812 feat(tools): add Microsoft 365 integration via Graph API
Adds a Microsoft365Tool that provides email, calendar, OneDrive, and
Teams operations through the Microsoft Graph API.

- OAuth2 PKCE flow with encrypted token caching
- Config-gated registration (microsoft365.enabled)
- Full parameter validation and security policy enforcement
- Comprehensive unit tests for auth, graph client, and tool execution
2026-03-15 15:31:20 +01:00

457 lines
12 KiB
Rust

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<serde_json::Value> {
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<serde_json::Value> = 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<serde_json::Value> {
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<serde_json::Value> {
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<String> {
let base = user_path(user_id);
let url = format!("{GRAPH_BASE}{base}/events");
let attendee_list: Vec<serde_json::Value> = 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<serde_json::Value> {
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<Vec<u8>> {
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<serde_json::Value> {
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<serde_json::Value> {
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"
);
}
}