Files
ajarbot/google_tools/gmail_client.py
Jordan Ramos dc14baf426 Add Gmail and Google Calendar integration
Implements on-demand Google tools (not adapter) for email and calendar access via OAuth2.

Features:
- OAuth2 user consent flow with automatic token refresh
- 3 Gmail tools: send_email, read_emails, get_email
- 3 Calendar tools: read_calendar, create_calendar_event, search_calendar
- Lazy loading pattern for Google clients
- Secure token storage with file permissions
- Browser-based setup: python bot_runner.py --setup-google

Architecture:
- Tools-only approach (zero API calls when not in use)
- User-initiated actions only (no continuous polling)
- MIME message creation for emails with threading support
- HTML to text conversion for email parsing
- ISO 8601 timestamp handling for calendar events

Files added:
- google_tools/oauth_manager.py: OAuth2 flow and token management
- google_tools/gmail_client.py: Gmail API wrapper
- google_tools/calendar_client.py: Calendar API wrapper
- google_tools/utils.py: Email/MIME helpers
- config/scheduled_tasks.yaml: Example scheduled tasks config

Files modified:
- tools.py: Added 6 Google tool handlers with lazy initialization
- bot_runner.py: Added --setup-google command for OAuth authorization
- requirements.txt: Added Google API dependencies
- .gitignore: Added google_credentials.yaml and google_oauth_token.json

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 10:29:28 -07:00

221 lines
6.4 KiB
Python

"""Gmail API client for sending and reading emails."""
from typing import Dict, List, Optional
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from .oauth_manager import GoogleOAuthManager
from .utils import (
create_mime_message,
format_email_summary,
get_email_body,
parse_email_message,
)
class GmailClient:
"""Client for Gmail API operations."""
def __init__(self, oauth_manager: GoogleOAuthManager):
"""Initialize Gmail client.
Args:
oauth_manager: Initialized OAuth manager with valid credentials
"""
self.oauth_manager = oauth_manager
self.service = None
self._initialize_service()
def _initialize_service(self) -> bool:
"""Initialize Gmail API service.
Returns:
True if initialized successfully, False otherwise
"""
credentials = self.oauth_manager.get_credentials()
if not credentials:
return False
try:
self.service = build("gmail", "v1", credentials=credentials)
return True
except Exception as e:
print(f"[Gmail] Failed to initialize service: {e}")
return False
def send_email(
self,
to: str,
subject: str,
body: str,
cc: Optional[List[str]] = None,
reply_to_message_id: Optional[str] = None,
) -> Dict:
"""Send an email.
Args:
to: Recipient email address
subject: Email subject
body: Email body (plain text or HTML)
cc: Optional list of CC recipients
reply_to_message_id: Optional message ID to reply to (for threading)
Returns:
Dict with success status and message_id or error
"""
if not self.service:
if not self._initialize_service():
return {
"success": False,
"error": "Not authorized. Run: python bot_runner.py --setup-google",
}
try:
message = create_mime_message(
to=to,
subject=subject,
body=body,
cc=cc,
reply_to_message_id=reply_to_message_id,
)
result = (
self.service.users()
.messages()
.send(userId="me", body=message)
.execute()
)
return {
"success": True,
"message_id": result.get("id"),
"thread_id": result.get("threadId"),
}
except HttpError as e:
return {"success": False, "error": str(e)}
def search_emails(
self,
query: str = "",
max_results: int = 10,
include_body: bool = False,
) -> Dict:
"""Search emails using Gmail search syntax.
Args:
query: Gmail search query (e.g., "from:john@example.com after:2026/02/10")
max_results: Maximum number of results to return (max: 50)
include_body: Whether to include full email body
Returns:
Dict with success status and emails or error
"""
if not self.service:
if not self._initialize_service():
return {
"success": False,
"error": "Not authorized. Run: python bot_runner.py --setup-google",
}
try:
# Limit max_results to 50 to avoid token overload
max_results = min(max_results, 50)
# Search for messages
results = (
self.service.users()
.messages()
.list(userId="me", q=query, maxResults=max_results)
.execute()
)
messages = results.get("messages", [])
if not messages:
return {
"success": True,
"emails": [],
"count": 0,
"summary": "No emails found.",
}
# Fetch full message details
emails = []
for msg in messages:
message = (
self.service.users()
.messages()
.get(userId="me", id=msg["id"], format="full")
.execute()
)
email_data = parse_email_message(message)
if include_body:
email_data["body"] = get_email_body(message)
emails.append(email_data)
summary = format_email_summary(emails, include_body=include_body)
return {
"success": True,
"emails": emails,
"count": len(emails),
"summary": summary,
}
except HttpError as e:
return {"success": False, "error": str(e)}
def get_email(self, message_id: str, format_type: str = "text") -> Dict:
"""Get full email by ID.
Args:
message_id: Gmail message ID
format_type: "text" or "html" (default: text)
Returns:
Dict with success status and email data or error
"""
if not self.service:
if not self._initialize_service():
return {
"success": False,
"error": "Not authorized. Run: python bot_runner.py --setup-google",
}
try:
message = (
self.service.users()
.messages()
.get(userId="me", id=message_id, format="full")
.execute()
)
email_data = parse_email_message(message)
email_data["body"] = get_email_body(message)
# Get attachment info if any
payload = message.get("payload", {})
attachments = []
for part in payload.get("parts", []):
if part.get("filename"):
attachments.append({
"filename": part["filename"],
"mime_type": part.get("mimeType"),
"size": part.get("body", {}).get("size", 0),
})
email_data["attachments"] = attachments
return {
"success": True,
"email": email_data,
}
except HttpError as e:
return {"success": False, "error": str(e)}