From ce35fc4911f0bcc75e822d5a7719d26224ae8077 Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni Date: Thu, 25 Sep 2025 10:16:36 +0100 Subject: [PATCH 1/6] gmail_toolkit integration --- camel/toolkits/__init__.py | 2 + camel/toolkits/gmail_toolkit.py | 1255 +++++++++++++++++++++++++++ examples/toolkits/gmail_toolkit.py | 103 +++ test/toolkits/test_gmail_toolkit.py | 742 ++++++++++++++++ 4 files changed, 2102 insertions(+) create mode 100644 camel/toolkits/gmail_toolkit.py create mode 100644 examples/toolkits/gmail_toolkit.py create mode 100644 test/toolkits/test_gmail_toolkit.py diff --git a/camel/toolkits/__init__.py b/camel/toolkits/__init__.py index f29ab67d40..298a96cc85 100644 --- a/camel/toolkits/__init__.py +++ b/camel/toolkits/__init__.py @@ -37,6 +37,7 @@ from .github_toolkit import GithubToolkit from .google_scholar_toolkit import GoogleScholarToolkit from .google_calendar_toolkit import GoogleCalendarToolkit +from .gmail_toolkit import GmailToolkit from .arxiv_toolkit import ArxivToolkit from .slack_toolkit import SlackToolkit from .whatsapp_toolkit import WhatsAppToolkit @@ -122,6 +123,7 @@ 'AsyncAskNewsToolkit', 'GoogleScholarToolkit', 'GoogleCalendarToolkit', + 'GmailToolkit', 'NotionToolkit', 'ArxivToolkit', 'HumanToolkit', diff --git a/camel/toolkits/gmail_toolkit.py b/camel/toolkits/gmail_toolkit.py new file mode 100644 index 0000000000..067febf4b9 --- /dev/null +++ b/camel/toolkits/gmail_toolkit.py @@ -0,0 +1,1255 @@ +# ========= 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 base64 +import os +import re +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +if TYPE_CHECKING: + from googleapiclient.discovery import Resource +else: + Resource = Any + +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 + +logger = get_logger(__name__) + +SCOPES = [ + 'https://mail.google.com/', + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.compose', + 'https://www.googleapis.com/auth/gmail.labels', + 'https://www.googleapis.com/auth/contacts.readonly', + 'https://www.googleapis.com/auth/userinfo.profile', +] + + +@MCPServer() +class GmailToolkit(BaseToolkit): + r"""A comprehensive toolkit for Gmail operations. + + This class provides methods for Gmail operations including sending emails, + managing drafts, fetching messages, managing labels, and handling contacts. + """ + + def __init__( + self, + timeout: Optional[float] = None, + ): + r"""Initializes a new instance of the GmailToolkit class. + + Args: + timeout (Optional[float]): The timeout value for API requests + in seconds. If None, no timeout is applied. + (default: :obj:`None`) + """ + super().__init__(timeout=timeout) + self.gmail_service: Any = self._get_gmail_service() + self.people_service: Any = self._get_people_service() + + def send_email( + self, + to: Union[str, List[str]], + subject: str, + body: str, + cc: Optional[Union[str, List[str]]] = None, + bcc: Optional[Union[str, List[str]]] = None, + attachments: Optional[List[str]] = None, + is_html: bool = False, + ) -> Dict[str, Any]: + r"""Send an email through Gmail. + + Args: + to (Union[str, List[str]]): Recipient email address(es). + subject (str): Email subject. + body (str): Email body content. + cc (Optional[Union[str, List[str]]]): CC recipient email + address(es). + bcc (Optional[Union[str, List[str]]]): BCC recipient email + address(es). + attachments (Optional[List[str]]): List of file paths to attach. + is_html (bool): Whether the body is HTML format. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + # Normalize recipients to lists + to_list = [to] if isinstance(to, str) else to + cc_list = [cc] if isinstance(cc, str) else (cc or []) + bcc_list = [bcc] if isinstance(bcc, str) else (bcc or []) + + # Validate email addresses + all_recipients = to_list + cc_list + bcc_list + for email in all_recipients: + if not self._is_valid_email(email): + return {"error": f"Invalid email address: {email}"} + + # Create message + message = self._create_message( + to_list, subject, body, cc_list, bcc_list, attachments, is_html + ) + + # Send message + sent_message = ( + self.gmail_service.users() + .messages() + .send(userId='me', body=message) + .execute() + ) + + return { + "success": True, + "message_id": sent_message.get('id'), + "thread_id": sent_message.get('threadId'), + "message": "Email sent successfully", + } + + except Exception as e: + logger.error("Failed to send email: %s", e) + return {"error": f"Failed to send email: {e!s}"} + + def reply_to_email( + self, + message_id: str, + reply_body: str, + reply_all: bool = False, + is_html: bool = False, + ) -> Dict[str, Any]: + r"""Reply to an email message. + + Args: + message_id (str): The ID of the message to reply to. + reply_body (str): The reply message body. + reply_all (bool): Whether to reply to all recipients. + is_html (bool): Whether the reply body is HTML format. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + # Get the original message + original_message = ( + self.gmail_service.users() + .messages() + .get(userId='me', id=message_id) + .execute() + ) + + # Extract headers + headers = original_message['payload'].get('headers', []) + subject = self._get_header_value(headers, 'Subject') + from_email = self._get_header_value(headers, 'From') + to_emails = self._get_header_value(headers, 'To') + cc_emails = self._get_header_value(headers, 'Cc') + + # Prepare reply subject + if not subject.startswith('Re: '): + subject = f"Re: {subject}" + + # Prepare recipients + if reply_all: + recipients = [from_email] + if to_emails: + recipients.extend( + [email.strip() for email in to_emails.split(',')] + ) + if cc_emails: + recipients.extend( + [email.strip() for email in cc_emails.split(',')] + ) + # Remove duplicates + recipients = list(set(recipients)) + + # Get current user's email and remove it from recipients + try: + profile_result = self.get_profile() + if profile_result.get('success'): + current_user_email = profile_result['profile'][ + 'email_address' + ] + # Remove current user from recipients (handle both + # plain email and "Name " format) + recipients = [ + email + for email in recipients + if email != current_user_email + and not email.endswith(f'<{current_user_email}>') + ] + except Exception as e: + logger.warning( + "Could not get current user email to filter from " + "recipients: %s", + e, + ) + else: + recipients = [from_email] + + # Create reply message + message = self._create_message( + recipients, subject, reply_body, is_html=is_html + ) + + # Send reply + sent_message = ( + self.gmail_service.users() + .messages() + .send(userId='me', body=message) + .execute() + ) + + return { + "success": True, + "message_id": sent_message.get('id'), + "thread_id": sent_message.get('threadId'), + "message": "Reply sent successfully", + } + + except Exception as e: + logger.error("Failed to reply to email: %s", e) + return {"error": f"Failed to reply to email: {e!s}"} + + def forward_email( + self, + message_id: str, + to: Union[str, List[str]], + forward_body: Optional[str] = None, + cc: Optional[Union[str, List[str]]] = None, + bcc: Optional[Union[str, List[str]]] = None, + ) -> Dict[str, Any]: + r"""Forward an email message. + + Args: + message_id (str): The ID of the message to forward. + to (Union[str, List[str]]): Recipient email address(es). + forward_body (Optional[str]): Additional message to include. + cc (Optional[Union[str, List[str]]]): CC recipient email + address(es). + bcc (Optional[Union[str, List[str]]]): BCC recipient email + address(es). + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + # Get the original message + original_message = ( + self.gmail_service.users() + .messages() + .get(userId='me', id=message_id) + .execute() + ) + + # Extract headers + headers = original_message['payload'].get('headers', []) + subject = self._get_header_value(headers, 'Subject') + from_email = self._get_header_value(headers, 'From') + date = self._get_header_value(headers, 'Date') + + # Prepare forward subject + if not subject.startswith('Fwd: '): + subject = f"Fwd: {subject}" + + # Prepare forward body + if forward_body: + body = f"{forward_body}\n\n--- Forwarded message ---\n" + else: + body = "--- Forwarded message ---\n" + + body += f"From: {from_email}\n" + body += f"Date: {date}\n" + body += f"Subject: {subject.replace('Fwd: ', '')}\n\n" + + # Add original message body + body += self._extract_message_body(original_message) + + # Normalize recipients + to_list = [to] if isinstance(to, str) else to + cc_list = [cc] if isinstance(cc, str) else (cc or []) + bcc_list = [bcc] if isinstance(bcc, str) else (bcc or []) + + # Create forward message + message = self._create_message( + to_list, subject, body, cc_list, bcc_list + ) + + # Send forward + sent_message = ( + self.gmail_service.users() + .messages() + .send(userId='me', body=message) + .execute() + ) + + return { + "success": True, + "message_id": sent_message.get('id'), + "thread_id": sent_message.get('threadId'), + "message": "Email forwarded successfully", + } + + except Exception as e: + logger.error("Failed to forward email: %s", e) + return {"error": f"Failed to forward email: {e!s}"} + + def create_email_draft( + self, + to: Union[str, List[str]], + subject: str, + body: str, + cc: Optional[Union[str, List[str]]] = None, + bcc: Optional[Union[str, List[str]]] = None, + attachments: Optional[List[str]] = None, + is_html: bool = False, + ) -> Dict[str, Any]: + r"""Create an email draft. + + Args: + to (Union[str, List[str]]): Recipient email address(es). + subject (str): Email subject. + body (str): Email body content. + cc (Optional[Union[str, List[str]]]): CC recipient email + address(es). + bcc (Optional[Union[str, List[str]]]): BCC recipient email + address(es). + attachments (Optional[List[str]]): List of file paths to attach. + is_html (bool): Whether the body is HTML format. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + # Normalize recipients to lists + to_list = [to] if isinstance(to, str) else to + cc_list = [cc] if isinstance(cc, str) else (cc or []) + bcc_list = [bcc] if isinstance(bcc, str) else (bcc or []) + + # Validate email addresses + all_recipients = to_list + cc_list + bcc_list + for email in all_recipients: + if not self._is_valid_email(email): + return {"error": f"Invalid email address: {email}"} + + # Create message + message = self._create_message( + to_list, subject, body, cc_list, bcc_list, attachments, is_html + ) + + # Create draft + draft = ( + self.gmail_service.users() + .drafts() + .create(userId='me', body={'message': message}) + .execute() + ) + + return { + "success": True, + "draft_id": draft.get('id'), + "message_id": draft.get('message', {}).get('id'), + "message": "Draft created successfully", + } + + except Exception as e: + logger.error("Failed to create draft: %s", e) + return {"error": f"Failed to create draft: {e!s}"} + + def send_draft(self, draft_id: str) -> Dict[str, Any]: + r"""Send a draft email. + + Args: + draft_id (str): The ID of the draft to send. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + # Send draft + sent_message = ( + self.gmail_service.users() + .drafts() + .send(userId='me', body={'id': draft_id}) + .execute() + ) + + return { + "success": True, + "message_id": sent_message.get('id'), + "thread_id": sent_message.get('threadId'), + "message": "Draft sent successfully", + } + + except Exception as e: + logger.error("Failed to send draft: %s", e) + return {"error": f"Failed to send draft: {e!s}"} + + def fetch_emails( + self, + query: str = "", + max_results: int = 10, + include_spam_trash: bool = False, + label_ids: Optional[List[str]] = None, + ) -> Dict[str, Any]: + r"""Fetch emails with filters and pagination. + + Args: + query (str): Gmail search query string. + max_results (int): Maximum number of emails to fetch. + include_spam_trash (bool): Whether to include spam and trash. + label_ids (Optional[List[str]]): List of label IDs to filter by. + + Returns: + Dict[str, Any]: A dictionary containing the fetched emails. + """ + try: + # Build request parameters + request_params = { + 'userId': 'me', + 'maxResults': max_results, + 'includeSpamTrash': include_spam_trash, + } + + if query: + request_params['q'] = query + if label_ids: + request_params['labelIds'] = label_ids + + # List messages + messages_result = ( + self.gmail_service.users() + .messages() + .list(**request_params) + .execute() + ) + + messages = messages_result.get('messages', []) + emails = [] + + # Fetch detailed information for each message + for msg in messages: + email_detail = self._get_message_details(msg['id']) + if email_detail: + emails.append(email_detail) + + return { + "success": True, + "emails": emails, + "total_count": len(emails), + "next_page_token": messages_result.get('nextPageToken'), + } + + except Exception as e: + logger.error("Failed to fetch emails: %s", e) + return {"error": f"Failed to fetch emails: {e!s}"} + + def fetch_message_by_id(self, message_id: str) -> Dict[str, Any]: + r"""Fetch a specific message by ID. + + Args: + message_id (str): The ID of the message to fetch. + + Returns: + Dict[str, Any]: A dictionary containing the message details. + """ + try: + message_detail = self._get_message_details(message_id) + if message_detail: + return {"success": True, "message": message_detail} + else: + return {"error": "Message not found"} + + except Exception as e: + logger.error("Failed to fetch message: %s", e) + return {"error": f"Failed to fetch message: {e!s}"} + + def fetch_thread_by_id(self, thread_id: str) -> Dict[str, Any]: + r"""Fetch a thread by ID. + + Args: + thread_id (str): The ID of the thread to fetch. + + Returns: + Dict[str, Any]: A dictionary containing the thread details. + """ + try: + thread = ( + self.gmail_service.users() + .threads() + .get(userId='me', id=thread_id) + .execute() + ) + + messages = [] + for message in thread.get('messages', []): + message_detail = self._get_message_details(message['id']) + if message_detail: + messages.append(message_detail) + + return { + "success": True, + "thread_id": thread_id, + "messages": messages, + "message_count": len(messages), + } + + except Exception as e: + logger.error("Failed to fetch thread: %s", e) + return {"error": f"Failed to fetch thread: {e!s}"} + + def modify_email_labels( + self, + message_id: str, + add_labels: Optional[List[str]] = None, + remove_labels: Optional[List[str]] = None, + ) -> Dict[str, Any]: + r"""Modify labels on an email message. + + Args: + message_id (str): The ID of the message to modify. + add_labels (Optional[List[str]]): Labels to add. + remove_labels (Optional[List[str]]): Labels to remove. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + body = {} + if add_labels: + body['addLabelIds'] = add_labels + if remove_labels: + body['removeLabelIds'] = remove_labels + + if not body: + return {"error": "No labels to add or remove"} + + modified_message = ( + self.gmail_service.users() + .messages() + .modify(userId='me', id=message_id, body=body) + .execute() + ) + + return { + "success": True, + "message_id": message_id, + "label_ids": modified_message.get('labelIds', []), + "message": "Labels modified successfully", + } + + except Exception as e: + logger.error("Failed to modify labels: %s", e) + return {"error": f"Failed to modify labels: {e!s}"} + + def move_to_trash(self, message_id: str) -> Dict[str, Any]: + r"""Move a message to trash. + + Args: + message_id (str): The ID of the message to move to trash. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + trashed_message = ( + self.gmail_service.users() + .messages() + .trash(userId='me', id=message_id) + .execute() + ) + + return { + "success": True, + "message_id": message_id, + "label_ids": trashed_message.get('labelIds', []), + "message": "Message moved to trash successfully", + } + + except Exception as e: + logger.error("Failed to move message to trash: %s", e) + return {"error": f"Failed to move message to trash: {e!s}"} + + def get_attachment( + self, + message_id: str, + attachment_id: str, + save_path: Optional[str] = None, + ) -> Dict[str, Any]: + r"""Get an attachment from a message. + + Args: + message_id (str): The ID of the message containing the attachment. + attachment_id (str): The ID of the attachment. + save_path (Optional[str]): Path to save the attachment file. + + Returns: + Dict[str, Any]: A dictionary containing the attachment data or + save result. + """ + try: + attachment = ( + self.gmail_service.users() + .messages() + .attachments() + .get(userId='me', messageId=message_id, id=attachment_id) + .execute() + ) + + # Decode the attachment data + file_data = base64.urlsafe_b64decode(attachment['data']) + + if save_path: + with open(save_path, 'wb') as f: + f.write(file_data) + return { + "success": True, + "message": f"Attachment saved to {save_path}", + "file_size": len(file_data), + } + else: + return { + "success": True, + "data": base64.b64encode(file_data).decode('utf-8'), + "file_size": len(file_data), + } + + except Exception as e: + logger.error("Failed to get attachment: %s", e) + return {"error": f"Failed to get attachment: {e!s}"} + + def list_threads( + self, + query: str = "", + max_results: int = 10, + include_spam_trash: bool = False, + label_ids: Optional[List[str]] = None, + ) -> Dict[str, Any]: + r"""List email threads. + + Args: + query (str): Gmail search query string. + max_results (int): Maximum number of threads to fetch. + include_spam_trash (bool): Whether to include spam and trash. + label_ids (Optional[List[str]]): List of label IDs to filter by. + + Returns: + Dict[str, Any]: A dictionary containing the thread list. + """ + try: + # Build request parameters + request_params = { + 'userId': 'me', + 'maxResults': max_results, + 'includeSpamTrash': include_spam_trash, + } + + if query: + request_params['q'] = query + if label_ids: + request_params['labelIds'] = label_ids + + # List threads + threads_result = ( + self.gmail_service.users() + .threads() + .list(**request_params) + .execute() + ) + + threads = threads_result.get('threads', []) + thread_list = [] + + for thread in threads: + thread_list.append( + { + "thread_id": thread['id'], + "snippet": thread.get('snippet', ''), + "history_id": thread.get('historyId', ''), + } + ) + + return { + "success": True, + "threads": thread_list, + "total_count": len(thread_list), + "next_page_token": threads_result.get('nextPageToken'), + } + + except Exception as e: + logger.error("Failed to list threads: %s", e) + return {"error": f"Failed to list threads: {e!s}"} + + def list_drafts(self, max_results: int = 10) -> Dict[str, Any]: + r"""List email drafts. + + Args: + max_results (int): Maximum number of drafts to fetch. + + Returns: + Dict[str, Any]: A dictionary containing the draft list. + """ + try: + drafts_result = ( + self.gmail_service.users() + .drafts() + .list(userId='me', maxResults=max_results) + .execute() + ) + + drafts = drafts_result.get('drafts', []) + draft_list = [] + + for draft in drafts: + draft_info = { + "draft_id": draft['id'], + "message_id": draft.get('message', {}).get('id', ''), + "thread_id": draft.get('message', {}).get('threadId', ''), + "snippet": draft.get('message', {}).get('snippet', ''), + } + draft_list.append(draft_info) + + return { + "success": True, + "drafts": draft_list, + "total_count": len(draft_list), + "next_page_token": drafts_result.get('nextPageToken'), + } + + except Exception as e: + logger.error("Failed to list drafts: %s", e) + return {"error": f"Failed to list drafts: {e!s}"} + + def list_gmail_labels(self) -> Dict[str, Any]: + r"""List all Gmail labels. + + Returns: + Dict[str, Any]: A dictionary containing the label list. + """ + try: + labels_result = ( + self.gmail_service.users().labels().list(userId='me').execute() + ) + + labels = labels_result.get('labels', []) + label_list = [] + + for label in labels: + label_info = { + "id": label['id'], + "name": label['name'], + "type": label.get('type', 'user'), + "messages_total": label.get('messagesTotal', 0), + "messages_unread": label.get('messagesUnread', 0), + "threads_total": label.get('threadsTotal', 0), + "threads_unread": label.get('threadsUnread', 0), + } + label_list.append(label_info) + + return { + "success": True, + "labels": label_list, + "total_count": len(label_list), + } + + except Exception as e: + logger.error("Failed to list labels: %s", e) + return {"error": f"Failed to list labels: {e!s}"} + + def create_label( + self, + name: str, + label_list_visibility: str = "labelShow", + message_list_visibility: str = "show", + ) -> Dict[str, Any]: + r"""Create a new Gmail label. + + Args: + name (str): The name of the label to create. + label_list_visibility (str): Label visibility in label list. + message_list_visibility (str): Label visibility in message list. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + label_object = { + 'name': name, + 'labelListVisibility': label_list_visibility, + 'messageListVisibility': message_list_visibility, + } + + created_label = ( + self.gmail_service.users() + .labels() + .create(userId='me', body=label_object) + .execute() + ) + + return { + "success": True, + "label_id": created_label['id'], + "label_name": created_label['name'], + "message": "Label created successfully", + } + + except Exception as e: + logger.error("Failed to create label: %s", e) + return {"error": f"Failed to create label: {e!s}"} + + def delete_label(self, label_id: str) -> Dict[str, Any]: + r"""Delete a Gmail label. + + Args: + label_id (str): The ID of the label to delete. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + self.gmail_service.users().labels().delete( + userId='me', id=label_id + ).execute() + + return { + "success": True, + "label_id": label_id, + "message": "Label deleted successfully", + } + + except Exception as e: + logger.error("Failed to delete label: %s", e) + return {"error": f"Failed to delete label: {e!s}"} + + def modify_thread_labels( + self, + thread_id: str, + add_labels: Optional[List[str]] = None, + remove_labels: Optional[List[str]] = None, + ) -> Dict[str, Any]: + r"""Modify labels on a thread. + + Args: + thread_id (str): The ID of the thread to modify. + add_labels (Optional[List[str]]): Labels to add. + remove_labels (Optional[List[str]]): Labels to remove. + + Returns: + Dict[str, Any]: A dictionary containing the result of the + operation. + """ + try: + body = {} + if add_labels: + body['addLabelIds'] = add_labels + if remove_labels: + body['removeLabelIds'] = remove_labels + + if not body: + return {"error": "No labels to add or remove"} + + modified_thread = ( + self.gmail_service.users() + .threads() + .modify(userId='me', id=thread_id, body=body) + .execute() + ) + + return { + "success": True, + "thread_id": thread_id, + "label_ids": modified_thread.get('labelIds', []), + "message": "Thread labels modified successfully", + } + + except Exception as e: + logger.error("Failed to modify thread labels: %s", e) + return {"error": f"Failed to modify thread labels: {e!s}"} + + def get_profile(self) -> Dict[str, Any]: + r"""Get Gmail profile information. + + Returns: + Dict[str, Any]: A dictionary containing the profile information. + """ + try: + profile = ( + self.gmail_service.users().getProfile(userId='me').execute() + ) + + return { + "success": True, + "profile": { + "email_address": profile.get('emailAddress', ''), + "messages_total": profile.get('messagesTotal', 0), + "threads_total": profile.get('threadsTotal', 0), + "history_id": profile.get('historyId', ''), + }, + } + + except Exception as e: + logger.error("Failed to get profile: %s", e) + return {"error": f"Failed to get profile: {e!s}"} + + def get_contacts( + self, + query: str = "", + max_results: int = 100, + ) -> Dict[str, Any]: + r"""Get contacts from Google People API. + + Args: + query (str): Search query for contacts. + max_results (int): Maximum number of contacts to fetch. + + Returns: + Dict[str, Any]: A dictionary containing the contacts. + """ + try: + # Build request parameters + request_params = { + 'resourceName': 'people/me', + 'personFields': ( + 'names,emailAddresses,phoneNumbers,organizations' + ), + 'pageSize': max_results, + } + + if query: + request_params['query'] = query + + # Search contacts + contacts_result = ( + self.people_service.people() + .connections() + .list(**request_params) + .execute() + ) + + connections = contacts_result.get('connections', []) + contact_list = [] + + for person in connections: + contact_info = { + "resource_name": person.get('resourceName', ''), + "names": person.get('names', []), + "email_addresses": person.get('emailAddresses', []), + "phone_numbers": person.get('phoneNumbers', []), + "organizations": person.get('organizations', []), + } + contact_list.append(contact_info) + + return { + "success": True, + "contacts": contact_list, + "total_count": len(contact_list), + "next_page_token": contacts_result.get('nextPageToken'), + } + + except Exception as e: + logger.error("Failed to get contacts: %s", e) + return {"error": f"Failed to get contacts: {e!s}"} + + def search_people( + self, + query: str, + max_results: int = 10, + ) -> Dict[str, Any]: + r"""Search for people in contacts. + + Args: + query (str): Search query for people. + max_results (int): Maximum number of results to fetch. + + Returns: + Dict[str, Any]: A dictionary containing the search results. + """ + try: + # Search people + search_result = ( + self.people_service.people() + .searchContacts( + query=query, + readMask='names,emailAddresses,phoneNumbers,organizations', + pageSize=max_results, + ) + .execute() + ) + + results = search_result.get('results', []) + people_list = [] + + for result in results: + person = result.get('person', {}) + person_info = { + "resource_name": person.get('resourceName', ''), + "names": person.get('names', []), + "email_addresses": person.get('emailAddresses', []), + "phone_numbers": person.get('phoneNumbers', []), + "organizations": person.get('organizations', []), + } + people_list.append(person_info) + + return { + "success": True, + "people": people_list, + "total_count": len(people_list), + } + + except Exception as e: + logger.error("Failed to search people: %s", e) + return {"error": f"Failed to search people: {e!s}"} + + # Helper methods + def _get_gmail_service(self): + r"""Get Gmail service object.""" + from googleapiclient.discovery import build + + try: + creds = self._authenticate() + service = build('gmail', 'v1', credentials=creds) + return service + except Exception as e: + raise ValueError(f"Failed to build Gmail service: {e}") from e + + def _get_people_service(self): + r"""Get People service object.""" + from googleapiclient.discovery import build + + try: + creds = self._authenticate() + service = build('people', 'v1', credentials=creds) + return service + except Exception as e: + raise ValueError(f"Failed to build People service: {e}") from e + + @api_keys_required( + [ + (None, "GOOGLE_CLIENT_ID"), + (None, "GOOGLE_CLIENT_SECRET"), + ] + ) + def _authenticate(self): + r"""Authenticate with Google APIs.""" + client_id = os.environ.get('GOOGLE_CLIENT_ID') + client_secret = os.environ.get('GOOGLE_CLIENT_SECRET') + refresh_token = os.environ.get('GOOGLE_REFRESH_TOKEN') + token_uri = os.environ.get( + 'GOOGLE_TOKEN_URI', 'https://oauth2.googleapis.com/token' + ) + + from google.auth.transport.requests import Request + from google.oauth2.credentials import Credentials + from google_auth_oauthlib.flow import InstalledAppFlow + + # For first-time authentication + if not refresh_token: + client_config = { + "installed": { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": token_uri, + "redirect_uris": ["http://localhost"], + } + } + + flow = InstalledAppFlow.from_client_config(client_config, SCOPES) + creds = flow.run_local_server(port=0) + return creds + else: + # If we have a refresh token, use it to get credentials + creds = Credentials( + None, + refresh_token=refresh_token, + token_uri=token_uri, + client_id=client_id, + client_secret=client_secret, + scopes=SCOPES, + ) + + # Refresh token if expired + if creds.expired: + creds.refresh(Request()) + + return creds + + def _create_message( + self, + to_list: List[str], + subject: str, + body: str, + cc_list: Optional[List[str]] = None, + bcc_list: Optional[List[str]] = None, + attachments: Optional[List[str]] = None, + is_html: bool = False, + ) -> Dict[str, str]: + r"""Create a message object for sending.""" + message = MIMEMultipart() + message['to'] = ', '.join(to_list) + message['subject'] = subject + + if cc_list: + message['cc'] = ', '.join(cc_list) + if bcc_list: + message['bcc'] = ', '.join(bcc_list) + + # Add body + if is_html: + message.attach(MIMEText(body, 'html')) + else: + message.attach(MIMEText(body, 'plain')) + + # Add attachments + if attachments: + for file_path in attachments: + if os.path.isfile(file_path): + with open(file_path, "rb") as attachment: + part = MIMEBase('application', 'octet-stream') + part.set_payload(attachment.read()) + encoders.encode_base64(part) + part.add_header( + 'Content-Disposition', + f'attachment; filename= ' + f'{os.path.basename(file_path)}', + ) + message.attach(part) + + # Encode message + raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode( + 'utf-8' + ) + return {'raw': raw_message} + + def _get_message_details( + self, message_id: str + ) -> Optional[Dict[str, Any]]: + r"""Get detailed information about a message.""" + try: + message = ( + self.gmail_service.users() + .messages() + .get(userId='me', id=message_id) + .execute() + ) + + headers = message['payload'].get('headers', []) + + return { + "message_id": message['id'], + "thread_id": message['threadId'], + "snippet": message.get('snippet', ''), + "subject": self._get_header_value(headers, 'Subject'), + "from": self._get_header_value(headers, 'From'), + "to": self._get_header_value(headers, 'To'), + "cc": self._get_header_value(headers, 'Cc'), + "bcc": self._get_header_value(headers, 'Bcc'), + "date": self._get_header_value(headers, 'Date'), + "body": self._extract_message_body(message), + "label_ids": message.get('labelIds', []), + "size_estimate": message.get('sizeEstimate', 0), + } + except Exception as e: + logger.error("Failed to get message details: %s", e) + return None + + def _get_header_value( + self, headers: List[Dict[str, str]], name: str + ) -> str: + r"""Get header value by name.""" + for header in headers: + if header['name'].lower() == name.lower(): + return header['value'] + return "" + + def _extract_message_body(self, message: Dict[str, Any]) -> str: + r"""Extract message body from message payload.""" + payload = message.get('payload', {}) + + # Handle multipart messages + if 'parts' in payload: + for part in payload['parts']: + if part['mimeType'] == 'text/plain': + data = part['body'].get('data', '') + if data: + return base64.urlsafe_b64decode(data).decode('utf-8') + elif part['mimeType'] == 'text/html': + data = part['body'].get('data', '') + if data: + return base64.urlsafe_b64decode(data).decode('utf-8') + else: + # Handle single part messages + if payload.get('mimeType') == 'text/plain': + data = payload['body'].get('data', '') + if data: + return base64.urlsafe_b64decode(data).decode('utf-8') + elif payload.get('mimeType') == 'text/html': + data = payload['body'].get('data', '') + if data: + return base64.urlsafe_b64decode(data).decode('utf-8') + + return "" + + def _is_valid_email(self, email: str) -> bool: + r"""Validate email address format.""" + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return re.match(pattern, email) is not None + + def get_tools(self) -> List[FunctionTool]: + r"""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(self.send_email), + FunctionTool(self.reply_to_email), + FunctionTool(self.forward_email), + FunctionTool(self.create_email_draft), + FunctionTool(self.send_draft), + FunctionTool(self.fetch_emails), + FunctionTool(self.fetch_message_by_id), + FunctionTool(self.fetch_thread_by_id), + FunctionTool(self.modify_email_labels), + FunctionTool(self.move_to_trash), + FunctionTool(self.get_attachment), + FunctionTool(self.list_threads), + FunctionTool(self.list_drafts), + FunctionTool(self.list_gmail_labels), + FunctionTool(self.create_label), + FunctionTool(self.delete_label), + FunctionTool(self.modify_thread_labels), + FunctionTool(self.get_profile), + FunctionTool(self.get_contacts), + FunctionTool(self.search_people), + ] diff --git a/examples/toolkits/gmail_toolkit.py b/examples/toolkits/gmail_toolkit.py new file mode 100644 index 0000000000..cce625608c --- /dev/null +++ b/examples/toolkits/gmail_toolkit.py @@ -0,0 +1,103 @@ +# ========= 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 sys +from pathlib import Path + +# Add camel to path - find the camel package directory +current_file = Path(__file__).resolve() +camel_root = current_file.parent.parent.parent +sys.path.insert(0, str(camel_root)) + +# Import after path modification +from camel.agents import ChatAgent # noqa: E402 +from camel.models import ModelFactory # noqa: E402 +from camel.toolkits import GmailToolkit # noqa: E402 +from camel.types import ModelPlatformType # noqa: E402 +from camel.types.enums import ModelType # noqa: E402 + +# Create a model instance +model = ModelFactory.create( + model_platform=ModelPlatformType.DEFAULT, + model_type=ModelType.DEFAULT, +) + +# Define system message for the Gmail assistant +sys_msg = ( + "You are a helpful Gmail assistant that can help users manage their " + "emails. You have access to all Gmail tools including sending emails, " + "fetching emails, managing labels, and more." +) + +# Initialize the Gmail toolkit +print("🔐 Initializing Gmail toolkit (browser may open for authentication)...") +gmail_toolkit = GmailToolkit() +print("✓ Gmail toolkit initialized!") + +# Get all Gmail tools +all_tools = gmail_toolkit.get_tools() +print(f"✓ Loaded {len(all_tools)} Gmail tools") + +# Initialize a ChatAgent with all Gmail tools +gmail_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 Gmail " + "Toolkit' and body 'This is a test email sent using the CAMEL Gmail " + "toolkit.'" +) + +response = gmail_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. Message ID: +1998015e3157fdee. + +Tool calls: +[ToolCallingRecord( + tool_name='send_email', + args={ + 'to': 'test@example.com', + 'subject': 'Hello from Gmail Toolkit', + 'body': 'This is a test email sent using the CAMEL Gmail toolkit.', + 'cc': None, + 'bcc': None, + 'attachments': None, + 'is_html': False + }, + result={ + 'success': True, + 'message_id': '1998015e3157fdee', + 'thread_id': '1998015e3157fdee', + 'message': 'Email sent successfully' + }, + tool_call_id='call_4VzMM1JkKjGN8J5rfT4wH2sF', + images=None +)] +""" diff --git a/test/toolkits/test_gmail_toolkit.py b/test/toolkits/test_gmail_toolkit.py new file mode 100644 index 0000000000..4cef6a35f7 --- /dev/null +++ b/test/toolkits/test_gmail_toolkit.py @@ -0,0 +1,742 @@ +# ========= 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 base64 +from unittest.mock import MagicMock, patch + +import pytest + +from camel.toolkits import FunctionTool, GmailToolkit + + +@pytest.fixture +def mock_gmail_service(): + with patch('googleapiclient.discovery.build') as mock_build: + mock_service = MagicMock() + mock_build.return_value = mock_service + + mock_users = MagicMock() + mock_service.users.return_value = mock_users + + mock_messages = MagicMock() + mock_users.messages.return_value = mock_messages + + mock_drafts = MagicMock() + mock_users.drafts.return_value = mock_drafts + + mock_threads = MagicMock() + mock_users.threads.return_value = mock_threads + + mock_labels = MagicMock() + mock_users.labels.return_value = mock_labels + + mock_attachments = MagicMock() + mock_messages.attachments.return_value = mock_attachments + + yield mock_service + + +@pytest.fixture +def mock_people_service(): + with patch('googleapiclient.discovery.build') as mock_build: + mock_service = MagicMock() + mock_build.return_value = mock_service + + mock_people = MagicMock() + mock_service.people.return_value = mock_people + + mock_connections = MagicMock() + mock_people.connections.return_value = mock_connections + + yield mock_service + + +@pytest.fixture +def gmail_toolkit(mock_gmail_service, mock_people_service): + with ( + patch.dict( + 'os.environ', + { + 'GOOGLE_CLIENT_ID': 'mock_client_id', + 'GOOGLE_CLIENT_SECRET': 'mock_client_secret', + 'GOOGLE_REFRESH_TOKEN': 'mock_refresh_token', + }, + ), + patch.object( + GmailToolkit, + '_get_gmail_service', + return_value=mock_gmail_service, + ), + patch.object( + GmailToolkit, + '_get_people_service', + return_value=mock_people_service, + ), + ): + toolkit = GmailToolkit() + toolkit.gmail_service = mock_gmail_service + toolkit.people_service = mock_people_service + yield toolkit + + +def test_send_email(gmail_toolkit, mock_gmail_service): + """Test sending an email successfully.""" + send_mock = MagicMock() + mock_gmail_service.users().messages().send.return_value = send_mock + send_mock.execute.return_value = {'id': 'msg123', 'threadId': 'thread123'} + + result = gmail_toolkit.send_email( + to='test@example.com', subject='Test Subject', body='Test Body' + ) + + assert result['success'] is True + assert result['message_id'] == 'msg123' + assert result['thread_id'] == 'thread123' + assert result['message'] == 'Email sent successfully' + + mock_gmail_service.users().messages().send.assert_called_once() + + +def test_send_email_with_attachments(gmail_toolkit, mock_gmail_service): + """Test sending an email with attachments.""" + send_mock = MagicMock() + mock_gmail_service.users().messages().send.return_value = send_mock + send_mock.execute.return_value = {'id': 'msg123', 'threadId': 'thread123'} + + 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 = gmail_toolkit.send_email( + to='test@example.com', + subject='Test Subject', + body='Test Body', + attachments=['/path/to/file.txt'], + ) + + assert result['success'] is True + + +def test_send_email_invalid_email(gmail_toolkit): + """Test sending email with invalid email address.""" + result = gmail_toolkit.send_email( + to='invalid-email', subject='Test Subject', body='Test Body' + ) + + assert 'error' in result + assert 'Invalid email address' in result['error'] + + +def test_send_email_failure(gmail_toolkit, mock_gmail_service): + """Test sending email failure.""" + send_mock = MagicMock() + mock_gmail_service.users().messages().send.return_value = send_mock + send_mock.execute.side_effect = Exception("API Error") + + result = gmail_toolkit.send_email( + to='test@example.com', subject='Test Subject', body='Test Body' + ) + + assert 'error' in result + assert 'Failed to send email' in result['error'] + + +def test_reply_to_email(gmail_toolkit, mock_gmail_service): + """Test replying to an email.""" + # Mock getting original message + get_mock = MagicMock() + mock_gmail_service.users().messages().get.return_value = get_mock + get_mock.execute.return_value = { + 'id': 'msg123', + 'threadId': 'thread123', + 'payload': { + 'headers': [ + {'name': 'Subject', 'value': 'Original Subject'}, + {'name': 'From', 'value': 'sender@example.com'}, + {'name': 'To', 'value': 'recipient@example.com'}, + {'name': 'Cc', 'value': 'cc@example.com'}, + {'name': 'Date', 'value': 'Mon, 1 Jan 2024 12:00:00 +0000'}, + ] + }, + } + + # Mock sending reply + send_mock = MagicMock() + mock_gmail_service.users().messages().send.return_value = send_mock + send_mock.execute.return_value = { + 'id': 'reply123', + 'threadId': 'thread123', + } + + result = gmail_toolkit.reply_to_email( + message_id='msg123', reply_body='This is a reply' + ) + + assert result['success'] is True + assert result['message_id'] == 'reply123' + assert result['message'] == 'Reply sent successfully' + + +def test_forward_email(gmail_toolkit, mock_gmail_service): + """Test forwarding an email.""" + # Mock getting original message + get_mock = MagicMock() + mock_gmail_service.users().messages().get.return_value = get_mock + get_mock.execute.return_value = { + 'id': 'msg123', + 'threadId': 'thread123', + 'payload': { + 'headers': [ + {'name': 'Subject', 'value': 'Original Subject'}, + {'name': 'From', 'value': 'sender@example.com'}, + {'name': 'Date', 'value': 'Mon, 1 Jan 2024 12:00:00 +0000'}, + ], + 'body': { + 'data': base64.urlsafe_b64encode(b'Original body').decode() + }, + }, + } + + # Mock sending forward + send_mock = MagicMock() + mock_gmail_service.users().messages().send.return_value = send_mock + send_mock.execute.return_value = { + 'id': 'forward123', + 'threadId': 'thread123', + } + + result = gmail_toolkit.forward_email( + message_id='msg123', to='forward@example.com' + ) + + assert result['success'] is True + assert result['message_id'] == 'forward123' + assert result['message'] == 'Email forwarded successfully' + + +def test_create_email_draft(gmail_toolkit, mock_gmail_service): + """Test creating an email draft.""" + create_mock = MagicMock() + mock_gmail_service.users().drafts().create.return_value = create_mock + create_mock.execute.return_value = { + 'id': 'draft123', + 'message': {'id': 'msg123'}, + } + + result = gmail_toolkit.create_email_draft( + to='test@example.com', subject='Test Subject', body='Test Body' + ) + + assert result['success'] is True + assert result['draft_id'] == 'draft123' + assert result['message_id'] == 'msg123' + assert result['message'] == 'Draft created successfully' + + +def test_send_draft(gmail_toolkit, mock_gmail_service): + """Test sending a draft.""" + send_mock = MagicMock() + mock_gmail_service.users().drafts().send.return_value = send_mock + send_mock.execute.return_value = {'id': 'msg123', 'threadId': 'thread123'} + + result = gmail_toolkit.send_draft(draft_id='draft123') + + assert result['success'] is True + assert result['message_id'] == 'msg123' + assert result['message'] == 'Draft sent successfully' + + +def test_fetch_emails(gmail_toolkit, mock_gmail_service): + """Test fetching emails.""" + list_mock = MagicMock() + mock_gmail_service.users().messages().list.return_value = list_mock + list_mock.execute.return_value = { + 'messages': [{'id': 'msg123'}, {'id': 'msg456'}], + 'nextPageToken': 'next_token', + } + + # Mock getting message details + get_mock = MagicMock() + mock_gmail_service.users().messages().get.return_value = get_mock + get_mock.execute.return_value = { + 'id': 'msg123', + 'threadId': 'thread123', + 'snippet': 'Test snippet', + 'payload': { + 'headers': [ + {'name': 'Subject', 'value': 'Test Subject'}, + {'name': 'From', 'value': 'sender@example.com'}, + {'name': 'To', 'value': 'recipient@example.com'}, + {'name': 'Date', 'value': 'Mon, 1 Jan 2024 12:00:00 +0000'}, + ], + 'body': {'data': base64.urlsafe_b64encode(b'Test body').decode()}, + }, + 'labelIds': ['INBOX'], + 'sizeEstimate': 1024, + } + + result = gmail_toolkit.fetch_emails(query='test', max_results=10) + + assert result['success'] is True + assert len(result['emails']) == 2 + assert result['total_count'] == 2 + assert result['next_page_token'] == 'next_token' + + +def test_fetch_message_by_id(gmail_toolkit, mock_gmail_service): + """Test fetching a specific message by ID.""" + get_mock = MagicMock() + mock_gmail_service.users().messages().get.return_value = get_mock + get_mock.execute.return_value = { + 'id': 'msg123', + 'threadId': 'thread123', + 'snippet': 'Test snippet', + 'payload': { + 'headers': [ + {'name': 'Subject', 'value': 'Test Subject'}, + {'name': 'From', 'value': 'sender@example.com'}, + {'name': 'To', 'value': 'recipient@example.com'}, + {'name': 'Date', 'value': 'Mon, 1 Jan 2024 12:00:00 +0000'}, + ], + 'body': {'data': base64.urlsafe_b64encode(b'Test body').decode()}, + }, + 'labelIds': ['INBOX'], + 'sizeEstimate': 1024, + } + + result = gmail_toolkit.fetch_message_by_id(message_id='msg123') + + assert result['success'] is True + assert result['message']['message_id'] == 'msg123' + assert result['message']['subject'] == 'Test Subject' + + +def test_fetch_thread_by_id(gmail_toolkit, mock_gmail_service): + """Test fetching a thread by ID.""" + get_mock = MagicMock() + mock_gmail_service.users().threads().get.return_value = get_mock + get_mock.execute.return_value = { + 'id': 'thread123', + 'messages': [{'id': 'msg123'}, {'id': 'msg456'}], + } + + # Mock getting message details + msg_get_mock = MagicMock() + mock_gmail_service.users().messages().get.return_value = msg_get_mock + msg_get_mock.execute.return_value = { + 'id': 'msg123', + 'threadId': 'thread123', + 'snippet': 'Test snippet', + 'payload': { + 'headers': [ + {'name': 'Subject', 'value': 'Test Subject'}, + {'name': 'From', 'value': 'sender@example.com'}, + {'name': 'To', 'value': 'recipient@example.com'}, + {'name': 'Date', 'value': 'Mon, 1 Jan 2024 12:00:00 +0000'}, + ], + 'body': {'data': base64.urlsafe_b64encode(b'Test body').decode()}, + }, + 'labelIds': ['INBOX'], + 'sizeEstimate': 1024, + } + + result = gmail_toolkit.fetch_thread_by_id(thread_id='thread123') + + assert result['success'] is True + assert result['thread_id'] == 'thread123' + assert len(result['messages']) == 2 + assert result['message_count'] == 2 + + +def test_modify_email_labels(gmail_toolkit, mock_gmail_service): + """Test modifying email labels.""" + modify_mock = MagicMock() + mock_gmail_service.users().messages().modify.return_value = modify_mock + modify_mock.execute.return_value = { + 'id': 'msg123', + 'labelIds': ['INBOX', 'IMPORTANT'], + } + + result = gmail_toolkit.modify_email_labels( + message_id='msg123', add_labels=['IMPORTANT'], remove_labels=['UNREAD'] + ) + + assert result['success'] is True + assert result['message_id'] == 'msg123' + assert 'IMPORTANT' in result['label_ids'] + assert result['message'] == 'Labels modified successfully' + + +def test_move_to_trash(gmail_toolkit, mock_gmail_service): + """Test moving a message to trash.""" + trash_mock = MagicMock() + mock_gmail_service.users().messages().trash.return_value = trash_mock + trash_mock.execute.return_value = {'id': 'msg123', 'labelIds': ['TRASH']} + + result = gmail_toolkit.move_to_trash(message_id='msg123') + + assert result['success'] is True + assert result['message_id'] == 'msg123' + assert 'TRASH' in result['label_ids'] + assert result['message'] == 'Message moved to trash successfully' + + +def test_get_attachment(gmail_toolkit, mock_gmail_service): + """Test getting an attachment.""" + attachment_mock = MagicMock() + mock_gmail_service.users().messages().attachments().get.return_value = ( + attachment_mock + ) + attachment_mock.execute.return_value = { + 'data': base64.urlsafe_b64encode(b'test attachment content').decode() + } + + result = gmail_toolkit.get_attachment( + message_id='msg123', attachment_id='att123' + ) + + assert result['success'] is True + assert result['file_size'] > 0 + assert 'data' in result + + +def test_get_attachment_save_to_file(gmail_toolkit, mock_gmail_service): + """Test getting an attachment and saving to file.""" + attachment_mock = MagicMock() + mock_gmail_service.users().messages().attachments().get.return_value = ( + attachment_mock + ) + attachment_mock.execute.return_value = { + 'data': base64.urlsafe_b64encode(b'test attachment content').decode() + } + + with patch('builtins.open', create=True) as mock_open: + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + result = gmail_toolkit.get_attachment( + message_id='msg123', + attachment_id='att123', + save_path='/path/to/save.txt', + ) + + assert result['success'] is True + assert 'saved to' in result['message'] + mock_file.write.assert_called_once() + + +def test_list_threads(gmail_toolkit, mock_gmail_service): + """Test listing threads.""" + list_mock = MagicMock() + mock_gmail_service.users().threads().list.return_value = list_mock + list_mock.execute.return_value = { + 'threads': [ + { + 'id': 'thread123', + 'snippet': 'Test thread snippet', + 'historyId': 'hist123', + } + ], + 'nextPageToken': 'next_token', + } + + result = gmail_toolkit.list_threads(query='test', max_results=10) + + assert result['success'] is True + assert len(result['threads']) == 1 + assert result['threads'][0]['thread_id'] == 'thread123' + assert result['total_count'] == 1 + + +def test_list_drafts(gmail_toolkit, mock_gmail_service): + """Test listing drafts.""" + list_mock = MagicMock() + mock_gmail_service.users().drafts().list.return_value = list_mock + list_mock.execute.return_value = { + 'drafts': [ + { + 'id': 'draft123', + 'message': { + 'id': 'msg123', + 'threadId': 'thread123', + 'snippet': 'Draft snippet', + }, + } + ], + 'nextPageToken': 'next_token', + } + + result = gmail_toolkit.list_drafts(max_results=10) + + assert result['success'] is True + assert len(result['drafts']) == 1 + assert result['drafts'][0]['draft_id'] == 'draft123' + assert result['total_count'] == 1 + + +def test_list_gmail_labels(gmail_toolkit, mock_gmail_service): + """Test listing Gmail labels.""" + list_mock = MagicMock() + mock_gmail_service.users().labels().list.return_value = list_mock + list_mock.execute.return_value = { + 'labels': [ + { + 'id': 'INBOX', + 'name': 'INBOX', + 'type': 'system', + 'messagesTotal': 10, + 'messagesUnread': 2, + 'threadsTotal': 5, + 'threadsUnread': 1, + } + ] + } + + result = gmail_toolkit.list_gmail_labels() + + assert result['success'] is True + assert len(result['labels']) == 1 + assert result['labels'][0]['id'] == 'INBOX' + assert result['labels'][0]['name'] == 'INBOX' + assert result['total_count'] == 1 + + +def test_create_label(gmail_toolkit, mock_gmail_service): + """Test creating a Gmail label.""" + create_mock = MagicMock() + mock_gmail_service.users().labels().create.return_value = create_mock + create_mock.execute.return_value = { + 'id': 'Label_123', + 'name': 'Test Label', + } + + result = gmail_toolkit.create_label( + name='Test Label', + label_list_visibility='labelShow', + message_list_visibility='show', + ) + + assert result['success'] is True + assert result['label_id'] == 'Label_123' + assert result['label_name'] == 'Test Label' + assert result['message'] == 'Label created successfully' + + +def test_delete_label(gmail_toolkit, mock_gmail_service): + """Test deleting a Gmail label.""" + delete_mock = MagicMock() + mock_gmail_service.users().labels().delete.return_value = delete_mock + delete_mock.execute.return_value = {} + + result = gmail_toolkit.delete_label(label_id='Label_123') + + assert result['success'] is True + assert result['label_id'] == 'Label_123' + assert result['message'] == 'Label deleted successfully' + + +def test_modify_thread_labels(gmail_toolkit, mock_gmail_service): + """Test modifying thread labels.""" + modify_mock = MagicMock() + mock_gmail_service.users().threads().modify.return_value = modify_mock + modify_mock.execute.return_value = { + 'id': 'thread123', + 'labelIds': ['INBOX', 'IMPORTANT'], + } + + result = gmail_toolkit.modify_thread_labels( + thread_id='thread123', + add_labels=['IMPORTANT'], + remove_labels=['UNREAD'], + ) + + assert result['success'] is True + assert result['thread_id'] == 'thread123' + assert 'IMPORTANT' in result['label_ids'] + assert result['message'] == 'Thread labels modified successfully' + + +def test_get_profile(gmail_toolkit, mock_gmail_service): + """Test getting Gmail profile.""" + profile_mock = MagicMock() + mock_gmail_service.users().getProfile.return_value = profile_mock + profile_mock.execute.return_value = { + 'emailAddress': 'user@example.com', + 'messagesTotal': 1000, + 'threadsTotal': 500, + 'historyId': 'hist123', + } + + result = gmail_toolkit.get_profile() + + assert result['success'] is True + assert result['profile']['email_address'] == 'user@example.com' + assert result['profile']['messages_total'] == 1000 + assert result['profile']['threads_total'] == 500 + + +def test_get_contacts(gmail_toolkit, mock_people_service): + """Test getting contacts.""" + connections_mock = MagicMock() + mock_people_service.people().connections().list.return_value = ( + connections_mock + ) + connections_mock.execute.return_value = { + 'connections': [ + { + 'resourceName': 'people/123', + 'names': [{'displayName': 'John Doe'}], + 'emailAddresses': [{'value': 'john@example.com'}], + 'phoneNumbers': [{'value': '+1234567890'}], + 'organizations': [{'name': 'Test Company'}], + } + ], + 'nextPageToken': 'next_token', + } + + result = gmail_toolkit.get_contacts(query='John', max_results=10) + + assert result['success'] is True + assert len(result['contacts']) == 1 + assert result['contacts'][0]['resource_name'] == 'people/123' + assert result['total_count'] == 1 + + +def test_search_people(gmail_toolkit, mock_people_service): + """Test searching for people.""" + search_mock = MagicMock() + mock_people_service.people().searchContacts.return_value = search_mock + search_mock.execute.return_value = { + 'results': [ + { + 'person': { + 'resourceName': 'people/123', + 'names': [{'displayName': 'John Doe'}], + 'emailAddresses': [{'value': 'john@example.com'}], + 'phoneNumbers': [{'value': '+1234567890'}], + 'organizations': [{'name': 'Test Company'}], + } + } + ] + } + + result = gmail_toolkit.search_people(query='John', max_results=10) + + assert result['success'] is True + assert len(result['people']) == 1 + assert result['people'][0]['resource_name'] == 'people/123' + assert result['total_count'] == 1 + + +def test_get_tools(gmail_toolkit): + """Test getting all tools from the toolkit.""" + tools = gmail_toolkit.get_tools() + + assert len(tools) == 21 # All the tools we implemented + assert all(isinstance(tool, FunctionTool) for tool in tools) + + # Check that all expected tools are present + tool_functions = [tool.func for tool in tools] + assert gmail_toolkit.send_email in tool_functions + assert gmail_toolkit.reply_to_email in tool_functions + assert gmail_toolkit.forward_email in tool_functions + assert gmail_toolkit.create_email_draft in tool_functions + assert gmail_toolkit.send_draft in tool_functions + assert gmail_toolkit.fetch_emails in tool_functions + assert gmail_toolkit.fetch_message_by_id in tool_functions + assert gmail_toolkit.fetch_thread_by_id in tool_functions + assert gmail_toolkit.modify_email_labels in tool_functions + assert gmail_toolkit.move_to_trash in tool_functions + assert gmail_toolkit.get_attachment in tool_functions + assert gmail_toolkit.list_threads in tool_functions + assert gmail_toolkit.list_drafts in tool_functions + assert gmail_toolkit.list_gmail_labels in tool_functions + assert gmail_toolkit.create_label in tool_functions + assert gmail_toolkit.delete_label in tool_functions + assert gmail_toolkit.modify_thread_labels in tool_functions + assert gmail_toolkit.get_profile in tool_functions + assert gmail_toolkit.get_contacts in tool_functions + assert gmail_toolkit.search_people in tool_functions + + +def test_error_handling(gmail_toolkit, mock_gmail_service): + """Test error handling in various methods.""" + # Test send_email error + send_mock = MagicMock() + mock_gmail_service.users().messages().send.return_value = send_mock + send_mock.execute.side_effect = Exception("API Error") + + result = gmail_toolkit.send_email( + to='test@example.com', subject='Test', body='Test' + ) + + assert 'error' in result + assert 'Failed to send email' in result['error'] + + # Test fetch_emails error + list_mock = MagicMock() + mock_gmail_service.users().messages().list.return_value = list_mock + list_mock.execute.side_effect = Exception("API Error") + + result = gmail_toolkit.fetch_emails() + assert 'error' in result + assert 'Failed to fetch emails' in result['error'] + + +def test_email_validation(gmail_toolkit): + """Test email validation functionality.""" + # Test valid emails + assert gmail_toolkit._is_valid_email('test@example.com') is True + assert gmail_toolkit._is_valid_email('user.name+tag@domain.co.uk') is True + + # Test invalid emails + assert gmail_toolkit._is_valid_email('invalid-email') is False + assert gmail_toolkit._is_valid_email('test@') is False + assert gmail_toolkit._is_valid_email('@example.com') is False + assert gmail_toolkit._is_valid_email('') is False + + +def test_message_creation_helpers(gmail_toolkit): + """Test helper methods for message creation.""" + # Test header value extraction + headers = [ + {'name': 'Subject', 'value': 'Test Subject'}, + {'name': 'From', 'value': 'sender@example.com'}, + {'name': 'To', 'value': 'recipient@example.com'}, + ] + + assert gmail_toolkit._get_header_value(headers, 'Subject') == ( + 'Test Subject' + ) + assert gmail_toolkit._get_header_value(headers, 'From') == ( + 'sender@example.com' + ) + assert gmail_toolkit._get_header_value(headers, 'NonExistent') == '' + + # Test message body extraction + message = { + 'payload': { + 'mimeType': 'text/plain', + 'body': { + 'data': base64.urlsafe_b64encode(b'Test body content').decode() + }, + } + } + + body = gmail_toolkit._extract_message_body(message) + assert body == 'Test body content' From 4a1de366237440e9ce5e6374366a06a795c2874d Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni Date: Tue, 30 Sep 2025 14:24:42 +0100 Subject: [PATCH 2/6] lazy imports and other fixes --- camel/toolkits/gmail_toolkit.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/camel/toolkits/gmail_toolkit.py b/camel/toolkits/gmail_toolkit.py index 067febf4b9..8d85613fe1 100644 --- a/camel/toolkits/gmail_toolkit.py +++ b/camel/toolkits/gmail_toolkit.py @@ -12,14 +12,9 @@ # limitations under the License. # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= -import base64 import os import re -from email import encoders -from email.mime.base import MIMEBase -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union if TYPE_CHECKING: from googleapiclient.discovery import Resource @@ -65,6 +60,9 @@ def __init__( (default: :obj:`None`) """ super().__init__(timeout=timeout) + + self._credentials = self._authenticate() + self.gmail_service: Any = self._get_gmail_service() self.people_service: Any = self._get_people_service() @@ -614,6 +612,8 @@ def get_attachment( save result. """ try: + import base64 + attachment = ( self.gmail_service.users() .messages() @@ -785,8 +785,8 @@ def list_gmail_labels(self) -> Dict[str, Any]: def create_label( self, name: str, - label_list_visibility: str = "labelShow", - message_list_visibility: str = "show", + label_list_visibility: Literal["labelShow", "labelHide"] = "labelShow", + message_list_visibility: Literal["show", "hide"] = "show", ) -> Dict[str, Any]: r"""Create a new Gmail label. @@ -1034,8 +1034,7 @@ def _get_gmail_service(self): from googleapiclient.discovery import build try: - creds = self._authenticate() - service = build('gmail', 'v1', credentials=creds) + service = build('gmail', 'v1', credentials=self._credentials) return service except Exception as e: raise ValueError(f"Failed to build Gmail service: {e}") from e @@ -1045,8 +1044,7 @@ def _get_people_service(self): from googleapiclient.discovery import build try: - creds = self._authenticate() - service = build('people', 'v1', credentials=creds) + service = build('people', 'v1', credentials=self._credentials) return service except Exception as e: raise ValueError(f"Failed to build People service: {e}") from e @@ -1113,6 +1111,13 @@ def _create_message( is_html: bool = False, ) -> Dict[str, str]: r"""Create a message object for sending.""" + + import base64 + from email import encoders + from email.mime.base import MIMEBase + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + message = MIMEMultipart() message['to'] = ', '.join(to_list) message['subject'] = subject @@ -1192,6 +1197,8 @@ def _get_header_value( def _extract_message_body(self, message: Dict[str, Any]) -> str: r"""Extract message body from message payload.""" + import base64 + payload = message.get('payload', {}) # Handle multipart messages From c54eeeede690d21ff04840912c1539276667b7cf Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni Date: Tue, 30 Sep 2025 14:37:27 +0100 Subject: [PATCH 3/6] dependencies update --- pyproject.toml | 1 + uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e200241fc0..888fc7ce44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ web_tools = [ "googlemaps>=4.10.0,<5", "requests_oauthlib>=1.3.1,<2", "google-api-python-client==2.166.0", + "google-auth>=2.0.0,<3.0.0", "google-auth-httplib2==0.2.0", "google-auth-oauthlib==1.2.1", "sympy>=1.13.3,<2", diff --git a/uv.lock b/uv.lock index 497fc7c7b1..7b81ee7933 100644 --- a/uv.lock +++ b/uv.lock @@ -1024,6 +1024,7 @@ web-tools = [ { name = "fastapi" }, { name = "firecrawl-py" }, { name = "google-api-python-client" }, + { name = "google-auth" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, { name = "googlemaps" }, @@ -1139,6 +1140,7 @@ requires-dist = [ { name = "google-api-python-client", marker = "extra == 'all'", specifier = "==2.166.0" }, { name = "google-api-python-client", marker = "extra == 'eigent'", specifier = "==2.166.0" }, { name = "google-api-python-client", marker = "extra == 'web-tools'", specifier = "==2.166.0" }, + { name = "google-auth", marker = "extra == 'web-tools'", specifier = ">=2.0.0,<3.0.0" }, { name = "google-auth-httplib2", marker = "extra == 'all'", specifier = "==0.2.0" }, { name = "google-auth-httplib2", marker = "extra == 'eigent'", specifier = "==0.2.0" }, { name = "google-auth-httplib2", marker = "extra == 'web-tools'", specifier = "==0.2.0" }, From b77d45d2198ea6169867a13048394c10f3a765ac Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni Date: Tue, 30 Sep 2025 19:57:22 +0100 Subject: [PATCH 4/6] Comprehensive OAUTH integration --- .env.example | 4 ++ camel/toolkits/gmail_toolkit.py | 123 +++++++++++++++++++++----------- 2 files changed, 86 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index f4a8b25816..6786551231 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,10 @@ # GOOGLE_API_KEY="Fill your API key here" # SEARCH_ENGINE_ID="Fill your API key here" +# Google OAUTH credentials (https://developers.google.com/identity/gsi/web/guides) +#GOOGLE_CLIENT_ID="Fill your client_id here" +#GOOGLE_CLIENT_SECRET="Fill your client_secret here" + # OpenWeatherMap API (https://home.openweathermap.org/users/sign_up) # OPENWEATHERMAP_API_KEY="Fill your API key here" diff --git a/camel/toolkits/gmail_toolkit.py b/camel/toolkits/gmail_toolkit.py index 8d85613fe1..6c41103de2 100644 --- a/camel/toolkits/gmail_toolkit.py +++ b/camel/toolkits/gmail_toolkit.py @@ -24,7 +24,7 @@ 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 import MCPServer logger = get_logger(__name__) @@ -1049,57 +1049,98 @@ def _get_people_service(self): except Exception as e: raise ValueError(f"Failed to build People service: {e}") from e - @api_keys_required( - [ - (None, "GOOGLE_CLIENT_ID"), - (None, "GOOGLE_CLIENT_SECRET"), - ] - ) def _authenticate(self): - r"""Authenticate with Google APIs.""" - client_id = os.environ.get('GOOGLE_CLIENT_ID') - client_secret = os.environ.get('GOOGLE_CLIENT_SECRET') - refresh_token = os.environ.get('GOOGLE_REFRESH_TOKEN') - token_uri = os.environ.get( - 'GOOGLE_TOKEN_URI', 'https://oauth2.googleapis.com/token' - ) + r"""Authenticate with Google APIs using OAuth2. + + Automatically saves and loads credentials from + ~/.camel/gmail_token.json to avoid repeated + browser logins. + """ + import json + from pathlib import Path + from dotenv import load_dotenv from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow - # For first-time authentication - if not refresh_token: - client_config = { - "installed": { - "client_id": client_id, - "client_secret": client_secret, - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": token_uri, - "redirect_uris": ["http://localhost"], - } - } + # Look for .env file in the project root (camel/) + env_file = Path(__file__).parent.parent.parent / '.env' + load_dotenv(env_file) - flow = InstalledAppFlow.from_client_config(client_config, SCOPES) - creds = flow.run_local_server(port=0) - return creds - else: - # If we have a refresh token, use it to get credentials - creds = Credentials( - None, - refresh_token=refresh_token, - token_uri=token_uri, - client_id=client_id, - client_secret=client_secret, - scopes=SCOPES, - ) + client_id = os.environ.get('GOOGLE_CLIENT_ID') + client_secret = os.environ.get('GOOGLE_CLIENT_SECRET') - # Refresh token if expired - if creds.expired: - creds.refresh(Request()) + token_file = Path.home() / '.camel' / 'gmail_token.json' + creds = None + + # COMPONENT 1: Load saved credentials + if token_file.exists(): + try: + with open(token_file, 'r') as f: + data = json.load(f) + creds = Credentials( + token=data.get('token'), + refresh_token=data.get('refresh_token'), + token_uri=data.get( + 'token_uri', 'https://oauth2.googleapis.com/token' + ), + client_id=client_id, + client_secret=client_secret, + scopes=SCOPES, + ) + except Exception as e: + logger.warning(f"Failed to load saved token: {e}") + creds = None + # COMPONENT 2: Refresh if expired + if creds and creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + logger.info("Access token refreshed") + return creds + except Exception as e: + logger.warning(f"Token refresh failed: {e}") + creds = None + + # COMPONENT 3: Return if valid + if creds and creds.valid: return creds + # COMPONENT 4: Browser OAuth (first-time or invalid credentials) + client_config = { + "installed": { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://localhost"], + } + } + + flow = InstalledAppFlow.from_client_config(client_config, SCOPES) + creds = flow.run_local_server(port=0) + + # Save new credentials + token_file.parent.mkdir(parents=True, exist_ok=True) + with open(token_file, 'w') as f: + json.dump( + { + 'token': creds.token, + 'refresh_token': creds.refresh_token, + 'token_uri': creds.token_uri, + 'scopes': creds.scopes, + }, + f, + ) + try: + os.chmod(token_file, 0o600) + except Exception: + pass + logger.info(f"Credentials saved to {token_file}") + + return creds + def _create_message( self, to_list: List[str], From 855257f79a3e0de2556ea20d31ec8e5882738cf4 Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni Date: Mon, 6 Oct 2025 13:21:26 +0100 Subject: [PATCH 5/6] bug fixes, attachment/message extraction improvement --- .env.example | 140 --------- camel/toolkits/gmail_toolkit.py | 464 +++++++++++++++++++++++----- examples/toolkits/gmail_toolkit.py | 18 +- pyproject.toml | 1 + test/toolkits/test_gmail_toolkit.py | 274 ++++++++++++---- uv.lock | 2 + 6 files changed, 610 insertions(+), 289 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 6786551231..0000000000 --- a/.env.example +++ /dev/null @@ -1,140 +0,0 @@ -# To use these environment variables: -# 1. Populate the .env file with your API keys. -# 2. Include the following code snippet in your Python script: -# from dotenv import load_dotenv -# import os -# -# load_dotenv() # Load environment variables from .env file - -#=========================================== -# Models API -#=========================================== - -# OpenAI API (https://platform.openai.com/signup) -# OPENAI_API_KEY="Fill your API key here" - -# Anthropic API (https://www.anthropic.com/) -# ANTHROPIC_API_KEY="Fill your API key here" - -# Groq API (https://groq.com/) -# GROQ_API_KEY="Fill your API key here" - -# Cohere API (https://cohere.ai/) -# COHERE_API_KEY="Fill your API key here" - -# Hugging Face API (https://huggingface.co/join) -# HF_TOKEN="Fill your API key here" - -# Azure OpenAI API (https://azure.microsoft.com/products/cognitive-services/openai-service/) -# AZURE_OPENAI_API_KEY="Fill your API key here" -# AZURE_API_VERSION="Fill your API Version here" -# AZURE_DEPLOYMENT_NAME="Fill your Deployment Name here" -# AZURE_OPENAI_BASE_URL="Fill your Base URL here" - -# Mistral API (https://mistral.ai/) -# MISTRAL_API_KEY="Fill your API key here" - -# Reka API (https://www.reka.ai/) -# REKA_API_KEY="Fill your API key here" - -# Zhipu AI API (https://www.zhipu.ai/) -# ZHIPUAI_API_KEY="Fill your API key here" -# ZHIPUAI_API_BASE_URL="Fill your Base URL here" - -# Qwen API (https://help.aliyun.com/document_detail/611472.html) -# QWEN_API_KEY="Fill your API key here" - -# LingYi API (https://platform.lingyiwanwu.com/apikeys) -# YI_API_KEY="Fill your API key here" - -# NVIDIA API (https://build.nvidia.com/explore/discover) -# NVIDIA_API_KEY="Fill your API key here" - -# InternLM API (https://internlm.intern-ai.org.cn/api/tokens) -# INTERNLM_API_KEY="Fill your API key here" - -# Moonshot API (https://platform.moonshot.cn/) -# MOONSHOT_API_KEY="Fill your API key here" - -# ModelScope API (https://www.modelscope.cn/my/myaccesstoken) -# MODELSCOPE_SDK_TOKEN="Fill your API key here" - -# JINA API (https://jina.ai/) -# JINA_API_KEY="Fill your API key here" - -#=========================================== -# Tools & Services API -#=========================================== - -# Google Search API (https://developers.google.com/custom-search/v1/overview) -# GOOGLE_API_KEY="Fill your API key here" -# SEARCH_ENGINE_ID="Fill your API key here" - -# Google OAUTH credentials (https://developers.google.com/identity/gsi/web/guides) -#GOOGLE_CLIENT_ID="Fill your client_id here" -#GOOGLE_CLIENT_SECRET="Fill your client_secret here" - -# OpenWeatherMap API (https://home.openweathermap.org/users/sign_up) -# OPENWEATHERMAP_API_KEY="Fill your API key here" - -# Neo4j Database (https://neo4j.com/) -# NEO4J_URI="Fill your API key here" -# NEO4J_USERNAME="Fill your User Name here" -# NEO4J_PASSWORD="Fill your Password here" - -# Firecrawl API (https://www.firecrawl.dev/) -# FIRECRAWL_API_KEY="Fill your API key here" - -# MINERU API (https://mineru.net) -# MINERU_API_KEY="Fill your API key here" - -# AskNews API (https://docs.asknews.app/en/reference) -# ASKNEWS_CLIENT_ID="Fill your Client ID here" -# ASKNEWS_CLIENT_SECRET="Fill your Client Secret here" - -# Chunkr API (https://chunkr.ai/) -# CHUNKR_API_KEY="Fill your API key here" - -# Meshy API (https://www.meshy.ai/api) -# MESHY_API_KEY="Fill your API key here" - -# Dappier API (https://api.dappier.com/) -# DAPPIER_API_KEY="Fill your API key here" - -# Discord Bot API (https://discord.com/developers/applications) -# DISCORD_BOT_TOKEN="Fill your API key here" - -# OpenBB Platform API (https://my.openbb.co/app/credentials) -# OPENBB_TOKEN="Fill your API key here" - -# AWS API (https://github.com/aws-samples/bedrock-access-gateway/blob/main/README.md) -# BEDROCK_API_BASE_URL="Fill your API Base Url here" -# BEDROCK_API_KEY="Fill your API Key here" - -# Bocha Platform API(https://open.bochaai.com) -# BOCHA_API_KEY="Fill your API key here" - -# Klavis AI API (https://www.klavis.ai) -# KLAVIS_API_KEY="Fill your API key here" - -# ACI API (https://platform.aci.dev/) -# ACI_API_KEY="Fill your API key here" -# LINKED_ACCOUNT_OWNER="Fill your Linked Account Owner here" - -#Bohrium API(https://www.bohrium.com/settings/user) -#BOHRIUM_API_KEY="Fill your API key here" - -#Langfuse API(https://langfuse.com/) -#LANGFUSE_SECRET_KEY="Fill your API key here" -#LANGFUSE_PUBLIC_KEY="Fill your API key here" - -#METASO API(https://metaso.cn/search-api/api-keys) -#METASO_API_KEY="Fill your API key here" - -# E2B -# E2B_API_KEY="Fill your e2b or e2b-compatible sandbox provider API Key here" -# E2B_DOMAIN="Fill your custom e2b domain here" - -# 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 diff --git a/camel/toolkits/gmail_toolkit.py b/camel/toolkits/gmail_toolkit.py index 6c41103de2..c1072ccbae 100644 --- a/camel/toolkits/gmail_toolkit.py +++ b/camel/toolkits/gmail_toolkit.py @@ -14,12 +14,7 @@ import os import re -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union - -if TYPE_CHECKING: - from googleapiclient.discovery import Resource -else: - Resource = Any +from typing import Any, Dict, List, Literal, Optional, Union from camel.logger import get_logger from camel.toolkits import FunctionTool @@ -87,7 +82,9 @@ def send_email( bcc (Optional[Union[str, List[str]]]): BCC recipient email address(es). attachments (Optional[List[str]]): List of file paths to attach. - is_html (bool): Whether the body is HTML format. + is_html (bool): Whether the body is HTML format. Set to True when + sending formatted emails with HTML tags (e.g., bold, + links, images). Use False (default) for plain text emails. Returns: Dict[str, Any]: A dictionary containing the result of the @@ -142,7 +139,9 @@ def reply_to_email( message_id (str): The ID of the message to reply to. reply_body (str): The reply message body. reply_all (bool): Whether to reply to all recipients. - is_html (bool): Whether the reply body is HTML format. + is_html (bool): Whether the body is HTML format. Set to True when + sending formatted emails with HTML tags (e.g., bold, + links, images). Use False (default) for plain text emails. Returns: Dict[str, Any]: A dictionary containing the result of the @@ -237,6 +236,7 @@ def forward_email( forward_body: Optional[str] = None, cc: Optional[Union[str, List[str]]] = None, bcc: Optional[Union[str, List[str]]] = None, + include_attachments: bool = True, ) -> Dict[str, Any]: r"""Forward an email message. @@ -248,12 +248,17 @@ def forward_email( address(es). bcc (Optional[Union[str, List[str]]]): BCC recipient email address(es). + include_attachments (bool): Whether to include original + attachments. Defaults to True. Only includes real + attachments, not inline images. Returns: Dict[str, Any]: A dictionary containing the result of the - operation. + operation, including the number of attachments forwarded. """ try: + import tempfile + # Get the original message original_message = ( self.gmail_service.users() @@ -290,9 +295,45 @@ def forward_email( cc_list = [cc] if isinstance(cc, str) else (cc or []) bcc_list = [bcc] if isinstance(bcc, str) else (bcc or []) - # Create forward message + # Handle attachments + attachment_paths = [] + temp_files: List[str] = [] + + if include_attachments: + # Extract attachment metadata + attachments = self._extract_attachments(original_message) + for att in attachments: + try: + # Create temp file + temp_file = tempfile.NamedTemporaryFile( + delete=False, suffix=f"_{att['filename']}" + ) + temp_files.append(temp_file.name) + + # Download attachment + result = self.get_attachment( + message_id=message_id, + attachment_id=att['attachment_id'], + save_path=temp_file.name, + ) + + if result.get('success'): + attachment_paths.append(temp_file.name) + + except Exception as e: + logger.warning( + f"Failed to download attachment " + f"{att['filename']}: {e}" + ) + + # Create forward message (now with attachments!) message = self._create_message( - to_list, subject, body, cc_list, bcc_list + to_list, + subject, + body, + cc_list, + bcc_list, + attachments=attachment_paths if attachment_paths else None, ) # Send forward @@ -303,11 +344,21 @@ def forward_email( .execute() ) + # Clean up temp files + for temp_file_path in temp_files: + try: + os.unlink(temp_file_path) + except Exception as e: + logger.warning( + f"Failed to delete temp file {temp_file}: {e}" + ) + return { "success": True, "message_id": sent_message.get('id'), "thread_id": sent_message.get('threadId'), "message": "Email forwarded successfully", + "attachments_forwarded": len(attachment_paths), } except Exception as e: @@ -335,7 +386,9 @@ def create_email_draft( bcc (Optional[Union[str, List[str]]]): BCC recipient email address(es). attachments (Optional[List[str]]): List of file paths to attach. - is_html (bool): Whether the body is HTML format. + is_html (bool): Whether the body is HTML format. Set to True when + sending formatted emails with HTML tags (e.g., bold, + links, images). Use False (default) for plain text emails. Returns: Dict[str, Any]: A dictionary containing the result of the @@ -420,7 +473,13 @@ def fetch_emails( query (str): Gmail search query string. max_results (int): Maximum number of emails to fetch. include_spam_trash (bool): Whether to include spam and trash. - label_ids (Optional[List[str]]): List of label IDs to filter by. + label_ids (Optional[List[str]]): List of label IDs to filter + emails by. Only emails with ALL of the specified + labels will be returned. + Label IDs can be: + - System labels: 'INBOX', 'SENT', 'DRAFT', 'SPAM', 'TRASH', + 'UNREAD', 'STARRED', 'IMPORTANT', 'CATEGORY_PERSONAL', etc. + - Custom label IDs: Retrieved from list_gmail_labels() method. Returns: Dict[str, Any]: A dictionary containing the fetched emails. @@ -466,26 +525,6 @@ def fetch_emails( logger.error("Failed to fetch emails: %s", e) return {"error": f"Failed to fetch emails: {e!s}"} - def fetch_message_by_id(self, message_id: str) -> Dict[str, Any]: - r"""Fetch a specific message by ID. - - Args: - message_id (str): The ID of the message to fetch. - - Returns: - Dict[str, Any]: A dictionary containing the message details. - """ - try: - message_detail = self._get_message_details(message_id) - if message_detail: - return {"success": True, "message": message_detail} - else: - return {"error": "Message not found"} - - except Exception as e: - logger.error("Failed to fetch message: %s", e) - return {"error": f"Failed to fetch message: {e!s}"} - def fetch_thread_by_id(self, thread_id: str) -> Dict[str, Any]: r"""Fetch a thread by ID. @@ -530,8 +569,17 @@ def modify_email_labels( Args: message_id (str): The ID of the message to modify. - add_labels (Optional[List[str]]): Labels to add. - remove_labels (Optional[List[str]]): Labels to remove. + add_labels (Optional[List[str]]): List of label IDs to add to + the message. + Label IDs can be: + - System labels: 'INBOX', 'STARRED', 'IMPORTANT', + 'UNREAD', etc. + - Custom label IDs: Retrieved from list_gmail_labels() method. + Example: ['STARRED', 'IMPORTANT'] marks email as starred + and important. + remove_labels (Optional[List[str]]): List of label IDs to + remove from the message. Uses the same format as add_labels. + Example: ['UNREAD'] marks the email as read. Returns: Dict[str, Any]: A dictionary containing the result of the @@ -657,7 +705,13 @@ def list_threads( query (str): Gmail search query string. max_results (int): Maximum number of threads to fetch. include_spam_trash (bool): Whether to include spam and trash. - label_ids (Optional[List[str]]): List of label IDs to filter by. + label_ids (Optional[List[str]]): List of label IDs to filter + threads by. Only threads with ALL of the specified labels + will be returned. + Label IDs can be: + - System labels: 'INBOX', 'SENT', 'DRAFT', 'SPAM', 'TRASH', + 'UNREAD', 'STARRED', 'IMPORTANT', 'CATEGORY_PERSONAL', etc. + - Custom label IDs: Retrieved from list_gmail_labels() method. Returns: Dict[str, Any]: A dictionary containing the thread list. @@ -828,7 +882,12 @@ def delete_label(self, label_id: str) -> Dict[str, Any]: r"""Delete a Gmail label. Args: - label_id (str): The ID of the label to delete. + label_id (str): List of label IDs to filter emails by. + Only emails with ALL of the specified labels will be returned. + Label IDs can be: + - System labels: 'INBOX', 'SENT', 'DRAFT', 'SPAM', 'TRASH', + 'UNREAD', 'STARRED', 'IMPORTANT', 'CATEGORY_PERSONAL', etc. + - Custom label IDs: Retrieved from list_gmail_labels() method. Returns: Dict[str, Any]: A dictionary containing the result of the @@ -859,8 +918,18 @@ def modify_thread_labels( Args: thread_id (str): The ID of the thread to modify. - add_labels (Optional[List[str]]): Labels to add. - remove_labels (Optional[List[str]]): Labels to remove. + add_labels (Optional[List[str]]): List of label IDs to add to all + messages in the thread. + Label IDs can be: + - System labels: 'INBOX', 'STARRED', 'IMPORTANT', + 'UNREAD', etc. + - Custom label IDs: Retrieved from list_gmail_labels(). + Example: ['STARRED', 'IMPORTANT'] marks thread as + starred and important. + remove_labels (Optional[List[str]]): List of label IDs to + remove from all messages in the thread. Uses the same + format as add_labels. + Example: ['UNREAD'] marks the entire thread as read. Returns: Dict[str, Any]: A dictionary containing the result of the @@ -1123,21 +1192,24 @@ def _authenticate(self): # Save new credentials token_file.parent.mkdir(parents=True, exist_ok=True) - with open(token_file, 'w') as f: - json.dump( - { - 'token': creds.token, - 'refresh_token': creds.refresh_token, - 'token_uri': creds.token_uri, - 'scopes': creds.scopes, - }, - f, - ) try: + with open(token_file, 'w') as f: + json.dump( + { + 'token': creds.token, + 'refresh_token': creds.refresh_token, + 'token_uri': creds.token_uri, + 'scopes': creds.scopes, + }, + f, + ) os.chmod(token_file, 0o600) - except Exception: - pass - logger.info(f"Credentials saved to {token_file}") + logger.info(f"Credentials saved to {token_file}") + except Exception as e: + logger.warning( + f"Failed to save credentials to {token_file}: {e}. " + "You may need to re-authenticate next time." + ) return creds @@ -1220,6 +1292,7 @@ def _get_message_details( "bcc": self._get_header_value(headers, 'Bcc'), "date": self._get_header_value(headers, 'Date'), "body": self._extract_message_body(message), + "attachments": self._extract_attachments(message), "label_ids": message.get('labelIds', []), "size_estimate": message.get('sizeEstimate', 0), } @@ -1237,34 +1310,274 @@ def _get_header_value( return "" def _extract_message_body(self, message: Dict[str, Any]) -> str: - r"""Extract message body from message payload.""" + r"""Extract message body from message payload. + + Recursively traverses the entire message tree and collects all text + content from text/plain and text/html parts. Special handling for + multipart/alternative containers: recursively searches for one format + (preferring plain text) to avoid duplication when both formats contain + the same content. All other text parts are collected to ensure no + information is lost. + + Args: + message (Dict[str, Any]): The Gmail message dictionary containing + the payload to extract text from. + + Returns: + str: The extracted message body text with multiple parts separated + by double newlines, or an empty string if no text content is + found. + """ import base64 + import re + text_parts = [] + + def decode_text_data(data: str, mime_type: str) -> str | None: + """Helper to decode base64 text data. + + Args: + data: Base64 encoded text data. + mime_type: MIME type for logging purposes. + + Returns: + Decoded text string, or None if decoding fails or text + is empty. + """ + if not data: + return None + try: + text = base64.urlsafe_b64decode(data).decode('utf-8') + return text if text.strip() else None + except Exception as e: + logger.warning(f"Failed to decode {mime_type}: {e}") + return None + + def strip_html_tags(html_content: str) -> str: + """Strip HTML tags and convert to readable plain text. + + Uses regex to remove tags and clean up formatting while preserving + basic document structure. + + Args: + html_content: HTML content to strip. + + Returns: + Plain text version of HTML content. + """ + if not html_content or not html_content.strip(): + return "" + + text = html_content + + # Remove script and style elements completely + text = re.sub( + r']*>.*?', + '', + text, + flags=re.DOTALL | re.IGNORECASE, + ) + text = re.sub( + r']*>.*?', + '', + text, + flags=re.DOTALL | re.IGNORECASE, + ) + + # Convert common HTML entities + text = text.replace(' ', ' ') + text = text.replace('&', '&') + text = text.replace('<', '<') + text = text.replace('>', '>') + text = text.replace('"', '"') + text = text.replace(''', "'") + text = text.replace('’', "'") + text = text.replace('‘', "'") + text = text.replace('”', '"') + text = text.replace('“', '"') + text = text.replace('—', '—') + text = text.replace('–', '-') + + # Convert
and
to newlines + text = re.sub(r'', '\n', text, flags=re.IGNORECASE) + + # Convert block-level closing tags to newlines + text = re.sub( + r'', '\n', text, flags=re.IGNORECASE + ) + + # Convert
to separator + text = re.sub(r'', '\n---\n', text, flags=re.IGNORECASE) + + # Remove all remaining HTML tags + text = re.sub(r'<[^>]+>', '', text) + + # Clean up whitespace + text = re.sub( + r'\n\s*\n\s*\n+', '\n\n', text + ) # Multiple blank lines to double newline + text = re.sub(r' +', ' ', text) # Multiple spaces to single space + text = re.sub(r'\n ', '\n', text) # Remove leading spaces on lines + text = re.sub( + r' \n', '\n', text + ) # Remove trailing spaces on lines + + return text.strip() + + def find_text_recursive( + part: Dict[str, Any], target_mime: str + ) -> str | None: + """Recursively search for text content of a specific MIME type. + + Args: + part: Message part to search in. + target_mime: Target MIME type ('text/plain' or 'text/html'). + + Returns: + Decoded text string if found, None otherwise. + """ + mime = part.get('mimeType', '') + + # Found the target type at this level + if mime == target_mime: + data = part.get('body', {}).get('data', '') + decoded = decode_text_data(data, target_mime) + # Strip HTML tags if this is HTML content + if decoded and target_mime == 'text/html': + return strip_html_tags(decoded) + return decoded + + # Not found, but has nested parts? Search recursively + if 'parts' in part: + for nested_part in part['parts']: + result = find_text_recursive(nested_part, target_mime) + if result: + return result + + return None + + def extract_from_part(part: Dict[str, Any]): + """Recursively collect all text from message parts.""" + mime_type = part.get('mimeType', '') + + # Special handling for multipart/alternative + if mime_type == 'multipart/alternative' and 'parts' in part: + # Recursively search for one format (prefer plain text) + plain_text = None + html_text = None + + # Search each alternative branch recursively + for nested_part in part['parts']: + if not plain_text: + plain_text = find_text_recursive( + nested_part, 'text/plain' + ) + if not html_text: + html_text = find_text_recursive( + nested_part, 'text/html' + ) + + # Prefer plain text, fall back to HTML + chosen_text = plain_text if plain_text else html_text + if chosen_text: + text_parts.append(chosen_text) + + # If this part has nested parts (but not multipart/alternative) + elif 'parts' in part: + for nested_part in part['parts']: + extract_from_part(nested_part) + + # If this is a text leaf, extract and collect it + elif mime_type == 'text/plain': + data = part.get('body', {}).get('data', '') + text = decode_text_data(data, 'plain text body') + if text: + text_parts.append(text) + + # Lines 1458-1462 + elif mime_type == 'text/html': + data = part.get('body', {}).get('data', '') + html_text = decode_text_data(data, 'HTML body') + if html_text: + text = strip_html_tags(html_text) + if text: + text_parts.append(text) + + # Traverse the entire tree and collect all text parts payload = message.get('payload', {}) + extract_from_part(payload) - # Handle multipart messages - if 'parts' in payload: - for part in payload['parts']: - if part['mimeType'] == 'text/plain': - data = part['body'].get('data', '') - if data: - return base64.urlsafe_b64decode(data).decode('utf-8') - elif part['mimeType'] == 'text/html': - data = part['body'].get('data', '') - if data: - return base64.urlsafe_b64decode(data).decode('utf-8') - else: - # Handle single part messages - if payload.get('mimeType') == 'text/plain': - data = payload['body'].get('data', '') - if data: - return base64.urlsafe_b64decode(data).decode('utf-8') - elif payload.get('mimeType') == 'text/html': - data = payload['body'].get('data', '') - if data: - return base64.urlsafe_b64decode(data).decode('utf-8') + if not text_parts: + return "" - return "" + # Return all text parts combined + return '\n\n'.join(text_parts) + + def _extract_attachments( + self, message: Dict[str, Any] + ) -> List[Dict[str, Any]]: + r"""Extract attachment information from message payload. + + Recursively traverses the message tree to find all attachments + and extracts their metadata. Distinguishes between regular attachments + and inline images embedded in HTML content. + + Args: + message (Dict[str, Any]): The Gmail message dictionary containing + the payload to extract attachments from. + + Returns: + List[Dict[str, Any]]: List of attachment dictionaries, each + containing: + - attachment_id: Gmail's unique identifier for the attachment + - filename: Name of the attached file + - mime_type: MIME type of the attachment + - size: Size of the attachment in bytes + - is_inline: Whether this is an inline image (embedded in HTML) + """ + attachments = [] + + def is_inline_image(part: Dict[str, Any]) -> bool: + """Check if this part is an inline image.""" + headers = part.get('headers', []) + for header in headers: + name = header.get('name', '').lower() + value = header.get('value', '').lower() + # Check for Content-Disposition: inline + if name == 'content-disposition' and 'inline' in value: + return True + # Check for Content-ID (usually indicates inline) + if name == 'content-id': + return True + return False + + def find_attachments(part: Dict[str, Any]): + """Recursively find attachments in message parts.""" + # Check if this part has an attachmentId (indicates it's an + # attachment) + if 'body' in part and 'attachmentId' in part['body']: + attachment_info = { + 'attachment_id': part['body']['attachmentId'], + 'filename': part.get('filename', 'unnamed'), + 'mime_type': part.get( + 'mimeType', 'application/octet-stream' + ), + 'size': part['body'].get('size', 0), + 'is_inline': is_inline_image(part), + } + attachments.append(attachment_info) + + # Recurse into nested parts + if 'parts' in part: + for nested_part in part['parts']: + find_attachments(nested_part) + + # Start traversal from the message payload + payload = message.get('payload', {}) + if payload: + find_attachments(payload) + + return attachments def _is_valid_email(self, email: str) -> bool: r"""Validate email address format.""" @@ -1286,7 +1599,6 @@ def get_tools(self) -> List[FunctionTool]: FunctionTool(self.create_email_draft), FunctionTool(self.send_draft), FunctionTool(self.fetch_emails), - FunctionTool(self.fetch_message_by_id), FunctionTool(self.fetch_thread_by_id), FunctionTool(self.modify_email_labels), FunctionTool(self.move_to_trash), diff --git a/examples/toolkits/gmail_toolkit.py b/examples/toolkits/gmail_toolkit.py index cce625608c..0d5a6e3fcc 100644 --- a/examples/toolkits/gmail_toolkit.py +++ b/examples/toolkits/gmail_toolkit.py @@ -12,20 +12,12 @@ # limitations under the License. # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= -import sys -from pathlib import Path -# Add camel to path - find the camel package directory -current_file = Path(__file__).resolve() -camel_root = current_file.parent.parent.parent -sys.path.insert(0, str(camel_root)) - -# Import after path modification -from camel.agents import ChatAgent # noqa: E402 -from camel.models import ModelFactory # noqa: E402 -from camel.toolkits import GmailToolkit # noqa: E402 -from camel.types import ModelPlatformType # noqa: E402 -from camel.types.enums import ModelType # noqa: E402 +from camel.agents import ChatAgent +from camel.models import ModelFactory +from camel.toolkits import GmailToolkit +from camel.types import ModelPlatformType +from camel.types.enums import ModelType # Create a model instance model = ModelFactory.create( diff --git a/pyproject.toml b/pyproject.toml index 888fc7ce44..5d2a42396c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -321,6 +321,7 @@ all = [ "pyowm>=3.3.0,<4", "googlemaps>=4.10.0,<5", "google-api-python-client==2.166.0", + "google-auth>=2.0.0,<3.0.0", "google-auth-httplib2==0.2.0", "google-auth-oauthlib==1.2.1", "requests_oauthlib>=1.3.1,<2", diff --git a/test/toolkits/test_gmail_toolkit.py b/test/toolkits/test_gmail_toolkit.py index 4cef6a35f7..59a7db6255 100644 --- a/test/toolkits/test_gmail_toolkit.py +++ b/test/toolkits/test_gmail_toolkit.py @@ -17,7 +17,7 @@ import pytest -from camel.toolkits import FunctionTool, GmailToolkit +from camel.toolkits import GmailToolkit @pytest.fixture @@ -298,34 +298,6 @@ def test_fetch_emails(gmail_toolkit, mock_gmail_service): assert result['next_page_token'] == 'next_token' -def test_fetch_message_by_id(gmail_toolkit, mock_gmail_service): - """Test fetching a specific message by ID.""" - get_mock = MagicMock() - mock_gmail_service.users().messages().get.return_value = get_mock - get_mock.execute.return_value = { - 'id': 'msg123', - 'threadId': 'thread123', - 'snippet': 'Test snippet', - 'payload': { - 'headers': [ - {'name': 'Subject', 'value': 'Test Subject'}, - {'name': 'From', 'value': 'sender@example.com'}, - {'name': 'To', 'value': 'recipient@example.com'}, - {'name': 'Date', 'value': 'Mon, 1 Jan 2024 12:00:00 +0000'}, - ], - 'body': {'data': base64.urlsafe_b64encode(b'Test body').decode()}, - }, - 'labelIds': ['INBOX'], - 'sizeEstimate': 1024, - } - - result = gmail_toolkit.fetch_message_by_id(message_id='msg123') - - assert result['success'] is True - assert result['message']['message_id'] == 'msg123' - assert result['message']['subject'] == 'Test Subject' - - def test_fetch_thread_by_id(gmail_toolkit, mock_gmail_service): """Test fetching a thread by ID.""" get_mock = MagicMock() @@ -643,37 +615,6 @@ def test_search_people(gmail_toolkit, mock_people_service): assert result['total_count'] == 1 -def test_get_tools(gmail_toolkit): - """Test getting all tools from the toolkit.""" - tools = gmail_toolkit.get_tools() - - assert len(tools) == 21 # All the tools we implemented - assert all(isinstance(tool, FunctionTool) for tool in tools) - - # Check that all expected tools are present - tool_functions = [tool.func for tool in tools] - assert gmail_toolkit.send_email in tool_functions - assert gmail_toolkit.reply_to_email in tool_functions - assert gmail_toolkit.forward_email in tool_functions - assert gmail_toolkit.create_email_draft in tool_functions - assert gmail_toolkit.send_draft in tool_functions - assert gmail_toolkit.fetch_emails in tool_functions - assert gmail_toolkit.fetch_message_by_id in tool_functions - assert gmail_toolkit.fetch_thread_by_id in tool_functions - assert gmail_toolkit.modify_email_labels in tool_functions - assert gmail_toolkit.move_to_trash in tool_functions - assert gmail_toolkit.get_attachment in tool_functions - assert gmail_toolkit.list_threads in tool_functions - assert gmail_toolkit.list_drafts in tool_functions - assert gmail_toolkit.list_gmail_labels in tool_functions - assert gmail_toolkit.create_label in tool_functions - assert gmail_toolkit.delete_label in tool_functions - assert gmail_toolkit.modify_thread_labels in tool_functions - assert gmail_toolkit.get_profile in tool_functions - assert gmail_toolkit.get_contacts in tool_functions - assert gmail_toolkit.search_people in tool_functions - - def test_error_handling(gmail_toolkit, mock_gmail_service): """Test error handling in various methods.""" # Test send_email error @@ -740,3 +681,216 @@ def test_message_creation_helpers(gmail_toolkit): body = gmail_toolkit._extract_message_body(message) assert body == 'Test body content' + + +def test_extract_attachments_regular_attachment(gmail_toolkit): + """Test extracting a regular attachment (not inline).""" + message = { + 'payload': { + 'parts': [ + { + 'mimeType': 'text/plain', + 'body': { + 'data': base64.urlsafe_b64encode( + b'Email body' + ).decode() + }, + }, + { + 'filename': 'document.pdf', + 'mimeType': 'application/pdf', + 'headers': [ + { + 'name': 'Content-Disposition', + 'value': 'attachment; filename="document.pdf"', + } + ], + 'body': {'attachmentId': 'ANGjdJ123', 'size': 102400}, + }, + ] + } + } + + attachments = gmail_toolkit._extract_attachments(message) + + assert len(attachments) == 1 + assert attachments[0]['attachment_id'] == 'ANGjdJ123' + assert attachments[0]['filename'] == 'document.pdf' + assert attachments[0]['mime_type'] == 'application/pdf' + assert attachments[0]['size'] == 102400 + assert attachments[0]['is_inline'] is False + + +def test_extract_attachments_inline_image(gmail_toolkit): + """Test extracting inline images with Content-ID.""" + message = { + 'payload': { + 'parts': [ + { + 'mimeType': 'text/html', + 'body': { + 'data': base64.urlsafe_b64encode( + b'' + ).decode() + }, + }, + { + 'filename': 'logo.png', + 'mimeType': 'image/png', + 'headers': [ + {'name': 'Content-ID', 'value': ''} + ], + 'body': {'attachmentId': 'ANGjdJ456', 'size': 2048}, + }, + { + 'filename': 'signature.jpg', + 'mimeType': 'image/jpeg', + 'headers': [ + { + 'name': 'Content-Disposition', + 'value': 'inline; filename="signature.jpg"', + } + ], + 'body': {'attachmentId': 'ANGjdJ789', 'size': 3072}, + }, + ] + } + } + + attachments = gmail_toolkit._extract_attachments(message) + + assert len(attachments) == 2 + # First inline image (Content-ID) + assert attachments[0]['attachment_id'] == 'ANGjdJ456' + assert attachments[0]['filename'] == 'logo.png' + assert attachments[0]['is_inline'] is True + # Second inline image (Content-Disposition: inline) + assert attachments[1]['attachment_id'] == 'ANGjdJ789' + assert attachments[1]['filename'] == 'signature.jpg' + assert attachments[1]['is_inline'] is True + + +def test_extract_message_body_multipart_alternative(gmail_toolkit): + """Test extracting body from multipart/alternative (prefers plain text).""" + message = { + 'payload': { + 'mimeType': 'multipart/alternative', + 'parts': [ + { + 'mimeType': 'text/plain', + 'body': { + 'data': base64.urlsafe_b64encode( + b'Plain text version' + ).decode() + }, + }, + { + 'mimeType': 'text/html', + 'body': { + 'data': base64.urlsafe_b64encode( + b'HTML version' + ).decode() + }, + }, + ], + } + } + + body = gmail_toolkit._extract_message_body(message) + + # Should prefer plain text over HTML + assert body == 'Plain text version' + assert '' not in body + + +def test_extract_message_body_nested_multipart_mixed(gmail_toolkit): + """Test extracting body from nested multipart/mixed structure.""" + message = { + 'payload': { + 'mimeType': 'multipart/mixed', + 'parts': [ + { + 'mimeType': 'multipart/alternative', + 'parts': [ + { + 'mimeType': 'text/plain', + 'body': { + 'data': base64.urlsafe_b64encode( + b'Main content' + ).decode() + }, + }, + { + 'mimeType': 'text/html', + 'body': { + 'data': base64.urlsafe_b64encode( + b'Main content HTML' + ).decode() + }, + }, + ], + }, + { + 'filename': 'attachment.pdf', + 'mimeType': 'application/pdf', + 'body': {'attachmentId': 'ANGjdJ999', 'size': 5000}, + }, + ], + } + } + + body = gmail_toolkit._extract_message_body(message) + + # Should extract plain text from nested structure + assert 'Main content' in body + # Should not include HTML tags + assert '' not in body + + +def test_extract_message_body_multiple_text_parts(gmail_toolkit): + """Test extracting body when multiple text parts exist.""" + message = { + 'payload': { + 'mimeType': 'multipart/mixed', + 'parts': [ + { + 'mimeType': 'text/plain', + 'body': { + 'data': base64.urlsafe_b64encode( + b'First part' + ).decode() + }, + }, + { + 'mimeType': 'multipart/alternative', + 'parts': [ + { + 'mimeType': 'text/plain', + 'body': { + 'data': base64.urlsafe_b64encode( + b'Second part' + ).decode() + }, + } + ], + }, + { + 'mimeType': 'text/plain', + 'body': { + 'data': base64.urlsafe_b64encode( + b'Third part' + ).decode() + }, + }, + ], + } + } + + body = gmail_toolkit._extract_message_body(message) + + # Should collect all text parts + assert 'First part' in body + assert 'Second part' in body + assert 'Third part' in body + # Parts should be separated by double newlines + assert '\n\n' in body diff --git a/uv.lock b/uv.lock index 7b81ee7933..1c45c21582 100644 --- a/uv.lock +++ b/uv.lock @@ -682,6 +682,7 @@ all = [ { name = "fish-audio-sdk" }, { name = "flask" }, { name = "google-api-python-client" }, + { name = "google-auth" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, { name = "google-cloud-aiplatform" }, @@ -1140,6 +1141,7 @@ requires-dist = [ { name = "google-api-python-client", marker = "extra == 'all'", specifier = "==2.166.0" }, { name = "google-api-python-client", marker = "extra == 'eigent'", specifier = "==2.166.0" }, { name = "google-api-python-client", marker = "extra == 'web-tools'", specifier = "==2.166.0" }, + { name = "google-auth", marker = "extra == 'all'", specifier = ">=2.0.0,<3.0.0" }, { name = "google-auth", marker = "extra == 'web-tools'", specifier = ">=2.0.0,<3.0.0" }, { name = "google-auth-httplib2", marker = "extra == 'all'", specifier = "==0.2.0" }, { name = "google-auth-httplib2", marker = "extra == 'eigent'", specifier = "==0.2.0" }, From a6444dc291a0f72a53af963cda75eb70dc40ddbc Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni Date: Tue, 30 Sep 2025 14:37:27 +0100 Subject: [PATCH 6/6] dependencies update --- uv.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/uv.lock b/uv.lock index 1c45c21582..9186f6e37d 100644 --- a/uv.lock +++ b/uv.lock @@ -1141,7 +1141,6 @@ requires-dist = [ { name = "google-api-python-client", marker = "extra == 'all'", specifier = "==2.166.0" }, { name = "google-api-python-client", marker = "extra == 'eigent'", specifier = "==2.166.0" }, { name = "google-api-python-client", marker = "extra == 'web-tools'", specifier = "==2.166.0" }, - { name = "google-auth", marker = "extra == 'all'", specifier = ">=2.0.0,<3.0.0" }, { name = "google-auth", marker = "extra == 'web-tools'", specifier = ">=2.0.0,<3.0.0" }, { name = "google-auth-httplib2", marker = "extra == 'all'", specifier = "==0.2.0" }, { name = "google-auth-httplib2", marker = "extra == 'eigent'", specifier = "==0.2.0" },