1pub 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
18const MAX_ONEDRIVE_DOWNLOAD_SIZE: usize = 10 * 1024 * 1024;
20
21const 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 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 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 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 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}