2026-02-14 10:29:28 -07:00
|
|
|
"""Gmail API client for sending and reading emails."""
|
|
|
|
|
|
2026-02-24 12:32:05 -07:00
|
|
|
import base64
|
|
|
|
|
from pathlib import Path
|
2026-02-14 10:29:28 -07:00
|
|
|
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,
|
feat: RSO observation system, child safety, Discord adapter, Telegram watchdog, email attachments
Core agent improvements:
- RSO (Relevance Scoring & Observation) system: interaction_logger, memory_scorer, signal_detector
- Memory access logging (memory_access_log table) for relevance scoring; high-signal turn detection
- Rich conversation storage for notable turns; compact_conversation truncates long user messages
- Task-type classifier (query/action/analysis/creative) for observation tagging
- Nested sub-agent visibility: deep delegations now register against the main agent's manager
Child safety (Gabriel profile):
- child_safety.py: filtering, audit logging, prompt constants for restricted sessions
- .kiro/specs/child-safety-profile: requirements, design, tasks specs
- GABRIEL_BOT_PROPOSAL.md: initial proposal doc
- Reduced context window (10 msgs) and tutor-mode identity for restricted users
Telegram adapter:
- Polling watchdog: auto-restarts updater if polling drops unexpectedly
- get_me() with exponential-backoff retry on NetworkError at startup
- Correct stop() ordering: signal watchdog before cancelling tasks
Email / Gmail:
- send_email: supports file attachments (attachments list param)
- get_email: surfaces attachment metadata in response
Scheduled tasks / weather:
- Remove OpenWeatherMap API calls from morning-weather task; use wttr.in exclusively
- New scheduled tasks and scheduler state persistence
Discord:
- adapters/discord/__init__.py scaffold
- discord-plugin: MCP plugin for Claude Code Discord integration (server.ts, skills, config)
Infrastructure:
- n8n workflow exports (garvis_webhook, content_pipeline variants)
- memory_workspace: context, homelab-repo-updates, weekly observation summaries, error logs
- UCS C240 migration plan doc
- requirements.txt: new deps
- .claude/settings.json, fix_hooks.py: hook/permission tuning
2026-04-23 07:54:01 -06:00
|
|
|
attachments: Optional[List[str]] = None,
|
2026-02-14 10:29:28 -07:00
|
|
|
) -> 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,
|
feat: RSO observation system, child safety, Discord adapter, Telegram watchdog, email attachments
Core agent improvements:
- RSO (Relevance Scoring & Observation) system: interaction_logger, memory_scorer, signal_detector
- Memory access logging (memory_access_log table) for relevance scoring; high-signal turn detection
- Rich conversation storage for notable turns; compact_conversation truncates long user messages
- Task-type classifier (query/action/analysis/creative) for observation tagging
- Nested sub-agent visibility: deep delegations now register against the main agent's manager
Child safety (Gabriel profile):
- child_safety.py: filtering, audit logging, prompt constants for restricted sessions
- .kiro/specs/child-safety-profile: requirements, design, tasks specs
- GABRIEL_BOT_PROPOSAL.md: initial proposal doc
- Reduced context window (10 msgs) and tutor-mode identity for restricted users
Telegram adapter:
- Polling watchdog: auto-restarts updater if polling drops unexpectedly
- get_me() with exponential-backoff retry on NetworkError at startup
- Correct stop() ordering: signal watchdog before cancelling tasks
Email / Gmail:
- send_email: supports file attachments (attachments list param)
- get_email: surfaces attachment metadata in response
Scheduled tasks / weather:
- Remove OpenWeatherMap API calls from morning-weather task; use wttr.in exclusively
- New scheduled tasks and scheduler state persistence
Discord:
- adapters/discord/__init__.py scaffold
- discord-plugin: MCP plugin for Claude Code Discord integration (server.ts, skills, config)
Infrastructure:
- n8n workflow exports (garvis_webhook, content_pipeline variants)
- memory_workspace: context, homelab-repo-updates, weekly observation summaries, error logs
- UCS C240 migration plan doc
- requirements.txt: new deps
- .claude/settings.json, fix_hooks.py: hook/permission tuning
2026-04-23 07:54:01 -06:00
|
|
|
attachments=attachments,
|
2026-02-14 10:29:28 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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 = []
|
2026-02-24 12:32:05 -07:00
|
|
|
|
|
|
|
|
def extract_attachments(parts):
|
|
|
|
|
"""Recursively extract attachments from message parts."""
|
|
|
|
|
for part in parts:
|
|
|
|
|
filename = part.get("filename")
|
|
|
|
|
if filename:
|
|
|
|
|
body = part.get("body", {})
|
|
|
|
|
attachment_id = body.get("attachmentId")
|
|
|
|
|
if attachment_id:
|
|
|
|
|
attachments.append({
|
|
|
|
|
"filename": filename,
|
|
|
|
|
"attachment_id": attachment_id,
|
|
|
|
|
"mime_type": part.get("mimeType"),
|
|
|
|
|
"size": body.get("size", 0),
|
|
|
|
|
})
|
|
|
|
|
# Recursively check nested parts
|
|
|
|
|
if "parts" in part:
|
|
|
|
|
extract_attachments(part["parts"])
|
|
|
|
|
|
|
|
|
|
if "parts" in payload:
|
|
|
|
|
extract_attachments(payload["parts"])
|
2026-02-14 10:29:28 -07:00
|
|
|
|
|
|
|
|
email_data["attachments"] = attachments
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"email": email_data,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except HttpError as e:
|
|
|
|
|
return {"success": False, "error": str(e)}
|
2026-02-24 12:32:05 -07:00
|
|
|
|
|
|
|
|
def download_attachment(
|
|
|
|
|
self,
|
|
|
|
|
message_id: str,
|
|
|
|
|
attachment_id: str,
|
|
|
|
|
filename: str,
|
|
|
|
|
output_dir: str = "downloads",
|
|
|
|
|
) -> Dict:
|
|
|
|
|
"""Download an email attachment.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
message_id: Gmail message ID
|
|
|
|
|
attachment_id: Attachment ID from the message
|
|
|
|
|
filename: Original filename of the attachment
|
|
|
|
|
output_dir: Directory to save the attachment (default: "downloads")
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
Dict with success status and file path 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:
|
|
|
|
|
# Get the attachment data
|
|
|
|
|
attachment = (
|
|
|
|
|
self.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"])
|
|
|
|
|
|
|
|
|
|
# Create output directory if it doesn't exist
|
|
|
|
|
output_path = Path(output_dir)
|
|
|
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
# Save the file
|
|
|
|
|
file_path = output_path / filename
|
|
|
|
|
with open(file_path, "wb") as f:
|
|
|
|
|
f.write(file_data)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"success": True,
|
|
|
|
|
"file_path": str(file_path),
|
|
|
|
|
"filename": filename,
|
|
|
|
|
"size": len(file_data),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except HttpError as e:
|
|
|
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return {"success": False, "error": f"Failed to save attachment: {str(e)}"}
|