Skip to main content

construct/tools/microsoft365/
mod.rs

1//! Microsoft 365 integration tool — Graph API access for Mail, Teams, Calendar,
2//! OneDrive, and SharePoint via a single action-dispatched tool surface.
3//!
4//! Auth is handled through direct HTTP calls to the Microsoft identity platform
5//! (client credentials or device code flow) with token caching.
6
7pub mod auth;
8pub mod graph_client;
9pub mod types;
10
11use crate::security::SecurityPolicy;
12use crate::security::policy::ToolOperation;
13use crate::tools::traits::{Tool, ToolResult};
14use async_trait::async_trait;
15use serde_json::json;
16use std::sync::Arc;
17
18/// Maximum download size for OneDrive files (10 MB).
19const MAX_ONEDRIVE_DOWNLOAD_SIZE: usize = 10 * 1024 * 1024;
20
21/// Default number of items to return in list operations.
22const DEFAULT_TOP: u32 = 25;
23
24pub struct Microsoft365Tool {
25    config: types::Microsoft365ResolvedConfig,
26    security: Arc<SecurityPolicy>,
27    token_cache: Arc<auth::TokenCache>,
28    http_client: reqwest::Client,
29}
30
31impl Microsoft365Tool {
32    pub fn new(
33        config: types::Microsoft365ResolvedConfig,
34        security: Arc<SecurityPolicy>,
35        construct_dir: &std::path::Path,
36    ) -> anyhow::Result<Self> {
37        let http_client =
38            crate::config::build_runtime_proxy_client_with_timeouts("tool.microsoft365", 60, 10);
39        let token_cache = Arc::new(auth::TokenCache::new(config.clone(), construct_dir)?);
40        Ok(Self {
41            config,
42            security,
43            token_cache,
44            http_client,
45        })
46    }
47
48    async fn get_token(&self) -> anyhow::Result<String> {
49        self.token_cache.get_token(&self.http_client).await
50    }
51
52    fn user_id(&self) -> &str {
53        &self.config.user_id
54    }
55
56    async fn dispatch(&self, action: &str, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
57        match action {
58            "mail_list" => self.handle_mail_list(args).await,
59            "mail_send" => self.handle_mail_send(args).await,
60            "teams_message_list" => self.handle_teams_message_list(args).await,
61            "teams_message_send" => self.handle_teams_message_send(args).await,
62            "calendar_events_list" => self.handle_calendar_events_list(args).await,
63            "calendar_event_create" => self.handle_calendar_event_create(args).await,
64            "calendar_event_delete" => self.handle_calendar_event_delete(args).await,
65            "onedrive_list" => self.handle_onedrive_list(args).await,
66            "onedrive_download" => self.handle_onedrive_download(args).await,
67            "sharepoint_search" => self.handle_sharepoint_search(args).await,
68            _ => Ok(ToolResult {
69                success: false,
70                output: String::new(),
71                error: Some(format!("Unknown action: {action}")),
72            }),
73        }
74    }
75
76    // ── Read actions ────────────────────────────────────────────────
77
78    async fn handle_mail_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
79        self.security
80            .enforce_tool_operation(ToolOperation::Read, "microsoft365.mail_list")
81            .map_err(|e| anyhow::anyhow!(e))?;
82
83        let token = self.get_token().await?;
84        let folder = args["folder"].as_str();
85        let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
86            .unwrap_or(DEFAULT_TOP);
87
88        let result =
89            graph_client::mail_list(&self.http_client, &token, self.user_id(), folder, top).await?;
90
91        Ok(ToolResult {
92            success: true,
93            output: serde_json::to_string_pretty(&result)?,
94            error: None,
95        })
96    }
97
98    async fn handle_teams_message_list(
99        &self,
100        args: &serde_json::Value,
101    ) -> anyhow::Result<ToolResult> {
102        self.security
103            .enforce_tool_operation(ToolOperation::Read, "microsoft365.teams_message_list")
104            .map_err(|e| anyhow::anyhow!(e))?;
105
106        let token = self.get_token().await?;
107        let team_id = args["team_id"]
108            .as_str()
109            .ok_or_else(|| anyhow::anyhow!("team_id is required"))?;
110        let channel_id = args["channel_id"]
111            .as_str()
112            .ok_or_else(|| anyhow::anyhow!("channel_id is required"))?;
113        let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
114            .unwrap_or(DEFAULT_TOP);
115
116        let result =
117            graph_client::teams_message_list(&self.http_client, &token, team_id, channel_id, top)
118                .await?;
119
120        Ok(ToolResult {
121            success: true,
122            output: serde_json::to_string_pretty(&result)?,
123            error: None,
124        })
125    }
126
127    async fn handle_calendar_events_list(
128        &self,
129        args: &serde_json::Value,
130    ) -> anyhow::Result<ToolResult> {
131        self.security
132            .enforce_tool_operation(ToolOperation::Read, "microsoft365.calendar_events_list")
133            .map_err(|e| anyhow::anyhow!(e))?;
134
135        let token = self.get_token().await?;
136        let start = args["start"]
137            .as_str()
138            .ok_or_else(|| anyhow::anyhow!("start datetime is required"))?;
139        let end = args["end"]
140            .as_str()
141            .ok_or_else(|| anyhow::anyhow!("end datetime is required"))?;
142        let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
143            .unwrap_or(DEFAULT_TOP);
144
145        let result = graph_client::calendar_events_list(
146            &self.http_client,
147            &token,
148            self.user_id(),
149            start,
150            end,
151            top,
152        )
153        .await?;
154
155        Ok(ToolResult {
156            success: true,
157            output: serde_json::to_string_pretty(&result)?,
158            error: None,
159        })
160    }
161
162    async fn handle_onedrive_list(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
163        self.security
164            .enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_list")
165            .map_err(|e| anyhow::anyhow!(e))?;
166
167        let token = self.get_token().await?;
168        let path = args["path"].as_str();
169
170        let result =
171            graph_client::onedrive_list(&self.http_client, &token, self.user_id(), path).await?;
172
173        Ok(ToolResult {
174            success: true,
175            output: serde_json::to_string_pretty(&result)?,
176            error: None,
177        })
178    }
179
180    async fn handle_onedrive_download(
181        &self,
182        args: &serde_json::Value,
183    ) -> anyhow::Result<ToolResult> {
184        self.security
185            .enforce_tool_operation(ToolOperation::Read, "microsoft365.onedrive_download")
186            .map_err(|e| anyhow::anyhow!(e))?;
187
188        let token = self.get_token().await?;
189        let item_id = args["item_id"]
190            .as_str()
191            .ok_or_else(|| anyhow::anyhow!("item_id is required"))?;
192        let max_size = args["max_size"]
193            .as_u64()
194            .and_then(|v| usize::try_from(v).ok())
195            .unwrap_or(MAX_ONEDRIVE_DOWNLOAD_SIZE)
196            .min(MAX_ONEDRIVE_DOWNLOAD_SIZE);
197
198        let bytes = graph_client::onedrive_download(
199            &self.http_client,
200            &token,
201            self.user_id(),
202            item_id,
203            max_size,
204        )
205        .await?;
206
207        // Return base64-encoded for binary safety.
208        use base64::Engine;
209        let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
210
211        Ok(ToolResult {
212            success: true,
213            output: format!(
214                "Downloaded {} bytes (base64 encoded):\n{encoded}",
215                bytes.len()
216            ),
217            error: None,
218        })
219    }
220
221    async fn handle_sharepoint_search(
222        &self,
223        args: &serde_json::Value,
224    ) -> anyhow::Result<ToolResult> {
225        self.security
226            .enforce_tool_operation(ToolOperation::Read, "microsoft365.sharepoint_search")
227            .map_err(|e| anyhow::anyhow!(e))?;
228
229        let token = self.get_token().await?;
230        let query = args["query"]
231            .as_str()
232            .ok_or_else(|| anyhow::anyhow!("query is required"))?;
233        let top = u32::try_from(args["top"].as_u64().unwrap_or(u64::from(DEFAULT_TOP)))
234            .unwrap_or(DEFAULT_TOP);
235
236        let result = graph_client::sharepoint_search(&self.http_client, &token, query, top).await?;
237
238        Ok(ToolResult {
239            success: true,
240            output: serde_json::to_string_pretty(&result)?,
241            error: None,
242        })
243    }
244
245    // ── Write actions ───────────────────────────────────────────────
246
247    async fn handle_mail_send(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
248        self.security
249            .enforce_tool_operation(ToolOperation::Act, "microsoft365.mail_send")
250            .map_err(|e| anyhow::anyhow!(e))?;
251
252        let token = self.get_token().await?;
253        let to: Vec<String> = args["to"]
254            .as_array()
255            .ok_or_else(|| anyhow::anyhow!("to must be an array of email addresses"))?
256            .iter()
257            .filter_map(|v| v.as_str().map(String::from))
258            .collect();
259
260        if to.is_empty() {
261            anyhow::bail!("to must contain at least one email address");
262        }
263
264        let subject = args["subject"]
265            .as_str()
266            .ok_or_else(|| anyhow::anyhow!("subject is required"))?;
267        let body = args["body"]
268            .as_str()
269            .ok_or_else(|| anyhow::anyhow!("body is required"))?;
270
271        graph_client::mail_send(
272            &self.http_client,
273            &token,
274            self.user_id(),
275            &to,
276            subject,
277            body,
278        )
279        .await?;
280
281        Ok(ToolResult {
282            success: true,
283            output: format!("Email sent to: {}", to.join(", ")),
284            error: None,
285        })
286    }
287
288    async fn handle_teams_message_send(
289        &self,
290        args: &serde_json::Value,
291    ) -> anyhow::Result<ToolResult> {
292        self.security
293            .enforce_tool_operation(ToolOperation::Act, "microsoft365.teams_message_send")
294            .map_err(|e| anyhow::anyhow!(e))?;
295
296        let token = self.get_token().await?;
297        let team_id = args["team_id"]
298            .as_str()
299            .ok_or_else(|| anyhow::anyhow!("team_id is required"))?;
300        let channel_id = args["channel_id"]
301            .as_str()
302            .ok_or_else(|| anyhow::anyhow!("channel_id is required"))?;
303        let body = args["body"]
304            .as_str()
305            .ok_or_else(|| anyhow::anyhow!("body is required"))?;
306
307        graph_client::teams_message_send(&self.http_client, &token, team_id, channel_id, body)
308            .await?;
309
310        Ok(ToolResult {
311            success: true,
312            output: "Teams message sent".to_string(),
313            error: None,
314        })
315    }
316
317    async fn handle_calendar_event_create(
318        &self,
319        args: &serde_json::Value,
320    ) -> anyhow::Result<ToolResult> {
321        self.security
322            .enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_create")
323            .map_err(|e| anyhow::anyhow!(e))?;
324
325        let token = self.get_token().await?;
326        let subject = args["subject"]
327            .as_str()
328            .ok_or_else(|| anyhow::anyhow!("subject is required"))?;
329        let start = args["start"]
330            .as_str()
331            .ok_or_else(|| anyhow::anyhow!("start datetime is required"))?;
332        let end = args["end"]
333            .as_str()
334            .ok_or_else(|| anyhow::anyhow!("end datetime is required"))?;
335        let attendees: Vec<String> = args["attendees"]
336            .as_array()
337            .map(|arr| {
338                arr.iter()
339                    .filter_map(|v| v.as_str().map(String::from))
340                    .collect()
341            })
342            .unwrap_or_default();
343        let body_text = args["body"].as_str();
344
345        let event_id = graph_client::calendar_event_create(
346            &self.http_client,
347            &token,
348            self.user_id(),
349            subject,
350            start,
351            end,
352            &attendees,
353            body_text,
354        )
355        .await?;
356
357        Ok(ToolResult {
358            success: true,
359            output: format!("Calendar event created (id: {event_id})"),
360            error: None,
361        })
362    }
363
364    async fn handle_calendar_event_delete(
365        &self,
366        args: &serde_json::Value,
367    ) -> anyhow::Result<ToolResult> {
368        self.security
369            .enforce_tool_operation(ToolOperation::Act, "microsoft365.calendar_event_delete")
370            .map_err(|e| anyhow::anyhow!(e))?;
371
372        let token = self.get_token().await?;
373        let event_id = args["event_id"]
374            .as_str()
375            .ok_or_else(|| anyhow::anyhow!("event_id is required"))?;
376
377        graph_client::calendar_event_delete(&self.http_client, &token, self.user_id(), event_id)
378            .await?;
379
380        Ok(ToolResult {
381            success: true,
382            output: format!("Calendar event {event_id} deleted"),
383            error: None,
384        })
385    }
386}
387
388#[async_trait]
389impl Tool for Microsoft365Tool {
390    fn name(&self) -> &str {
391        "microsoft365"
392    }
393
394    fn description(&self) -> &str {
395        "Microsoft 365 integration: manage Outlook mail, Teams messages, Calendar events, \
396         OneDrive files, and SharePoint search via Microsoft Graph API"
397    }
398
399    fn parameters_schema(&self) -> serde_json::Value {
400        json!({
401            "type": "object",
402            "required": ["action"],
403            "properties": {
404                "action": {
405                    "type": "string",
406                    "enum": [
407                        "mail_list",
408                        "mail_send",
409                        "teams_message_list",
410                        "teams_message_send",
411                        "calendar_events_list",
412                        "calendar_event_create",
413                        "calendar_event_delete",
414                        "onedrive_list",
415                        "onedrive_download",
416                        "sharepoint_search"
417                    ],
418                    "description": "The Microsoft 365 action to perform"
419                },
420                "folder": {
421                    "type": "string",
422                    "description": "Mail folder ID (for mail_list, e.g. 'inbox', 'sentitems')"
423                },
424                "to": {
425                    "type": "array",
426                    "items": { "type": "string" },
427                    "description": "Recipient email addresses (for mail_send)"
428                },
429                "subject": {
430                    "type": "string",
431                    "description": "Email subject or calendar event subject"
432                },
433                "body": {
434                    "type": "string",
435                    "description": "Message body text"
436                },
437                "team_id": {
438                    "type": "string",
439                    "description": "Teams team ID (for teams_message_list/send)"
440                },
441                "channel_id": {
442                    "type": "string",
443                    "description": "Teams channel ID (for teams_message_list/send)"
444                },
445                "start": {
446                    "type": "string",
447                    "description": "Start datetime in ISO 8601 format (for calendar actions)"
448                },
449                "end": {
450                    "type": "string",
451                    "description": "End datetime in ISO 8601 format (for calendar actions)"
452                },
453                "attendees": {
454                    "type": "array",
455                    "items": { "type": "string" },
456                    "description": "Attendee email addresses (for calendar_event_create)"
457                },
458                "event_id": {
459                    "type": "string",
460                    "description": "Calendar event ID (for calendar_event_delete)"
461                },
462                "path": {
463                    "type": "string",
464                    "description": "OneDrive folder path (for onedrive_list)"
465                },
466                "item_id": {
467                    "type": "string",
468                    "description": "OneDrive item ID (for onedrive_download)"
469                },
470                "max_size": {
471                    "type": "integer",
472                    "description": "Maximum download size in bytes (for onedrive_download, default 10MB)"
473                },
474                "query": {
475                    "type": "string",
476                    "description": "Search query (for sharepoint_search)"
477                },
478                "top": {
479                    "type": "integer",
480                    "description": "Maximum number of items to return (default 25)"
481                }
482            }
483        })
484    }
485
486    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
487        let action = match args["action"].as_str() {
488            Some(a) => a.to_string(),
489            None => {
490                return Ok(ToolResult {
491                    success: false,
492                    output: String::new(),
493                    error: Some("'action' parameter is required".to_string()),
494                });
495            }
496        };
497
498        match self.dispatch(&action, &args).await {
499            Ok(result) => Ok(result),
500            Err(e) => Ok(ToolResult {
501                success: false,
502                output: String::new(),
503                error: Some(format!("microsoft365.{action} failed: {e}")),
504            }),
505        }
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn tool_name_is_microsoft365() {
515        // Verify the schema is valid JSON with the expected structure.
516        let schema_str = r#"{"type":"object","required":["action"]}"#;
517        let _: serde_json::Value = serde_json::from_str(schema_str).unwrap();
518    }
519
520    #[test]
521    fn parameters_schema_has_action_enum() {
522        let schema = json!({
523            "type": "object",
524            "required": ["action"],
525            "properties": {
526                "action": {
527                    "type": "string",
528                    "enum": [
529                        "mail_list",
530                        "mail_send",
531                        "teams_message_list",
532                        "teams_message_send",
533                        "calendar_events_list",
534                        "calendar_event_create",
535                        "calendar_event_delete",
536                        "onedrive_list",
537                        "onedrive_download",
538                        "sharepoint_search"
539                    ]
540                }
541            }
542        });
543
544        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
545        assert_eq!(actions.len(), 10);
546        assert!(actions.contains(&json!("mail_list")));
547        assert!(actions.contains(&json!("sharepoint_search")));
548    }
549
550    #[test]
551    fn action_dispatch_table_is_exhaustive() {
552        let valid_actions = [
553            "mail_list",
554            "mail_send",
555            "teams_message_list",
556            "teams_message_send",
557            "calendar_events_list",
558            "calendar_event_create",
559            "calendar_event_delete",
560            "onedrive_list",
561            "onedrive_download",
562            "sharepoint_search",
563        ];
564        assert_eq!(valid_actions.len(), 10);
565        assert!(!valid_actions.contains(&"invalid_action"));
566    }
567}