diff --git a/.env.example b/.env.example index b4c96a3d27..fe3de2f016 100644 --- a/.env.example +++ b/.env.example @@ -140,4 +140,9 @@ # Grok API key # XAI_API_KEY="Fill your Grok API Key here" -# XAI_API_BASE_URL="Fill your Grok API Base URL here" \ No newline at end of file +# XAI_API_BASE_URL="Fill your Grok API Base URL here" + +# Microsoft Graph API (https://portal.azure.com/) +# MICROSOFT_TENANT_ID="Fill your Tenant ID here (Optional, default is 'common')" +# MICROSOFT_CLIENT_ID="Fill your Client ID here" +# MICROSOFT_CLIENT_SECRET="Fill your Client Secret here" \ No newline at end of file diff --git a/camel/toolkits/__init__.py b/camel/toolkits/__init__.py index 298a96cc85..895a101cf4 100644 --- a/camel/toolkits/__init__.py +++ b/camel/toolkits/__init__.py @@ -95,6 +95,7 @@ from .notion_mcp_toolkit import NotionMCPToolkit from .vertex_ai_veo_toolkit import VertexAIVeoToolkit from .minimax_mcp_toolkit import MinimaxMCPToolkit +from .microsoft_outlook_mail_toolkit import OutlookMailToolkit __all__ = [ 'BaseToolkit', @@ -180,4 +181,5 @@ 'NotionMCPToolkit', 'VertexAIVeoToolkit', 'MinimaxMCPToolkit', + "OutlookMailToolkit", ] diff --git a/camel/toolkits/microsoft_outlook_mail_toolkit.py b/camel/toolkits/microsoft_outlook_mail_toolkit.py new file mode 100644 index 0000000000..86edfa3b68 --- /dev/null +++ b/camel/toolkits/microsoft_outlook_mail_toolkit.py @@ -0,0 +1,1781 @@ +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= + +import json +import os +import threading +import time +from http.server import BaseHTTPRequestHandler +from pathlib import Path +from typing import Any, Dict, List, Optional + +import requests +from dotenv import load_dotenv + +from camel.logger import get_logger +from camel.toolkits import FunctionTool +from camel.toolkits.base import BaseToolkit +from camel.utils import MCPServer, api_keys_required +from camel.utils.commons import run_async + +load_dotenv() +logger = get_logger(__name__) + + +class RedirectHandler(BaseHTTPRequestHandler): + """Handler for OAuth redirect requests.""" + + def do_GET(self): + """Handles GET request and extracts authorization code.""" + from urllib.parse import parse_qs, urlparse + + try: + query = parse_qs(urlparse(self.path).query) + code = query.get("code", [None])[0] + self.server.code = code + self.send_response(200) + self.end_headers() + self.wfile.write( + b"Authentication complete. You can close this window." + ) + except Exception as e: + self.server.code = None + self.send_response(500) + self.end_headers() + self.wfile.write( + f"Error during authentication: {e}".encode("utf-8") + ) + + def log_message(self, format, *args): + pass + + +class CustomAzureCredential: + """Creates a custom Azure credential to pass into MSGraph client. + + Implements Azure credential interface with automatic token refresh using + a refresh token. Updates the refresh token file whenever Microsoft issues + a new refresh token during the refresh flow. + + Args: + client_id (str): The OAuth client ID. + client_secret (str): The OAuth client secret. + tenant_id (str): The Microsoft tenant ID. + refresh_token (str): The refresh token from OAuth flow. + scopes (List[str]): List of OAuth permission scopes. + refresh_token_file_path (Optional[Path]): File path of json file + with refresh token. + """ + + def __init__( + self, + client_id: str, + client_secret: str, + tenant_id: str, + refresh_token: str, + scopes: List[str], + refresh_token_file_path: Optional[Path], + ): + self.client_id = client_id + self.client_secret = client_secret + self.tenant_id = tenant_id + self.refresh_token = refresh_token + self.scopes = scopes + self.refresh_token_file_path = refresh_token_file_path + + self._access_token = None + self._expires_at = 0 + self._lock = threading.Lock() + + def _refresh_access_token(self): + """Refreshes the access token using the refresh token. + + Requests a new access token from Microsoft's token endpoint. + If Microsoft returns a new refresh token, updates both in-memory + and refresh token file. + + Raises: + Exception: If token refresh fails or returns an error. + """ + token_url = ( + f"https://login.microsoftonline.com/{self.tenant_id}" + f"/oauth2/v2.0/token" + ) + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "refresh_token", + "refresh_token": self.refresh_token, + "scope": " ".join(self.scopes), + } + + response = requests.post(token_url, data=data) + result = response.json() + + # Raise exception if error in response + if "error" in result: + error_desc = result.get('error_description', result['error']) + error_msg = f"Token refresh failed: {error_desc}" + logger.error(error_msg) + raise Exception(error_msg) + + # Update access token and expiration (60 second buffer) + self._access_token = result["access_token"] + self._expires_at = int(time.time()) + int(result["expires_in"]) - 60 + + # Save new refresh token if Microsoft provides one + if "refresh_token" in result: + self.refresh_token = result["refresh_token"] + self._save_refresh_token(self.refresh_token) + + def _save_refresh_token(self, refresh_token: str): + """Saves the refresh token to file. + + Args: + refresh_token (str): The refresh token to save. + """ + if not self.refresh_token_file_path: + logger.info("Token file path not set, skipping token save") + return + + token_data = {"refresh_token": refresh_token} + + try: + # Create parent directories if they don't exist + self.refresh_token_file_path.parent.mkdir( + parents=True, exist_ok=True + ) + + # Write new refresh token to file + with open(self.refresh_token_file_path, 'w') as f: + json.dump(token_data, f, indent=2) + except Exception as e: + logger.warning(f"Failed to save refresh token: {e!s}") + + def get_token(self, *args, **kwargs): + """Gets a valid AccessToken object for msgraph. + + Called by Microsoft Graph SDK when making API requests. + Automatically refreshes the token if expired. + + Args: + *args: Positional arguments that msgraph might pass . + **kwargs: Keyword arguments that msgraph might pass . + + Returns: + AccessToken: Azure AccessToken with token and expiration. + + Raises: + Exception: If requested scopes exceed allowed scopes. + """ + from azure.core.credentials import AccessToken + + # Check if token needs refresh + now = int(time.time()) + if now >= self._expires_at: + with self._lock: + # Double-check after lock (another thread may have refreshed) + if now >= self._expires_at: + self._refresh_access_token() + + return AccessToken(self._access_token, self._expires_at) + + +@MCPServer() +class OutlookMailToolkit(BaseToolkit): + """A comprehensive toolkit for Microsoft Outlook Mail operations. + + This class provides methods for Outlook Mail operations including sending + emails, managing drafts, replying to mails, deleting mails, fetching + mails and attachments and changing folder of mails. + API keys can be accessed in the Azure portal (https://portal.azure.com/) + """ + + def __init__( + self, + timeout: Optional[float] = None, + refresh_token_file_path: Optional[str] = None, + ): + """Initializes a new instance of the OutlookMailToolkit. + + Args: + timeout (Optional[float]): The timeout value for API requests + in seconds. If None, no timeout is applied. + (default: :obj:`None`) + refresh_token_file_path (Optional[str]): The path of json file + where refresh token is stored. If None, authentication using + web browser will be required on each initialization. If + provided, the refresh token is read from the file, used, and + automatically updated when it nears expiry. + (default: :obj:`None`) + """ + super().__init__(timeout=timeout) + + self.scopes = ["Mail.Send", "Mail.ReadWrite"] + self.redirect_uri = self._get_dynamic_redirect_uri() + self.refresh_token_file_path = ( + Path(refresh_token_file_path) if refresh_token_file_path else None + ) + self.credentials = self._authenticate() + self.client = self._get_graph_client( + credentials=self.credentials, scopes=self.scopes + ) + + def _get_dynamic_redirect_uri(self) -> str: + """Finds an available port and returns a dynamic redirect URI. + + Returns: + str: A redirect URI with format 'http://localhost:' where + port is an available port on the system. + """ + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('127.0.0.1', 0)) + port = s.getsockname()[1] + return f'http://localhost:{port}' + + def _get_auth_url(self, client_id, tenant_id, redirect_uri, scopes): + """Constructs the Microsoft authorization URL. + + Args: + client_id (str): The OAuth client ID. + tenant_id (str): The Microsoft tenant ID. + redirect_uri (str): The redirect URI for OAuth callback. + scopes (List[str]): List of permission scopes. + + Returns: + str: The complete authorization URL. + """ + from urllib.parse import urlencode + + params = { + 'client_id': client_id, + 'response_type': 'code', + 'redirect_uri': redirect_uri, + 'scope': " ".join(scopes), + } + auth_url = ( + f'https://login.microsoftonline.com/{tenant_id}' + f'/oauth2/v2.0/authorize?{urlencode(params)}' + ) + return auth_url + + def _load_token_from_file(self) -> Optional[str]: + """Loads refresh token from disk. + + Returns: + Optional[str]: Refresh token if file exists and valid, else None. + """ + if not self.refresh_token_file_path: + return None + + if not self.refresh_token_file_path.exists(): + return None + + try: + with open(self.refresh_token_file_path, 'r') as f: + token_data = json.load(f) + + refresh_token = token_data.get('refresh_token') + if refresh_token: + logger.info( + f"Refresh token loaded from {self.refresh_token_file_path}" + ) + return refresh_token + + logger.warning("Token file missing 'refresh_token' field") + return None + + except Exception as e: + logger.warning(f"Failed to load token file: {e!s}") + return None + + def _save_token_to_file(self, refresh_token: str): + """Saves refresh token to disk. + + Args: + refresh_token (str): The refresh token to save. + """ + if not self.refresh_token_file_path: + logger.info("Token file path not set, skipping token save") + return + + try: + # Create parent directories if they don't exist + self.refresh_token_file_path.parent.mkdir( + parents=True, exist_ok=True + ) + + with open(self.refresh_token_file_path, 'w') as f: + json.dump({"refresh_token": refresh_token}, f, indent=2) + logger.info( + f"Refresh token saved to {self.refresh_token_file_path}" + ) + except Exception as e: + logger.warning(f"Failed to save token to file: {e!s}") + + def _authenticate_using_refresh_token( + self, + ) -> CustomAzureCredential: + """Authenticates using a saved refresh token. + + Loads the refresh token from disk and creates a credential object + that will automatically refresh access tokens as needed. + + Returns: + _RefreshableCredential: Credential with auto-refresh capability. + + Raises: + ValueError: If refresh token cannot be loaded or is invalid. + """ + refresh_token = self._load_token_from_file() + + if not refresh_token: + raise ValueError("No valid refresh token found in file") + + # Create credential with automatic refresh capability + credentials = CustomAzureCredential( + client_id=self.client_id, + client_secret=self.client_secret, + tenant_id=self.tenant_id, + refresh_token=refresh_token, + scopes=self.scopes, + refresh_token_file_path=self.refresh_token_file_path, + ) + + logger.info("Authentication with saved token successful") + return credentials + + def _authenticate_using_browser(self): + """Authenticates using browser-based OAuth flow. + + Opens browser for user authentication, exchanges authorization + code for tokens, and saves refresh token for future use. + + Returns: + AuthorizationCodeCredential : Credential for Microsoft Graph API. + + Raises: + ValueError: If authentication fails or no authorization code. + """ + import webbrowser + from http.server import HTTPServer + from urllib.parse import urlparse + + from azure.identity import TokenCachePersistenceOptions + from azure.identity.aio import AuthorizationCodeCredential + + # offline_access scope is needed so the azure credential can refresh + # internally after access token expires as azure handles it internally + # Do not add offline_access to self.scopes as MSAL does not allow it + scope = [*self.scopes, "offline_access"] + + auth_url = self._get_auth_url( + client_id=self.client_id, + tenant_id=self.tenant_id, + redirect_uri=self.redirect_uri, + scopes=scope, + ) + + # Convert redirect URI string to tuple for HTTPServer + parsed_uri = urlparse(self.redirect_uri) + server_address = (parsed_uri.hostname, parsed_uri.port) + server = HTTPServer(server_address, RedirectHandler) + + # Initialize code attribute to None + server.code = None + + # Open authorization URL + logger.info(f"Opening browser for authentication: {auth_url}") + webbrowser.open(auth_url) + + # Capture authorization code via local server + server.handle_request() + + # Close the server after getting the code + server.server_close() + + if not server.code: + raise ValueError("Failed to get authorization code") + + authorization_code = server.code + # Set up token cache to store tokens + cache_opts = TokenCachePersistenceOptions() + + # Create credentials + credentials = AuthorizationCodeCredential( + tenant_id=self.tenant_id, + client_id=self.client_id, + authorization_code=authorization_code, + redirect_uri=self.redirect_uri, + client_secret=self.client_secret, + token_cache_persistence_options=cache_opts, + ) + + return credentials + + @api_keys_required( + [ + (None, "MICROSOFT_CLIENT_ID"), + (None, "MICROSOFT_CLIENT_SECRET"), + ] + ) + def _authenticate(self): + """Authenticates and creates credential for Microsoft Graph. + + Implements two-stage authentication: + 1. Attempts to use saved refresh token if refresh_token_file_path is + provided + 2. Falls back to browser OAuth if no token or token invalid + + Returns: + AuthorizationCodeCredential or CustomAzureCredential + + Raises: + ValueError: If authentication fails through both methods. + """ + from azure.identity.aio import AuthorizationCodeCredential + + try: + self.tenant_id = os.getenv("MICROSOFT_TENANT_ID", "common") + self.client_id = os.getenv("MICROSOFT_CLIENT_ID") + self.client_secret = os.getenv("MICROSOFT_CLIENT_SECRET") + + # Try saved refresh token first if token file path is provided + if ( + self.refresh_token_file_path + and self.refresh_token_file_path.exists() + ): + try: + credentials: CustomAzureCredential = ( + self._authenticate_using_refresh_token() + ) + return credentials + except Exception as e: + logger.warning( + f"Authentication using refresh token failed: {e!s}. " + f"Falling back to browser authentication" + ) + + # Fall back to browser authentication + credentials: AuthorizationCodeCredential = ( + self._authenticate_using_browser() + ) + return credentials + + except Exception as e: + error_msg = f"Failed to authenticate: {e!s}" + logger.error(error_msg) + raise ValueError(error_msg) + + def _get_graph_client(self, credentials, scopes): + """Creates Microsoft Graph API client. + + Args: + credentials : AuthorizationCodeCredential or CustomAzureCredential. + scopes (List[str]): List of permission scopes. + + Returns: + GraphServiceClient: Microsoft Graph API client. + + Raises: + ValueError: If client creation fails. + """ + from msgraph import GraphServiceClient + + try: + client = GraphServiceClient(credentials=credentials, scopes=scopes) + return client + except Exception as e: + error_msg = f"Failed to create Graph client: {e!s}" + logger.error(error_msg) + raise ValueError(error_msg) + + def is_email_valid(self, email: str) -> bool: + """Validates a single email address. + + Args: + email (str): Email address to validate. + + Returns: + bool: True if the email is valid, False otherwise. + """ + import re + from email.utils import parseaddr + + # Extract email address from both formats : "Email" , "Name " + _, addr = parseaddr(email) + + email_pattern = re.compile( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + ) + return bool(addr and email_pattern.match(addr)) + + def _get_invalid_emails(self, *lists: Optional[List[str]]) -> List[str]: + """Finds invalid email addresses from multiple email lists. + + Args: + *lists: Variable number of optional email address lists. + + Returns: + List[str]: List of invalid email addresses. Empty list if all + emails are valid. + """ + invalid_emails = [] + for email_list in lists: + if email_list is None: + continue + for email in email_list: + if not self.is_email_valid(email): + invalid_emails.append(email) + return invalid_emails + + def _create_attachments(self, file_paths: List[str]) -> List[Any]: + """Creates Microsoft Graph FileAttachment objects from file paths. + + Args: + file_paths (List[str]): List of local file paths to attach. + + Returns: + List[Any]: List of FileAttachment objects ready for Graph API use. + + Raises: + ValueError: If any file cannot be read or attached. + """ + from msgraph.generated.models.file_attachment import FileAttachment + + attachment_list = [] + + for file_path in file_paths: + try: + if not os.path.isfile(file_path): + raise ValueError( + f"Path does not exist or is not a file: {file_path}" + ) + + with open(file_path, "rb") as file: + file_content = file.read() + + file_name = os.path.basename(file_path) + + # Create attachment with proper properties + attachment_obj = FileAttachment() + attachment_obj.odata_type = "#microsoft.graph.fileAttachment" + attachment_obj.name = file_name + attachment_obj.content_bytes = file_content + + attachment_list.append(attachment_obj) + + except Exception as e: + raise ValueError(f"Failed to attach file {file_path}: {e!s}") + + return attachment_list + + def _create_recipients(self, email_list: Optional[List[str]]) -> List[Any]: + """Creates Microsoft Graph Recipient objects from email addresses. + + Supports both simple email format ("email@example.com") and + name-email format ("John Doe "). + + Args: + email_list (Optional[List[str]]): List of email addresses, + which can include display names. + + Returns: + List[Any]: List of Recipient objects ready for Graph API use. + """ + from email.utils import parseaddr + + from msgraph.generated.models import email_address, recipient + + if not email_list: + return [] + + recipients: List[Any] = [] + for email in email_list: + # Extract email address from both formats: "Email", "Name " + name, addr = parseaddr(email) + address = email_address.EmailAddress(address=addr) + if name: + address.name = name + recp = recipient.Recipient(email_address=address) + recipients.append(recp) + return recipients + + def _create_message( + self, + to_email: Optional[List[str]] = None, + subject: Optional[str] = None, + content: Optional[str] = None, + is_content_html: bool = False, + attachments: Optional[List[str]] = None, + cc_recipients: Optional[List[str]] = None, + bcc_recipients: Optional[List[str]] = None, + reply_to: Optional[List[str]] = None, + ): + """Creates a message object for sending or updating emails. + + This helper method is used internally to construct Microsoft Graph + message objects. It's used by methods like send_email, + create_draft_email, and update_draft_message. All parameters are + optional to allow partial updates when modifying existing messages. + + Args: + to_email (Optional[List[str]]): List of recipient email addresses. + (default: :obj:`None`) + subject (Optional[str]): The subject of the email. + (default: :obj:`None`) + content (Optional[str]): The body content of the email. + (default: :obj:`None`) + is_content_html (bool): If True, the content type will be set to + HTML; otherwise, it will be Text. (default: :obj:`False`) + attachments (Optional[List[str]]): List of file paths to attach + to the email. (default: :obj:`None`) + cc_recipients (Optional[List[str]]): List of CC recipient email + addresses. (default: :obj:`None`) + bcc_recipients (Optional[List[str]]): List of BCC recipient email + addresses. (default: :obj:`None`) + reply_to (Optional[List[str]]): List of email addresses that will + receive replies when recipients use the "Reply" button. This + allows replies to be directed to different addresses than the + sender's address. (default: :obj:`None`) + + Returns: + message.Message: A Microsoft Graph message object with only the + provided fields set. + """ + from msgraph.generated.models import body_type, item_body, message + + # Determine content type + if is_content_html: + content_type = body_type.BodyType.Html + else: + content_type = body_type.BodyType.Text + + mail_message = message.Message() + + # Set body content if provided + if content: + message_body = item_body.ItemBody( + content_type=content_type, content=content + ) + mail_message.body = message_body + + # Set to recipients if provided + if to_email: + mail_message.to_recipients = self._create_recipients(to_email) + + # Set subject if provided + if subject: + mail_message.subject = subject + + # Add CC recipients if provided + if cc_recipients: + mail_message.cc_recipients = self._create_recipients(cc_recipients) + + # Add BCC recipients if provided + if bcc_recipients: + mail_message.bcc_recipients = self._create_recipients( + bcc_recipients + ) + + # Add reply-to addresses if provided + if reply_to: + mail_message.reply_to = self._create_recipients(reply_to) + + # Add attachments if provided + if attachments: + mail_message.attachments = self._create_attachments(attachments) + + return mail_message + + async def send_email( + self, + to_email: List[str], + subject: str, + content: str, + is_content_html: bool = False, + attachments: Optional[List[str]] = None, + cc_recipients: Optional[List[str]] = None, + bcc_recipients: Optional[List[str]] = None, + reply_to: Optional[List[str]] = None, + save_to_sent_items: bool = True, + ) -> Dict[str, Any]: + """Sends an email via Microsoft Outlook. + + Args: + to_email (List[str]): List of recipient email addresses. + subject (str): The subject of the email. + content (str): The body content of the email. + is_content_html (bool): If True, the content type will be set to + HTML; otherwise, it will be Text. (default: :obj:`False`) + attachments (Optional[List[str]]): List of file paths to attach + to the email. (default: :obj:`None`) + cc_recipients (Optional[List[str]]): List of CC recipient email + addresses. (default: :obj:`None`) + bcc_recipients (Optional[List[str]]): List of BCC recipient email + addresses. (default: :obj:`None`) + reply_to (Optional[List[str]]): List of email addresses that will + receive replies when recipients use the "Reply" button. This + allows replies to be directed to different addresses than the + sender's address. (default: :obj:`None`) + save_to_sent_items (bool): Whether to save the email to sent + items. (default: :obj:`True`) + + Returns: + Dict[str, Any]: A dictionary containing the result of the email + sending operation. + + """ + from msgraph.generated.users.item.send_mail.send_mail_post_request_body import ( # noqa: E501 + SendMailPostRequestBody, + ) + + try: + # Validate all email addresses + invalid_emails = self._get_invalid_emails( + to_email, cc_recipients, bcc_recipients, reply_to + ) + if invalid_emails: + error_msg = ( + f"Invalid email address(es) provided: " + f"{', '.join(invalid_emails)}" + ) + logger.error(error_msg) + return {"error": error_msg} + + mail_message = self._create_message( + to_email=to_email, + subject=subject, + content=content, + is_content_html=is_content_html, + attachments=attachments, + cc_recipients=cc_recipients, + bcc_recipients=bcc_recipients, + reply_to=reply_to, + ) + + request = SendMailPostRequestBody( + message=mail_message, + save_to_sent_items=save_to_sent_items, + ) + + await self.client.me.send_mail.post(request) + + logger.info("Email sent successfully.") + return { + 'status': 'success', + 'message': 'Email sent successfully', + 'recipients': to_email, + 'subject': subject, + } + except Exception as e: + error_msg = f"Failed to send email: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + def get_tools(self) -> List[FunctionTool]: + """Returns a list of FunctionTool objects representing the + functions in the toolkit. + + Returns: + List[FunctionTool]: A list of FunctionTool objects + representing the functions in the toolkit. + """ + return [ + FunctionTool(run_async(self.send_email)), + FunctionTool(run_async(self.create_draft_email)), + FunctionTool(run_async(self.send_draft_email)), + FunctionTool(run_async(self.delete_email)), + FunctionTool(run_async(self.move_message_to_folder)), + FunctionTool(run_async(self.get_attachments)), + FunctionTool(run_async(self.get_message)), + FunctionTool(run_async(self.list_messages)), + FunctionTool(run_async(self.reply_to_email)), + FunctionTool(run_async(self.update_draft_message)), + ] + + async def create_draft_email( + self, + to_email: List[str], + subject: str, + content: str, + is_content_html: bool = False, + attachments: Optional[List[str]] = None, + cc_recipients: Optional[List[str]] = None, + bcc_recipients: Optional[List[str]] = None, + reply_to: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Creates a draft email in Microsoft Outlook. + + Args: + to_email (List[str]): List of recipient email addresses. + subject (str): The subject of the email. + content (str): The body content of the email. + is_content_html (bool): If True, the content type will be set to + HTML; otherwise, it will be Text. (default: :obj:`False`) + attachments (Optional[List[str]]): List of file paths to attach + to the email. (default: :obj:`None`) + cc_recipients (Optional[List[str]]): List of CC recipient email + addresses. (default: :obj:`None`) + bcc_recipients (Optional[List[str]]): List of BCC recipient email + addresses. (default: :obj:`None`) + reply_to (Optional[List[str]]): List of email addresses that will + receive replies when recipients use the "Reply" button. This + allows replies to be directed to different addresses than the + sender's address. (default: :obj:`None`) + + Returns: + Dict[str, Any]: A dictionary containing the result of the draft + email creation operation, including the draft ID. + + """ + # Validate all email addresses + invalid_emails = self._get_invalid_emails( + to_email, cc_recipients, bcc_recipients, reply_to + ) + if invalid_emails: + error_msg = ( + f"Invalid email address(es) provided: " + f"{', '.join(invalid_emails)}" + ) + logger.error(error_msg) + return {"error": error_msg} + + try: + request_body = self._create_message( + to_email=to_email, + subject=subject, + content=content, + is_content_html=is_content_html, + attachments=attachments, + cc_recipients=cc_recipients, + bcc_recipients=bcc_recipients, + reply_to=reply_to, + ) + + result = await self.client.me.messages.post(request_body) + + logger.info("Draft email created successfully.") + return { + 'status': 'success', + 'message': 'Draft email created successfully', + 'draft_id': result.id, + 'recipients': to_email, + 'subject': subject, + } + except Exception as e: + error_msg = f"Failed to create draft email: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + async def send_draft_email(self, draft_id: str) -> Dict[str, Any]: + """Sends a draft email via Microsoft Outlook. + + Args: + draft_id (str): The ID of the draft email to send. Can be + obtained either by creating a draft via + `create_draft_email()` or from the 'message_id' field in + messages returned by `list_messages()`. + + Returns: + Dict[str, Any]: A dictionary containing the result of the draft + email sending operation. + + """ + try: + await self.client.me.messages.by_message_id(draft_id).send.post() + + logger.info(f"Draft email with ID {draft_id} sent successfully.") + return { + 'status': 'success', + 'message': 'Draft email sent successfully', + 'draft_id': draft_id, + } + except Exception as e: + error_msg = f"Failed to send draft email: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + async def delete_email(self, message_id: str) -> Dict[str, Any]: + """Deletes an email from Microsoft Outlook. + + Args: + message_id (str): The ID of the email to delete. Can be obtained + from the 'message_id' field in messages returned by + `list_messages()`. + + Returns: + Dict[str, Any]: A dictionary containing the result of the email + deletion operation. + + """ + try: + await self.client.me.messages.by_message_id(message_id).delete() + logger.info(f"Email with ID {message_id} deleted successfully.") + return { + 'status': 'success', + 'message': 'Email deleted successfully', + 'message_id': message_id, + } + except Exception as e: + error_msg = f"Failed to delete email: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + async def move_message_to_folder( + self, message_id: str, destination_folder_id: str + ) -> Dict[str, Any]: + """Moves an email to a specified folder in Microsoft Outlook. + + Args: + message_id (str): The ID of the email to move. Can be obtained + from the 'message_id' field in messages returned by + `list_messages()`. + destination_folder_id (str): The destination folder ID, or + a well-known folder name. Supported well-known folder names are + ("inbox", "drafts", "sentitems", "deleteditems", "junkemail", + "archive", "outbox"). + + Returns: + Dict[str, Any]: A dictionary containing the result of the email + move operation. + + """ + from msgraph.generated.users.item.messages.item.move.move_post_request_body import ( # noqa: E501 + MovePostRequestBody, + ) + + try: + request_body = MovePostRequestBody( + destination_id=destination_folder_id, + ) + message = self.client.me.messages.by_message_id(message_id) + await message.move.post(request_body) + + logger.info( + f"Email with ID {message_id} moved to folder " + f"{destination_folder_id} successfully." + ) + return { + 'status': 'success', + 'message': 'Email moved successfully', + 'message_id': message_id, + 'destination_folder_id': destination_folder_id, + } + except Exception as e: + error_msg = f"Failed to move email: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + async def get_attachments( + self, + message_id: str, + metadata_only: bool = True, + include_inline_attachments: bool = False, + save_path: Optional[str] = None, + ) -> Dict[str, Any]: + """Retrieves attachments from a Microsoft Outlook email message. + + This method fetches attachments from a specified email message and can + either return metadata only or download the full attachment content. + Inline attachments (like embedded images) can optionally be included + or excluded from the results. + Also, if a save_path is provided, attachments will be saved to disk. + + Args: + message_id (str): The unique identifier of the email message from + which to retrieve attachments. Can be obtained from the + 'message_id' field in messages returned by `list_messages()`. + metadata_only (bool): If True, returns only attachment metadata + (name, size, content type, etc.) without downloading the actual + file content. If False, downloads the full attachment content. + (default: :obj:`True`) + include_inline_attachments (bool): If True, includes inline + attachments (such as embedded images) in the results. If False, + filters them out. (default: :obj:`False`) + save_path (Optional[str]): The local directory path where + attachments should be saved. If provided, attachments are saved + to disk and the file paths are returned. If None, attachment + content is returned as base64-encoded strings (only when + metadata_only=False). (default: :obj:`None`) + + Returns: + Dict[str, Any]: A dictionary containing the attachment retrieval + results + + """ + try: + request_config = None + if metadata_only: + request_config = self._build_attachment_query() + + attachments_response = await self._fetch_attachments( + message_id, request_config + ) + if not attachments_response: + return { + 'status': 'success', + 'message_id': message_id, + 'attachments': [], + 'total_count': 0, + } + + attachments_list = [] + for attachment in attachments_response.value: + if not include_inline_attachments and attachment.is_inline: + continue + info = self._process_attachment( + attachment, + metadata_only, + save_path, + ) + attachments_list.append(info) + + return { + 'status': 'success', + 'message_id': message_id, + 'attachments': attachments_list, + 'total_count': len(attachments_list), + } + + except Exception as e: + error_msg = f"Failed to get attachments: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + def _build_attachment_query(self): + """Constructs the query configuration for fetching attachments. + + Args: + metadata_only (bool): Whether to fetch only metadata or include + content bytes. + + Returns: + AttachmentsRequestBuilderGetRequestConfiguration: Query config + for the Graph API request. + """ + from msgraph.generated.users.item.messages.item.attachments.attachments_request_builder import ( # noqa: E501 + AttachmentsRequestBuilder, + ) + + query_params = AttachmentsRequestBuilder.AttachmentsRequestBuilderGetQueryParameters( # noqa: E501 + select=[ + "id", + "lastModifiedDateTime", + "name", + "contentType", + "size", + "isInline", + ] + ) + + return AttachmentsRequestBuilder.AttachmentsRequestBuilderGetRequestConfiguration( # noqa: E501 + query_parameters=query_params + ) + + async def _fetch_attachments( + self, message_id: str, request_config: Optional[Any] = None + ): + """Fetches attachments from the Microsoft Graph API. + + Args: + message_id (str): The email message ID. + request_config (Optional[Any]): The request configuration with + query parameters. (default: :obj:`None`) + + + Returns: + Attachments response from the Graph API. + """ + if not request_config: + return await self.client.me.messages.by_message_id( + message_id + ).attachments.get() + return await self.client.me.messages.by_message_id( + message_id + ).attachments.get(request_configuration=request_config) + + def _process_attachment( + self, + attachment, + metadata_only: bool, + save_path: Optional[str], + ): + """Processes a single attachment and extracts its information. + + Args: + attachment: The attachment object from Graph API. + metadata_only (bool): Whether to include content bytes. + save_path (Optional[str]): Path to save attachment file. + + Returns: + Dict: Dictionary containing attachment information. + """ + import base64 + + info = { + 'id': attachment.id, + 'name': attachment.name, + 'content_type': attachment.content_type, + 'size': attachment.size, + 'is_inline': getattr(attachment, 'is_inline', False), + 'last_modified_date_time': ( + attachment.last_modified_date_time.isoformat() + ), + } + + if not metadata_only: + content_bytes = getattr(attachment, 'content_bytes', None) + if content_bytes: + # Decode once because bytes contain Base64 text ' + decoded_bytes = base64.b64decode(content_bytes) + + if save_path: + file_path = self._save_attachment_file( + save_path, attachment.name, decoded_bytes + ) + info['saved_path'] = file_path + logger.info( + f"Attachment {attachment.name} saved to {file_path}" + ) + else: + info['content_bytes'] = content_bytes + + return info + + def _save_attachment_file( + self, + save_path: str, + attachment_name: str, + content_bytes: bytes, + cannot_overwrite: bool = True, + ) -> str: + """Saves attachment content to a file on disk. + + Args: + save_path (str): Directory path where file should be saved. + attachment_name (str): Name of the attachment file. + content_bytes (bytes): The file content as bytes. + cannot_overwrite (bool): If True, appends counter to filename + if file exists. (default: :obj:`True`) + + Returns: + str: The full file path where the attachment was saved. + """ + import os + + os.makedirs(save_path, exist_ok=True) + file_path = os.path.join(save_path, attachment_name) + file_path_already_exists = os.path.exists(file_path) + if cannot_overwrite and file_path_already_exists: + count = 1 + name, ext = os.path.splitext(attachment_name) + while os.path.exists(file_path): + file_path = os.path.join(save_path, f"{name}_{count}{ext}") + count += 1 + with open(file_path, 'wb') as f: + f.write(content_bytes) + return file_path + + def _handle_html_body(self, body_content: str) -> str: + """Converts HTML email body to plain text. + + Note: This method performs client-side HTML-to-text conversion. + + Args: + body_content (str): The HTML content of the email body. This + content is already sanitized by Microsoft Graph API. + + Returns: + str: Plain text version of the email body with cleaned whitespace + and removed HTML tags. + """ + try: + import html2text + + parser = html2text.HTML2Text() + + parser.ignore_links = False + parser.inline_links = True + parser.protect_links = True + parser.skip_internal_links = True + + parser.ignore_images = False + parser.images_as_html = False + parser.images_to_alt = False + parser.images_with_size = False + + parser.ignore_emphasis = False + parser.body_width = 0 + parser.single_line_break = True + + return parser.handle(body_content).strip() + + except Exception as e: + logger.error(f"Failed to parse HTML body: {e!s}") + return body_content + + def _get_recipients(self, recipient_list: Optional[List[Any]]): + """Gets a list of recipients from a recipient list object.""" + recipients: List[Dict[str, str]] = [] + if not recipient_list: + return recipients + for recipient_info in recipient_list: + email = recipient_info.email_address.address + name = recipient_info.email_address.name + recipients.append({'address': email, 'name': name}) + return recipients + + async def _extract_message_details( + self, + message: Any, + return_html_content: bool = False, + include_attachments: bool = False, + attachment_metadata_only: bool = True, + include_inline_attachments: bool = False, + attachment_save_path: Optional[str] = None, + ) -> Dict[str, Any]: + """Extracts detailed information from a message object. + + This function processes a message object (either from a list response + or a direct fetch) and extracts all relevant details. It can + optionally fetch attachments but does not make additional API calls + for basic message information. + + Args: + message (Any): The Microsoft Graph message object to extract + details from. + return_html_content (bool): If True and body content type is HTML, + returns the raw HTML content without converting it to plain + text. If False and body_type is 'text', HTML content will be + converted to plain text. + (default: :obj:`False`) + include_attachments (bool): Whether to include attachment + information. If True, will make an API call to fetch + attachments. (default: :obj:`False`) + attachment_metadata_only (bool): If True, returns only attachment + metadata without downloading content. If False, downloads full + attachment content. Only used when include_attachments=True. + (default: :obj:`True`) + include_inline_attachments (bool): If True, includes inline + attachments in the results. Only used when + include_attachments=True. (default: :obj:`False`) + attachment_save_path (Optional[str]): Directory path where + attachments should be saved. Only used when + include_attachments=True and attachment_metadata_only=False. + (default: :obj:`None`) + + Returns: + Dict[str, Any]: A dictionary containing the message details + including: + - Basic info (message_id, subject, from, received_date_time, + body etc.) + - Recipients (to_recipients, cc_recipients, bcc_recipients) + - Attachment information (if requested) + + """ + try: + # Validate message object + from msgraph.generated.models.message import Message + + if not isinstance(message, Message): + return {'error': 'Invalid message object provided'} + # Extract basic details + details = { + 'message_id': message.id, + 'subject': message.subject, + # Draft messages have from_ as None + 'from': ( + self._get_recipients([message.from_]) + if message.from_ + else None + ), + 'to_recipients': self._get_recipients(message.to_recipients), + 'cc_recipients': self._get_recipients(message.cc_recipients), + 'bcc_recipients': self._get_recipients(message.bcc_recipients), + 'received_date_time': ( + message.received_date_time.isoformat() + if message.received_date_time + else None + ), + 'sent_date_time': ( + message.sent_date_time.isoformat() + if message.sent_date_time + else None + ), + 'has_non_inline_attachments': message.has_attachments, + 'importance': (str(message.importance)), + 'is_read': message.is_read, + 'is_draft': message.is_draft, + 'body_preview': message.body_preview, + } + + body_content = message.body.content if message.body else '' + content_type = message.body.content_type if (message.body) else '' + + # Convert HTML to text if requested and content is HTML + is_content_html = content_type and "html" in str(content_type) + if is_content_html and not return_html_content and body_content: + body_content = self._handle_html_body(body_content) + + details['body'] = body_content + details['body_type'] = content_type + + # Include attachments if requested + if not include_attachments: + return details + + attachments_info = await self.get_attachments( + message_id=details['message_id'], + metadata_only=attachment_metadata_only, + include_inline_attachments=include_inline_attachments, + save_path=attachment_save_path, + ) + details['attachments'] = attachments_info.get('attachments', []) + return details + + except Exception as e: + error_msg = f"Failed to extract message details: {e!s}" + logger.error(error_msg) + raise ValueError(error_msg) + + async def get_message( + self, + message_id: str, + return_html_content: bool = False, + include_attachments: bool = False, + attachment_metadata_only: bool = True, + include_inline_attachments: bool = False, + attachment_save_path: Optional[str] = None, + ) -> Dict[str, Any]: + """Retrieves a single email message by ID from Microsoft Outlook. + + This method fetches a specific email message using its unique + identifier and returns detailed information including subject, sender, + recipients, body content, and optionally attachments. + + Args: + message_id (str): The unique identifier of the email message to + retrieve. Can be obtained from the 'message_id' field in + messages returned by `list_messages()`. + return_html_content (bool): If True and body content type is HTML, + returns the raw HTML content without converting it to plain + text. If False and body_type is HTML, content will be converted + to plain text. (default: :obj:`False`) + include_attachments (bool): Whether to include attachment + information in the response. (default: :obj:`False`) + attachment_metadata_only (bool): If True, returns only attachment + metadata without downloading content. If False, downloads full + attachment content. Only used when include_attachments=True. + (default: :obj:`True`) + include_inline_attachments (bool): If True, includes inline + attachments in the results. Only used when + include_attachments=True. (default: :obj:`False`) + attachment_save_path (Optional[str]): Directory path where + attachments should be saved. Only used when + include_attachments=True and attachment_metadata_only=False. + (default: :obj:`None`) + + Returns: + Dict[str, Any]: A dictionary containing the message details + including message_id, subject, from, to_recipients, + cc_recipients, bcc_recipients, received_date_time, + sent_date_time, body, body_type, has_attachments, importance, + is_read, is_draft, body_preview, and optionally attachments. + + """ + try: + message = await self.client.me.messages.by_message_id( + message_id + ).get() + + if not message: + error_msg = f"Message with ID {message_id} not found" + logger.error(error_msg) + return {"error": error_msg} + + details = await self._extract_message_details( + message=message, + return_html_content=return_html_content, + include_attachments=include_attachments, + attachment_metadata_only=attachment_metadata_only, + include_inline_attachments=include_inline_attachments, + attachment_save_path=attachment_save_path, + ) + + logger.info(f"Message with ID {message_id} retrieved successfully") + return { + 'status': 'success', + 'message': details, + } + + except Exception as e: + error_msg = f"Failed to get message: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + async def _get_messages_from_folder( + self, + folder_id: str, + request_config, + ): + """Fetches messages from a specific folder. + + Args: + folder_id (str): The folder ID or well-known folder name. + request_config: The request configuration with query parameters. + + Returns: + Messages response from the Graph API, or None if folder not found. + """ + try: + messages = await self.client.me.mail_folders.by_mail_folder_id( + folder_id + ).messages.get(request_configuration=request_config) + return messages + except Exception as e: + logger.warning( + f"Failed to get messages from folder {folder_id}: {e!s}" + ) + return None + + async def list_messages( + self, + folder_ids: Optional[List[str]] = None, + filter_query: Optional[str] = None, + order_by: Optional[List[str]] = None, + top: int = 10, + skip: int = 0, + return_html_content: bool = False, + include_attachment_metadata: bool = False, + ) -> Dict[str, Any]: + """ + Retrieves messages from Microsoft Outlook using Microsoft Graph API. + + Note: Each folder requires a separate API call. Use folder_ids=None + to search the entire mailbox in one call for better performance. + + When using $filter and $orderby in the same query to get messages, + make sure to specify properties in the following ways: + Properties that appear in $orderby must also appear in $filter. + Properties that appear in $orderby are in the same order as in $filter. + Properties that are present in $orderby appear in $filter before any + properties that aren't. + Failing to do this results in the following error: + Error code: InefficientFilter + Error message: The restriction or sort order is too complex for this + operation. + + Args: + folder_ids (Optional[List[str]]): Folder IDs or well-known names + ("inbox", "drafts", "sentitems", "deleteditems", "junkemail", + "archive", "outbox"). None searches the entire mailbox. + filter_query (Optional[str]): OData filter for messages. + Examples: + - Sender: "from/emailAddress/address eq 'john@example.com'" + - Subject: "subject eq 'Meeting Notes'", + "contains(subject, 'urgent')" + - Read status: "isRead eq false", "isRead eq true" + - Attachments: "hasAttachments eq true/false" + - Importance: "importance eq 'high'/'normal'/'low'" + - Date: "receivedDateTime ge 2024-01-01T00:00:00Z" + - Combine: "isRead eq false and hasAttachments eq true" + - Negation: "not(isRead eq true)" + Reference: https://learn.microsoft.com/en-us/graph/filter-query-parameter + order_by (Optional[List[str]]): OData orderBy for sorting messages. + Examples: + - Date: "receivedDateTime desc/asc", "sentDateTime desc" + - Sender: "from/emailAddress/address asc/desc", + - Subject: "subject asc/desc" + - Importance: "importance desc/asc" + - Size: "size desc/asc" + - Multi-field: "importance desc, receivedDateTime desc" + Reference: https://learn.microsoft.com/en-us/graph/query-parameters + top (int): Max messages per folder (default: 10) + skip (int): Messages to skip for pagination (default: 0) + return_html_content (bool): Return raw HTML if True; + else convert to text (default: False) + include_attachment_metadata (bool): Include attachment metadata + (name, size, type); content not included (default: False) + + Returns: + Dict[str, Any]: Dictionary containing messages and + attachment metadata if requested. + """ + + try: + from msgraph.generated.users.item.mail_folders.item.messages.messages_request_builder import ( # noqa: E501 + MessagesRequestBuilder, + ) + + # Build query parameters + if order_by: + query_params = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters( # noqa: E501 + top=top, + skip=skip, + orderby=order_by, + ) + else: + query_params = MessagesRequestBuilder.MessagesRequestBuilderGetQueryParameters( # noqa: E501 + top=top, + skip=skip, + ) + + if filter_query: + query_params.filter = filter_query + + request_config = MessagesRequestBuilder.MessagesRequestBuilderGetRequestConfiguration( # noqa: E501 + query_parameters=query_params + ) + if not folder_ids: + # Search entire mailbox in a single API call + messages_response = await self.client.me.messages.get( + request_configuration=request_config + ) + all_messages = [] + if messages_response and messages_response.value: + for message in messages_response.value: + details = await self._extract_message_details( + message=message, + return_html_content=return_html_content, + include_attachments=include_attachment_metadata, + attachment_metadata_only=True, + include_inline_attachments=True, + attachment_save_path=None, + ) + all_messages.append(details) + + logger.info( + f"Retrieved {len(all_messages)} messages from mailbox" + ) + + return { + 'status': 'success', + 'messages': all_messages, + 'total_count': len(all_messages), + 'skip': skip, + 'top': top, + 'folders_searched': ['all'], + } + # Search specific folders (requires multiple API calls) + all_messages = [] + for folder_id in folder_ids: + messages_response = await self._get_messages_from_folder( + folder_id=folder_id, + request_config=request_config, + ) + + if not messages_response or not messages_response.value: + continue + + # Extract details from each message + for message in messages_response.value: + details = await self._extract_message_details( + message=message, + return_html_content=return_html_content, + include_attachments=include_attachment_metadata, + attachment_metadata_only=True, + include_inline_attachments=False, + attachment_save_path=None, + ) + all_messages.append(details) + + logger.info( + f"Retrieved {len(all_messages)} messages from " + f"{len(folder_ids)} folder(s)" + ) + + return { + 'status': 'success', + 'messages': all_messages, + 'total_count': len(all_messages), + 'skip': skip, + 'top': top, + 'folders_searched': folder_ids, + } + + except Exception as e: + error_msg = f"Failed to list messages: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + async def reply_to_email( + self, + message_id: str, + content: str, + reply_all: bool = False, + ) -> Dict[str, Any]: + """Replies to an email in Microsoft Outlook. + + Args: + message_id (str): The ID of the email to reply to. + content (str): The body content of the reply email. + reply_all (bool): If True, replies to all recipients of the + original email. If False, replies only to the sender. + (default: :obj:`False`) + + Returns: + Dict[str, Any]: A dictionary containing the result of the email + reply operation. + + Raises: + ValueError: If replying to the email fails. + """ + from msgraph.generated.users.item.messages.item.reply.reply_post_request_body import ( # noqa: E501 + ReplyPostRequestBody, + ) + from msgraph.generated.users.item.messages.item.reply_all.reply_all_post_request_body import ( # noqa: E501 + ReplyAllPostRequestBody, + ) + + try: + message_request = self.client.me.messages.by_message_id(message_id) + if reply_all: + request_body_reply_all = ReplyAllPostRequestBody( + comment=content + ) + await message_request.reply_all.post(request_body_reply_all) + else: + request_body = ReplyPostRequestBody(comment=content) + await message_request.reply.post(request_body) + + reply_type = "Reply All" if reply_all else "Reply" + logger.info( + f"{reply_type} to email with ID {message_id} sent " + "successfully." + ) + + return { + 'status': 'success', + 'message': f'{reply_type} sent successfully', + 'message_id': message_id, + 'reply_type': reply_type.lower(), + } + + except Exception as e: + error_msg = f"Failed to reply to email: {e!s}" + logger.error(error_msg) + return {"error": error_msg} + + async def update_draft_message( + self, + message_id: str, + subject: Optional[str] = None, + content: Optional[str] = None, + is_content_html: bool = False, + to_email: Optional[List[str]] = None, + cc_recipients: Optional[List[str]] = None, + bcc_recipients: Optional[List[str]] = None, + reply_to: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Updates an existing draft email message in Microsoft Outlook. + + Important: Any parameter provided will completely replace the original + value. For example, if you want to add a new recipient while keeping + existing ones, you must pass all recipients (both original and new) in + the to_email parameter. + + Note: This method is intended for draft messages only and not for + sent messages. + + Args: + message_id (str): The ID of the draft message to update. + subject (Optional[str]): Change the subject of the email. + Replaces the original subject completely. + (default: :obj:`None`) + content (Optional[str]): Change the body content of the email. + Replaces the original content completely. + (default: :obj:`None`) + is_content_html (bool): Change the content type. If True, sets + content type to HTML; if False, sets to plain text. + (default: :obj:`False`) + to_email (Optional[List[str]]): Change the recipient email + addresses. Replaces all original recipients completely. + (default: :obj:`None`) + cc_recipients (Optional[List[str]]): Change the CC recipient + email addresses. Replaces all original CC recipients + completely. (default: :obj:`None`) + bcc_recipients (Optional[List[str]]): Change the BCC recipient + email addresses. Replaces all original BCC recipients + completely. (default: :obj:`None`) + reply_to (Optional[List[str]]): Change the email addresses that + will receive replies. Replaces all original reply-to addresses + completely. (default: :obj:`None`) + + Returns: + Dict[str, Any]: A dictionary containing the result of the update + operation. + + """ + try: + # Validate all email addresses if provided + invalid_emails = self._get_invalid_emails( + to_email, cc_recipients, bcc_recipients, reply_to + ) + if invalid_emails: + error_msg = ( + f"Invalid email address(es) provided: " + f"{', '.join(invalid_emails)}" + ) + logger.error(error_msg) + return {"error": error_msg} + + # Create message with only the fields to update + mail_message = self._create_message( + to_email=to_email, + subject=subject, + content=content, + is_content_html=is_content_html, + cc_recipients=cc_recipients, + bcc_recipients=bcc_recipients, + reply_to=reply_to, + ) + + # Update the message using PATCH + await self.client.me.messages.by_message_id(message_id).patch( + mail_message + ) + + logger.info( + f"Draft message with ID {message_id} updated successfully." + ) + + # Build dict of updated parameters + updated_params: Dict[str, Any] = dict() + if subject: + updated_params['subject'] = subject + if content: + updated_params['content'] = content + if to_email: + updated_params['to_email'] = to_email + if cc_recipients: + updated_params['cc_recipients'] = cc_recipients + if bcc_recipients: + updated_params['bcc_recipients'] = bcc_recipients + if reply_to: + updated_params['reply_to'] = reply_to + + return { + 'status': 'success', + 'message': 'Draft message updated successfully', + 'message_id': message_id, + 'updated_params': updated_params, + } + + except Exception as e: + error_msg = f"Failed to update draft message: {e!s}" + logger.error(error_msg) + return {"error": error_msg} diff --git a/examples/toolkits/microsoft_outlook_mail_toolkit.py b/examples/toolkits/microsoft_outlook_mail_toolkit.py new file mode 100644 index 0000000000..69643d6c76 --- /dev/null +++ b/examples/toolkits/microsoft_outlook_mail_toolkit.py @@ -0,0 +1,96 @@ +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= + + +from camel.agents import ChatAgent +from camel.models import ModelFactory +from camel.toolkits import OutlookMailToolkit +from camel.types import ModelPlatformType +from camel.types.enums import ModelType + +# Create a model instance +model = ModelFactory.create( + model_platform=ModelPlatformType.DEFAULT, + model_type=ModelType.DEFAULT, +) + +# Define system message for the Outlook assistant +sys_msg = ( + "You are a helpful Microsoft Outlook assistant that can help users manage " + "their emails. You have access to all Outlook tools including sending " + "emails, fetching emails, managing drafts, and more." +) + +# Initialize the Outlook toolkit +print("Initializing Outlook toolkit (browser may open for authentication)...") +outlook_toolkit = OutlookMailToolkit() +print("Outlook toolkit initialized!") + +# Get all Outlook tools +all_tools = outlook_toolkit.get_tools() +print(f"Loaded {len(all_tools)} Outlook tools") + +# Initialize a ChatAgent with all Outlook tools +outlook_agent = ChatAgent( + system_message=sys_msg, + model=model, + tools=all_tools, +) + +# Example: Send an email +print("\nExample: Sending an email") +print("=" * 50) + +user_message = ( + "Send an email to test@example.com with subject " + "'Hello from Outlook Toolkit' and body 'This is a test email " + "sent using the CAMEL Outlook toolkit.'" +) + +response = outlook_agent.step(user_message) +print("Agent Response:") +print(response.msgs[0].content) +print("\nTool calls:") +print(response.info['tool_calls']) + +""" +Example: Sending an email +================================================== +Agent Response: +Done  your message has been sent to test@example.com. + +Tool calls: +[ToolCallingRecord( + tool_name='send_email', + args={ + 'to_email': ['test@example.com'], + 'subject': 'Hello from Outlook Toolkit', + 'content': 'This is a test email sent using the CAMEL toolkit.', + 'is_content_html': False, + 'attachments': None, + 'cc_recipients': None, + 'bcc_recipients': None, + 'reply_to': None, + 'save_to_sent_items': True + }, + result={ + 'status': 'success', + 'message': 'Email sent successfully', + 'recipients': ['test@example.com'], + 'subject': 'Hello from Outlook Toolkit' + }, + tool_call_id='call_abc123', + images=None +)] +""" diff --git a/pyproject.toml b/pyproject.toml index 359df6096e..4d7cd24a51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,6 +148,9 @@ communication_tools = [ "notion-client>=2.2.1,<3", "praw>=7.7.1,<8", "resend>=2.0.0,<3", + "azure-identity>=1.25.1,<2", + "msgraph-sdk>=1.46.0,<2", + "msal>=1.34.0,<2" ] data_tools = [ "numpy>=1.2,<=2.2", @@ -349,6 +352,9 @@ all = [ "agentops>=0.3.21,<0.4", "praw>=7.7.1,<8", "resend>=2.0.0,<3", + "azure-identity>=1.25.1,<2", + "msgraph-sdk>=1.46.0,<2", + "msal>=1.34.0,<2", "textblob>=0.17.1,<0.18", "scholarly[tor]==1.7.11", "notion-client>=2.2.1,<3", diff --git a/test/toolkits/test_microsoft_outlook_mail_toolkit.py b/test/toolkits/test_microsoft_outlook_mail_toolkit.py new file mode 100644 index 0000000000..4cfa8ff537 --- /dev/null +++ b/test/toolkits/test_microsoft_outlook_mail_toolkit.py @@ -0,0 +1,790 @@ +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= + +import os +from unittest.mock import AsyncMock, MagicMock, mock_open, patch + +import pytest + +from camel.toolkits import OutlookMailToolkit + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def mock_graph_service(): + """Mock Microsoft Graph API service.""" + with patch("msgraph.GraphServiceClient") as mock_service_client: + mock_client = MagicMock() + mock_service_client.return_value = mock_client + + # Mock me endpoint + mock_me = MagicMock() + mock_client.me = mock_me + + # Mock messages endpoint + mock_messages = MagicMock() + mock_me.messages = mock_messages + + # Mock send_mail endpoint + mock_send_mail = MagicMock() + mock_me.send_mail = mock_send_mail + mock_send_mail.post = AsyncMock() + + # Mock messages.get for listing messages + mock_messages.get = AsyncMock() + + # Mock messages.post for creating drafts + mock_messages.post = AsyncMock() + + # Mock messages.by_message_id for specific message operations + mock_by_message_id = MagicMock() + mock_messages.by_message_id = MagicMock( + return_value=mock_by_message_id + ) + + # Mock get message by ID + mock_by_message_id.get = AsyncMock() + + # Mock send draft + mock_send = MagicMock() + mock_by_message_id.send = mock_send + mock_send.post = AsyncMock() + + # Mock delete message + mock_by_message_id.delete = AsyncMock() + + # Mock move message + mock_move = MagicMock() + mock_by_message_id.move = mock_move + mock_move.post = AsyncMock() + + # Mock attachments + mock_attachments = MagicMock() + mock_by_message_id.attachments = mock_attachments + mock_attachments.get = AsyncMock() + + # Mock reply endpoint + mock_reply = MagicMock() + mock_by_message_id.reply = mock_reply + mock_reply.post = AsyncMock() + + # Mock reply_all endpoint + mock_reply_all = MagicMock() + mock_by_message_id.reply_all = mock_reply_all + mock_reply_all.post = AsyncMock() + + # Mock patch endpoint for updating messages + mock_by_message_id.patch = AsyncMock() + + # Mock mail_folders for folder-specific operations + mock_mail_folders = MagicMock() + mock_me.mail_folders = mock_mail_folders + + # Mock by_mail_folder_id + mock_by_folder_id = MagicMock() + mock_mail_folders.by_mail_folder_id = MagicMock( + return_value=mock_by_folder_id + ) + + # Mock folder messages + mock_folder_messages = MagicMock() + mock_by_folder_id.messages = mock_folder_messages + mock_folder_messages.get = AsyncMock() + + yield mock_client + + +@pytest.fixture +def outlook_toolkit(mock_graph_service): + """Fixture that provides a mocked OutlookMailToolkit instance.""" + # Create a mock credentials object to avoid OAuth authentication + mock_credentials = MagicMock() + mock_credentials.valid = True + + with ( + patch.dict( + 'os.environ', + { + 'MICROSOFT_TENANT_ID': 'mock_tenant_id', + 'MICROSOFT_CLIENT_ID': 'mock_client_id', + 'MICROSOFT_CLIENT_SECRET': 'mock_client_secret', + }, + ), + patch.object( + OutlookMailToolkit, + '_authenticate', + return_value=mock_credentials, + ), + patch.object( + OutlookMailToolkit, + '_get_graph_client', + return_value=mock_graph_service, + ), + ): + toolkit = OutlookMailToolkit() + toolkit.client = mock_graph_service + yield toolkit + + +async def test_send_email(outlook_toolkit, mock_graph_service): + """Test sending an email successfully.""" + mock_graph_service.me.send_mail.post.return_value = None + + result = await outlook_toolkit.send_email( + to_email=['test@example.com'], + subject='Test Subject', + content='Test Body', + ) + + assert result['status'] == 'success' + assert result['message'] == 'Email sent successfully' + assert result['recipients'] == ['test@example.com'] + assert result['subject'] == 'Test Subject' + + mock_graph_service.me.send_mail.post.assert_called_once() + + +async def test_send_email_with_attachments( + outlook_toolkit, mock_graph_service +): + """Test sending an email with attachments.""" + mock_graph_service.me.send_mail.post.return_value = None + + with ( + patch('os.path.isfile', return_value=True), + patch('builtins.open', create=True) as mock_open, + ): + mock_open.return_value.__enter__.return_value.read.return_value = ( + b'test content' + ) + + result = await outlook_toolkit.send_email( + to_email=['test@example.com'], + subject='Test Subject', + content='Test Body', + attachments=['/path/to/file.txt'], + ) + + assert result['status'] == 'success' + mock_graph_service.me.send_mail.post.assert_called_once() + + +async def test_send_email_invalid_email(outlook_toolkit): + """Test sending email with invalid email address.""" + result = await outlook_toolkit.send_email( + to_email=['invalid-email'], + subject='Test Subject', + content='Test Body', + ) + + assert 'error' in result + assert 'Invalid email address' in result['error'] + + +async def test_send_email_failure(outlook_toolkit, mock_graph_service): + """Test sending email failure.""" + mock_graph_service.me.send_mail.post.side_effect = Exception("API Error") + + result = await outlook_toolkit.send_email( + to_email=['test@example.com'], + subject='Test Subject', + content='Test Body', + ) + + assert 'error' in result + assert 'Failed to send email' in result['error'] + + +async def test_create_email_draft(outlook_toolkit, mock_graph_service): + """Test creating an email draft.""" + mock_draft_result = MagicMock() + mock_draft_result.id = 'draft123' + mock_graph_service.me.messages.post.return_value = mock_draft_result + + result = await outlook_toolkit.create_draft_email( + to_email=['test@example.com'], + subject='Test Subject', + content='Test Body', + ) + + assert result['status'] == 'success' + assert result['draft_id'] == 'draft123' + assert result['message'] == 'Draft email created successfully' + assert result['recipients'] == ['test@example.com'] + assert result['subject'] == 'Test Subject' + + mock_graph_service.me.messages.post.assert_called_once() + + +async def test_create_email_draft_with_attachments( + outlook_toolkit, mock_graph_service +): + """Test creating an email draft with attachments.""" + mock_draft_result = MagicMock() + mock_draft_result.id = 'draft123' + mock_graph_service.me.messages.post.return_value = mock_draft_result + + with ( + patch('os.path.isfile', return_value=True), + patch('builtins.open', create=True) as mock_open, + ): + mock_open.return_value.__enter__.return_value.read.return_value = ( + b'test content' + ) + + result = await outlook_toolkit.create_draft_email( + to_email=['test@example.com'], + subject='Test Subject', + content='Test Body', + attachments=['/path/to/file.txt'], + ) + + assert result['status'] == 'success' + assert result['draft_id'] == 'draft123' + mock_graph_service.me.messages.post.assert_called_once() + + +async def test_create_email_draft_invalid_email(outlook_toolkit): + """Test creating draft with invalid email address.""" + result = await outlook_toolkit.create_draft_email( + to_email=['invalid-email'], + subject='Test Subject', + content='Test Body', + ) + + assert 'error' in result + assert 'Invalid email address' in result['error'] + + +async def test_create_email_draft_failure(outlook_toolkit, mock_graph_service): + """Test creating email draft failure.""" + mock_graph_service.me.messages.post.side_effect = Exception("API Error") + + result = await outlook_toolkit.create_draft_email( + to_email=['test@example.com'], + subject='Test Subject', + content='Test Body', + ) + + assert 'error' in result + assert 'Failed to create draft email' in result['error'] + + +async def test_send_draft_email(outlook_toolkit, mock_graph_service): + """Test sending a draft email.""" + mock_graph_service.me.messages.by_message_id().send.post.return_value = ( + None + ) + + result = await outlook_toolkit.send_draft_email(draft_id='draft123') + + assert result['status'] == 'success' + assert result['message'] == 'Draft email sent successfully' + assert result['draft_id'] == 'draft123' + + mock_graph_service.me.messages.by_message_id().send.post.assert_called_once() + + +async def test_send_draft_email_failure(outlook_toolkit, mock_graph_service): + """Test sending draft email failure.""" + mock_graph_service.me.messages.by_message_id().send.post.side_effect = ( + Exception("API Error") + ) + + result = await outlook_toolkit.send_draft_email(draft_id='draft123') + + assert 'error' in result + assert 'Failed to send draft email' in result['error'] + + +async def test_delete_email(outlook_toolkit, mock_graph_service): + """Test deleting an email successfully.""" + mock_graph_service.me.messages.by_message_id().delete.return_value = None + + result = await outlook_toolkit.delete_email(message_id='msg123') + + assert result['status'] == 'success' + assert result['message'] == 'Email deleted successfully' + assert result['message_id'] == 'msg123' + + mock_graph_service.me.messages.by_message_id().delete.assert_called_once() + + +async def test_delete_email_failure(outlook_toolkit, mock_graph_service): + """Test deleting email failure.""" + mock_graph_service.me.messages.by_message_id().delete.side_effect = ( + Exception("API Error") + ) + + result = await outlook_toolkit.delete_email(message_id='msg123') + + assert 'error' in result + assert 'Failed to delete email' in result['error'] + + +async def test_move_message_to_folder(outlook_toolkit, mock_graph_service): + """Test moving an email to a folder successfully.""" + mock_graph_service.me.messages.by_message_id().move.post.return_value = ( + None + ) + + result = await outlook_toolkit.move_message_to_folder( + message_id='msg123', destination_folder_id='inbox' + ) + + assert result['status'] == 'success' + assert result['message'] == 'Email moved successfully' + assert result['message_id'] == 'msg123' + assert result['destination_folder_id'] == 'inbox' + + mock_graph_service.me.messages.by_message_id().move.post.assert_called_once() + + +async def test_move_message_to_folder_failure( + outlook_toolkit, mock_graph_service +): + """Test moving email failure.""" + mock_graph_service.me.messages.by_message_id().move.post.side_effect = ( + Exception("API Error") + ) + + result = await outlook_toolkit.move_message_to_folder( + message_id='msg123', destination_folder_id='inbox' + ) + + assert 'error' in result + assert 'Failed to move email' in result['error'] + + +async def test_get_attachments(outlook_toolkit, mock_graph_service): + """Test getting attachments and saving to disk.""" + import base64 + + mock_attachment = MagicMock() + mock_attachment.name = 'document.pdf' + mock_attachment.is_inline = False + mock_attachment.content_bytes = base64.b64encode(b'test content') + + mock_response = MagicMock() + mock_response.value = [mock_attachment] + mock_attachments = mock_graph_service.me.messages.by_message_id() + mock_attachments.attachments.get.return_value = mock_response + + with ( + patch('os.makedirs'), + patch('os.path.exists', return_value=False), + patch('builtins.open', create=True), + ): + result = await outlook_toolkit.get_attachments( + message_id='msg123', + ) + + assert result['status'] == 'success' + assert result['total_count'] == 1 + + +async def test_get_attachments_exclude_inline( + outlook_toolkit, mock_graph_service +): + """Test getting attachments excluding inline attachments (default).""" + mock_attachment1 = MagicMock() + mock_attachment1.name = 'image.png' + mock_attachment1.is_inline = True + + mock_response = MagicMock() + mock_response.value = [mock_attachment1] + mock_attachments = mock_graph_service.me.messages.by_message_id() + mock_attachments.attachments.get.return_value = mock_response + + result = await outlook_toolkit.get_attachments( + message_id='msg123', + include_inline_attachments=False, + ) + + assert result['status'] == 'success' + assert result['total_count'] == 0 # Only non-inline attachment + assert not result['attachments'] + + +async def test_get_attachments_include_inline( + outlook_toolkit, mock_graph_service +): + """Test getting attachments including inline attachments.""" + mock_attachment1 = MagicMock() + mock_attachment1.name = 'document.pdf' + mock_attachment1.is_inline = True + + mock_attachment2 = MagicMock() + mock_attachment2.name = 'image.png' + mock_attachment2.is_inline = True + + mock_response = MagicMock() + mock_response.value = [mock_attachment1, mock_attachment2] + mock_attachments = mock_graph_service.me.messages.by_message_id() + mock_attachments.attachments.get.return_value = mock_response + + result = await outlook_toolkit.get_attachments( + message_id='msg123', + metadata_only=True, + include_inline_attachments=True, + ) + + assert result['status'] == 'success' + assert result['total_count'] == 2 # Both attachments included + assert result['attachments'][0]['name'] == 'document.pdf' + assert result['attachments'][1]['name'] == 'image.png' + + +async def test_get_attachments_failure(outlook_toolkit, mock_graph_service): + """Test getting attachments failure.""" + mock_attachments = mock_graph_service.me.messages.by_message_id() + mock_attachments.attachments.get.side_effect = Exception("API Error") + + result = await outlook_toolkit.get_attachments(message_id='msg123') + + assert 'error' in result + assert 'Failed to get attachments' in result['error'] + + +async def test_get_attachments_with_content_and_save_path( + outlook_toolkit, mock_graph_service +): + """Test getting attachments with metadata_only=False and save_path.""" + import base64 + import tempfile + + original_content = b'This is a test PDF file content.' + encoded_content = base64.b64encode(original_content) + + mock_attachment = MagicMock() + mock_attachment.id = 'attachment-id-456' + mock_attachment.name = 'invoice.pdf' + mock_attachment.is_inline = False + mock_attachment.content_bytes = encoded_content + + mock_response = MagicMock() + mock_response.value = [mock_attachment] + mock_attachments = mock_graph_service.me.messages.by_message_id() + mock_attachments.attachments.get.return_value = mock_response + + with tempfile.TemporaryDirectory() as temp_dir: + m_open = mock_open() + with ( + patch('os.makedirs') as mock_makedirs, + patch('os.path.exists', return_value=False), + patch('builtins.open', m_open), + ): + result = await outlook_toolkit.get_attachments( + message_id='msg456', + metadata_only=False, + save_path=temp_dir, + ) + + assert result['status'] == 'success' + assert result['total_count'] == 1 + attachment_info = result['attachments'][0] + assert attachment_info['name'] == 'invoice.pdf' + assert 'saved_path' in attachment_info + assert 'content_bytes' not in attachment_info + + expected_path = os.path.join(temp_dir, 'invoice.pdf') + assert attachment_info['saved_path'] == expected_path + mock_makedirs.assert_called_once_with(temp_dir, exist_ok=True) + m_open.assert_called_once_with(expected_path, 'wb') + handle = m_open() + handle.write.assert_called_once_with(original_content) + + +@pytest.fixture( + params=[ + ('Plain text email body.', 'text', 'Plain text email body.'), + ( + '

