From ce35fc4911f0bcc75e822d5a7719d26224ae8077 Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni Date: Thu, 25 Sep 2025 10:16:36 +0100 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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" }, From 5e27a315442fc7eebe033d513b25cc466bbd56d7 Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni Date: Mon, 3 Nov 2025 14:06:16 +0400 Subject: [PATCH 7/9] addressed comments --- .env.example | 138 +++ camel/toolkits/gmail_toolkit.py | 213 ++++- .../reference/camel.toolkits.gmail_toolkit.md | 857 ++++++++++++++++++ test/toolkits/test_gmail_toolkit.py | 6 +- 4 files changed, 1163 insertions(+), 51 deletions(-) create mode 100644 .env.example create mode 100644 docs/reference/camel.toolkits.gmail_toolkit.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..32ff01fdf3 --- /dev/null +++ b/.env.example @@ -0,0 +1,138 @@ +# 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 Gmail API + +# 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 c1072ccbae..7b061fdeba 100644 --- a/camel/toolkits/gmail_toolkit.py +++ b/camel/toolkits/gmail_toolkit.py @@ -24,7 +24,6 @@ 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', @@ -59,7 +58,19 @@ def __init__( self._credentials = self._authenticate() self.gmail_service: Any = self._get_gmail_service() - self.people_service: Any = self._get_people_service() + self._people_service: Any = None + + @property + def people_service(self) -> Any: + r"""Lazily initialize and return the Google People service.""" + if self._people_service is None: + self._people_service = self._get_people_service() + return self._people_service + + @people_service.setter + def people_service(self, service: Any) -> None: + r"""Allow overriding/injecting the People service (e.g., in tests).""" + self._people_service = service def send_email( self, @@ -136,7 +147,8 @@ def reply_to_email( r"""Reply to an email message. Args: - message_id (str): The ID of the message to reply to. + message_id (str): The ID of the message to reply to (found in + send_email/list_drafts results or users.messages.get). reply_body (str): The reply message body. reply_all (bool): Whether to reply to all recipients. is_html (bool): Whether the body is HTML format. Set to True when @@ -156,12 +168,34 @@ def reply_to_email( .execute() ) - # Extract headers + # Extract headers (single pass, case-insensitive) 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') + subject = from_email = to_emails = cc_emails = None + missing = {'subject', 'from', 'to', 'cc'} + + for header in headers: + name = (header.get('name') or '').lower() + if name not in missing: + continue + value = header.get('value') + if name == 'subject': + subject = value + elif name == 'from': + from_email = value + elif name == 'to': + to_emails = value + elif name == 'cc': + cc_emails = value + missing.discard(name) + if not missing: + break + + # Extract identifiers for reply context + message_id_header = ( + self._get_header_value(headers, 'Message-Id') + or self._get_header_value(headers, 'Message-ID') + ) + thread_id = original_message.get('threadId') # Prepare reply subject if not subject.startswith('Re: '): @@ -205,16 +239,21 @@ def reply_to_email( else: recipients = [from_email] - # Create reply message + # Create reply message with reply headers message = self._create_message( - recipients, subject, reply_body, is_html=is_html + recipients, + subject, + reply_body, + is_html=is_html, + in_reply_to=message_id_header or original_message.get('id'), + references=[message_id_header] if message_id_header else None, ) - # Send reply + # Send reply in the same thread sent_message = ( self.gmail_service.users() .messages() - .send(userId='me', body=message) + .send(userId='me', body={**message, 'threadId': thread_id}) .execute() ) @@ -241,7 +280,8 @@ def forward_email( r"""Forward an email message. Args: - message_id (str): The ID of the message to forward. + message_id (str): The ID of the message to forward (found in + send_email/list_drafts results or users.messages.get). 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 @@ -267,11 +307,25 @@ def forward_email( .execute() ) - # Extract headers + # Extract headers (single pass, case-insensitive) 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') + subject = from_email = date = None + missing = {'subject', 'from', 'date'} + + for header in headers: + name = (header.get('name') or '').lower() + if name not in missing: + continue + value = header.get('value') + if name == 'subject': + subject = value + elif name == 'from': + from_email = value + elif name == 'date': + date = value + missing.discard(name) + if not missing: + break # Prepare forward subject if not subject.startswith('Fwd: '): @@ -350,7 +404,7 @@ def forward_email( os.unlink(temp_file_path) except Exception as e: logger.warning( - f"Failed to delete temp file {temp_file}: {e}" + f"Failed to delete temp file {temp_file_path}: {e}" ) return { @@ -466,6 +520,7 @@ def fetch_emails( max_results: int = 10, include_spam_trash: bool = False, label_ids: Optional[List[str]] = None, + page_token: Optional[str] = None, ) -> Dict[str, Any]: r"""Fetch emails with filters and pagination. @@ -480,6 +535,8 @@ def fetch_emails( - System labels: 'INBOX', 'SENT', 'DRAFT', 'SPAM', 'TRASH', 'UNREAD', 'STARRED', 'IMPORTANT', 'CATEGORY_PERSONAL', etc. - Custom label IDs: Retrieved from list_gmail_labels() method. + page_token (Optional[str]): Pagination token from a previous + response. If provided, fetches the next page of results. Returns: Dict[str, Any]: A dictionary containing the fetched emails. @@ -501,7 +558,7 @@ def fetch_emails( messages_result = ( self.gmail_service.users() .messages() - .list(**request_params) + .list(**({**request_params, **({"pageToken": page_token} if page_token else {})})) .execute() ) @@ -568,7 +625,8 @@ def modify_email_labels( r"""Modify labels on an email message. Args: - message_id (str): The ID of the message to modify. + message_id (str): The ID of the message to modify (found in + send_email/list_drafts results or users.messages.get). add_labels (Optional[List[str]]): List of label IDs to add to the message. Label IDs can be: @@ -617,7 +675,9 @@ 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. + message_id (str): The ID of the message to move to trash + (found in send_email/list_drafts results or + users.messages.get). Returns: Dict[str, Any]: A dictionary containing the result of the @@ -651,7 +711,9 @@ def get_attachment( r"""Get an attachment from a message. Args: - message_id (str): The ID of the message containing the attachment. + message_id (str): The ID of the message containing the + attachment (found in send_email/list_drafts results + or users.messages.get). attachment_id (str): The ID of the attachment. save_path (Optional[str]): Path to save the attachment file. @@ -698,6 +760,7 @@ def list_threads( max_results: int = 10, include_spam_trash: bool = False, label_ids: Optional[List[str]] = None, + page_token: Optional[str] = None, ) -> Dict[str, Any]: r"""List email threads. @@ -712,6 +775,8 @@ def list_threads( - System labels: 'INBOX', 'SENT', 'DRAFT', 'SPAM', 'TRASH', 'UNREAD', 'STARRED', 'IMPORTANT', 'CATEGORY_PERSONAL', etc. - Custom label IDs: Retrieved from list_gmail_labels() method. + page_token (Optional[str]): Pagination token from a previous + response. If provided, fetches the next page of results. Returns: Dict[str, Any]: A dictionary containing the thread list. @@ -733,7 +798,7 @@ def list_threads( threads_result = ( self.gmail_service.users() .threads() - .list(**request_params) + .list(**({**request_params, **({"pageToken": page_token} if page_token else {})})) .execute() ) @@ -760,11 +825,13 @@ def list_threads( 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]: + def list_drafts(self, max_results: int = 10, page_token: Optional[str] = None) -> Dict[str, Any]: r"""List email drafts. Args: max_results (int): Maximum number of drafts to fetch. + page_token (Optional[str]): Pagination token from a previous + response. If provided, fetches the next page of results. Returns: Dict[str, Any]: A dictionary containing the draft list. @@ -773,7 +840,11 @@ def list_drafts(self, max_results: int = 10) -> Dict[str, Any]: drafts_result = ( self.gmail_service.users() .drafts() - .list(userId='me', maxResults=max_results) + .list( + userId='me', + maxResults=max_results, + **({"pageToken": page_token} if page_token else {}) + ) .execute() ) @@ -882,12 +953,11 @@ def delete_label(self, label_id: str) -> Dict[str, Any]: r"""Delete a Gmail label. Args: - 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. + label_id (str): The ID of the user-created label to delete. + Retrieve the label ID from `list_gmail_labels()`. + Note: System labels (e.g., 'INBOX', 'SENT', 'DRAFT', 'SPAM', + 'TRASH', 'UNREAD', 'STARRED', 'IMPORTANT', + 'CATEGORY_PERSONAL', etc.) cannot be deleted. Returns: Dict[str, Any]: A dictionary containing the result of the @@ -990,14 +1060,15 @@ def get_profile(self) -> Dict[str, Any]: def get_contacts( self, - query: str = "", max_results: int = 100, + page_token: Optional[str] = None, ) -> Dict[str, Any]: - r"""Get contacts from Google People API. + r"""List connections from Google People API. Args: - query (str): Search query for contacts. max_results (int): Maximum number of contacts to fetch. + page_token (Optional[str]): Pagination token from a previous + response. If provided, fetches the next page of results. Returns: Dict[str, Any]: A dictionary containing the contacts. @@ -1012,14 +1083,11 @@ def get_contacts( 'pageSize': max_results, } - if query: - request_params['query'] = query - # Search contacts contacts_result = ( self.people_service.people() .connections() - .list(**request_params) + .list(**({**request_params, **({"pageToken": page_token} if page_token else {})})) .execute() ) @@ -1140,6 +1208,17 @@ def _authenticate(self): client_id = os.environ.get('GOOGLE_CLIENT_ID') client_secret = os.environ.get('GOOGLE_CLIENT_SECRET') + if not client_id or not client_secret: + missing_vars = [] + if not client_id: + missing_vars.append('GOOGLE_CLIENT_ID') + if not client_secret: + missing_vars.append('GOOGLE_CLIENT_SECRET') + raise ValueError( + f"Missing required environment variables: {', '.join(missing_vars)}. " + "Please set these in your .env file or environment variables." + ) + token_file = Path.home() / '.camel' / 'gmail_token.json' creds = None @@ -1167,6 +1246,28 @@ def _authenticate(self): try: creds.refresh(Request()) logger.info("Access token refreshed") + + # Save refreshed credentials to disk + token_file.parent.mkdir(parents=True, exist_ok=True) + 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) + logger.info(f"Refreshed credentials saved to {token_file}") + except Exception as e: + logger.warning( + f"Failed to save refreshed credentials to {token_file}: {e}. " + "Token refreshed but not persisted." + ) + return creds except Exception as e: logger.warning(f"Token refresh failed: {e}") @@ -1222,6 +1323,8 @@ def _create_message( bcc_list: Optional[List[str]] = None, attachments: Optional[List[str]] = None, is_html: bool = False, + in_reply_to: Optional[str] = None, + references: Optional[List[str]] = None, ) -> Dict[str, str]: r"""Create a message object for sending.""" @@ -1240,6 +1343,12 @@ def _create_message( if bcc_list: message['bcc'] = ', '.join(bcc_list) + # Set reply headers when provided + if in_reply_to: + message['In-Reply-To'] = in_reply_to + if references: + message['References'] = ' '.join(references) + # Add body if is_html: message.attach(MIMEText(body, 'html')) @@ -1280,19 +1389,25 @@ def _get_message_details( ) headers = message['payload'].get('headers', []) + # Build a name->value map in one pass (case-insensitive) + header_map = {} + for header in headers: + name = header.get('name') + if name: + header_map[name.lower()] = header.get('value', '') 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'), + "subject": header_map.get('subject', ''), + "from": header_map.get('from', ''), + "to": header_map.get('to', ''), + "cc": header_map.get('cc', ''), + "bcc": header_map.get('bcc', ''), + "date": header_map.get('date', ''), "body": self._extract_message_body(message), - "attachments": self._extract_attachments(message), + "attachments": self._extract_attachments(message, include_inline=True), "label_ids": message.get('labelIds', []), "size_estimate": message.get('sizeEstimate', 0), } @@ -1494,7 +1609,6 @@ def extract_from_part(part: Dict[str, Any]): 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') @@ -1514,7 +1628,7 @@ def extract_from_part(part: Dict[str, Any]): return '\n\n'.join(text_parts) def _extract_attachments( - self, message: Dict[str, Any] + self, message: Dict[str, Any], include_inline: bool = False ) -> List[Dict[str, Any]]: r"""Extract attachment information from message payload. @@ -1577,7 +1691,10 @@ def find_attachments(part: Dict[str, Any]): if payload: find_attachments(payload) - return attachments + # Return based on include_inline toggle + if include_inline: + return attachments + return [att for att in attachments if not att['is_inline']] def _is_valid_email(self, email: str) -> bool: r"""Validate email address format.""" diff --git a/docs/reference/camel.toolkits.gmail_toolkit.md b/docs/reference/camel.toolkits.gmail_toolkit.md new file mode 100644 index 0000000000..4eabd5d00d --- /dev/null +++ b/docs/reference/camel.toolkits.gmail_toolkit.md @@ -0,0 +1,857 @@ + + + + +## GmailToolkit + +```python +class GmailToolkit(BaseToolkit): +``` + +A comprehensive toolkit for Gmail operations. + +This toolkit provides methods for Gmail operations including sending emails, +managing drafts, fetching messages, managing labels, and handling contacts. +It supports both plain text and HTML email formats, attachment handling, +and comprehensive Gmail API integration. + +**Parameters:** + +- **timeout** (Optional[float]): The timeout value for API requests in seconds. If None, no timeout is applied. (default: :obj:`None`) + +**Note:** + +This toolkit requires Google OAuth2 authentication. On first use, it will open a browser window for authentication and store credentials for future use. + +**Authentication Scopes:** + +The toolkit requests the following Gmail API scopes: +- `https://www.googleapis.com/auth/gmail.readonly` - Read-only access +- `https://www.googleapis.com/auth/gmail.send` - Send emails +- `https://www.googleapis.com/auth/gmail.modify` - Modify emails +- `https://www.googleapis.com/auth/gmail.compose` - Compose emails +- `https://www.googleapis.com/auth/gmail.labels` - Manage labels +- `https://www.googleapis.com/auth/contacts.readonly` - Read contacts +- `https://www.googleapis.com/auth/userinfo.profile` - User profile access + + + +### __init__ + +```python +def __init__( + self, + timeout: Optional[float] = None, +): +``` + +Initialize a new instance of the GmailToolkit class. + +**Parameters:** + +- **timeout** (Optional[float]): The timeout value for API requests in seconds. If None, no timeout is applied. (default: :obj:`None`) + + + +### send_email + +```python +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]: +``` + +Send an email through Gmail. + +**Parameters:** + +- **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. 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 operation with keys: + - `success` (bool): Whether the operation was successful + - `message_id` (str): The ID of the sent message + - `thread_id` (str): The ID of the email thread + - `message` (str): Status message + +**Example:** + +```python +from camel.toolkits import GmailToolkit + +gmail = GmailToolkit() +result = gmail.send_email( + to="recipient@example.com", + subject="Hello from CAMEL", + body="This is a test email", + cc=["cc@example.com"], + attachments=["/path/to/file.pdf"], + is_html=False +) +print(result) +# {'success': True, 'message_id': 'abc123', 'thread_id': 'abc123', 'message': 'Email sent successfully'} +``` + + + +### create_email_draft + +```python +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]: +``` + +Create a draft email in Gmail. + +**Parameters:** + +- **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 with keys: + - `success` (bool): Whether the operation was successful + - `draft_id` (str): The ID of the created draft + - `message` (str): Status message + + + +### fetch_emails + +```python +def fetch_emails( + self, + query: str = "", + max_results: int = 10, + include_spam_trash: bool = False, + label_ids: Optional[List[str]] = None, +) -> Dict[str, Any]: +``` + +Retrieve messages from Gmail. + +**Parameters:** + +- **query** (str): Gmail search query (e.g., "from:example@gmail.com", "subject:urgent"). (default: :obj:`""`) +- **max_results** (int): Maximum number of messages to retrieve. (default: :obj:`10`) +- **include_spam_trash** (bool): Whether to include messages from spam and trash. (default: :obj:`False`) +- **label_ids** (Optional[List[str]]): List of label IDs to filter by. + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `messages` (List[Dict]): List of message objects with metadata + - `total_count` (int): Total number of messages found + - `message` (str): Status message + +**Example:** + +```python +# Get recent messages +result = gmail.fetch_emails(max_results=5) + +# Search for specific emails +result = gmail.fetch_emails(query="from:important@company.com", max_results=10) + +# Get unread messages +result = gmail.fetch_emails(query="is:unread") +``` + + + +### _get_message_details (internal) + +```python +def _get_message_details(self, message_id: str) -> Optional[Dict[str, Any]]: +``` + +Get detailed fields for a message (subject, from, to, cc, bcc, date, body, attachments). Intended for internal use. + + + +### get_attachment + +```python +def get_attachment( + self, + message_id: str, + attachment_id: str, + save_path: Optional[str] = None, +) -> Dict[str, Any]: +``` + +Retrieve attachments from a specific message. + +**Parameters:** + +- **message_id** (str): The ID of the message containing the attachment. +- **attachment_id** (str): The ID of the attachment to retrieve. +- **save_path** (Optional[str]): Path to save the attachment. If None, returns the attachment data. + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `attachment_data` (bytes): The attachment data (if save_path is None) + - `saved_path` (str): The path where the attachment was saved (if save_path is provided) + - `filename` (str): The name of the attachment file + - `size` (int): The size of the attachment in bytes + + + +### list_gmail_labels + +```python +def list_gmail_labels(self) -> Dict[str, Any]: +``` + +Retrieve all labels from Gmail. + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `labels` (List[Dict]): List of label objects with metadata + - `message` (str): Status message + + + +### create_label + +```python +def create_label( + self, + name: str, + message_list_visibility: str = "show", + label_list_visibility: str = "labelShow", +) -> Dict[str, Any]: +``` + +Create a new label in Gmail. + +**Parameters:** + +- **name** (str): The name of the label to create. +- **message_list_visibility** (str): Visibility of the label in the message list. (default: :obj:`"show"`) +- **label_list_visibility** (str): Visibility of the label in the label list. (default: :obj:`"labelShow"`) + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `label_id` (str): The ID of the created label + - `message` (str): Status message + + + +### modify_email_labels + +```python +def modify_email_labels( + self, + message_id: str, + add_label_ids: Optional[List[str]] = None, + remove_label_ids: Optional[List[str]] = None, +) -> Dict[str, Any]: +``` + +Modify labels of a specific message. + +**Parameters:** + +- **message_id** (str): The ID of the message to modify. +- **add_label_ids** (Optional[List[str]]): List of label IDs to add to the message. +- **remove_label_ids** (Optional[List[str]]): List of label IDs to remove from the message. + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `message_id` (str): The ID of the modified message + - `message` (str): Status message + + + +### move_to_trash + +```python +def move_to_trash(self, message_id: str) -> Dict[str, Any]: +``` + +Delete a specific message. + +**Parameters:** + +- **message_id** (str): The ID of the message to delete. + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `message` (str): Status message + + + +### get_contacts + +```python +def get_contacts( + self, + max_results: int = 100, + page_size: int = 100, +) -> Dict[str, Any]: +``` + +Retrieve contacts from Google Contacts. + +**Parameters:** + +- **max_results** (int): Maximum number of contacts to retrieve. (default: :obj:`100`) +- **page_size** (int): Number of contacts per page. (default: :obj:`100`) + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `contacts` (List[Dict]): List of contact objects with metadata + - `total_count` (int): Total number of contacts found + - `message` (str): Status message + + + +### search_people + +```python +def search_people( + self, + query: str, + max_results: int = 10, +) -> Dict[str, Any]: +``` + +Search for contacts by name or email. + +**Parameters:** + +- **query** (str): Search query for contacts. +- **max_results** (int): Maximum number of contacts to retrieve. (default: :obj:`10`) + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `contacts` (List[Dict]): List of matching contact objects + - `total_count` (int): Total number of contacts found + - `message` (str): Status message + + + +### get_profile + +```python +def get_profile(self) -> Dict[str, Any]: +``` + +Retrieve the current user's Gmail profile information. + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `profile` (Dict): User profile information including email, name, etc. + - `message` (str): Status message + + + +### list_threads + +```python +def list_threads( + self, + query: str = "", + max_results: int = 10, + include_spam_trash: bool = False, + label_ids: Optional[List[str]] = None, +) -> Dict[str, Any]: +``` + +Retrieve email threads from Gmail. + +**Parameters:** + +- **query** (str): Gmail search query for threads. (default: :obj:`""`) +- **max_results** (int): Maximum number of threads to retrieve. (default: :obj:`10`) +- **include_spam_trash** (bool): Whether to include threads from spam and trash. (default: :obj:`False`) +- **label_ids** (Optional[List[str]]): List of label IDs to filter by. + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `threads` (List[Dict]): List of thread objects with metadata + - `total_count` (int): Total number of threads found + - `message` (str): Status message + + + +### fetch_thread_by_id + +```python +def fetch_thread_by_id( + self, + thread_id: str, +) -> Dict[str, Any]: +``` + +Retrieve a specific email thread by ID. + +**Parameters:** + +- **thread_id** (str): The ID of the thread to retrieve. +- **format** (Literal["minimal", "full", "metadata"]): The format of the thread to retrieve. (default: :obj:`"full"`) + +**Returns:** + +- **Dict[str, Any]**: A dictionary containing the result of the operation with keys: + - `success` (bool): Whether the operation was successful + - `thread` (Dict): The thread object with all messages + - `thread_id` (str): The ID of the retrieved thread + + + +### get_tools + +```python +def get_tools(self) -> List[FunctionTool]: +``` + +Get all available Gmail tools as FunctionTool objects. + +**Returns:** + +- **List[FunctionTool]**: List of FunctionTool objects for all Gmail operations. + +**Example:** + +```python +from camel.toolkits import GmailToolkit +from camel.agents import ChatAgent + +# Initialize Gmail toolkit +gmail_toolkit = GmailToolkit() + +# Get all tools +tools = gmail_toolkit.get_tools() + +# Create an agent with Gmail tools +agent = ChatAgent( + system_message="You are a helpful Gmail assistant.", + tools=tools +) + +# Use the agent +response = agent.step("Send an email to john@example.com with subject 'Meeting' and body 'Let's meet tomorrow'") +``` + +## Usage Examples + +### Basic Email Operations + +```python +from camel.toolkits import GmailToolkit + +# Initialize the toolkit +gmail = GmailToolkit() + +# Send a simple email +result = gmail.send_email( + to="recipient@example.com", + subject="Hello from CAMEL", + body="This is a test email sent using the CAMEL Gmail toolkit." +) + +# Send an HTML email with attachments +result = gmail.send_email( + to=["user1@example.com", "user2@example.com"], + subject="Important Update", + body="

