""" 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"
{body.replace(chr(10), '
')}