""" 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), '
')}

", 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" ] } }