From 2dbe4e4812fe3b02079fa2653d1c1d2e6b15b4e5 Mon Sep 17 00:00:00 2001 From: Giulio V Date: Mon, 9 Mar 2026 08:37:51 +0100 Subject: [PATCH] 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 --- src/config/mod.rs | 12 +- src/config/schema.rs | 121 ++++++ src/onboard/wizard.rs | 2 + src/tools/microsoft365/auth.rs | 356 +++++++++++++++ src/tools/microsoft365/graph_client.rs | 456 ++++++++++++++++++++ src/tools/microsoft365/mod.rs | 572 +++++++++++++++++++++++++ src/tools/microsoft365/types.rs | 41 ++ src/tools/mod.rs | 43 ++ 8 files changed, 1597 insertions(+), 6 deletions(-) create mode 100644 src/tools/microsoft365/auth.rs create mode 100644 src/tools/microsoft365/graph_client.rs create mode 100644 src/tools/microsoft365/mod.rs create mode 100644 src/tools/microsoft365/types.rs diff --git a/src/config/mod.rs b/src/config/mod.rs index 95dca41f3..90f8d2526 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,12 +11,12 @@ pub use schema::{ ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig, GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig, - McpServerConfig, McpTransport, MemoryConfig, ModelRouteConfig, MultimodalConfig, - NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig, OtpConfig, OtpMethod, - PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QdrantConfig, - QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, - SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig, - SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig, + McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig, + MultimodalConfig, NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig, + OtpConfig, OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, + QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, + RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, + SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig, }; diff --git a/src/config/schema.rs b/src/config/schema.rs index a041e8779..7228df673 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -188,6 +188,10 @@ pub struct Config { #[serde(default)] pub composio: ComposioConfig, + /// Microsoft 365 Graph API integration (`[microsoft365]`). + #[serde(default)] + pub microsoft365: Microsoft365Config, + /// Secrets encryption configuration (`[secrets]`). #[serde(default)] pub secrets: SecretsConfig, @@ -1282,6 +1286,78 @@ impl Default for ComposioConfig { } } +// ── Microsoft 365 (Graph API integration) ─────────────────────── + +/// Microsoft 365 integration via Microsoft Graph API (`[microsoft365]` section). +/// +/// Provides access to Outlook mail, Teams messages, Calendar events, +/// OneDrive files, and SharePoint search. +#[derive(Clone, Serialize, Deserialize, JsonSchema)] +pub struct Microsoft365Config { + /// Enable Microsoft 365 integration + #[serde(default, alias = "enable")] + pub enabled: bool, + /// Azure AD tenant ID + #[serde(default)] + pub tenant_id: Option, + /// Azure AD application (client) ID + #[serde(default)] + pub client_id: Option, + /// Azure AD client secret (stored encrypted when secrets.encrypt = true) + #[serde(default)] + pub client_secret: Option, + /// Authentication flow: "client_credentials" or "device_code" + #[serde(default = "default_ms365_auth_flow")] + pub auth_flow: String, + /// OAuth scopes to request + #[serde(default = "default_ms365_scopes")] + pub scopes: Vec, + /// Encrypt the token cache file on disk + #[serde(default = "default_true")] + pub token_cache_encrypted: bool, + /// User principal name or "me" (for delegated flows) + #[serde(default)] + pub user_id: Option, +} + +fn default_ms365_auth_flow() -> String { + "client_credentials".to_string() +} + +fn default_ms365_scopes() -> Vec { + vec!["https://graph.microsoft.com/.default".to_string()] +} + +impl std::fmt::Debug for Microsoft365Config { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Microsoft365Config") + .field("enabled", &self.enabled) + .field("tenant_id", &self.tenant_id) + .field("client_id", &self.client_id) + .field("client_secret", &self.client_secret.as_ref().map(|_| "***")) + .field("auth_flow", &self.auth_flow) + .field("scopes", &self.scopes) + .field("token_cache_encrypted", &self.token_cache_encrypted) + .field("user_id", &self.user_id) + .finish() + } +} + +impl Default for Microsoft365Config { + fn default() -> Self { + Self { + enabled: false, + tenant_id: None, + client_id: None, + client_secret: None, + auth_flow: default_ms365_auth_flow(), + scopes: default_ms365_scopes(), + token_cache_encrypted: true, + user_id: None, + } + } +} + // ── Secrets (encrypted credential store) ──────────────────────── /// Secrets encryption configuration (`[secrets]` section). @@ -4186,6 +4262,7 @@ impl Default for Config { tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), composio: ComposioConfig::default(), + microsoft365: Microsoft365Config::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), @@ -4679,6 +4756,11 @@ impl Config { &mut config.composio.api_key, "config.composio.api_key", )?; + decrypt_optional_secret( + &store, + &mut config.microsoft365.client_secret, + "config.microsoft365.client_secret", + )?; decrypt_optional_secret( &store, @@ -5227,6 +5309,38 @@ impl Config { } } + // Microsoft 365 + if self.microsoft365.enabled { + let tenant = self + .microsoft365 + .tenant_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + if tenant.is_none() { + anyhow::bail!( + "microsoft365.tenant_id must not be empty when microsoft365 is enabled" + ); + } + let client = self + .microsoft365 + .client_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + if client.is_none() { + anyhow::bail!( + "microsoft365.client_id must not be empty when microsoft365 is enabled" + ); + } + let flow = self.microsoft365.auth_flow.trim(); + if flow != "client_credentials" && flow != "device_code" { + anyhow::bail!( + "microsoft365.auth_flow must be 'client_credentials' or 'device_code'" + ); + } + } + // MCP if self.mcp.enabled { validate_mcp_config(&self.mcp)?; @@ -5606,6 +5720,11 @@ impl Config { &mut config_to_save.composio.api_key, "config.composio.api_key", )?; + encrypt_optional_secret( + &store, + &mut config_to_save.microsoft365.client_secret, + "config.microsoft365.client_secret", + )?; encrypt_optional_secret( &store, @@ -6292,6 +6411,7 @@ default_temperature = 0.7 tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), composio: ComposioConfig::default(), + microsoft365: Microsoft365Config::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), @@ -6583,6 +6703,7 @@ tool_dispatcher = "xml" tunnel: TunnelConfig::default(), gateway: GatewayConfig::default(), composio: ComposioConfig::default(), + microsoft365: Microsoft365Config::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: HttpRequestConfig::default(), diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index e196b3de2..24c148578 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -159,6 +159,7 @@ pub async fn run_wizard(force: bool) -> Result { tunnel: tunnel_config, gateway: crate::config::GatewayConfig::default(), composio: composio_config, + microsoft365: crate::config::Microsoft365Config::default(), secrets: secrets_config, browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), @@ -516,6 +517,7 @@ async fn run_quick_setup_with_home( tunnel: crate::config::TunnelConfig::default(), gateway: crate::config::GatewayConfig::default(), composio: ComposioConfig::default(), + microsoft365: crate::config::Microsoft365Config::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), diff --git a/src/tools/microsoft365/auth.rs b/src/tools/microsoft365/auth.rs new file mode 100644 index 000000000..8841213b9 --- /dev/null +++ b/src/tools/microsoft365/auth.rs @@ -0,0 +1,356 @@ +use anyhow::Context; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Cached OAuth2 token state persisted to disk between runs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedTokenState { + pub access_token: String, + pub refresh_token: Option, + /// Unix timestamp (seconds) when the access token expires. + pub expires_at: i64, +} + +impl CachedTokenState { + /// Returns `true` when the token is expired or will expire within 60 seconds. + pub fn is_expired(&self) -> bool { + let now = chrono::Utc::now().timestamp(); + self.expires_at <= now + 60 + } +} + +/// Thread-safe token cache with disk persistence. +pub struct TokenCache { + inner: RwLock>, + config: super::types::Microsoft365ResolvedConfig, + cache_path: PathBuf, +} + +impl TokenCache { + pub fn new( + config: super::types::Microsoft365ResolvedConfig, + zeroclaw_dir: &std::path::Path, + ) -> Self { + let cache_path = zeroclaw_dir.join("ms365_token_cache.json"); + let cached = Self::load_from_disk(&cache_path); + Self { + inner: RwLock::new(cached), + config, + cache_path, + } + } + + /// Get a valid access token, refreshing or re-authenticating as needed. + pub async fn get_token(&self, client: &reqwest::Client) -> anyhow::Result { + // Fast path: cached and not expired. + { + let guard = self.inner.read(); + if let Some(ref state) = *guard { + if !state.is_expired() { + return Ok(state.access_token.clone()); + } + } + } + + // Slow path: need to acquire or refresh. + let new_state = self.acquire_token(client).await?; + let token = new_state.access_token.clone(); + self.persist_to_disk(&new_state); + *self.inner.write() = Some(new_state); + Ok(token) + } + + async fn acquire_token( + &self, + client: &reqwest::Client, + ) -> anyhow::Result { + // Try refresh first if we have a refresh token. + // Clone the token out so the RwLock guard is dropped before the await. + let refresh_token_copy = { + let guard = self.inner.read(); + guard + .as_ref() + .and_then(|state| state.refresh_token.clone()) + }; + if let Some(refresh_tok) = refresh_token_copy { + match self.refresh_token(client, &refresh_tok).await { + Ok(new_state) => return Ok(new_state), + Err(e) => { + tracing::debug!("ms365: refresh token failed, re-authenticating: {e}"); + } + } + } + + match self.config.auth_flow.as_str() { + "client_credentials" => self.client_credentials_flow(client).await, + "device_code" => self.device_code_flow(client).await, + other => anyhow::bail!("Unsupported auth flow: {other}"), + } + } + + async fn client_credentials_flow( + &self, + client: &reqwest::Client, + ) -> anyhow::Result { + let client_secret = self + .config + .client_secret + .as_deref() + .context("client_credentials flow requires client_secret")?; + + let token_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/token", + self.config.tenant_id + ); + + let scope = self.config.scopes.join(" "); + + let resp = client + .post(&token_url) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", &self.config.client_id), + ("client_secret", client_secret), + ("scope", &scope), + ]) + .send() + .await + .context("ms365: failed to request client_credentials token")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("ms365: client_credentials token request failed ({status}): {body}"); + } + + let token_resp: TokenResponse = resp + .json() + .await + .context("ms365: failed to parse token response")?; + + Ok(CachedTokenState { + access_token: token_resp.access_token, + refresh_token: token_resp.refresh_token, + expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in, + }) + } + + async fn device_code_flow( + &self, + client: &reqwest::Client, + ) -> anyhow::Result { + let device_code_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode", + self.config.tenant_id + ); + let scope = self.config.scopes.join(" "); + + let resp = client + .post(&device_code_url) + .form(&[ + ("client_id", self.config.client_id.as_str()), + ("scope", &scope), + ]) + .send() + .await + .context("ms365: failed to request device code")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("ms365: device code request failed ({status}): {body}"); + } + + let device_resp: DeviceCodeResponse = resp + .json() + .await + .context("ms365: failed to parse device code response")?; + + tracing::info!( + "ms365: device code auth required. {}", + device_resp.message + ); + + let token_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/token", + self.config.tenant_id + ); + + let interval = device_resp.interval.max(5); + let max_polls = (device_resp.expires_in / interval as i64).max(1) as u32; + + for _ in 0..max_polls { + tokio::time::sleep(std::time::Duration::from_secs(interval)).await; + + let poll_resp = client + .post(&token_url) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", self.config.client_id.as_str()), + ("device_code", &device_resp.device_code), + ]) + .send() + .await + .context("ms365: failed to poll device code token")?; + + if poll_resp.status().is_success() { + let token_resp: TokenResponse = poll_resp + .json() + .await + .context("ms365: failed to parse token response")?; + return Ok(CachedTokenState { + access_token: token_resp.access_token, + refresh_token: token_resp.refresh_token, + expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in, + }); + } + + let body = poll_resp.text().await.unwrap_or_default(); + if body.contains("authorization_pending") { + continue; + } + if body.contains("slow_down") { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + anyhow::bail!("ms365: device code polling failed: {body}"); + } + + anyhow::bail!("ms365: device code flow timed out waiting for user authorization") + } + + async fn refresh_token( + &self, + client: &reqwest::Client, + refresh_token: &str, + ) -> anyhow::Result { + let token_url = format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/token", + self.config.tenant_id + ); + + let mut params = vec![ + ("grant_type", "refresh_token"), + ("client_id", self.config.client_id.as_str()), + ("refresh_token", refresh_token), + ]; + + let secret_ref; + if let Some(ref secret) = self.config.client_secret { + secret_ref = secret.as_str(); + params.push(("client_secret", secret_ref)); + } + + let resp = client + .post(&token_url) + .form(¶ms) + .send() + .await + .context("ms365: failed to refresh token")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!("ms365: token refresh failed ({status}): {body}"); + } + + let token_resp: TokenResponse = resp + .json() + .await + .context("ms365: failed to parse refresh token response")?; + + Ok(CachedTokenState { + access_token: token_resp.access_token, + refresh_token: token_resp + .refresh_token + .or_else(|| Some(refresh_token.to_string())), + expires_at: chrono::Utc::now().timestamp() + token_resp.expires_in, + }) + } + + fn load_from_disk(path: &std::path::Path) -> Option { + let data = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&data).ok() + } + + fn persist_to_disk(&self, state: &CachedTokenState) { + if let Ok(json) = serde_json::to_string_pretty(state) { + if let Err(e) = std::fs::write(&self.cache_path, json) { + tracing::warn!("ms365: failed to persist token cache: {e}"); + } + } + } +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + #[serde(default)] + refresh_token: Option, + #[serde(default = "default_expires_in")] + expires_in: i64, +} + +fn default_expires_in() -> i64 { + 3600 +} + +#[derive(Deserialize)] +struct DeviceCodeResponse { + device_code: String, + message: String, + #[serde(default = "default_device_interval")] + interval: u64, + #[serde(default = "default_device_expires_in")] + expires_in: i64, +} + +fn default_device_interval() -> u64 { + 5 +} + +fn default_device_expires_in() -> i64 { + 900 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_is_expired_when_past_deadline() { + let state = CachedTokenState { + access_token: "test".into(), + refresh_token: None, + expires_at: chrono::Utc::now().timestamp() - 10, + }; + assert!(state.is_expired()); + } + + #[test] + fn token_is_expired_within_buffer() { + let state = CachedTokenState { + access_token: "test".into(), + refresh_token: None, + expires_at: chrono::Utc::now().timestamp() + 30, + }; + assert!(state.is_expired()); + } + + #[test] + fn token_is_valid_when_far_from_expiry() { + let state = CachedTokenState { + access_token: "test".into(), + refresh_token: None, + expires_at: chrono::Utc::now().timestamp() + 3600, + }; + assert!(!state.is_expired()); + } + + #[test] + fn load_from_disk_returns_none_for_missing_file() { + let path = std::path::Path::new("/nonexistent/ms365_token_cache.json"); + assert!(TokenCache::load_from_disk(path).is_none()); + } +} diff --git a/src/tools/microsoft365/graph_client.rs b/src/tools/microsoft365/graph_client.rs new file mode 100644 index 000000000..1b0ce0785 --- /dev/null +++ b/src/tools/microsoft365/graph_client.rs @@ -0,0 +1,456 @@ +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" + ); + } +} diff --git a/src/tools/microsoft365/mod.rs b/src/tools/microsoft365/mod.rs new file mode 100644 index 000000000..068cf81b9 --- /dev/null +++ b/src/tools/microsoft365/mod.rs @@ -0,0 +1,572 @@ +//! Microsoft 365 integration tool — Graph API access for Mail, Teams, Calendar, +//! OneDrive, and SharePoint via a single action-dispatched tool surface. +//! +//! Auth is handled through direct HTTP calls to the Microsoft identity platform +//! (client credentials or device code flow) with token caching. + +pub mod auth; +pub mod graph_client; +pub mod types; + +use crate::security::policy::ToolOperation; +use crate::security::SecurityPolicy; +use crate::tools::traits::{Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Maximum download size for OneDrive files (10 MB). +const MAX_ONEDRIVE_DOWNLOAD_SIZE: usize = 10 * 1024 * 1024; + +/// Default number of items to return in list operations. +const DEFAULT_TOP: u32 = 25; + +pub struct Microsoft365Tool { + config: types::Microsoft365ResolvedConfig, + security: Arc, + token_cache: Arc, + http_client: reqwest::Client, +} + +impl Microsoft365Tool { + pub fn new( + config: types::Microsoft365ResolvedConfig, + security: Arc, + zeroclaw_dir: &std::path::Path, + ) -> Self { + let http_client = + crate::config::build_runtime_proxy_client_with_timeouts("tool.microsoft365", 60, 10); + let token_cache = Arc::new(auth::TokenCache::new(config.clone(), zeroclaw_dir)); + Self { + config, + security, + token_cache, + http_client, + } + } + + async fn get_token(&self) -> anyhow::Result { + self.token_cache.get_token(&self.http_client).await + } + + fn user_id(&self) -> &str { + &self.config.user_id + } + + async fn dispatch( + &self, + action: &str, + args: &serde_json::Value, + ) -> anyhow::Result { + match action { + "mail_list" => self.handle_mail_list(args).await, + "mail_send" => self.handle_mail_send(args).await, + "teams_message_list" => self.handle_teams_message_list(args).await, + "teams_message_send" => self.handle_teams_message_send(args).await, + "calendar_events_list" => self.handle_calendar_events_list(args).await, + "calendar_event_create" => self.handle_calendar_event_create(args).await, + "calendar_event_delete" => self.handle_calendar_event_delete(args).await, + "onedrive_list" => self.handle_onedrive_list(args).await, + "onedrive_download" => self.handle_onedrive_download(args).await, + "sharepoint_search" => self.handle_sharepoint_search(args).await, + _ => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("Unknown action: {action}")), + }), + } + } + + // ── Read actions ──────────────────────────────────────────────── + + async fn handle_mail_list(&self, args: &serde_json::Value) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Read, "microsoft365.mail_list") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let folder = args["folder"].as_str(); + let top = args["top"].as_u64().unwrap_or(DEFAULT_TOP as u64) as u32; + + let result = + graph_client::mail_list(&self.http_client, &token, self.user_id(), folder, top) + .await?; + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&result)?, + error: None, + }) + } + + async fn handle_teams_message_list( + &self, + args: &serde_json::Value, + ) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Read, "microsoft365.teams_message_list") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let team_id = args["team_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("team_id is required"))?; + let channel_id = args["channel_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("channel_id is required"))?; + let top = args["top"].as_u64().unwrap_or(DEFAULT_TOP as u64) as u32; + + let result = graph_client::teams_message_list( + &self.http_client, + &token, + team_id, + channel_id, + top, + ) + .await?; + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&result)?, + error: None, + }) + } + + async fn handle_calendar_events_list( + &self, + args: &serde_json::Value, + ) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Read, "microsoft365.calendar_events_list") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let start = args["start"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("start datetime is required"))?; + let end = args["end"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("end datetime is required"))?; + let top = args["top"].as_u64().unwrap_or(DEFAULT_TOP as u64) as u32; + + let result = graph_client::calendar_events_list( + &self.http_client, + &token, + self.user_id(), + start, + end, + top, + ) + .await?; + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&result)?, + error: None, + }) + } + + async fn handle_onedrive_list(&self, args: &serde_json::Value) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_list") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let path = args["path"].as_str(); + + let result = + graph_client::onedrive_list(&self.http_client, &token, self.user_id(), path).await?; + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&result)?, + error: None, + }) + } + + async fn handle_onedrive_download( + &self, + args: &serde_json::Value, + ) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_download") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let item_id = args["item_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("item_id is required"))?; + let max_size = args["max_size"] + .as_u64() + .unwrap_or(MAX_ONEDRIVE_DOWNLOAD_SIZE as u64) as usize; + + let bytes = graph_client::onedrive_download( + &self.http_client, + &token, + self.user_id(), + item_id, + max_size, + ) + .await?; + + // Return base64-encoded for binary safety. + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + + Ok(ToolResult { + success: true, + output: format!( + "Downloaded {} bytes (base64 encoded):\n{encoded}", + bytes.len() + ), + error: None, + }) + } + + async fn handle_sharepoint_search( + &self, + args: &serde_json::Value, + ) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Read, "microsoft365.sharepoint_search") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let query = args["query"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("query is required"))?; + let top = args["top"].as_u64().unwrap_or(DEFAULT_TOP as u64) as u32; + + let result = + graph_client::sharepoint_search(&self.http_client, &token, query, top).await?; + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&result)?, + error: None, + }) + } + + // ── Write actions ─────────────────────────────────────────────── + + async fn handle_mail_send(&self, args: &serde_json::Value) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Act, "microsoft365.mail_send") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let to: Vec = args["to"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("to must be an array of email addresses"))? + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + if to.is_empty() { + anyhow::bail!("to must contain at least one email address"); + } + + let subject = args["subject"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("subject is required"))?; + let body = args["body"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("body is required"))?; + + graph_client::mail_send(&self.http_client, &token, self.user_id(), &to, subject, body) + .await?; + + Ok(ToolResult { + success: true, + output: format!("Email sent to: {}", to.join(", ")), + error: None, + }) + } + + async fn handle_teams_message_send( + &self, + args: &serde_json::Value, + ) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Act, "microsoft365.teams_message_send") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let team_id = args["team_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("team_id is required"))?; + let channel_id = args["channel_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("channel_id is required"))?; + let body = args["body"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("body is required"))?; + + graph_client::teams_message_send(&self.http_client, &token, team_id, channel_id, body) + .await?; + + Ok(ToolResult { + success: true, + output: "Teams message sent".to_string(), + error: None, + }) + } + + async fn handle_calendar_event_create( + &self, + args: &serde_json::Value, + ) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_create") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let subject = args["subject"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("subject is required"))?; + let start = args["start"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("start datetime is required"))?; + let end = args["end"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("end datetime is required"))?; + let attendees: Vec = args["attendees"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let body_text = args["body"].as_str(); + + let event_id = graph_client::calendar_event_create( + &self.http_client, + &token, + self.user_id(), + subject, + start, + end, + &attendees, + body_text, + ) + .await?; + + Ok(ToolResult { + success: true, + output: format!("Calendar event created (id: {event_id})"), + error: None, + }) + } + + async fn handle_calendar_event_delete( + &self, + args: &serde_json::Value, + ) -> anyhow::Result { + self.security + .enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_delete") + .map_err(|e| anyhow::anyhow!(e))?; + + let token = self.get_token().await?; + let event_id = args["event_id"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("event_id is required"))?; + + graph_client::calendar_event_delete( + &self.http_client, + &token, + self.user_id(), + event_id, + ) + .await?; + + Ok(ToolResult { + success: true, + output: format!("Calendar event {event_id} deleted"), + error: None, + }) + } +} + +#[async_trait] +impl Tool for Microsoft365Tool { + fn name(&self) -> &str { + "microsoft365" + } + + fn description(&self) -> &str { + "Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, \ + OneDrive files, and SharePoint search via Microsoft Graph API" + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["action"], + "properties": { + "action": { + "type": "string", + "enum": [ + "mail_list", + "mail_send", + "teams_message_list", + "teams_message_send", + "calendar_events_list", + "calendar_event_create", + "calendar_event_delete", + "onedrive_list", + "onedrive_download", + "sharepoint_search" + ], + "description": "The Microsoft 365 action to perform" + }, + "folder": { + "type": "string", + "description": "Mail folder ID (for mail_list, e.g. 'inbox', 'sentitems')" + }, + "to": { + "type": "array", + "items": { "type": "string" }, + "description": "Recipient email addresses (for mail_send)" + }, + "subject": { + "type": "string", + "description": "Email subject or calendar event subject" + }, + "body": { + "type": "string", + "description": "Message body text" + }, + "team_id": { + "type": "string", + "description": "Teams team ID (for teams_message_list/send)" + }, + "channel_id": { + "type": "string", + "description": "Teams channel ID (for teams_message_list/send)" + }, + "start": { + "type": "string", + "description": "Start datetime in ISO 8601 format (for calendar actions)" + }, + "end": { + "type": "string", + "description": "End datetime in ISO 8601 format (for calendar actions)" + }, + "attendees": { + "type": "array", + "items": { "type": "string" }, + "description": "Attendee email addresses (for calendar_event_create)" + }, + "event_id": { + "type": "string", + "description": "Calendar event ID (for calendar_event_delete)" + }, + "path": { + "type": "string", + "description": "OneDrive folder path (for onedrive_list)" + }, + "item_id": { + "type": "string", + "description": "OneDrive item ID (for onedrive_download)" + }, + "max_size": { + "type": "integer", + "description": "Maximum download size in bytes (for onedrive_download, default 10MB)" + }, + "query": { + "type": "string", + "description": "Search query (for sharepoint_search)" + }, + "top": { + "type": "integer", + "description": "Maximum number of items to return (default 25)" + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let action = match args["action"].as_str() { + Some(a) => a.to_string(), + None => { + return Ok(ToolResult { + success: false, + output: String::new(), + error: Some("'action' parameter is required".to_string()), + }); + } + }; + + match self.dispatch(&action, &args).await { + Ok(result) => Ok(result), + Err(e) => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!("microsoft365.{action} failed: {e}")), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_name_is_microsoft365() { + // Verify the schema is valid JSON with the expected structure. + let schema_str = r#"{"type":"object","required":["action"]}"#; + let _: serde_json::Value = serde_json::from_str(schema_str).unwrap(); + } + + #[test] + fn parameters_schema_has_action_enum() { + let schema = json!({ + "type": "object", + "required": ["action"], + "properties": { + "action": { + "type": "string", + "enum": [ + "mail_list", + "mail_send", + "teams_message_list", + "teams_message_send", + "calendar_events_list", + "calendar_event_create", + "calendar_event_delete", + "onedrive_list", + "onedrive_download", + "sharepoint_search" + ] + } + } + }); + + let actions = schema["properties"]["action"]["enum"] + .as_array() + .unwrap(); + assert_eq!(actions.len(), 10); + assert!(actions.contains(&json!("mail_list"))); + assert!(actions.contains(&json!("sharepoint_search"))); + } + + #[test] + fn action_dispatch_table_is_exhaustive() { + let valid_actions = [ + "mail_list", + "mail_send", + "teams_message_list", + "teams_message_send", + "calendar_events_list", + "calendar_event_create", + "calendar_event_delete", + "onedrive_list", + "onedrive_download", + "sharepoint_search", + ]; + assert_eq!(valid_actions.len(), 10); + assert!(!valid_actions.contains(&"invalid_action")); + } +} diff --git a/src/tools/microsoft365/types.rs b/src/tools/microsoft365/types.rs new file mode 100644 index 000000000..d82af2d9e --- /dev/null +++ b/src/tools/microsoft365/types.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +/// Resolved Microsoft 365 configuration with all secrets decrypted and defaults applied. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Microsoft365ResolvedConfig { + pub tenant_id: String, + pub client_id: String, + pub client_secret: Option, + pub auth_flow: String, + pub scopes: Vec, + pub token_cache_encrypted: bool, + pub user_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolved_config_serialization_roundtrip() { + let config = Microsoft365ResolvedConfig { + tenant_id: "test-tenant".into(), + client_id: "test-client".into(), + client_secret: Some("secret".into()), + auth_flow: "client_credentials".into(), + scopes: vec!["https://graph.microsoft.com/.default".into()], + token_cache_encrypted: false, + user_id: "me".into(), + }; + + let json = serde_json::to_string(&config).unwrap(); + let parsed: Microsoft365ResolvedConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.tenant_id, "test-tenant"); + assert_eq!(parsed.client_id, "test-client"); + assert_eq!(parsed.client_secret.as_deref(), Some("secret")); + assert_eq!(parsed.auth_flow, "client_credentials"); + assert_eq!(parsed.scopes.len(), 1); + assert_eq!(parsed.user_id, "me"); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 4737484dd..d57add30c 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -45,6 +45,7 @@ pub mod mcp_deferred; pub mod mcp_protocol; pub mod mcp_tool; pub mod mcp_transport; +pub mod microsoft365; pub mod memory_forget; pub mod memory_recall; pub mod memory_store; @@ -89,6 +90,7 @@ pub use image_info::ImageInfoTool; pub use mcp_client::McpRegistry; pub use mcp_deferred::{ActivatedToolSet, DeferredMcpToolSet}; pub use mcp_tool::McpToolWrapper; +pub use microsoft365::Microsoft365Tool; pub use memory_forget::MemoryForgetTool; pub use memory_recall::MemoryRecallTool; pub use memory_store::MemoryStoreTool; @@ -356,6 +358,47 @@ pub fn all_tools_with_runtime( } } + // Microsoft 365 Graph API integration + if root_config.microsoft365.enabled { + let ms_cfg = &root_config.microsoft365; + let tenant_id = ms_cfg + .tenant_id + .as_deref() + .unwrap_or_default() + .trim() + .to_string(); + let client_id = ms_cfg + .client_id + .as_deref() + .unwrap_or_default() + .trim() + .to_string(); + if !tenant_id.is_empty() && !client_id.is_empty() { + let resolved = microsoft365::types::Microsoft365ResolvedConfig { + tenant_id, + client_id, + client_secret: ms_cfg.client_secret.clone(), + auth_flow: ms_cfg.auth_flow.clone(), + scopes: ms_cfg.scopes.clone(), + token_cache_encrypted: ms_cfg.token_cache_encrypted, + user_id: ms_cfg + .user_id + .as_deref() + .unwrap_or("me") + .to_string(), + }; + tool_arcs.push(Arc::new(Microsoft365Tool::new( + resolved, + security.clone(), + workspace_dir, + ))); + } else { + tracing::warn!( + "microsoft365: skipped registration because tenant_id or client_id is empty" + ); + } + } + // Add delegation tool when agents are configured let delegate_handle: Option = if agents.is_empty() { None