HTML email body.

', + 'html', + 'HTML email body.', + ), + ], + ids=['plain_text', 'html_to_text'], +) +def create_mock_message(request): + """Parametrized fixture that creates mock message objects.""" + from datetime import datetime, timezone + + body_content, body_type, expected_body = request.param + + mock_msg = MagicMock() + mock_msg.id = 'msg123' + mock_msg.subject = 'Test Subject' + mock_msg.body_preview = body_content[:25] + '...' + mock_msg.is_read = False + mock_msg.is_draft = False + mock_msg.has_attachments = False + mock_msg.importance = 'normal' + mock_msg.received_date_time = datetime( + 2024, 1, 15, 10, 30, tzinfo=timezone.utc + ) + mock_msg.sent_date_time = datetime( + 2024, 1, 15, 10, 29, tzinfo=timezone.utc + ) + + # Mock body + mock_body = MagicMock() + mock_body.content = body_content + mock_body.content_type = body_type + mock_msg.body = mock_body + + # Mock from address + mock_from = MagicMock() + mock_from.email_address.address = 'sender@example.com' + mock_from.email_address.name = 'Sender Name' + mock_msg.from_ = mock_from + + # Mock recipients + mock_to = MagicMock() + mock_to.email_address.address = 'recipient@example.com' + mock_to.email_address.name = 'Recipient Name' + mock_msg.to_recipients = [mock_to] + mock_msg.cc_recipients = [] + mock_msg.bcc_recipients = [] + + # Add expected body for assertions + mock_msg.expected_body = expected_body + + return mock_msg + + +async def test_get_message( + outlook_toolkit, mock_graph_service, create_mock_message +): + """Test getting messages with different content types.""" + with patch( + 'camel.toolkits.microsoft_outlook_mail_toolkit.isinstance', + return_value=True, + ): + mock_graph_service.me.messages.by_message_id().get.return_value = ( + create_mock_message + ) + + result = await outlook_toolkit.get_message(message_id='msg123') + + assert result['status'] == 'success' + assert 'message' in result + + message = result['message'] + assert message['message_id'] == 'msg123' + assert message['subject'] == 'Test Subject' + assert message['body'] == create_mock_message.expected_body + assert message['is_read'] is False + assert message['from'][0]['address'] == 'sender@example.com' + assert message['to_recipients'][0]['address'] == 'recipient@example.com' + + mock_graph_service.me.messages.by_message_id().get.assert_called_once() + + +async def test_get_message_failure(outlook_toolkit, mock_graph_service): + """Test get_message handles API errors correctly.""" + mock_graph_service.me.messages.by_message_id().get.side_effect = Exception( + "API Error" + ) + + result = await outlook_toolkit.get_message(message_id='msg123') + + assert 'error' in result + assert 'Failed to get message' in result['error'] + + +async def test_list_messages( + outlook_toolkit, mock_graph_service, create_mock_message +): + """Test listing messages with different content types.""" + with patch( + 'camel.toolkits.microsoft_outlook_mail_toolkit.isinstance', + return_value=True, + ): + mock_response = MagicMock() + mock_response.value = [create_mock_message] + mock_graph_service.me.messages.get.return_value = mock_response + + result = await outlook_toolkit.list_messages() + + assert result['status'] == 'success' + assert 'messages' in result + assert result['total_count'] == 1 + assert len(result['messages']) == 1 + + message = result['messages'][0] + assert message['message_id'] == 'msg123' + assert message['subject'] == 'Test Subject' + assert message['body'] == create_mock_message.expected_body + assert message['is_read'] is False + assert message['from'][0]['address'] == 'sender@example.com' + assert message['to_recipients'][0]['address'] == 'recipient@example.com' + + mock_graph_service.me.messages.get.assert_called_once() + + +async def test_list_messages_failure(outlook_toolkit, mock_graph_service): + """Test list_messages handles API errors correctly.""" + mock_graph_service.me.messages.get.side_effect = Exception("API Error") + + result = await outlook_toolkit.list_messages() + + assert 'error' in result + assert 'Failed to list messages' in result['error'] + + +async def test_reply_to_email(outlook_toolkit, mock_graph_service): + """Test replying to an email (reply to sender only).""" + mock_graph_service.me.messages.by_message_id().reply.post.return_value = ( + None + ) + + result = await outlook_toolkit.reply_to_email( + message_id='msg123', + content='This is my reply', + reply_all=False, + ) + + assert result['status'] == 'success' + assert result['message'] == 'Reply sent successfully' + assert result['message_id'] == 'msg123' + assert result['reply_type'] == 'reply' + + mock_graph_service.me.messages.by_message_id().reply.post.assert_called_once() + mock_graph_service.me.messages.by_message_id().reply_all.post.assert_not_called() + + +async def test_reply_to_email_all(outlook_toolkit, mock_graph_service): + """Test replying to all recipients of an email.""" + mock_reply_all = mock_graph_service.me.messages.by_message_id().reply_all + mock_reply_all.post.return_value = None + + result = await outlook_toolkit.reply_to_email( + message_id='msg456', + content='This is my reply to all', + reply_all=True, + ) + + assert result['status'] == 'success' + assert result['message'] == 'Reply All sent successfully' + assert result['message_id'] == 'msg456' + assert result['reply_type'] == 'reply all' + + mock_reply_all.post.assert_called_once() + mock_graph_service.me.messages.by_message_id().reply.post.assert_not_called() + + +async def test_reply_to_email_failure(outlook_toolkit, mock_graph_service): + """Test reply to email failure when using simple reply.""" + mock_graph_service.me.messages.by_message_id().reply.post.side_effect = ( + Exception("API Error: Unable to send reply") + ) + + result = await outlook_toolkit.reply_to_email( + message_id='msg123', + content='This reply will fail', + reply_all=False, + ) + + assert 'error' in result + assert 'Failed to reply to email' in result['error'] + assert 'API Error: Unable to send reply' in result['error'] + + +async def test_reply_to_email_all_failure(outlook_toolkit, mock_graph_service): + """Test reply to email failure when using reply all.""" + mock_reply_all = mock_graph_service.me.messages.by_message_id().reply_all + mock_reply_all.post.side_effect = Exception( + "API Error: Unable to send reply all" + ) + + result = await outlook_toolkit.reply_to_email( + message_id='msg456', + content='This reply all will fail', + reply_all=True, + ) + + assert 'error' in result + assert 'Failed to reply to email' in result['error'] + assert 'API Error: Unable to send reply all' in result['error'] + + +async def test_update_draft_message(outlook_toolkit, mock_graph_service): + """Test updating a draft message with all parameters.""" + mock_by_message_id = mock_graph_service.me.messages.by_message_id() + mock_by_message_id.patch.return_value = None + + result = await outlook_toolkit.update_draft_message( + message_id='draft123', + subject='Updated Subject', + content='Updated content', + is_content_html=True, + to_email=['new@example.com'], + cc_recipients=['cc@example.com'], + bcc_recipients=['bcc@example.com'], + reply_to=['reply@example.com'], + ) + + assert result['status'] == 'success' + assert result['message'] == 'Draft message updated successfully' + assert result['message_id'] == 'draft123' + assert result['updated_params']['subject'] == 'Updated Subject' + assert result['updated_params']['content'] == 'Updated content' + assert result['updated_params']['to_email'] == ['new@example.com'] + assert result['updated_params']['cc_recipients'] == ['cc@example.com'] + assert result['updated_params']['bcc_recipients'] == ['bcc@example.com'] + assert result['updated_params']['reply_to'] == ['reply@example.com'] + + mock_by_message_id.patch.assert_called_once() + + +async def test_update_draft_message_subject_only( + outlook_toolkit, mock_graph_service +): + """Test updating only the subject of a draft message.""" + mock_by_message_id = mock_graph_service.me.messages.by_message_id() + mock_by_message_id.patch.return_value = None + + result = await outlook_toolkit.update_draft_message( + message_id='draft456', + subject='New Subject Only', + ) + + assert result['status'] == 'success' + assert result['message_id'] == 'draft456' + assert result['updated_params']['subject'] == 'New Subject Only' + assert 'content' not in result['updated_params'] + assert 'to_email' not in result['updated_params'] + + mock_by_message_id.patch.assert_called_once() + + +async def test_update_draft_message_failure( + outlook_toolkit, mock_graph_service +): + """Test update draft message failure.""" + mock_by_message_id = mock_graph_service.me.messages.by_message_id() + mock_by_message_id.patch.side_effect = Exception( + "API Error: Unable to update draft" + ) + + result = await outlook_toolkit.update_draft_message( + message_id='draft123', + subject='This update will fail', + ) + + assert 'error' in result + assert 'Failed to update draft message' in result['error'] + assert 'API Error: Unable to update draft' in result['error'] diff --git a/uv.lock b/uv.lock index 73b9dce297..e815311cfe 100644 --- a/uv.lock +++ b/uv.lock @@ -578,11 +578,11 @@ name = "azure-identity" version = "1.25.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "azure-core", marker = "python_full_version < '3.13'" }, - { name = "cryptography", marker = "python_full_version < '3.13'" }, - { name = "msal", marker = "python_full_version < '3.13'" }, - { name = "msal-extensions", marker = "python_full_version < '3.13'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } wheels = [ @@ -815,6 +815,7 @@ all = [ { name = "apify-client" }, { name = "arxiv" }, { name = "arxiv2text" }, + { name = "azure-identity" }, { name = "azure-storage-blob" }, { name = "beautifulsoup4" }, { name = "botocore" }, @@ -867,6 +868,8 @@ all = [ { name = "microsandbox" }, { name = "mistralai" }, { name = "mock" }, + { name = "msal" }, + { name = "msgraph-sdk" }, { name = "mypy" }, { name = "nebula3-python" }, { name = "neo4j" }, @@ -942,7 +945,10 @@ all = [ { name = "yt-dlp" }, ] communication-tools = [ + { name = "azure-identity" }, { name = "discord-py" }, + { name = "msal" }, + { name = "msgraph-sdk" }, { name = "notion-client" }, { name = "praw" }, { name = "pygithub" }, @@ -1231,6 +1237,8 @@ requires-dist = [ { name = "arxiv2text", marker = "extra == 'all'", specifier = ">=0.1.14,<0.2" }, { name = "arxiv2text", marker = "extra == 'research-tools'", specifier = ">=0.1.14,<0.2" }, { name = "astor", specifier = ">=0.8.1" }, + { name = "azure-identity", marker = "extra == 'all'", specifier = ">=1.25.1,<2" }, + { name = "azure-identity", marker = "extra == 'communication-tools'", specifier = ">=1.25.1,<2" }, { name = "azure-storage-blob", marker = "extra == 'all'", specifier = ">=12.21.0,<13" }, { name = "azure-storage-blob", marker = "extra == 'storage'", specifier = ">=12.21.0,<13" }, { name = "beautifulsoup4", marker = "extra == 'all'", specifier = ">=4,<5" }, @@ -1373,6 +1381,10 @@ requires-dist = [ { name = "mistralai", marker = "extra == 'model-platforms'", specifier = ">=1.1.0,<2" }, { name = "mock", marker = "extra == 'all'", specifier = ">=5,<6" }, { name = "mock", marker = "extra == 'dev'", specifier = ">=5,<6" }, + { name = "msal", marker = "extra == 'all'", specifier = ">=1.34.0,<2" }, + { name = "msal", marker = "extra == 'communication-tools'", specifier = ">=1.34.0,<2" }, + { name = "msgraph-sdk", marker = "extra == 'all'", specifier = ">=1.46.0,<2" }, + { name = "msgraph-sdk", marker = "extra == 'communication-tools'", specifier = ">=1.46.0,<2" }, { name = "mypy", marker = "extra == 'all'", specifier = ">=1.5.1,<2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.5.1,<2" }, { name = "myst-parser", marker = "extra == 'docs'" }, @@ -5321,6 +5333,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/89/2d6653e4c6bfa535da59d84d7c8bcc1678b35299ed43c1d11fb1c07a2179/microsandbox-0.1.8-py3-none-any.whl", hash = "sha256:b4503f6efd0f58e1acbac782399d3020cc704031279637fe5c60bdb5da267cd8", size = 12112, upload-time = "2025-05-22T13:07:13.176Z" }, ] +[[package]] +name = "microsoft-kiota-abstractions" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "std-uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/6c/fd855a03545ae261b28d179b206e5f80a0e7c95fac5a580514c4dabedca0/microsoft_kiota_abstractions-1.9.7.tar.gz", hash = "sha256:731ed60c2df74ca80d1bf36d40a4c390aab353db3a76796c63ea9e9a220ce65c", size = 24447, upload-time = "2025-09-09T13:53:42.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/d8/d699a2cb209c72f1258af5f582a7868d1b006e57cc4394b68b0f996ba370/microsoft_kiota_abstractions-1.9.7-py3-none-any.whl", hash = "sha256:8add66c38d05ab9a496c1c843bb16e04b70edc4651dc290b9629b14009f5c0c0", size = 44404, upload-time = "2025-09-09T13:53:41.312Z" }, +] + +[[package]] +name = "microsoft-kiota-authentication-azure" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "azure-core" }, + { name = "microsoft-kiota-abstractions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/9a/3deb5d951e55e059fbde93deaf1b3fdd1ec3a6e8bdac01280c640dac7b8c/microsoft_kiota_authentication_azure-1.9.7.tar.gz", hash = "sha256:1ecef94097ca8029e5b903bfef8dbbf47ba75bc1521907164a84b6617226696b", size = 4987, upload-time = "2025-09-09T13:53:52.47Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/49/d12e7eabd6fc7039bfa301dfff26f0fced9bd164564b96b6d99fffcb020b/microsoft_kiota_authentication_azure-1.9.7-py3-none-any.whl", hash = "sha256:a2d776bef22d10be65df1ea9e8f1737e46981bd14cdb70e3fe4f4a066e92b139", size = 6908, upload-time = "2025-09-09T13:53:51.735Z" }, +] + +[[package]] +name = "microsoft-kiota-http" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "microsoft-kiota-abstractions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/a9/7efe67311902394a208545ae067dfc7e957383939b0ee6ff43e1955afbe7/microsoft_kiota_http-1.9.7.tar.gz", hash = "sha256:abcacca784649308ab93d8578c2afb581a42deed048b183d7bbdc48c325dd6a1", size = 21249, upload-time = "2025-09-09T13:54:00.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/99/1d625b9353cabb3aaddb468c379b1e1fc726795281e94437096846b434b1/microsoft_kiota_http-1.9.7-py3-none-any.whl", hash = "sha256:14ce6b14c4fa93608f535f2c6ae21d35b1d0e2635ab70501fa3a3afc90135261", size = 31577, upload-time = "2025-09-09T13:53:59.616Z" }, +] + +[[package]] +name = "microsoft-kiota-serialization-form" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-kiota-abstractions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/d7/dc6d782f75608be4b1733df6592e4c7e819b6e32b290ac45304f74c0c0cf/microsoft_kiota_serialization_form-1.9.7.tar.gz", hash = "sha256:d3297a60778c0437513334b703225ce108fd109f13c1993afea599b85dc5a528", size = 8999, upload-time = "2025-09-09T13:54:08.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/62/9929fc1fe0ff76af5ff6dd8179b71c58105675465536141cd491e03f5a1d/microsoft_kiota_serialization_form-1.9.7-py3-none-any.whl", hash = "sha256:72d2dc5e57a993145702870ad89c85cebe3336d4d34f231d951ee1bc83ad11b9", size = 10671, upload-time = "2025-09-09T13:54:08.169Z" }, +] + +[[package]] +name = "microsoft-kiota-serialization-json" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-kiota-abstractions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f8/e13c48e610a00f2abfa9fa19f03e2cf21fe98486dfc5a453ce6c0490d3f2/microsoft_kiota_serialization_json-1.9.7.tar.gz", hash = "sha256:1e54ff90b185fe21cca94ebbf8468bf44a2ca5f082c4cf04dbd2d42a9472837a", size = 9416, upload-time = "2025-09-09T13:54:18.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/6b/761e45c91086fb45e69ee1b85d538f6e5fc89b86f6ade148e8c5575259ce/microsoft_kiota_serialization_json-1.9.7-py3-none-any.whl", hash = "sha256:6f44012f00cf7c4c4d8b9195e7f8a691d186021b5d9a20e791a77c800b5be531", size = 11056, upload-time = "2025-09-09T13:54:17.395Z" }, +] + +[[package]] +name = "microsoft-kiota-serialization-multipart" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-kiota-abstractions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/23/31b11fd0e44bb79923ea8310c2f3d0bc1e16f56d35d2fc73203a260a0a73/microsoft_kiota_serialization_multipart-1.9.7.tar.gz", hash = "sha256:1a13d193d078dea86711d8c6e89ac142aff5033079c7be4061279b2da5c83ef8", size = 5150, upload-time = "2025-09-09T13:54:35.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/ca/98efd66c8e7180928fe2901f4c766799991836e536a8a0aca9186b5d7c7a/microsoft_kiota_serialization_multipart-1.9.7-py3-none-any.whl", hash = "sha256:cd72ee004039ee64a35bd5254afd3f8bc89877e948282ab0fe0a7efab75f68bb", size = 6651, upload-time = "2025-09-09T13:54:34.811Z" }, +] + +[[package]] +name = "microsoft-kiota-serialization-text" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-kiota-abstractions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/5c/479378981c7b8fb22d6ba693f07db457a18d3efc86dd083ebe31d6192d37/microsoft_kiota_serialization_text-1.9.7.tar.gz", hash = "sha256:d57a082d5c6ea1e650286314cac9a9e7a2662aa4beb80635bf4addd33d252bd5", size = 7306, upload-time = "2025-09-09T13:54:26.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/8b/b8b6482719d9ecc4d87f07aa8726d33c18004e0630ef5cd2891ee8bf2ada/microsoft_kiota_serialization_text-1.9.7-py3-none-any.whl", hash = "sha256:47c4d774883bec269a6eb077a5ca2f26ae6715986c8defa374d536a9664dc43e", size = 8840, upload-time = "2025-09-09T13:54:25.642Z" }, +] + [[package]] name = "mistralai" version = "1.9.11" @@ -5506,9 +5611,9 @@ name = "msal" version = "1.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "python_full_version < '3.13'" }, - { name = "pyjwt", extra = ["crypto"], marker = "python_full_version < '3.13'" }, - { name = "requests", marker = "python_full_version < '3.13'" }, + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } wheels = [ @@ -5520,13 +5625,45 @@ name = "msal-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "msal", marker = "python_full_version < '3.13'" }, + { name = "msal" }, ] sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msgraph-core" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "microsoft-kiota-abstractions" }, + { name = "microsoft-kiota-authentication-azure" }, + { name = "microsoft-kiota-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/4e/123f9530ec43b306c597bb830c62bedab830ffa76e0edf33ea88a26f756e/msgraph_core-1.3.8.tar.gz", hash = "sha256:6e883f9d4c4ad57501234749e07b010478c1a5f19550ef4cf005bbcac4a63ae7", size = 25506, upload-time = "2025-09-11T22:46:57.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/4d/01432f60727ae452787014cad0d5bc9e035c6e11a670f12c23f7fc926d90/msgraph_core-1.3.8-py3-none-any.whl", hash = "sha256:86d83edcf62119946f201d13b7e857c947ef67addb088883940197081de85bea", size = 34473, upload-time = "2025-09-11T22:46:56.026Z" }, +] + +[[package]] +name = "msgraph-sdk" +version = "1.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-identity" }, + { name = "microsoft-kiota-serialization-form" }, + { name = "microsoft-kiota-serialization-json" }, + { name = "microsoft-kiota-serialization-multipart" }, + { name = "microsoft-kiota-serialization-text" }, + { name = "msgraph-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/9c/67bf198b55abe01a0c72c1f890c9e70a5c1a365011651489fbb6ac252317/msgraph_sdk-1.49.0.tar.gz", hash = "sha256:33e0570007f33c8efb90ae244a12a350284c668f7b600de4e7ab115772191449", size = 6155614, upload-time = "2025-11-19T16:09:51.719Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/1f/2b60ab01056a2e53a98d6d3de17670126c37bb1da011e6f5e1d9065c1766/msgraph_sdk-1.49.0-py3-none-any.whl", hash = "sha256:bf316f8ec98fb64bf3a72aaa2ef2cd4487d579e460583a95c17a185d78aacb54", size = 25210496, upload-time = "2025-11-19T16:09:48.178Z" }, +] + [[package]] name = "multidict" version = "6.7.0" @@ -9568,6 +9705,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "std-uritemplate" +version = "2.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/62/61866776cd32df3f984ff2f79b1428e10700e0a33ca7a7536e3fcba3cf2a/std_uritemplate-2.0.8.tar.gz", hash = "sha256:138ceff2c5bfef18a650372a5e8c82fe7f780c87235513de6c342fb5f7e18347", size = 6018, upload-time = "2025-10-16T15:51:29.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/97/b4f2f442fee92a1406f08b4fbc990bd7d02dc84b3b5e6315a59fa9b2a9f4/std_uritemplate-2.0.8-py3-none-any.whl", hash = "sha256:839807a7f9d07f0bad1a88977c3428bd97b9ff0d229412a0bf36123d8c724257", size = 6512, upload-time = "2025-10-16T15:51:28.713Z" }, +] + [[package]] name = "stem" version = "1.8.2"