Important Update

Please review the attached document.

", + cc="manager@example.com", + attachments=["/path/to/document.pdf"], + is_html=True +) + +# Create a draft +draft_result = gmail.create_draft( + to="colleague@example.com", + subject="Draft: Project Proposal", + body="Here's the initial draft of our project proposal..." +) +``` + +### Message Management + +```python +# Get recent messages +messages = gmail.fetch_emails(max_results=10) + +# Search for specific emails +urgent_emails = gmail.fetch_emails(query="is:unread subject:urgent") + +# Get messages from a specific sender +from_sender = gmail.fetch_emails(query="from:important@company.com") + +# Get message details (internal helper) +message = gmail._get_message_details("message_id_here") + +# Move a message to trash +delete_result = gmail.move_to_trash("message_id_here") +``` + +### Label Management + +```python +# Get all labels +labels = gmail.list_gmail_labels() + +# Create a new label +new_label = gmail.create_label("Important Projects") + +# Modify message labels +gmail.modify_email_labels( + message_id="message_id_here", + add_label_ids=["label_id_here"], + remove_label_ids=["INBOX"] +) +``` + +### Contact Management + +```python +# Get all contacts +contacts = gmail.get_contacts() + +# Search for specific contacts +search_results = gmail.search_contacts("John Smith") + +# Get user profile +profile = gmail.get_profile() +``` + +### Thread Management + +```python +# Get recent threads +threads = gmail.list_threads(max_results=5) + +# Get a specific thread +thread = gmail.fetch_thread_by_id("thread_id_here") + +# Search for threads +project_threads = gmail.get_threads(query="subject:project") +``` + +## Error Handling + +All methods return a dictionary with a `success` boolean field. Check this field to determine if the operation was successful: + +```python +result = gmail.send_email( + to="invalid-email", + subject="Test", + body="Test message" +) + +if result["success"]: + print(f"Email sent successfully! Message ID: {result['message_id']}") +else: + print(f"Failed to send email: {result['message']}") +``` + +## Authentication + +The Gmail toolkit uses OAuth2 authentication with Google's Gmail API. This section covers the complete authentication setup and mechanisms. + +### Prerequisites + +Before using the Gmail toolkit, you need to set up Google API credentials: + +1. **Google Cloud Console Setup:** + - Go to the [Google Cloud Console](https://console.cloud.google.com/) + - Create a new project or select an existing one + - Enable the Gmail API and Google People API + - Create OAuth 2.0 credentials (Desktop application type) + +2. **Download Credentials:** + - Download the `credentials.json` file from the Google Cloud Console + - Place it in your project directory or set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable + +### Authentication Flow + +The Gmail toolkit implements a multi-step OAuth2 authentication process: + +#### 1. Initial Authentication +On first use, the toolkit will: + +```python +# The authentication process happens automatically when initializing +gmail = GmailToolkit() # This triggers the OAuth flow +``` + +**What happens behind the scenes:** +- Loads the `credentials.json` file +- Opens a browser window for user consent +- Requests permission for all required Gmail scopes +- Exchanges authorization code for access and refresh tokens +- Stores tokens securely in `token.json` for future use + +#### 2. Token Storage +The toolkit stores authentication tokens in a local `token.json` file: + +```json +{ + "token": "ya29.a0AfH6SMC...", + "refresh_token": "1//04...", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "your-client-id.apps.googleusercontent.com", + "client_secret": "your-client-secret", + "scopes": ["https://mail.google.com/", ...], + "expiry": "2024-01-01T12:00:00Z" +} +``` + +#### 3. Automatic Token Refresh +The toolkit automatically handles token refresh: + +- **Access tokens** expire after 1 hour +- **Refresh tokens** are used to obtain new access tokens +- **Automatic refresh** happens before each API call if needed +- **No user intervention** required after initial setup + +### Required Scopes + +The toolkit requests the following Gmail API scopes: + +| Scope | Purpose | Access Level | +|-------|---------|--------------| +| `https://www.googleapis.com/auth/gmail.readonly` | Read emails and metadata | Read-only | +| `https://www.googleapis.com/auth/gmail.send` | Send emails | Write | +| `https://www.googleapis.com/auth/gmail.modify` | Modify emails and labels | Write | +| `https://www.googleapis.com/auth/gmail.compose` | Create drafts and compose | Write | +| `https://www.googleapis.com/auth/gmail.labels` | Manage labels | Write | +| `https://www.googleapis.com/auth/contacts.readonly` | Read Google Contacts | Read-only | +| `https://www.googleapis.com/auth/userinfo.profile` | Access user profile | Read-only | + +### Environment Variables + +You can configure authentication using environment variables: + +```bash +# Set the path to your credentials file +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/credentials.json" + +# Optional: Set custom token storage location +export GMAIL_TOKEN_PATH="/custom/path/token.json" +``` + +### Authentication Methods + +#### Method 1: Credentials File (Recommended) +```python +# Place credentials.json in your project directory +gmail = GmailToolkit() +``` + +#### Method 2: Environment Variable +```python +import os +os.environ['GOOGLE_APPLICATION_CREDENTIALS'] = '/path/to/credentials.json' +gmail = GmailToolkit() +``` + +#### Method 3: Explicit Path +```python +# The toolkit will look for credentials.json in the current directory +# or use the path specified in GOOGLE_APPLICATION_CREDENTIALS +gmail = GmailToolkit() +``` + +### Troubleshooting Authentication + +#### Common Issues and Solutions + +**1. "Credentials not found" Error:** +```python +# Error: FileNotFoundError: [Errno 2] No such file or directory: 'credentials.json' +# Solution: Ensure credentials.json is in the correct location +``` + +**2. "Invalid credentials" Error:** +```python +# Error: google.auth.exceptions.RefreshError: The credentials do not contain the necessary fields +# Solution: Re-download credentials.json from Google Cloud Console +``` + +**3. "Access denied" Error:** +```python +# Error: googleapiclient.errors.HttpError: +# Solution: Check that Gmail API is enabled in Google Cloud Console +``` + +**4. "Token expired" Error:** +```python +# Error: google.auth.exceptions.RefreshError: The credentials have been revoked +# Solution: Delete token.json and re-authenticate +``` + +#### Debug Authentication Issues + +```python +import logging +logging.basicConfig(level=logging.DEBUG) + +# This will show detailed authentication logs +gmail = GmailToolkit() +``` + +### Security Considerations + +#### Token Security +- **Never commit** `token.json` to version control +- **Use environment variables** for production deployments +- **Rotate credentials** regularly in production +- **Limit scope** to only required permissions + +#### Production Deployment +```python +# For production, use service account credentials +# or implement your own token management system +import os +from google.oauth2 import service_account + +# Load service account credentials +credentials = service_account.Credentials.from_service_account_file( + '/path/to/service-account.json', + scopes=SCOPES +) + +# Use with Gmail toolkit (requires additional implementation) +``` + +### Re-authentication + +If you need to re-authenticate (e.g., after changing scopes): + +```python +import os + +# Delete the existing token file +if os.path.exists('token.json'): + os.remove('token.json') + +# Re-initialize the toolkit +gmail = GmailToolkit() # This will trigger re-authentication +``` + +### Multi-User Support + +For applications supporting multiple users: + +```python +# Each user needs their own token file +user_id = "user123" +token_file = f"token_{user_id}.json" + +# You would need to modify the toolkit to support custom token paths +# This is an advanced use case requiring custom implementation +``` + +### Best Practices + +1. **Credential Management:** + - Store credentials securely + - Use environment variables in production + - Implement proper error handling + +2. **Token Handling:** + - Monitor token expiration + - Implement retry logic for token refresh + - Log authentication events + +3. **Scope Management:** + - Request only necessary scopes + - Regularly review and update permissions + - Document scope requirements + +4. **Error Handling:** + - Handle authentication errors gracefully + - Provide clear error messages to users + - Implement fallback mechanisms + +### Example: Complete Authentication Setup + +```python +import os +from camel.toolkits import GmailToolkit + +def setup_gmail_authentication(): + """Complete Gmail authentication setup.""" + + # Check if credentials file exists + if not os.path.exists('credentials.json'): + raise FileNotFoundError( + "credentials.json not found. Please download it from Google Cloud Console." + ) + + try: + # Initialize Gmail toolkit (triggers authentication) + gmail = GmailToolkit() + print("✓ Gmail authentication successful!") + return gmail + + except Exception as e: + print(f"✗ Gmail authentication failed: {e}") + print("Please check your credentials.json file and try again.") + raise + +# Usage +if __name__ == "__main__": + gmail = setup_gmail_authentication() + + # Test authentication by getting user profile + profile = gmail.get_user_profile() + if profile['success']: + print(f"Authenticated as: {profile['profile']['emailAddress']}") + else: + print("Authentication test failed") +``` + +This comprehensive authentication documentation covers all aspects of setting up and using the Gmail toolkit with proper security considerations and troubleshooting guidance. diff --git a/test/toolkits/test_gmail_toolkit.py b/test/toolkits/test_gmail_toolkit.py index 59a7db6255..cb1e4201c7 100644 --- a/test/toolkits/test_gmail_toolkit.py +++ b/test/toolkits/test_gmail_toolkit.py @@ -581,7 +581,7 @@ def test_get_contacts(gmail_toolkit, mock_people_service): 'nextPageToken': 'next_token', } - result = gmail_toolkit.get_contacts(query='John', max_results=10) + result = gmail_toolkit.get_contacts(max_results=10) assert result['success'] is True assert len(result['contacts']) == 1 @@ -711,7 +711,7 @@ def test_extract_attachments_regular_attachment(gmail_toolkit): } } - attachments = gmail_toolkit._extract_attachments(message) + attachments = gmail_toolkit._extract_attachments(message, include_inline=True) assert len(attachments) == 1 assert attachments[0]['attachment_id'] == 'ANGjdJ123' @@ -757,7 +757,7 @@ def test_extract_attachments_inline_image(gmail_toolkit): } } - attachments = gmail_toolkit._extract_attachments(message) + attachments = gmail_toolkit._extract_attachments(message, include_inline=True) assert len(attachments) == 2 # First inline image (Content-ID) From 121615bf0a20fc0728c926ffcf46b964ca557151 Mon Sep 17 00:00:00 2001 From: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:46:59 +0800 Subject: [PATCH 8/9] enhance: gmail_toolkit integration PR3205 (#3382) --- camel/toolkits/gmail_toolkit.py | 230 +++++++++++++++++----------- pyproject.toml | 5 +- test/toolkits/test_gmail_toolkit.py | 8 +- uv.lock | 23 ++- 4 files changed, 171 insertions(+), 95 deletions(-) diff --git a/camel/toolkits/gmail_toolkit.py b/camel/toolkits/gmail_toolkit.py index 7b061fdeba..540fdce558 100644 --- a/camel/toolkits/gmail_toolkit.py +++ b/camel/toolkits/gmail_toolkit.py @@ -191,15 +191,20 @@ def reply_to_email( break # Extract identifiers for reply context - message_id_header = ( - self._get_header_value(headers, 'Message-Id') - or self._get_header_value(headers, 'Message-ID') - ) + message_id_header = self._get_header_value( + headers, 'Message-Id' + ) or self._get_header_value(headers, 'Message-ID') thread_id = original_message.get('threadId') # Prepare reply subject - if not subject.startswith('Re: '): + if subject and not subject.startswith('Re: '): subject = f"Re: {subject}" + elif not subject: + subject = "Re: (No Subject)" + + # Validate from_email + if not from_email: + return {"error": "Original message has no sender address"} # Prepare recipients if reply_all: @@ -212,8 +217,8 @@ def reply_to_email( recipients.extend( [email.strip() for email in cc_emails.split(',')] ) - # Remove duplicates - recipients = list(set(recipients)) + # Remove duplicates and None values + recipients = [r for r in list(set(recipients)) if r] # Get current user's email and remove it from recipients try: @@ -224,12 +229,16 @@ def reply_to_email( ] # 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}>') - ] + filtered_recipients = [] + for email in recipients: + # Extract email from "Name " format + match = re.search(r'<([^>]+)>$', email.strip()) + email_addr = ( + match.group(1) if match else email.strip() + ) + if email_addr != current_user_email: + filtered_recipients.append(email) + recipients = filtered_recipients except Exception as e: logger.warning( "Could not get current user email to filter from " @@ -328,8 +337,10 @@ def forward_email( break # Prepare forward subject - if not subject.startswith('Fwd: '): + if subject and not subject.startswith('Fwd: '): subject = f"Fwd: {subject}" + elif not subject: + subject = "Fwd: (No Subject)" # Prepare forward body if forward_body: @@ -353,68 +364,70 @@ def forward_email( 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) + try: + 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, + attachments=attachment_paths if attachment_paths else None, + ) - # Download attachment - result = self.get_attachment( - message_id=message_id, - attachment_id=att['attachment_id'], - save_path=temp_file.name, - ) + # Send forward + sent_message = ( + self.gmail_service.users() + .messages() + .send(userId='me', body=message) + .execute() + ) - if result.get('success'): - attachment_paths.append(temp_file.name) + return { + "success": True, + "message_id": sent_message.get('id'), + "thread_id": sent_message.get('threadId'), + "message": "Email forwarded successfully", + "attachments_forwarded": len(attachment_paths), + } + finally: + # 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 download attachment " - f"{att['filename']}: {e}" + f"Failed to delete temp file {temp_file_path}: {e}" ) - # Create forward message (now with attachments!) - message = self._create_message( - to_list, - subject, - body, - cc_list, - bcc_list, - attachments=attachment_paths if attachment_paths else None, - ) - - # Send forward - sent_message = ( - self.gmail_service.users() - .messages() - .send(userId='me', body=message) - .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_path}: {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: logger.error("Failed to forward email: %s", e) return {"error": f"Failed to forward email: {e!s}"} @@ -555,10 +568,13 @@ def fetch_emails( request_params['labelIds'] = label_ids # List messages + if page_token: + request_params['pageToken'] = page_token + messages_result = ( self.gmail_service.users() .messages() - .list(**({**request_params, **({"pageToken": page_token} if page_token else {})})) + .list(**request_params) .execute() ) @@ -676,7 +692,7 @@ def move_to_trash(self, message_id: str) -> Dict[str, Any]: Args: message_id (str): The ID of the message to move to trash - (found in send_email/list_drafts results or + (found in send_email/list_drafts results or users.messages.get). Returns: @@ -711,8 +727,8 @@ def get_attachment( r"""Get an attachment from a message. Args: - message_id (str): The ID of the message containing the - attachment (found in send_email/list_drafts results + message_id (str): The ID of the message containing the + attachment (found in send_email/list_drafts results or users.messages.get). attachment_id (str): The ID of the attachment. save_path (Optional[str]): Path to save the attachment file. @@ -795,10 +811,13 @@ def list_threads( request_params['labelIds'] = label_ids # List threads + if page_token: + request_params['pageToken'] = page_token + threads_result = ( self.gmail_service.users() .threads() - .list(**({**request_params, **({"pageToken": page_token} if page_token else {})})) + .list(**request_params) .execute() ) @@ -825,7 +844,9 @@ def list_threads( 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, page_token: Optional[str] = None) -> Dict[str, Any]: + def list_drafts( + self, max_results: int = 10, page_token: Optional[str] = None + ) -> Dict[str, Any]: r"""List email drafts. Args: @@ -843,7 +864,7 @@ def list_drafts(self, max_results: int = 10, page_token: Optional[str] = None) - .list( userId='me', maxResults=max_results, - **({"pageToken": page_token} if page_token else {}) + **({"pageToken": page_token} if page_token else {}), ) .execute() ) @@ -1084,10 +1105,13 @@ def get_contacts( } # Search contacts + if page_token: + request_params['pageToken'] = page_token + contacts_result = ( self.people_service.people() .connections() - .list(**({**request_params, **({"pageToken": page_token} if page_token else {})})) + .list(**request_params) .execute() ) @@ -1171,7 +1195,15 @@ def _get_gmail_service(self): from googleapiclient.discovery import build try: - service = build('gmail', 'v1', credentials=self._credentials) + # Build service with optional timeout + if self.timeout is not None: + import httplib2 + + http = httplib2.Http(timeout=self.timeout) + http = self._credentials.authorize(http) + service = build('gmail', 'v1', http=http) + else: + service = build('gmail', 'v1', credentials=self._credentials) return service except Exception as e: raise ValueError(f"Failed to build Gmail service: {e}") from e @@ -1181,7 +1213,15 @@ def _get_people_service(self): from googleapiclient.discovery import build try: - service = build('people', 'v1', credentials=self._credentials) + # Build service with optional timeout + if self.timeout is not None: + import httplib2 + + http = httplib2.Http(timeout=self.timeout) + http = self._credentials.authorize(http) + service = build('people', 'v1', http=http) + else: + service = build('people', 'v1', credentials=self._credentials) return service except Exception as e: raise ValueError(f"Failed to build People service: {e}") from e @@ -1215,7 +1255,8 @@ def _authenticate(self): if not client_secret: missing_vars.append('GOOGLE_CLIENT_SECRET') raise ValueError( - f"Missing required environment variables: {', '.join(missing_vars)}. " + f"Missing required environment variables: " + f"{', '.join(missing_vars)}. " "Please set these in your .env file or environment variables." ) @@ -1246,7 +1287,7 @@ def _authenticate(self): try: creds.refresh(Request()) logger.info("Access token refreshed") - + # Save refreshed credentials to disk token_file.parent.mkdir(parents=True, exist_ok=True) try: @@ -1264,10 +1305,11 @@ def _authenticate(self): logger.info(f"Refreshed credentials saved to {token_file}") except Exception as e: logger.warning( - f"Failed to save refreshed credentials to {token_file}: {e}. " + f"Failed to save refreshed credentials to " + f"{token_file}: {e}. " "Token refreshed but not persisted." ) - + return creds except Exception as e: logger.warning(f"Token refresh failed: {e}") @@ -1407,7 +1449,9 @@ def _get_message_details( "bcc": header_map.get('bcc', ''), "date": header_map.get('date', ''), "body": self._extract_message_body(message), - "attachments": self._extract_attachments(message, include_inline=True), + "attachments": self._extract_attachments( + message, include_inline=True + ), "label_ids": message.get('labelIds', []), "size_estimate": message.get('sizeEstimate', 0), } @@ -1448,7 +1492,7 @@ def _extract_message_body(self, message: Dict[str, Any]) -> str: text_parts = [] - def decode_text_data(data: str, mime_type: str) -> str | None: + def decode_text_data(data: str, mime_type: str) -> Optional[str]: """Helper to decode base64 text data. Args: @@ -1541,7 +1585,7 @@ def strip_html_tags(html_content: str) -> str: def find_text_recursive( part: Dict[str, Any], target_mime: str - ) -> str | None: + ) -> Optional[str]: """Recursively search for text content of a specific MIME type. Args: @@ -1697,9 +1741,19 @@ def find_attachments(part: Dict[str, Any]): return [att for att in attachments if not att['is_inline']] def _is_valid_email(self, email: str) -> bool: - r"""Validate email address format.""" + r"""Validate email address format. + + Supports both formats: + - Plain email: john@example.com + - Named email: John Doe + """ + # Extract email from "Name " format if present + match = re.search(r'<([^>]+)>$', email.strip()) + email_to_check = match.group(1) if match else email.strip() + + # Validate the email address pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - return re.match(pattern, email) is not None + return re.match(pattern, email_to_check) is not None def get_tools(self) -> List[FunctionTool]: r"""Returns a list of FunctionTool objects representing the diff --git a/pyproject.toml b/pyproject.toml index 154794ff47..055871eaea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -302,6 +302,7 @@ eigent = [ "google-api-python-client==2.166.0", "google-auth-httplib2==0.2.0", "google-auth-oauthlib==1.2.1", + "httplib2>=0.31.0", ] all = [ "numpy>=1.2,<=2.2", @@ -432,6 +433,7 @@ all = [ "reportlab>=4.4.2", "onnxruntime<=1.19.2", "google-cloud-aiplatform>=1.111.0", + "httplib2>=0.31.0", ] [project.urls] @@ -656,7 +658,8 @@ module = [ "surrealdb.*", "microsandbox.*", "sentence_transformers.*", - "markitdown" + "markitdown", + "httplib2", ] ignore_missing_imports = true diff --git a/test/toolkits/test_gmail_toolkit.py b/test/toolkits/test_gmail_toolkit.py index cb1e4201c7..5defecdaeb 100644 --- a/test/toolkits/test_gmail_toolkit.py +++ b/test/toolkits/test_gmail_toolkit.py @@ -711,7 +711,9 @@ def test_extract_attachments_regular_attachment(gmail_toolkit): } } - attachments = gmail_toolkit._extract_attachments(message, include_inline=True) + attachments = gmail_toolkit._extract_attachments( + message, include_inline=True + ) assert len(attachments) == 1 assert attachments[0]['attachment_id'] == 'ANGjdJ123' @@ -757,7 +759,9 @@ def test_extract_attachments_inline_image(gmail_toolkit): } } - attachments = gmail_toolkit._extract_attachments(message, include_inline=True) + attachments = gmail_toolkit._extract_attachments( + message, include_inline=True + ) assert len(attachments) == 2 # First inline image (Content-ID) diff --git a/uv.lock b/uv.lock index 152f9c800f..2f22d3ac49 100644 --- a/uv.lock +++ b/uv.lock @@ -852,6 +852,7 @@ all = [ { name = "googlemaps" }, { name = "gradio" }, { name = "html2text" }, + { name = "httplib2" }, { name = "ibm-watsonx-ai", version = "1.3.42", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ibm-watsonx-ai", version = "1.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "imageio", extra = ["pyav"] }, @@ -1037,6 +1038,7 @@ eigent = [ { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, + { name = "httplib2" }, { name = "imageio", extra = ["pyav"] }, { name = "markitdown" }, { name = "markitdown", extra = ["all"], marker = "python_full_version < '3.13'" }, @@ -1309,6 +1311,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" }, @@ -1328,6 +1331,8 @@ requires-dist = [ { name = "html2text", marker = "extra == 'all'", specifier = ">=2024.2.26" }, { name = "html2text", marker = "extra == 'owl'", specifier = ">=2024.2.26" }, { name = "html2text", marker = "extra == 'web-tools'", specifier = ">=2024.2.26" }, + { name = "httplib2", marker = "extra == 'all'", specifier = ">=0.31.0" }, + { name = "httplib2", marker = "extra == 'eigent'", specifier = ">=0.31.0" }, { name = "httpx", specifier = ">=0.28.0,<1.0.0.dev0" }, { name = "huggingface-hub", marker = "extra == 'huggingface'" }, { name = "ibm-watsonx-ai", marker = "extra == 'all'", specifier = ">=1.3.11" }, @@ -3631,6 +3636,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, @@ -3640,6 +3647,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, @@ -3649,6 +3658,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, @@ -3658,6 +3669,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, @@ -3665,6 +3678,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] @@ -6658,7 +6673,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -7625,7 +7640,7 @@ name = "pyobjc-framework-cocoa" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/6f/89837da349fe7de6476c426f118096b147de923139556d98af1832c64b97/pyobjc_framework_cocoa-12.0.tar.gz", hash = "sha256:02d69305b698015a20fcc8e1296e1528e413d8cf9fdcd590478d359386d76e8a", size = 2771906, upload-time = "2025-10-21T08:30:51.765Z" } wheels = [ @@ -7643,8 +7658,8 @@ name = "pyobjc-framework-quartz" version = "12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/0b/3c34fc9de790daff5ca49d1f36cb8dcc353ac10e4e29b4759e397a3831f4/pyobjc_framework_quartz-12.0.tar.gz", hash = "sha256:5bcb9e78d671447e04d89e2e3c39f3135157892243facc5f8468aa333e40d67f", size = 3159509, upload-time = "2025-10-21T08:40:01.918Z" } wheels = [ From d1d79e53db07ee07f28eecd6149931ca0a5fbebe Mon Sep 17 00:00:00 2001 From: Waleed Alzarooni <131400134+waleedalzarooni@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:12:21 +0400 Subject: [PATCH 9/9] env vars added --- .env.example | 4 +++- camel/toolkits/gmail_toolkit.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 32ff01fdf3..f6c752d5c4 100644 --- a/.env.example +++ b/.env.example @@ -70,7 +70,9 @@ # GOOGLE_API_KEY="Fill your API key here" # SEARCH_ENGINE_ID="Fill your API key here" -# Google Gmail API +# Google Gmail API (https://console.cloud.google.com/) +# GOOGLE_CLIENT_ID="Fill your API key here" +# GOOGLE_CLIENT_SECRET="Fill your API key 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 540fdce558..fd429ec0d2 100644 --- a/camel/toolkits/gmail_toolkit.py +++ b/camel/toolkits/gmail_toolkit.py @@ -40,6 +40,7 @@ class GmailToolkit(BaseToolkit): This class provides methods for Gmail operations including sending emails, managing drafts, fetching messages, managing labels, and handling contacts. + API keys can be accessed in google cloud console (https://console.cloud.google.com/) """ def __init__(