diff --git a/.env.example b/.env.example index f4a8b25816..f6c752d5c4 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 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/__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..fd429ec0d2 --- /dev/null +++ b/camel/toolkits/gmail_toolkit.py @@ -0,0 +1,1787 @@ +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= + +import os +import re +from typing import Any, Dict, List, Literal, Optional, Union + +from camel.logger import get_logger +from camel.toolkits import FunctionTool +from camel.toolkits.base import BaseToolkit +from camel.utils import MCPServer + +logger = get_logger(__name__) + +SCOPES = [ + '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. + API keys can be accessed in google cloud console (https://console.cloud.google.com/) + """ + + 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._credentials = self._authenticate() + + self.gmail_service: Any = self._get_gmail_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, + 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. 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. + """ + 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 (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 + 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. + """ + try: + # Get the original message + original_message = ( + self.gmail_service.users() + .messages() + .get(userId='me', id=message_id) + .execute() + ) + + # Extract headers (single pass, case-insensitive) + headers = original_message['payload'].get('headers', []) + 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 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: + 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 and None values + recipients = [r for r in list(set(recipients)) if r] + + # 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) + 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 " + "recipients: %s", + e, + ) + else: + recipients = [from_email] + + # Create reply message with reply headers + message = self._create_message( + 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 in the same thread + sent_message = ( + self.gmail_service.users() + .messages() + .send(userId='me', body={**message, 'threadId': thread_id}) + .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, + include_attachments: bool = True, + ) -> Dict[str, Any]: + r"""Forward an email message. + + Args: + 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 + 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, including the number of attachments forwarded. + """ + try: + import tempfile + + # Get the original message + original_message = ( + self.gmail_service.users() + .messages() + .get(userId='me', id=message_id) + .execute() + ) + + # Extract headers (single pass, case-insensitive) + headers = original_message['payload'].get('headers', []) + 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 subject and not subject.startswith('Fwd: '): + subject = f"Fwd: {subject}" + elif not subject: + subject = "Fwd: (No 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 []) + + # Handle attachments + attachment_paths = [] + temp_files: List[str] = [] + + 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, + ) + + # 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", + "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 delete temp file {temp_file_path}: {e}" + ) + + 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. 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. + """ + 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, + page_token: Optional[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 + 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. + 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. + """ + 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 + if page_token: + request_params['pageToken'] = page_token + + 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_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 (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: + - 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 + 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 + (found in send_email/list_drafts results or + users.messages.get). + + 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 (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. + + Returns: + Dict[str, Any]: A dictionary containing the attachment data or + save result. + """ + try: + import base64 + + 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, + page_token: Optional[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 + 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. + 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. + """ + 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 + if page_token: + request_params['pageToken'] = page_token + + 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, 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. + """ + try: + drafts_result = ( + self.gmail_service.users() + .drafts() + .list( + userId='me', + maxResults=max_results, + **({"pageToken": page_token} if page_token else {}), + ) + .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: Literal["labelShow", "labelHide"] = "labelShow", + message_list_visibility: Literal["show", "hide"] = "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 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 + 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]]): 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 + 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, + max_results: int = 100, + page_token: Optional[str] = None, + ) -> Dict[str, Any]: + r"""List connections from Google People API. + + Args: + 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. + """ + try: + # Build request parameters + request_params = { + 'resourceName': 'people/me', + 'personFields': ( + 'names,emailAddresses,phoneNumbers,organizations' + ), + 'pageSize': max_results, + } + + # Search contacts + if page_token: + request_params['pageToken'] = page_token + + 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: + # 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 + + def _get_people_service(self): + r"""Get People service object.""" + from googleapiclient.discovery import build + + try: + # 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 + + def _authenticate(self): + 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 + + # Look for .env file in the project root (camel/) + env_file = Path(__file__).parent.parent.parent / '.env' + load_dotenv(env_file) + + 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: " + f"{', '.join(missing_vars)}. " + "Please set these in your .env file or environment variables." + ) + + 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") + + # 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 " + f"{token_file}: {e}. " + "Token refreshed but not persisted." + ) + + 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) + 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"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 + + 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, + in_reply_to: Optional[str] = None, + references: Optional[List[str]] = None, + ) -> 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 + + if cc_list: + message['cc'] = ', '.join(cc_list) + 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')) + 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', []) + # 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": 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, include_inline=True + ), + "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. + + 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) -> Optional[str]: + """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 + ) -> Optional[str]: + """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) + + 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) + + if not text_parts: + return "" + + # Return all text parts combined + return '\n\n'.join(text_parts) + + def _extract_attachments( + self, message: Dict[str, Any], include_inline: bool = False + ) -> 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 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. + + 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_to_check) 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_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/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/examples/toolkits/gmail_toolkit.py b/examples/toolkits/gmail_toolkit.py new file mode 100644 index 0000000000..0d5a6e3fcc --- /dev/null +++ b/examples/toolkits/gmail_toolkit.py @@ -0,0 +1,95 @@ +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. ========= + + +from camel.agents import ChatAgent +from camel.models import ModelFactory +from camel.toolkits import GmailToolkit +from camel.types import ModelPlatformType +from camel.types.enums import ModelType + +# Create a model instance +model = ModelFactory.create( + model_platform=ModelPlatformType.DEFAULT, + model_type=ModelType.DEFAULT, +) + +# Define system message for the 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/pyproject.toml b/pyproject.toml index 28da857b4a..055871eaea 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", @@ -301,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", @@ -321,6 +323,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", @@ -430,6 +433,7 @@ all = [ "reportlab>=4.4.2", "onnxruntime<=1.19.2", "google-cloud-aiplatform>=1.111.0", + "httplib2>=0.31.0", ] [project.urls] @@ -654,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 new file mode 100644 index 0000000000..5defecdaeb --- /dev/null +++ b/test/toolkits/test_gmail_toolkit.py @@ -0,0 +1,900 @@ +# ========= 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 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_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(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_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' + + +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, include_inline=True + ) + + 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, include_inline=True + ) + + 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 9c5d1fdfa7..2f22d3ac49 100644 --- a/uv.lock +++ b/uv.lock @@ -843,6 +843,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" }, @@ -851,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"] }, @@ -1036,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'" }, @@ -1192,6 +1195,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" }, @@ -1307,6 +1311,8 @@ 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" }, { name = "google-auth-httplib2", marker = "extra == 'web-tools'", specifier = "==0.2.0" }, @@ -1325,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" }, @@ -3628,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" }, @@ -3637,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" }, @@ -3646,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" }, @@ -3655,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" }, @@ -3662,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" }, ] @@ -6655,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 = [ @@ -7622,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 = [ @@ -7640,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 = [