feat(platform): add new services, tools, tests and crews modules
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
This commit is contained in:
37
tools/agent_email/__init__.py
Normal file
37
tools/agent_email/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
AgentEmailTool - Email tool for AI agents
|
||||
|
||||
Usage:
|
||||
from agent_email import AgentEmailTool
|
||||
|
||||
tool = AgentEmailTool(agent_id="sofiia")
|
||||
inbox = tool.create_inbox()
|
||||
tool.send(to=["user@example.com"], subject="Hello", body="Test")
|
||||
emails = tool.receive()
|
||||
"""
|
||||
|
||||
from .agent_email import (
|
||||
AgentEmailTool,
|
||||
EmailConfig,
|
||||
SecureCredentialsStore,
|
||||
PIIGuard,
|
||||
AuditLogger,
|
||||
RateLimiter,
|
||||
EmailError,
|
||||
RateLimitError,
|
||||
register_tools
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentEmailTool",
|
||||
"EmailConfig",
|
||||
"SecureCredentialsStore",
|
||||
"PIIGuard",
|
||||
"AuditLogger",
|
||||
"RateLimiter",
|
||||
"EmailError",
|
||||
"RateLimitError",
|
||||
"register_tools"
|
||||
]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
962
tools/agent_email/agent_email.py
Normal file
962
tools/agent_email/agent_email.py
Normal file
@@ -0,0 +1,962 @@
|
||||
"""
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
18
tools/agent_email/requirements.txt
Normal file
18
tools/agent_email/requirements.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
# AgentEmailTool - Requirements
|
||||
|
||||
# Primary SDK
|
||||
agentmail>=0.2.0
|
||||
|
||||
# Encryption for credentials
|
||||
cryptography>=41.0.0
|
||||
|
||||
# HTTP client (for async)
|
||||
httpx>=0.25.0
|
||||
|
||||
# Fallback email (optional)
|
||||
# imap-tools>=0.14
|
||||
|
||||
# Development
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.21.0
|
||||
python-dotenv>=1.0.0
|
||||
113
tools/agent_email/tests/test_receive_analyze.py
Normal file
113
tools/agent_email/tests/test_receive_analyze.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Test 2: Receive and Analyze Email
|
||||
|
||||
Demonstrates receiving emails and analyzing them for credentials/auth.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from agent_email import AgentEmailTool
|
||||
|
||||
os.environ["AGENTMAIL_API_KEY"] = "your-api-key-here"
|
||||
|
||||
|
||||
def test_receive_and_analyze():
|
||||
"""Test receiving and analyzing emails"""
|
||||
|
||||
tool = AgentEmailTool(agent_id="sofiia-test")
|
||||
|
||||
# Get unread emails
|
||||
print("Checking for unread emails...")
|
||||
emails = tool.receive(unread_only=True, limit=10)
|
||||
|
||||
print(f"Found {len(emails)} unread emails\n")
|
||||
|
||||
for email in emails:
|
||||
print(f"From: {email.get('from')}")
|
||||
print(f"Subject: {email.get('subject')}")
|
||||
print(f"Date: {email.get('date')}")
|
||||
|
||||
# Analyze email
|
||||
analysis = tool.analyze_and_extract(email)
|
||||
|
||||
print("\n--- Analysis ---")
|
||||
print(f"Summary: {analysis['summary']}")
|
||||
|
||||
if analysis['action_items']:
|
||||
print(f"Action Items: {analysis['action_items']}")
|
||||
|
||||
if analysis['credentials']:
|
||||
print(f"Credentials Found:")
|
||||
for cred in analysis['credentials']:
|
||||
print(f" - {cred['type']}: {cred['redacted']}")
|
||||
|
||||
if analysis['auth_urls']:
|
||||
print(f"Auth URLs: {analysis['auth_urls']}")
|
||||
|
||||
if analysis['files']:
|
||||
print(f"Files: {analysis['files']}")
|
||||
|
||||
print(f"Sentiment: {analysis['sentiment']}")
|
||||
print("\n" + "="*50 + "\n")
|
||||
|
||||
return emails
|
||||
|
||||
|
||||
# Test with sample email data
|
||||
def test_analysis_only():
|
||||
"""Test analysis without actual API calls"""
|
||||
|
||||
tool = AgentEmailTool(agent_id="sofiia-test")
|
||||
|
||||
# Sample OTP email
|
||||
sample_email = {
|
||||
"id": "test-123",
|
||||
"from": "service@example.com",
|
||||
"to": ["sofiia@agentmail.to"],
|
||||
"subject": "Your verification code",
|
||||
"body": """Your verification code is: 123456
|
||||
|
||||
This code will expire in 5 minutes.
|
||||
|
||||
If you didn't request this, please ignore.""",
|
||||
"html": """<p>Your verification code is: <strong>123456</strong></p>""",
|
||||
"date": "2026-02-23T10:00:00Z"
|
||||
}
|
||||
|
||||
analysis = tool.analyze_and_extract(sample_email)
|
||||
|
||||
print("=== OTP Email Analysis ===")
|
||||
print(f"Summary: {analysis['summary']}")
|
||||
print(f"Credentials: {analysis['credentials']}")
|
||||
print(f"Auth URLs: {analysis['auth_urls']}")
|
||||
print(f"Sentiment: {analysis['sentiment']}")
|
||||
|
||||
# Sample magic link email
|
||||
sample_email2 = {
|
||||
"id": "test-456",
|
||||
"from": "github@github.com",
|
||||
"to": ["sofiia@agentmail.to"],
|
||||
"subject": "Sign in to GitHub",
|
||||
"body": """Click the link below to sign in to GitHub:
|
||||
|
||||
https://github.com/session/abc123def
|
||||
|
||||
If you didn't request this, you can ignore this email.""",
|
||||
"date": "2026-02-23T09:00:00Z"
|
||||
}
|
||||
|
||||
analysis2 = tool.analyze_and_extract(sample_email2)
|
||||
|
||||
print("\n=== Magic Link Email Analysis ===")
|
||||
print(f"Summary: {analysis2['summary']}")
|
||||
print(f"Auth URLs: {analysis2['auth_urls']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run analysis test first
|
||||
test_analysis_only()
|
||||
|
||||
# Then try receiving real emails
|
||||
# test_receive_and_analyze()
|
||||
60
tools/agent_email/tests/test_send_email.py
Normal file
60
tools/agent_email/tests/test_send_email.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Test 1: Send Email
|
||||
|
||||
Demonstrates creating an inbox and sending an email.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
from agent_email import AgentEmailTool
|
||||
|
||||
# Set API key (use env var or set directly)
|
||||
os.environ["AGENTMAIL_API_KEY"] = "your-api-key-here"
|
||||
|
||||
def test_send_email():
|
||||
"""Test sending an email"""
|
||||
|
||||
# Initialize tool
|
||||
tool = AgentEmailTool(agent_id="sofiia-test")
|
||||
|
||||
# Create inbox (or use existing)
|
||||
print("Creating inbox...")
|
||||
inbox = tool.create_inbox(
|
||||
username="sofiia-test",
|
||||
display_name="Sofiia Test Agent"
|
||||
)
|
||||
print(f"Inbox created: {inbox['email_address']}")
|
||||
|
||||
# Send email
|
||||
print("\nSending email...")
|
||||
result = tool.send(
|
||||
to=["recipient@example.com"],
|
||||
subject="Test from Sofiia Agent",
|
||||
body="""Hello!
|
||||
|
||||
This is a test email sent from Sofiia Agent via AgentMail.
|
||||
|
||||
Best regards,
|
||||
Sofiia""",
|
||||
html="""<html>
|
||||
<body>
|
||||
<p>Hello!</p>
|
||||
<p>This is a test email sent from <strong>Sofiia Agent</strong> via AgentMail.</p>
|
||||
<p>Best regards,<br>Sofiia</p>
|
||||
</body>
|
||||
</html>"""
|
||||
)
|
||||
|
||||
print(f"Email sent: {result['message_id']}")
|
||||
print(f"Status: {result['status']}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_send_email()
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
Reference in New Issue
Block a user