""" Microsoft 365 Integration Adapter Provides OAuth-based integration with Microsoft 365 for productivity apps. """ import logging import os import httpx from typing import Dict, Any, List, Optional from datetime import datetime, timedelta from urllib.parse import urlencode logger = logging.getLogger(__name__) class Microsoft365Adapter: """ Adapter for Microsoft 365 OAuth integration. Supports: - OAuth 2.0 authentication (Microsoft Graph API) - Outlook email and calendar - Teams chat and meetings - OneDrive and SharePoint - Tasks and notes """ def __init__(self, db, workspace_id: str): self.db = db self.workspace_id = workspace_id self.service_name = "microsoft365" self.base_url = "https://graph.microsoft.com/v1.0" # OAuth credentials from environment self.client_id = os.getenv("MICROSOFT_CLIENT_ID") self.client_secret = os.getenv("MICROSOFT_CLIENT_SECRET") self.redirect_uri = os.getenv("MICROSOFT_REDIRECT_URI") # Token storage self._access_token: Optional[str] = None _refresh_token: Optional[str] = None self._token_expires_at: Optional[datetime] = None async def get_oauth_url(self) -> str: """ Generate Microsoft OAuth authorization URL. Returns: Authorization URL to redirect user to Microsoft OAuth consent screen """ if not self.client_id: raise ValueError("MICROSOFT_CLIENT_ID not configured") # Microsoft OAuth endpoint auth_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" # Build authorization URL with comprehensive scopes params = { "client_id": self.client_id, "redirect_uri": self.redirect_uri, "response_type": "code", "scope": "Mail.ReadWrite Mail.Send Calendars.ReadWrite Tasks.ReadWrite " "Files.ReadWrite.All offline_access User.Read", "state": self.workspace_id, "response_mode": "query" } auth_url_with_params = f"{auth_url}?{urlencode(params)}" logger.info(f"Generated Microsoft 365 OAuth URL for workspace {self.workspace_id}") return auth_url_with_params async def exchange_code_for_token(self, code: str) -> Dict[str, Any]: """ Exchange OAuth authorization code for access token. Args: code: Authorization code from OAuth callback Returns: Token response with access_token, refresh_token, expires_in, etc. """ if not self.client_id or not self.client_secret: raise ValueError("Microsoft OAuth credentials not configured") token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" data = { "grant_type": "authorization_code", "code": code, "redirect_uri": self.redirect_uri, "client_id": self.client_id, "client_secret": self.client_secret, } try: async with httpx.AsyncClient() as client: response = await client.post(token_url, data=data) response.raise_for_status() token_data = response.json() # Store tokens self._access_token = token_data.get("access_token") _refresh_token = token_data.get("refresh_token") # Calculate token expiration if "expires_in" in token_data: self._token_expires_at = datetime.now() + timedelta( seconds=token_data["expires_in"] ) logger.info(f"Successfully obtained Microsoft 365 access token for workspace {self.workspace_id}") return token_data except httpx.HTTPStatusError as e: logger.error(f"Microsoft 365 token exchange failed: {e}") raise async def test_connection(self) -> bool: """ Test the Microsoft 365 API connection. Returns: True if connection successful, False otherwise """ if not self._access_token: return False try: async with httpx.AsyncClient() as client: # Test by getting user info response = await client.get( f"{self.base_url}/me", headers={ "Authorization": f"Bearer {self._access_token}" } ) response.raise_for_status() logger.info(f"Microsoft 365 connection test successful for workspace {self.workspace_id}") return True except Exception as e: logger.error(f"Microsoft 365 connection test failed: {e}") return False async def get_emails(self, folder_id: str = None, limit: int = 20) -> List[Dict[str, Any]]: """ Retrieve emails from Outlook. Args: folder_id: Folder ID (empty for inbox) limit: Maximum number of results Returns: List of email objects """ if not self._access_token: raise ValueError("Microsoft 365 access token not available") try: # Build URL for messages if folder_id: url = f"{self.base_url}/me/mailFolders/{folder_id}/messages" else: url = f"{self.base_url}/me/mailFolders/Inbox/messages" async with httpx.AsyncClient() as client: response = await client.get( url, headers={ "Authorization": f"Bearer {self._access_token}" }, params={"$top": limit} ) response.raise_for_status() data = response.json() emails = data.get("value", []) logger.info(f"Retrieved {len(emails)} Microsoft 365 emails for workspace {self.workspace_id}") return emails except Exception as e: logger.error(f"Failed to retrieve Microsoft 365 emails: {e}") raise async def send_email(self, to: List[str], subject: str, body: str, cc: List[str] = None, attachments: List[Dict] = None) -> Dict[str, Any]: """ Send an email via Outlook. Args: to: Recipient email addresses subject: Email subject body: Email body (HTML) cc: CC recipients attachments: List of attachment objects Returns: Sent message object """ if not self._access_token: raise ValueError("Microsoft 365 access token not available") try: # Build recipients to_recipients = [{"emailAddress": {"address": email}} for email in to] cc_recipients = [{"emailAddress": {"address": email}} for email in cc] if cc else [] # Build message message = { "message": { "subject": subject, "body": { "contentType": "HTML", "content": body }, "toRecipients": to_recipients, "ccRecipients": cc_recipients } } if attachments: message["message"]["attachments"] = attachments async with httpx.AsyncClient() as client: response = await client.post( f"{self.base_url}/me/sendMail", headers={ "Authorization": f"Bearer {self._access_token}", "Content-Type": "application/json" }, json=message ) response.raise_for_status() logger.info(f"Sent Microsoft 365 email for workspace {self.workspace_id}") return {"status": "sent"} except Exception as e: logger.error(f"Failed to send Microsoft 365 email: {e}") raise async def get_calendar_events(self, start_date: str = None, end_date: str = None, limit: int = 20) -> List[Dict[str, Any]]: """ Retrieve calendar events from Outlook. Args: start_date: Start date (ISO 8601 format) end_date: End date (ISO 8601 format) limit: Maximum number of results Returns: List of event objects """ if not self._access_token: raise ValueError("Microsoft 365 access token not available") try: # Build calendar view URL url = f"{self.base_url}/me/calendarView" params = {"$top": limit} if start_date and end_date: params["startDateTime"] = start_date params["endDateTime"] = end_date async with httpx.AsyncClient() as client: response = await client.get( url, headers={ "Authorization": f"Bearer {self._access_token}" }, params=params ) response.raise_for_status() data = response.json() events = data.get("value", []) logger.info(f"Retrieved {len(events)} Microsoft 365 calendar events for workspace {self.workspace_id}") return events except Exception as e: logger.error(f"Failed to retrieve Microsoft 365 calendar events: {e}") raise async def create_calendar_event(self, subject: str, start: str, end: str, body: str = None, attendees: List[str] = None) -> Dict[str, Any]: """ Create a calendar event in Outlook. Args: subject: Event subject start: Start time (ISO 8601 format) end: End time (ISO 8601 format) body: Event body attendees: List of attendee email addresses Returns: Created event object """ if not self._access_token: raise ValueError("Microsoft 365 access token not available") try: # Build event event = { "subject": subject, "start": { "dateTime": start, "timeZone": "UTC" }, "end": { "dateTime": end, "timeZone": "UTC" } } if body: event["body"] = { "contentType": "HTML", "content": body } if attendees: event["attendees"] = [ { "emailAddress": { "address": email }, "type": "required" } for email in attendees ] async with httpx.AsyncClient() as client: response = await client.post( f"{self.base_url}/me/events", headers={ "Authorization": f"Bearer {self._access_token}", "Content-Type": "application/json" }, json=event ) response.raise_for_status() event_data = response.json() logger.info(f"Created Microsoft 365 calendar event for workspace {self.workspace_id}") return event_data except Exception as e: logger.error(f"Failed to create Microsoft 365 calendar event: {e}") raise async def get_tasks(self, list_id: str = None) -> List[Dict[str, Any]]: """ Retrieve tasks from Microsoft To Do. Args: list_id: Task list ID (empty for default list) Returns: List of task objects """ if not self._access_token: raise ValueError("Microsoft 365 access token not available") try: # Build URL for tasks if list_id: url = f"{self.base_url}/me/todo/lists/{list_id}/tasks" else: url = f"{self.base_url}/me/todo/tasks" async with httpx.AsyncClient() as client: response = await client.get( url, headers={ "Authorization": f"Bearer {self._access_token}" } ) response.raise_for_status() data = response.json() tasks = data.get("value", []) logger.info(f"Retrieved {len(tasks)} Microsoft 365 tasks for workspace {self.workspace_id}") return tasks except Exception as e: logger.error(f"Failed to retrieve Microsoft 365 tasks: {e}") raise async def create_task(self, title: str, body: str = None, due_date: str = None, list_id: str = None) -> Dict[str, Any]: """ Create a task in Microsoft To Do. Args: title: Task title body: Task body/description due_date: Due date (ISO 8601 format) list_id: Task list ID Returns: Created task object """ if not self._access_token: raise ValueError("Microsoft 365 access token not available") try: # Build task task = { "title": title } if body: task["body"] = { "content": body, "contentType": "text" } if due_date: task["dueDateTime"] = { "dateTime": due_date, "timeZone": "UTC" } # Build URL if list_id: url = f"{self.base_url}/me/todo/lists/{list_id}/tasks" else: url = f"{self.base_url}/me/todo/tasks" async with httpx.AsyncClient() as client: response = await client.post( url, headers={ "Authorization": f"Bearer {self._access_token}", "Content-Type": "application/json" }, json=task ) response.raise_for_status() task_data = response.json() logger.info(f"Created Microsoft 365 task for workspace {self.workspace_id}") return task_data except Exception as e: logger.error(f"Failed to create Microsoft 365 task: {e}") raise async def get_teams_chats(self, limit: int = 20) -> List[Dict[str, Any]]: """ Retrieve Teams chats. Args: limit: Maximum number of results Returns: List of chat objects """ if not self._access_token: raise ValueError("Microsoft 365 access token not available") try: async with httpx.AsyncClient() as client: response = await client.get( f"{self.base_url}/me/chats", headers={ "Authorization": f"Bearer {self._access_token}" }, params={"$top": limit} ) response.raise_for_status() data = response.json() chats = data.get("value", []) logger.info(f"Retrieved {len(chats)} Microsoft Teams chats for workspace {self.workspace_id}") return chats except Exception as e: logger.error(f"Failed to retrieve Microsoft Teams chats: {e}") raise