New router intelligence modules (26 files): alert_ingest/store, audit_store, architecture_pressure, backlog_generator/store, cost_analyzer, data_governance, dependency_scanner, drift_analyzer, incident_* (5 files), llm_enrichment, platform_priority_digest, provider_budget, release_check_runner, risk_* (6 files), signature_state_store, sofiia_auto_router, tool_governance New services: - sofiia-console: Dockerfile, adapters/, monitor/nodes/ops/voice modules, launchd, react static - memory-service: integration_endpoints, integrations, voice_endpoints, static UI - aurora-service: full app suite (analysis, job_store, orchestrator, reporting, schemas, subagents) - sofiia-supervisor: new supervisor service - aistalk-bridge-lite: Telegram bridge lite - calendar-service: CalDAV calendar service with reminders - mlx-stt-service / mlx-tts-service: Apple Silicon speech services - binance-bot-monitor: market monitor service - node-worker: STT/TTS memory providers New tools (9): agent_email, browser_tool, contract_tool, observability_tool, oncall_tool, pr_reviewer_tool, repo_tool, safe_code_executor, secure_vault New crews: agromatrix_crew (10 modules: depth_classifier, doc_facts, doc_focus, farm_state, light_reply, llm_factory, memory_manager, proactivity, reflection_engine, session_context, style_adapter, telemetry) Tests: 85+ test files for all new modules Made-with: Cursor
963 lines
32 KiB
Python
963 lines
32 KiB
Python
"""
|
|
AgentEmailTool - Email tool for AI agents
|
|
Uses AgentMail.to as primary SDK, with fallback to smtplib/imap-tools
|
|
|
|
This tool provides email capabilities for DAARION agents:
|
|
- Create/manage email inboxes
|
|
- Send/receive emails
|
|
- Email authentication (OTP, magic links)
|
|
- Analyze email content
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import json
|
|
import asyncio
|
|
import logging
|
|
import tempfile
|
|
import hashlib
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime
|
|
from dataclasses import dataclass, field
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.base import MIMEBase
|
|
from email import encoders
|
|
import aiofiles
|
|
|
|
# Security
|
|
from cryptography.fernet import Fernet
|
|
import base64
|
|
|
|
# Rate limiting
|
|
from collections import defaultdict
|
|
from threading import Lock
|
|
|
|
# AgentMail SDK
|
|
try:
|
|
from agentmail import AgentMail, AsyncAgentMail
|
|
AGENTMAIL_AVAILABLE = True
|
|
except ImportError:
|
|
AGENTMAIL_AVAILABLE = False
|
|
|
|
# Fallback
|
|
try:
|
|
import smtplib
|
|
import imaplib
|
|
from email.parser import Parser
|
|
FALLBACK_AVAILABLE = True
|
|
except ImportError:
|
|
FALLBACK_AVAILABLE = False
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# CONFIGURATION & RATE LIMITING
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class EmailConfig:
|
|
"""Email tool configuration"""
|
|
agentmail_api_key: Optional[str] = None
|
|
|
|
# Fallback SMTP/IMAP settings
|
|
smtp_host: str = "smtp.gmail.com"
|
|
smtp_port: int = 587
|
|
imap_host: str = "imap.gmail.com"
|
|
imap_port: int = 993
|
|
|
|
# Security
|
|
encryption_key: Optional[bytes] = None
|
|
credentials_dir: str = "/tmp/agent_email_creds"
|
|
temp_dir: str = "/tmp/agent_email_attachments"
|
|
|
|
# Rate limiting
|
|
rate_limit_per_minute: int = 8
|
|
|
|
# PII protection
|
|
redact_pii_in_logs: bool = True
|
|
|
|
|
|
class RateLimiter:
|
|
"""Simple rate limiter for email operations"""
|
|
|
|
def __init__(self, max_per_minute: int = 8):
|
|
self.max_per_minute = max_per_minute
|
|
self.requests: Dict[str, List[float]] = defaultdict(list)
|
|
self.lock = Lock()
|
|
|
|
def is_allowed(self, agent_id: str) -> bool:
|
|
"""Check if request is allowed for agent"""
|
|
with self.lock:
|
|
now = time.time()
|
|
# Clean old requests
|
|
self.requests[agent_id] = [
|
|
t for t in self.requests[agent_id]
|
|
if now - t < 60
|
|
]
|
|
# Check limit
|
|
if len(self.requests[agent_id]) >= self.max_per_minute:
|
|
return False
|
|
# Add request
|
|
self.requests[agent_id].append(now)
|
|
return True
|
|
|
|
def wait_time(self, agent_id: str) -> float:
|
|
"""Get seconds to wait before next request"""
|
|
with self.lock:
|
|
if agent_id not in self.requests or not self.requests[agent_id]:
|
|
return 0
|
|
oldest = min(self.requests[agent_id])
|
|
return max(0, 60 - (time.time() - oldest))
|
|
|
|
|
|
# Global rate limiter
|
|
rate_limiter = RateLimiter()
|
|
|
|
|
|
# ============================================================================
|
|
# CREDENTIALS STORAGE (ENCRYPTED)
|
|
# ============================================================================
|
|
|
|
class SecureCredentialsStore:
|
|
"""Encrypted credentials storage using Fernet"""
|
|
|
|
def __init__(self, config: EmailConfig):
|
|
self.config = config
|
|
self.credentials_dir = Path(config.credentials_dir)
|
|
self.credentials_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Initialize encryption
|
|
if config.encryption_key:
|
|
self.fernet = Fernet(config.encryption_key)
|
|
else:
|
|
# Generate key if not provided
|
|
key = Fernet.generate_key()
|
|
self.fernet = Fernet(key)
|
|
# In production, store this key securely (e.g., in Second Me)
|
|
logger.warning("No encryption key provided, generated temporary key")
|
|
|
|
def _get_file_path(self, agent_id: str) -> Path:
|
|
"""Get credentials file path for agent"""
|
|
# Hash agent_id for filename
|
|
agent_hash = hashlib.sha256(agent_id.encode()).hexdigest()[:16]
|
|
return self.credentials_dir / f"creds_{agent_hash}.enc"
|
|
|
|
def save(self, agent_id: str, credentials: Dict[str, Any]) -> None:
|
|
"""Save encrypted credentials"""
|
|
file_path = self._get_file_path(agent_id)
|
|
data = json.dumps(credentials).encode()
|
|
encrypted = self.fernet.encrypt(data)
|
|
file_path.write_bytes(encrypted)
|
|
logger.info(f"Credentials saved for agent: {self._redact_agent(agent_id)}")
|
|
|
|
def load(self, agent_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Load decrypted credentials"""
|
|
file_path = self._get_file_path(agent_id)
|
|
if not file_path.exists():
|
|
return None
|
|
try:
|
|
encrypted = file_path.read_bytes()
|
|
decrypted = self.fernet.decrypt(encrypted)
|
|
return json.loads(decrypted)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load credentials: {e}")
|
|
return None
|
|
|
|
def delete(self, agent_id: str) -> None:
|
|
"""Delete credentials"""
|
|
file_path = self._get_file_path(agent_id)
|
|
if file_path.exists():
|
|
file_path.unlink()
|
|
logger.info(f"Credentials deleted for agent: {self._redact_agent(agent_id)}")
|
|
|
|
def _redact_agent(self, agent_id: str) -> str:
|
|
"""Redact agent_id for logging"""
|
|
if len(agent_id) > 8:
|
|
return f"{agent_id[:4]}...{agent_id[-4:]}"
|
|
return "****"
|
|
|
|
|
|
# ============================================================================
|
|
# PII & SECURITY HELPERS
|
|
# ============================================================================
|
|
|
|
class PIIGuard:
|
|
"""PII protection for logging and auditing"""
|
|
|
|
# Patterns to redact
|
|
PATTERNS = [
|
|
(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', '[EMAIL]'),
|
|
(r'\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b', '[CARD]'),
|
|
(r'\b\d{6,10}\b', '[OTP]'),
|
|
(r'(?i)(password|passwd|pwd)[=:\s]+\S+', '[PASSWORD]'),
|
|
(r'(?i)(token|api[_-]?key|secret)[=:\s]+\S+', '[TOKEN]'),
|
|
]
|
|
|
|
@classmethod
|
|
def redact(cls, text: str) -> str:
|
|
"""Redact PII from text"""
|
|
if not text:
|
|
return text
|
|
result = text
|
|
for pattern, replacement in cls.PATTERNS:
|
|
result = re.sub(pattern, replacement, result)
|
|
return result
|
|
|
|
@classmethod
|
|
def redact_dict(cls, data: Dict) -> Dict:
|
|
"""Redact PII from dictionary"""
|
|
return {k: cls.redact(str(v)) for k, v in data.items()}
|
|
|
|
|
|
class AuditLogger:
|
|
"""Audit log for email operations (no PII)"""
|
|
|
|
def __init__(self, log_dir: str = "/tmp/agent_email_logs"):
|
|
self.log_dir = Path(log_dir)
|
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def log(self, agent_id: str, operation: str, details: Dict[str, Any]) -> None:
|
|
"""Log operation without PII"""
|
|
safe_details = PIIGuard.redact_dict(details)
|
|
log_entry = {
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
"agent_id": hashlib.sha256(agent_id.encode()).hexdigest()[:16],
|
|
"operation": operation,
|
|
"details": safe_details
|
|
}
|
|
|
|
log_file = self.log_dir / f"audit_{datetime.utcnow().strftime('%Y%m%d')}.jsonl"
|
|
with open(log_file, "a") as f:
|
|
f.write(json.dumps(log_entry) + "\n")
|
|
|
|
|
|
# ============================================================================
|
|
# MAIN EMAIL TOOL CLASS
|
|
# ============================================================================
|
|
|
|
class AgentEmailTool:
|
|
"""
|
|
Email tool for AI agents.
|
|
|
|
Provides email inbox management, send/receive, and authentication automation.
|
|
|
|
Usage:
|
|
tool = AgentEmailTool(agent_id="sofiia")
|
|
inbox = tool.create_inbox()
|
|
tool.send(to=["user@example.com"], subject="Hello", body="Test")
|
|
emails = tool.receive(unread_only=True)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
agent_id: str,
|
|
config: Optional[EmailConfig] = None,
|
|
api_key: Optional[str] = None
|
|
):
|
|
"""
|
|
Initialize email tool for agent.
|
|
|
|
Args:
|
|
agent_id: Unique identifier for the agent
|
|
config: Email configuration (uses env vars if not provided)
|
|
api_key: AgentMail API key (overrides config)
|
|
"""
|
|
self.agent_id = agent_id
|
|
self.config = config or self._load_config()
|
|
self.api_key = api_key or self.config.agentmail_api_key or os.getenv("AGENTMAIL_API_KEY")
|
|
|
|
# Initialize storage and logging
|
|
self.creds_store = SecureCredentialsStore(self.config)
|
|
self.audit = AuditLogger()
|
|
|
|
# Client (initialized lazily)
|
|
self._client = None
|
|
self._async_client = None
|
|
|
|
# Temp directory for attachments
|
|
self.temp_dir = Path(self.config.temp_dir)
|
|
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _load_config(self) -> EmailConfig:
|
|
"""Load config from environment"""
|
|
return EmailConfig(
|
|
agentmail_api_key=os.getenv("AGENTMAIL_API_KEY"),
|
|
encryption_key=os.getenv("EMAIL_ENCRYPTION_KEY", "").encode() or None,
|
|
credentials_dir=os.getenv("EMAIL_CREDS_DIR", "/tmp/agent_email_creds"),
|
|
rate_limit_per_minute=int(os.getenv("EMAIL_RATE_LIMIT", "8")),
|
|
)
|
|
|
|
@property
|
|
def client(self):
|
|
"""Lazy-load AgentMail client"""
|
|
if not self._client and AGENTMAIL_AVAILABLE and self.api_key:
|
|
self._client = AgentMail(api_key=self.api_key)
|
|
return self._client
|
|
|
|
@property
|
|
def async_client(self):
|
|
"""Lazy-load async AgentMail client"""
|
|
if not self._async_client and AGENTMAIL_AVAILABLE and self.api_key:
|
|
self._async_client = AsyncAgentMail(api_key=self.api_key)
|
|
return self._async_client
|
|
|
|
# ========================================================================
|
|
# RATE LIMITING
|
|
# ========================================================================
|
|
|
|
def _check_rate_limit(self) -> None:
|
|
"""Check rate limit, raise if exceeded"""
|
|
if not rate_limiter.is_allowed(self.agent_id):
|
|
wait = rate_limiter.wait_time(self.agent_id)
|
|
raise RateLimitError(f"Rate limit exceeded. Wait {wait:.1f}s")
|
|
|
|
# ========================================================================
|
|
# INBOX MANAGEMENT
|
|
# ========================================================================
|
|
|
|
def create_inbox(
|
|
self,
|
|
username: Optional[str] = None,
|
|
domain: Optional[str] = None,
|
|
display_name: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Create a new email inbox for the agent.
|
|
|
|
Args:
|
|
username: Optional username (defaults to agent_id) - IGNORED in AgentMail API
|
|
domain: Optional domain - IGNORED in AgentMail API
|
|
display_name: Optional display name
|
|
|
|
Returns:
|
|
Dict with inbox details: inbox_id, email_address, api_key, etc.
|
|
|
|
Raises:
|
|
RateLimitError: If rate limit exceeded
|
|
EmailError: If creation fails
|
|
"""
|
|
self._check_rate_limit()
|
|
|
|
try:
|
|
if self.client:
|
|
inbox = self.client.inboxes.create()
|
|
|
|
result = {
|
|
"inbox_id": inbox.inbox_id,
|
|
"email_address": inbox.inbox_id,
|
|
"display_name": display_name or inbox.display_name,
|
|
"created_at": datetime.utcnow().isoformat(),
|
|
"provider": "agentmail"
|
|
}
|
|
|
|
# Store credentials
|
|
self.creds_store.save(self.agent_id, {
|
|
"inbox_id": result["inbox_id"],
|
|
"email": result["email_address"],
|
|
"provider": "agentmail",
|
|
"created_at": result["created_at"]
|
|
})
|
|
|
|
self.audit.log(self.agent_id, "create_inbox", result)
|
|
logger.info(f"Inbox created: {result['email_address']}")
|
|
return result
|
|
else:
|
|
raise EmailError("AgentMail not available and fallback not implemented")
|
|
|
|
except Exception as e:
|
|
self.audit.log(self.agent_id, "create_inbox_error", {"error": str(e)})
|
|
raise EmailError(f"Failed to create inbox: {e}")
|
|
|
|
def list_inboxes(self) -> List[Dict[str, Any]]:
|
|
"""List all inboxes for this agent"""
|
|
self._check_rate_limit()
|
|
|
|
creds = self.creds_store.load(self.agent_id)
|
|
if not creds:
|
|
return []
|
|
|
|
try:
|
|
if self.client:
|
|
response = self.client.inboxes.list()
|
|
return [
|
|
{
|
|
"inbox_id": ib.inbox_id,
|
|
"email": ib.inbox_id,
|
|
}
|
|
for ib in response.inboxes
|
|
]
|
|
except Exception as e:
|
|
logger.error(f"Failed to list inboxes: {e}")
|
|
return []
|
|
|
|
def delete_inbox(self, inbox_id: Optional[str] = None) -> Dict[str, Any]:
|
|
"""
|
|
Delete an inbox.
|
|
|
|
Args:
|
|
inbox_id: Specific inbox to delete (uses stored if not provided)
|
|
|
|
Returns:
|
|
Deletion confirmation
|
|
"""
|
|
self._check_rate_limit()
|
|
|
|
inbox_id = inbox_id or (self.creds_store.load(self.agent_id) or {}).get("inbox_id")
|
|
if not inbox_id:
|
|
raise EmailError("No inbox_id provided and no stored inbox found")
|
|
|
|
try:
|
|
if self.client:
|
|
self.client.inboxes.delete(inbox_id=inbox_id)
|
|
self.creds_store.delete(self.agent_id)
|
|
self.audit.log(self.agent_id, "delete_inbox", {"inbox_id": inbox_id})
|
|
return {"status": "deleted", "inbox_id": inbox_id}
|
|
except Exception as e:
|
|
raise EmailError(f"Failed to delete inbox: {e}")
|
|
|
|
# ========================================================================
|
|
# SENDING EMAILS
|
|
# ========================================================================
|
|
|
|
def send(
|
|
self,
|
|
to: List[str],
|
|
subject: str,
|
|
body: str,
|
|
html: Optional[str] = None,
|
|
attachments: Optional[List[str]] = None,
|
|
cc: Optional[List[str]] = None,
|
|
bcc: Optional[List[str]] = None,
|
|
inbox_id: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Send an email.
|
|
|
|
Args:
|
|
to: Recipient email addresses
|
|
subject: Email subject
|
|
body: Plain text body
|
|
html: HTML body (optional)
|
|
attachments: List of file paths to attach
|
|
cc: CC recipients
|
|
bcc: BCC recipients
|
|
inbox_id: Specific inbox to send from (uses stored if not provided)
|
|
|
|
Returns:
|
|
Dict with send confirmation: message_id, sent_at, etc.
|
|
|
|
Raises:
|
|
RateLimitError: If rate limit exceeded
|
|
EmailError: If send fails
|
|
"""
|
|
self._check_rate_limit()
|
|
|
|
# Get inbox_id
|
|
inbox_id = inbox_id or (self.creds_store.load(self.agent_id) or {}).get("inbox_id")
|
|
if not inbox_id:
|
|
raise EmailError("No inbox_id provided and no stored inbox found")
|
|
|
|
# Process attachments securely
|
|
attachment_paths = []
|
|
if attachments:
|
|
for path in attachments:
|
|
p = Path(path)
|
|
if p.exists() and p.is_file():
|
|
# Copy to temp dir (security)
|
|
temp_path = self.temp_dir / f"{time.time()}_{p.name}"
|
|
import shutil
|
|
shutil.copy2(p, temp_path)
|
|
attachment_paths.append(str(temp_path))
|
|
|
|
try:
|
|
if self.client:
|
|
result = self.client.inboxes.messages.send(
|
|
inbox_id=inbox_id,
|
|
to=to,
|
|
subject=subject,
|
|
text=body,
|
|
html=html or f"<p>{body.replace(chr(10), '<br>')}</p>",
|
|
attachments=attachment_paths or None,
|
|
cc=cc,
|
|
bcc=bcc
|
|
)
|
|
|
|
response = {
|
|
"status": "sent",
|
|
"message_id": result.message_id if hasattr(result, 'message_id') else str(result),
|
|
"sent_at": datetime.utcnow().isoformat(),
|
|
"to": to,
|
|
"subject": PIIGuard.redact(subject)
|
|
}
|
|
|
|
self.audit.log(self.agent_id, "send_email", response)
|
|
logger.info(f"Email sent: {subject}")
|
|
|
|
# Cleanup temp attachments
|
|
for path in attachment_paths:
|
|
try:
|
|
Path(path).unlink()
|
|
except:
|
|
pass
|
|
|
|
return response
|
|
else:
|
|
# Fallback to smtplib
|
|
return self._send_fallback(
|
|
to, subject, body, html, attachments, inbox_id
|
|
)
|
|
|
|
except Exception as e:
|
|
self.audit.log(self.agent_id, "send_error", {"error": str(e)})
|
|
raise EmailError(f"Failed to send email: {e}")
|
|
|
|
def _send_fallback(
|
|
self,
|
|
to: List[str],
|
|
subject: str,
|
|
body: str,
|
|
html: Optional[str],
|
|
attachments: Optional[List[str]],
|
|
inbox_id: str
|
|
) -> Dict[str, Any]:
|
|
"""Fallback send using smtplib"""
|
|
if not FALLBACK_AVAILABLE:
|
|
raise EmailError("No email provider available")
|
|
|
|
creds = self.creds_store.load(self.agent_id)
|
|
if not creds or not creds.get("smtp"):
|
|
raise EmailError("No fallback credentials configured")
|
|
|
|
smtp_creds = creds["smtp"]
|
|
|
|
msg = MIMEMultipart('alternative')
|
|
msg['Subject'] = subject
|
|
msg['From'] = creds.get("email", inbox_id)
|
|
msg['To'] = ", ".join(to)
|
|
|
|
msg.attach(MIMEText(body, 'plain'))
|
|
if html:
|
|
msg.attach(MIMEText(html, 'html'))
|
|
|
|
# Attachments
|
|
for filepath in attachments or []:
|
|
with open(filepath, 'rb') as f:
|
|
part = MIMEBase('application', 'octet-stream')
|
|
part.set_payload(f.read())
|
|
encoders.encode_base64(part)
|
|
part.add_header('Content-Disposition', f'attachment; filename={Path(filepath).name}')
|
|
msg.attach(part)
|
|
|
|
with smtplib.SMTP(smtp_creds["host"], smtp_creds["port"]) as server:
|
|
server.starttls()
|
|
server.login(smtp_creds["user"], smtp_creds["pass"])
|
|
server.send_message(msg)
|
|
|
|
return {"status": "sent", "provider": "fallback"}
|
|
|
|
# ========================================================================
|
|
# RECEIVING EMAILS
|
|
# ========================================================================
|
|
|
|
def receive(
|
|
self,
|
|
unread_only: bool = True,
|
|
limit: int = 20,
|
|
query: Optional[str] = None,
|
|
inbox_id: Optional[str] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Receive emails.
|
|
|
|
Args:
|
|
unread_only: Only get unread messages
|
|
limit: Maximum number of messages
|
|
query: Search query
|
|
inbox_id: Specific inbox (uses stored if not provided)
|
|
|
|
Returns:
|
|
List of email dicts with keys: id, from, to, subject, body, date, attachments, raw_html
|
|
"""
|
|
self._check_rate_limit()
|
|
|
|
inbox_id = inbox_id or (self.creds_store.load(self.agent_id) or {}).get("inbox_id")
|
|
if not inbox_id:
|
|
raise EmailError("No inbox_id provided")
|
|
|
|
try:
|
|
if self.client:
|
|
labels = ["unread"] if unread_only else None
|
|
response = self.client.inboxes.messages.list(
|
|
inbox_id=inbox_id,
|
|
limit=limit,
|
|
labels=labels
|
|
)
|
|
|
|
results = []
|
|
for msg in response.messages:
|
|
results.append({
|
|
"id": msg.message_id,
|
|
"from": msg.from_,
|
|
"to": msg.to,
|
|
"subject": msg.subject,
|
|
"body": msg.preview,
|
|
"html": getattr(msg, "html_content", ""),
|
|
"date": str(msg.timestamp),
|
|
"attachments": msg.attachments or [],
|
|
"labels": msg.labels or [],
|
|
"raw_html": ""
|
|
})
|
|
|
|
self.audit.log(self.agent_id, "receive_emails", {
|
|
"count": len(results),
|
|
"query": query
|
|
})
|
|
|
|
return results
|
|
else:
|
|
return self._receive_fallback(inbox_id, unread_only, limit)
|
|
|
|
except Exception as e:
|
|
self.audit.log(self.agent_id, "receive_error", {"error": str(e)})
|
|
raise EmailError(f"Failed to receive emails: {e}")
|
|
|
|
def _receive_fallback(
|
|
self,
|
|
inbox_id: str,
|
|
unread_only: bool,
|
|
limit: int
|
|
) -> List[Dict[str, Any]]:
|
|
"""Fallback receive using imaplib"""
|
|
creds = self.creds_store.load(self.agent_id)
|
|
if not creds or not creds.get("imap"):
|
|
raise EmailError("No fallback credentials")
|
|
|
|
imap_creds = creds["imap"]
|
|
|
|
with imaplib.IMAP4_SSL(imap_creds["host"], imap_creds["port"]) as mail:
|
|
mail.login(imap_creds["user"], imap_creds["pass"])
|
|
mail.select('INBOX')
|
|
|
|
status, data = mail.search(None, 'UNSEEN' if unread_only else 'ALL')
|
|
email_ids = data[0].split()[-limit:]
|
|
|
|
results = []
|
|
for eid in email_ids:
|
|
status, msg_data = mail.fetch(eid, '(RFC822)')
|
|
email_message = Parser().parsestr(msg_data[0][1])
|
|
|
|
results.append({
|
|
"id": email_message['Message-ID'],
|
|
"from": email_message['From'],
|
|
"to": email_message['To'],
|
|
"subject": email_message['Subject'],
|
|
"body": email_message.get_payload(),
|
|
"date": email_message['Date'],
|
|
"attachments": [],
|
|
"raw_html": ""
|
|
})
|
|
|
|
return results
|
|
|
|
# ========================================================================
|
|
# FORWARDING
|
|
# ========================================================================
|
|
|
|
def forward(
|
|
self,
|
|
email_id: str,
|
|
to: List[str],
|
|
inbox_id: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Forward an email to new recipients."""
|
|
self._check_rate_limit()
|
|
|
|
# Get original email
|
|
emails = self.receive(limit=100, inbox_id=inbox_id)
|
|
original = next((e for e in emails if e["id"] == email_id), None)
|
|
|
|
if not original:
|
|
raise EmailError(f"Email {email_id} not found")
|
|
|
|
# Forward with original content
|
|
forwarded_body = f"""
|
|
---------- Forwarded message ----------
|
|
From: {original['from']}
|
|
Subject: {original['subject']}
|
|
Date: {original['date']}
|
|
|
|
{original['body']}
|
|
"""
|
|
|
|
return self.send(
|
|
to=to,
|
|
subject=f"Fwd: {original['subject']}",
|
|
body=forwarded_body,
|
|
inbox_id=inbox_id
|
|
)
|
|
|
|
# ========================================================================
|
|
# EMAIL ANALYSIS
|
|
# ========================================================================
|
|
|
|
def analyze_and_extract(self, email_dict: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Analyze email and extract structured information.
|
|
|
|
Extracts:
|
|
- summary: Brief summary of email
|
|
- action_items: Any action items mentioned
|
|
- credentials: OTP, tokens, passwords found
|
|
- auth_urls: URLs for authentication
|
|
- files: Any file references
|
|
|
|
Args:
|
|
email_dict: Email dict from receive()
|
|
|
|
Returns:
|
|
Dict with extracted information
|
|
"""
|
|
body = email_dict.get("body", "")
|
|
html = email_dict.get("html", "")
|
|
subject = email_dict.get("subject", "")
|
|
|
|
result = {
|
|
"summary": self._extract_summary(subject, body),
|
|
"action_items": self._extract_action_items(body),
|
|
"credentials": self._extract_credentials(body),
|
|
"auth_urls": self._extract_auth_urls(body + html),
|
|
"files": self._extract_files(body + html),
|
|
"sentiment": self._analyze_sentiment(body)
|
|
}
|
|
|
|
self.audit.log(self.agent_id, "analyze_email", {
|
|
"email_id": email_dict.get("id", "unknown")
|
|
})
|
|
|
|
return result
|
|
|
|
def _extract_summary(self, subject: str, body: str) -> str:
|
|
"""Extract brief summary"""
|
|
lines = body.split('\n')
|
|
preview = ' '.join(lines[:3])[:200]
|
|
return f"Re: {subject} - {preview}..."
|
|
|
|
def _extract_action_items(self, body: str) -> List[str]:
|
|
"""Extract action items"""
|
|
action_patterns = [
|
|
r'(?i)(?:please|kindly|could you|would you)\s+(.+?)(?:\.|$)',
|
|
r'(?i)(?:action required|todo|to-do|deadline)\s*:?\s*(.+?)(?:\.|$)',
|
|
]
|
|
|
|
items = []
|
|
for pattern in action_patterns:
|
|
items.extend(re.findall(pattern, body))
|
|
return items[:5]
|
|
|
|
def _extract_credentials(self, body: str) -> List[Dict[str, str]]:
|
|
"""Extract credentials/OTP/tokens from email"""
|
|
credentials = []
|
|
|
|
# OTP patterns
|
|
otp_patterns = [
|
|
r'(?i)(?:code|otp|verification code|one-time)\s*:?\s*(\d{4,8})',
|
|
r'(?i)(\d{4,6})\s*(?:is your|is the|code)',
|
|
]
|
|
|
|
for pattern in otp_patterns:
|
|
matches = re.findall(pattern, body)
|
|
for match in matches:
|
|
credentials.append({
|
|
"type": "otp",
|
|
"value": match,
|
|
"redacted": f"{match[:2]}***{match[-2:]}"
|
|
})
|
|
|
|
return credentials
|
|
|
|
def _extract_auth_urls(self, text: str) -> List[str]:
|
|
"""Extract authentication URLs"""
|
|
url_pattern = r'https?://[^\s<>"\']+(?:reset|verify|login|auth|confirm|link|click)[^\s<>"\']*'
|
|
urls = re.findall(url_pattern, text, re.IGNORECASE)
|
|
return list(set(urls))[:5]
|
|
|
|
def _extract_files(self, text: str) -> List[str]:
|
|
"""Extract file references"""
|
|
patterns = [
|
|
r'(?i)(?:attached|attachment|file):\s*([^\s]+)',
|
|
r'(?i)\.pdf[^\s]*\.pdf',
|
|
r'(?i)\.doc[^\s]*\.doc',
|
|
r'(?i)\.xlsx?[^\s]*\.xls',
|
|
]
|
|
|
|
files = []
|
|
for pattern in patterns:
|
|
files.extend(re.findall(pattern, text))
|
|
return list(set(files))[:5]
|
|
|
|
def _analyze_sentiment(self, body: str) -> str:
|
|
"""Simple sentiment analysis"""
|
|
positive = ['thank', 'great', 'excellent', 'appreciate', 'welcome']
|
|
negative = ['sorry', 'issue', 'problem', 'error', 'failed', 'urgent']
|
|
|
|
body_lower = body.lower()
|
|
|
|
pos_count = sum(1 for w in positive if w in body_lower)
|
|
neg_count = sum(1 for w in negative if w in body_lower)
|
|
|
|
if pos_count > neg_count:
|
|
return "positive"
|
|
elif neg_count > pos_count:
|
|
return "negative"
|
|
return "neutral"
|
|
|
|
# ========================================================================
|
|
# AUTHENTICATION AUTOMATION
|
|
# ========================================================================
|
|
|
|
async def use_for_auth(
|
|
self,
|
|
service_url: str,
|
|
email_data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Use email for authentication (OTP, magic links).
|
|
|
|
Attempts to:
|
|
1. Extract OTP/token from email
|
|
2. Navigate to service and input OTP
|
|
3. Or click magic link
|
|
|
|
Args:
|
|
service_url: URL of service to authenticate with
|
|
email_data: Email dict containing OTP/link
|
|
|
|
Returns:
|
|
Dict with auth result: success, method, details
|
|
"""
|
|
analysis = self.analyze_and_extract(email_data)
|
|
|
|
result = {
|
|
"service_url": service_url,
|
|
"method": None,
|
|
"success": False,
|
|
"details": {}
|
|
}
|
|
|
|
# Try magic links first
|
|
if analysis["auth_urls"]:
|
|
# In production, would use browser automation
|
|
result["method"] = "magic_link"
|
|
result["details"]["urls_found"] = analysis["auth_urls"]
|
|
result["success"] = True # Would click link
|
|
|
|
# Try OTP
|
|
elif analysis["credentials"]:
|
|
otp = analysis["credentials"][0]["value"]
|
|
result["method"] = "otp"
|
|
result["details"]["otp"] = analysis["credentials"][0]["redacted"]
|
|
result["success"] = True # Would input OTP
|
|
|
|
self.audit.log(self.agent_id, "auth_attempt", result)
|
|
return result
|
|
|
|
# ========================================================================
|
|
# ASYNC OPERATIONS
|
|
# ========================================================================
|
|
|
|
async def create_inbox_async(
|
|
self,
|
|
username: Optional[str] = None,
|
|
domain: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Async version of create_inbox"""
|
|
if not self.async_client:
|
|
return self.create_inbox(username, domain)
|
|
|
|
self._check_rate_limit()
|
|
username = username or self.agent_id.lower().replace("_", "-")
|
|
|
|
inbox = await self.async_client.inboxes.create(
|
|
username=username,
|
|
domain=domain
|
|
)
|
|
|
|
return {
|
|
"inbox_id": str(inbox),
|
|
"email_address": f"{username}@{domain or 'agentmail.to'}",
|
|
"provider": "agentmail"
|
|
}
|
|
|
|
async def receive_async(
|
|
self,
|
|
unread_only: bool = True,
|
|
limit: int = 20,
|
|
inbox_id: Optional[str] = None
|
|
) -> List[Dict[str, Any]]:
|
|
"""Async version of receive"""
|
|
if not self.async_client:
|
|
return self.receive(unread_only, limit, inbox_id=inbox_id)
|
|
|
|
self._check_rate_limit()
|
|
inbox_id = inbox_id or (self.creds_store.load(self.agent_id) or {}).get("inbox_id")
|
|
|
|
messages = await self.async_client.inboxes.messages.list(
|
|
inbox_id=inbox_id,
|
|
limit=limit,
|
|
unread_only=unread_only
|
|
)
|
|
|
|
return [
|
|
{
|
|
"id": str(m),
|
|
"body": getattr(m, "text_content", "")
|
|
}
|
|
for m in messages
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# ERROR CLASSES
|
|
# ============================================================================
|
|
|
|
class EmailError(Exception):
|
|
"""Base email error"""
|
|
pass
|
|
|
|
class RateLimitError(EmailError):
|
|
"""Rate limit exceeded"""
|
|
pass
|
|
|
|
|
|
# ============================================================================
|
|
# REGISTRATION FOR OCTOTOOLS
|
|
# ============================================================================
|
|
|
|
def register_tools() -> Dict[str, Any]:
|
|
"""
|
|
Register AgentEmailTool with OctoTools.
|
|
|
|
Returns:
|
|
Dict of tool definitions
|
|
"""
|
|
return {
|
|
"agent_email": {
|
|
"class": AgentEmailTool,
|
|
"description": "Email tool for AI agents - send/receive emails, manage inboxes, automation",
|
|
"methods": [
|
|
"create_inbox",
|
|
"list_inboxes",
|
|
"delete_inbox",
|
|
"send",
|
|
"receive",
|
|
"forward",
|
|
"analyze_and_extract",
|
|
"use_for_auth"
|
|
]
|
|
}
|
|
}
|