diff --git a/.env.example b/.env.example index 3cbc375b..a5153d1d 100644 --- a/.env.example +++ b/.env.example @@ -201,6 +201,18 @@ VOICE_TOOLS_OPENAI_KEY= # WHATSAPP_ENABLED=false # WHATSAPP_ALLOWED_USERS=15551234567 +# Email (IMAP/SMTP — send and receive emails as Hermes) +# For Gmail: enable 2FA → create App Password at https://myaccount.google.com/apppasswords +# EMAIL_ADDRESS=hermes@gmail.com +# EMAIL_PASSWORD=xxxx xxxx xxxx xxxx +# EMAIL_IMAP_HOST=imap.gmail.com +# EMAIL_IMAP_PORT=993 +# EMAIL_SMTP_HOST=smtp.gmail.com +# EMAIL_SMTP_PORT=587 +# EMAIL_POLL_INTERVAL=15 +# EMAIL_ALLOWED_USERS=your@email.com +# EMAIL_HOME_ADDRESS=your@email.com + # Gateway-wide: allow ALL users without an allowlist (default: false = deny) # Only set to true if you intentionally want open access. # GATEWAY_ALLOW_ALL_USERS=false diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index d1370a52..ff964fdc 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -131,6 +131,14 @@ PLATFORM_HINTS = { "files arrive as downloadable documents. You can also include image " "URLs in markdown format ![alt](url) and they will be sent as photos." ), + "email": ( + "You are communicating via email. Write clear, well-structured responses " + "suitable for email. Use plain text formatting (no markdown). " + "Keep responses concise but complete. You can send file attachments — " + "include MEDIA:/absolute/path/to/file in your response. The subject line " + "is preserved for threading. Do not include greetings or sign-offs unless " + "contextually appropriate." + ), "cli": ( "You are a CLI AI Agent. Try not to use markdown but simple text " "renderable inside a terminal." diff --git a/cron/scheduler.py b/cron/scheduler.py index d5967d6a..348a25c2 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -103,6 +103,7 @@ def _deliver_result(job: dict, content: str) -> None: "slack": Platform.SLACK, "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, + "email": Platform.EMAIL, } platform = platform_map.get(platform_name.lower()) if not platform: diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index 858859fd..4d11c3a9 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -61,7 +61,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]: logger.warning("Channel directory: failed to build %s: %s", platform.value, e) # Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history - for plat_name in ("telegram", "whatsapp", "signal"): + for plat_name in ("telegram", "whatsapp", "signal", "email"): if plat_name not in platforms: platforms[plat_name] = _build_from_sessions(plat_name) diff --git a/gateway/config.py b/gateway/config.py index 127e6215..ba0840bf 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -28,6 +28,7 @@ class Platform(Enum): SLACK = "slack" SIGNAL = "signal" HOMEASSISTANT = "homeassistant" + EMAIL = "email" @dataclass @@ -167,6 +168,9 @@ class GatewayConfig: # Signal uses extra dict for config (http_url + account) elif platform == Platform.SIGNAL and config.extra.get("http_url"): connected.append(platform) + # Email uses extra dict for config (address + imap_host + smtp_host) + elif platform == Platform.EMAIL and config.extra.get("address"): + connected.append(platform) return connected def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]: @@ -420,6 +424,28 @@ def _apply_env_overrides(config: GatewayConfig) -> None: if hass_url: config.platforms[Platform.HOMEASSISTANT].extra["url"] = hass_url + # Email + email_addr = os.getenv("EMAIL_ADDRESS") + email_pwd = os.getenv("EMAIL_PASSWORD") + email_imap = os.getenv("EMAIL_IMAP_HOST") + email_smtp = os.getenv("EMAIL_SMTP_HOST") + if all([email_addr, email_pwd, email_imap, email_smtp]): + if Platform.EMAIL not in config.platforms: + config.platforms[Platform.EMAIL] = PlatformConfig() + config.platforms[Platform.EMAIL].enabled = True + config.platforms[Platform.EMAIL].extra.update({ + "address": email_addr, + "imap_host": email_imap, + "smtp_host": email_smtp, + }) + email_home = os.getenv("EMAIL_HOME_ADDRESS") + if email_home: + config.platforms[Platform.EMAIL].home_channel = HomeChannel( + platform=Platform.EMAIL, + chat_id=email_home, + name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"), + ) + # Session settings idle_minutes = os.getenv("SESSION_IDLE_MINUTES") if idle_minutes: diff --git a/gateway/platforms/email.py b/gateway/platforms/email.py new file mode 100644 index 00000000..3b2db3f6 --- /dev/null +++ b/gateway/platforms/email.py @@ -0,0 +1,533 @@ +""" +Email platform adapter for the Hermes gateway. + +Allows users to interact with Hermes by sending emails. +Uses IMAP to receive and SMTP to send messages. + +Environment variables: + EMAIL_IMAP_HOST — IMAP server host (e.g., imap.gmail.com) + EMAIL_IMAP_PORT — IMAP server port (default: 993) + EMAIL_SMTP_HOST — SMTP server host (e.g., smtp.gmail.com) + EMAIL_SMTP_PORT — SMTP server port (default: 587) + EMAIL_ADDRESS — Email address for the agent + EMAIL_PASSWORD — Email password or app-specific password + EMAIL_POLL_INTERVAL — Seconds between mailbox checks (default: 15) + EMAIL_ALLOWED_USERS — Comma-separated list of allowed sender addresses +""" + +import asyncio +import email as email_lib +import imaplib +import logging +import os +import re +import smtplib +import uuid +from datetime import datetime +from email.header import decode_header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from email import encoders +from pathlib import Path +from typing import Any, Dict, List, Optional + +from gateway.platforms.base import ( + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + cache_document_from_bytes, + cache_image_from_bytes, +) +from gateway.config import Platform, PlatformConfig + +logger = logging.getLogger(__name__) + +# Gmail-safe max length per email body +MAX_MESSAGE_LENGTH = 50_000 + +# Supported image extensions for inline detection +_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + + +def check_email_requirements() -> bool: + """Check if email platform dependencies are available.""" + addr = os.getenv("EMAIL_ADDRESS") + pwd = os.getenv("EMAIL_PASSWORD") + imap = os.getenv("EMAIL_IMAP_HOST") + smtp = os.getenv("EMAIL_SMTP_HOST") + if not all([addr, pwd, imap, smtp]): + return False + return True + + +def _decode_header_value(raw: str) -> str: + """Decode an RFC 2047 encoded email header into a plain string.""" + parts = decode_header(raw) + decoded = [] + for part, charset in parts: + if isinstance(part, bytes): + decoded.append(part.decode(charset or "utf-8", errors="replace")) + else: + decoded.append(part) + return " ".join(decoded) + + +def _extract_text_body(msg: email_lib.message.Message) -> str: + """Extract the plain-text body from a potentially multipart email.""" + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + disposition = str(part.get("Content-Disposition", "")) + # Skip attachments + if "attachment" in disposition: + continue + if content_type == "text/plain": + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + # Fallback: try text/html and strip tags + for part in msg.walk(): + content_type = part.get_content_type() + disposition = str(part.get("Content-Disposition", "")) + if "attachment" in disposition: + continue + if content_type == "text/html": + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + html = payload.decode(charset, errors="replace") + return _strip_html(html) + return "" + else: + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or "utf-8" + text = payload.decode(charset, errors="replace") + if msg.get_content_type() == "text/html": + return _strip_html(text) + return text + return "" + + +def _strip_html(html: str) -> str: + """Naive HTML tag stripper for fallback text extraction.""" + text = re.sub(r"", "\n", html, flags=re.IGNORECASE) + text = re.sub(r"]*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"

", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r" ", " ", text) + text = re.sub(r"&", "&", text) + text = re.sub(r"<", "<", text) + text = re.sub(r">", ">", text) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def _extract_email_address(raw: str) -> str: + """Extract bare email address from 'Name ' format.""" + match = re.search(r"<([^>]+)>", raw) + if match: + return match.group(1).strip().lower() + return raw.strip().lower() + + +def _extract_attachments(msg: email_lib.message.Message) -> List[Dict[str, Any]]: + """Extract attachment metadata and cache files locally.""" + attachments = [] + if not msg.is_multipart(): + return attachments + + for part in msg.walk(): + disposition = str(part.get("Content-Disposition", "")) + if "attachment" not in disposition and "inline" not in disposition: + continue + # Skip text/plain and text/html body parts + content_type = part.get_content_type() + if content_type in ("text/plain", "text/html") and "attachment" not in disposition: + continue + + filename = part.get_filename() + if filename: + filename = _decode_header_value(filename) + else: + ext = part.get_content_subtype() or "bin" + filename = f"attachment.{ext}" + + payload = part.get_payload(decode=True) + if not payload: + continue + + ext = Path(filename).suffix.lower() + if ext in _IMAGE_EXTS: + cached_path = cache_image_from_bytes(payload, ext) + attachments.append({ + "path": cached_path, + "filename": filename, + "type": "image", + "media_type": content_type, + }) + else: + cached_path = cache_document_from_bytes(payload, filename) + attachments.append({ + "path": cached_path, + "filename": filename, + "type": "document", + "media_type": content_type, + }) + + return attachments + + +class EmailAdapter(BasePlatformAdapter): + """Email gateway adapter using IMAP (receive) and SMTP (send).""" + + def __init__(self, config: PlatformConfig): + super().__init__(config, Platform.EMAIL) + + self._address = os.getenv("EMAIL_ADDRESS", "") + self._password = os.getenv("EMAIL_PASSWORD", "") + self._imap_host = os.getenv("EMAIL_IMAP_HOST", "") + self._imap_port = int(os.getenv("EMAIL_IMAP_PORT", "993")) + self._smtp_host = os.getenv("EMAIL_SMTP_HOST", "") + self._smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587")) + self._poll_interval = int(os.getenv("EMAIL_POLL_INTERVAL", "15")) + + # Track message IDs we've already processed to avoid duplicates + self._seen_uids: set = set() + self._poll_task: Optional[asyncio.Task] = None + + # Map chat_id (sender email) -> last subject + message-id for threading + self._thread_context: Dict[str, Dict[str, str]] = {} + + logger.info("[Email] Adapter initialized for %s", self._address) + + async def connect(self) -> bool: + """Connect to the IMAP server and start polling for new messages.""" + try: + # Test IMAP connection + imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port) + imap.login(self._address, self._password) + # Mark all existing messages as seen so we only process new ones + imap.select("INBOX") + status, data = imap.search(None, "ALL") + if status == "OK" and data[0]: + for uid in data[0].split(): + self._seen_uids.add(uid) + imap.logout() + logger.info("[Email] IMAP connection test passed. %d existing messages skipped.", len(self._seen_uids)) + except Exception as e: + logger.error("[Email] IMAP connection failed: %s", e) + return False + + try: + # Test SMTP connection + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) + smtp.starttls() + smtp.login(self._address, self._password) + smtp.quit() + logger.info("[Email] SMTP connection test passed.") + except Exception as e: + logger.error("[Email] SMTP connection failed: %s", e) + return False + + self._running = True + self._poll_task = asyncio.create_task(self._poll_loop()) + print(f"[Email] Connected as {self._address}") + return True + + async def disconnect(self) -> None: + """Stop polling and disconnect.""" + self._running = False + if self._poll_task: + self._poll_task.cancel() + try: + await self._poll_task + except asyncio.CancelledError: + pass + self._poll_task = None + logger.info("[Email] Disconnected.") + + async def _poll_loop(self) -> None: + """Poll IMAP for new messages at regular intervals.""" + while self._running: + try: + await self._check_inbox() + except asyncio.CancelledError: + break + except Exception as e: + logger.error("[Email] Poll error: %s", e) + await asyncio.sleep(self._poll_interval) + + async def _check_inbox(self) -> None: + """Check INBOX for unseen messages and dispatch them.""" + # Run IMAP operations in a thread to avoid blocking the event loop + loop = asyncio.get_running_loop() + messages = await loop.run_in_executor(None, self._fetch_new_messages) + for msg_data in messages: + await self._dispatch_message(msg_data) + + def _fetch_new_messages(self) -> List[Dict[str, Any]]: + """Fetch new (unseen) messages from IMAP. Runs in executor thread.""" + results = [] + try: + imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port) + imap.login(self._address, self._password) + imap.select("INBOX") + + status, data = imap.search(None, "UNSEEN") + if status != "OK" or not data[0]: + imap.logout() + return results + + for uid in data[0].split(): + if uid in self._seen_uids: + continue + self._seen_uids.add(uid) + + status, msg_data = imap.fetch(uid, "(RFC822)") + if status != "OK": + continue + + raw_email = msg_data[0][1] + msg = email_lib.message_from_bytes(raw_email) + + sender_raw = msg.get("From", "") + sender_addr = _extract_email_address(sender_raw) + sender_name = _decode_header_value(sender_raw) + # Remove email from name if present + if "<" in sender_name: + sender_name = sender_name.split("<")[0].strip().strip('"') + + subject = _decode_header_value(msg.get("Subject", "(no subject)")) + message_id = msg.get("Message-ID", "") + in_reply_to = msg.get("In-Reply-To", "") + body = _extract_text_body(msg) + attachments = _extract_attachments(msg) + + results.append({ + "uid": uid, + "sender_addr": sender_addr, + "sender_name": sender_name, + "subject": subject, + "message_id": message_id, + "in_reply_to": in_reply_to, + "body": body, + "attachments": attachments, + "date": msg.get("Date", ""), + }) + + imap.logout() + except Exception as e: + logger.error("[Email] IMAP fetch error: %s", e) + return results + + async def _dispatch_message(self, msg_data: Dict[str, Any]) -> None: + """Convert a fetched email into a MessageEvent and dispatch it.""" + sender_addr = msg_data["sender_addr"] + + # Skip self-messages + if sender_addr == self._address.lower(): + return + + subject = msg_data["subject"] + body = msg_data["body"].strip() + attachments = msg_data["attachments"] + + # Build message text: include subject as context + text = body + if subject and not subject.startswith("Re:"): + text = f"[Subject: {subject}]\n\n{body}" + + # Determine message type and media + media_urls = [] + media_types = [] + msg_type = MessageType.TEXT + + for att in attachments: + media_urls.append(att["path"]) + media_types.append(att["media_type"]) + if att["type"] == "image": + msg_type = MessageType.PHOTO + + # Store thread context for reply threading + self._thread_context[sender_addr] = { + "subject": subject, + "message_id": msg_data["message_id"], + } + + source = self.build_source( + chat_id=sender_addr, + chat_name=msg_data["sender_name"] or sender_addr, + chat_type="dm", + user_id=sender_addr, + user_name=msg_data["sender_name"] or sender_addr, + ) + + event = MessageEvent( + text=text or "(empty email)", + message_type=msg_type, + source=source, + message_id=msg_data["message_id"], + media_urls=media_urls, + media_types=media_types, + reply_to_message_id=msg_data["in_reply_to"] or None, + ) + + logger.info("[Email] New message from %s: %s", sender_addr, subject) + await self.handle_message(event) + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send an email reply to the given address.""" + try: + loop = asyncio.get_running_loop() + message_id = await loop.run_in_executor( + None, self._send_email, chat_id, content, reply_to + ) + return SendResult(success=True, message_id=message_id) + except Exception as e: + logger.error("[Email] Send failed to %s: %s", chat_id, e) + return SendResult(success=False, error=str(e)) + + def _send_email( + self, + to_addr: str, + body: str, + reply_to_msg_id: Optional[str] = None, + ) -> str: + """Send an email via SMTP. Runs in executor thread.""" + msg = MIMEMultipart() + msg["From"] = self._address + msg["To"] = to_addr + + # Thread context for reply + ctx = self._thread_context.get(to_addr, {}) + subject = ctx.get("subject", "Hermes Agent") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + msg["Subject"] = subject + + # Threading headers + original_msg_id = reply_to_msg_id or ctx.get("message_id") + if original_msg_id: + msg["In-Reply-To"] = original_msg_id + msg["References"] = original_msg_id + + msg_id = f"" + msg["Message-ID"] = msg_id + + msg.attach(MIMEText(body, "plain", "utf-8")) + + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) + smtp.starttls() + smtp.login(self._address, self._password) + smtp.send_message(msg) + smtp.quit() + + logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject) + return msg_id + + async def send_typing(self, chat_id: str) -> None: + """Email has no typing indicator — no-op.""" + pass + + async def send_image( + self, + chat_id: str, + image_url: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + """Send an image URL as part of an email body.""" + text = caption or "" + text += f"\n\nImage: {image_url}" + return await self.send(chat_id, text.strip(), reply_to) + + async def send_document( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + file_name: Optional[str] = None, + reply_to: Optional[str] = None, + ) -> SendResult: + """Send a file as an email attachment.""" + try: + loop = asyncio.get_running_loop() + message_id = await loop.run_in_executor( + None, + self._send_email_with_attachment, + chat_id, + caption or "", + file_path, + file_name, + ) + return SendResult(success=True, message_id=message_id) + except Exception as e: + logger.error("[Email] Send document failed: %s", e) + return SendResult(success=False, error=str(e)) + + def _send_email_with_attachment( + self, + to_addr: str, + body: str, + file_path: str, + file_name: Optional[str] = None, + ) -> str: + """Send an email with a file attachment via SMTP.""" + msg = MIMEMultipart() + msg["From"] = self._address + msg["To"] = to_addr + + ctx = self._thread_context.get(to_addr, {}) + subject = ctx.get("subject", "Hermes Agent") + if not subject.startswith("Re:"): + subject = f"Re: {subject}" + msg["Subject"] = subject + + original_msg_id = ctx.get("message_id") + if original_msg_id: + msg["In-Reply-To"] = original_msg_id + msg["References"] = original_msg_id + + msg_id = f"" + msg["Message-ID"] = msg_id + + if body: + msg.attach(MIMEText(body, "plain", "utf-8")) + + # Attach file + p = Path(file_path) + fname = file_name or p.name + with open(p, "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={fname}") + msg.attach(part) + + smtp = smtplib.SMTP(self._smtp_host, self._smtp_port) + smtp.starttls() + smtp.login(self._address, self._password) + smtp.send_message(msg) + smtp.quit() + + return msg_id + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Return basic info about the email chat.""" + ctx = self._thread_context.get(chat_id, {}) + return { + "name": chat_id, + "type": "dm", + "chat_id": chat_id, + "subject": ctx.get("subject", ""), + } diff --git a/gateway/run.py b/gateway/run.py index 63131dce..d91215a6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -672,6 +672,13 @@ class GatewayRunner: return None return HomeAssistantAdapter(config) + elif platform == Platform.EMAIL: + from gateway.platforms.email import EmailAdapter, check_email_requirements + if not check_email_requirements(): + logger.warning("Email: EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_IMAP_HOST, or EMAIL_SMTP_HOST not set") + return None + return EmailAdapter(config) + return None def _is_user_authorized(self, source: SessionSource) -> bool: @@ -701,6 +708,7 @@ class GatewayRunner: Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS", Platform.SLACK: "SLACK_ALLOWED_USERS", Platform.SIGNAL: "SIGNAL_ALLOWED_USERS", + Platform.EMAIL: "EMAIL_ALLOWED_USERS", } platform_allow_all_map = { Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS", @@ -708,6 +716,7 @@ class GatewayRunner: Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS", Platform.SLACK: "SLACK_ALLOW_ALL_USERS", Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS", + Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS", } # Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 3d146546..26a8f598 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -518,6 +518,32 @@ _PLATFORMS = [ "emoji": "📡", "token_var": "SIGNAL_HTTP_URL", }, + { + "key": "email", + "label": "Email", + "emoji": "📧", + "token_var": "EMAIL_ADDRESS", + "setup_instructions": [ + "1. Use a dedicated email account for your Hermes agent", + "2. For Gmail: enable 2FA, then create an App Password at", + " https://myaccount.google.com/apppasswords", + "3. For other providers: use your email password or app-specific password", + "4. IMAP must be enabled on your email account", + ], + "vars": [ + {"name": "EMAIL_ADDRESS", "prompt": "Email address", "password": False, + "help": "The email address Hermes will use (e.g., hermes@gmail.com)."}, + {"name": "EMAIL_PASSWORD", "prompt": "Email password (or app password)", "password": True, + "help": "For Gmail, use an App Password (not your regular password)."}, + {"name": "EMAIL_IMAP_HOST", "prompt": "IMAP host", "password": False, + "help": "e.g., imap.gmail.com for Gmail, outlook.office365.com for Outlook."}, + {"name": "EMAIL_SMTP_HOST", "prompt": "SMTP host", "password": False, + "help": "e.g., smtp.gmail.com for Gmail, smtp.office365.com for Outlook."}, + {"name": "EMAIL_ALLOWED_USERS", "prompt": "Allowed sender emails (comma-separated)", "password": False, + "is_allowlist": True, + "help": "Only emails from these addresses will be processed."}, + ], + }, ] @@ -543,6 +569,15 @@ def _platform_status(platform: dict) -> str: if val or account: return "partially configured" return "not configured" + if platform.get("key") == "email": + pwd = get_env_value("EMAIL_PASSWORD") + imap = get_env_value("EMAIL_IMAP_HOST") + smtp = get_env_value("EMAIL_SMTP_HOST") + if all([val, pwd, imap, smtp]): + return "configured" + if any([val, pwd, imap, smtp]): + return "partially configured" + return "not configured" if val: return "configured" return "not configured" diff --git a/hermes_cli/status.py b/hermes_cli/status.py index f27824a6..971dad47 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -208,6 +208,7 @@ def show_status(args): "WhatsApp": ("WHATSAPP_ENABLED", None), "Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"), "Slack": ("SLACK_BOT_TOKEN", None), + "Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"), } for name, (token_var, home_var) in platforms.items(): diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py new file mode 100644 index 00000000..05cb11f5 --- /dev/null +++ b/tests/gateway/test_email.py @@ -0,0 +1,1034 @@ +"""Tests for the Email gateway platform adapter. + +Covers: +1. Platform enum exists with correct value +2. Config loading from env vars via _apply_env_overrides +3. Adapter init and config parsing +4. Helper functions (header decoding, body extraction, address extraction, HTML stripping) +5. Authorization integration (platform in allowlist maps) +6. Send message tool routing (platform in platform_map) +7. check_email_requirements function +8. Attachment extraction and caching +9. Message dispatch and threading +""" + +import os +import unittest +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch, MagicMock, AsyncMock + +from gateway.platforms.base import SendResult + + +class TestPlatformEnum(unittest.TestCase): + """Verify EMAIL is in the Platform enum.""" + + def test_email_in_platform_enum(self): + from gateway.config import Platform + self.assertEqual(Platform.EMAIL.value, "email") + + +class TestConfigEnvOverrides(unittest.TestCase): + """Verify email config is loaded from environment variables.""" + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + }, clear=False) + def test_email_config_loaded_from_env(self): + from gateway.config import GatewayConfig, Platform, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + self.assertIn(Platform.EMAIL, config.platforms) + self.assertTrue(config.platforms[Platform.EMAIL].enabled) + self.assertEqual(config.platforms[Platform.EMAIL].extra["address"], "hermes@test.com") + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + "EMAIL_HOME_ADDRESS": "user@test.com", + }, clear=False) + def test_email_home_channel_loaded(self): + from gateway.config import GatewayConfig, Platform, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + home = config.platforms[Platform.EMAIL].home_channel + self.assertIsNotNone(home) + self.assertEqual(home.chat_id, "user@test.com") + + @patch.dict(os.environ, {}, clear=True) + def test_email_not_loaded_without_env(self): + from gateway.config import GatewayConfig, Platform, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + self.assertNotIn(Platform.EMAIL, config.platforms) + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + }, clear=False) + def test_email_in_connected_platforms(self): + from gateway.config import GatewayConfig, Platform, _apply_env_overrides + config = GatewayConfig() + _apply_env_overrides(config) + connected = config.get_connected_platforms() + self.assertIn(Platform.EMAIL, connected) + + +class TestCheckRequirements(unittest.TestCase): + """Verify check_email_requirements function.""" + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "a@b.com", + "EMAIL_PASSWORD": "pw", + "EMAIL_IMAP_HOST": "imap.b.com", + "EMAIL_SMTP_HOST": "smtp.b.com", + }, clear=False) + def test_requirements_met(self): + from gateway.platforms.email import check_email_requirements + self.assertTrue(check_email_requirements()) + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "a@b.com", + }, clear=True) + def test_requirements_not_met(self): + from gateway.platforms.email import check_email_requirements + self.assertFalse(check_email_requirements()) + + @patch.dict(os.environ, {}, clear=True) + def test_requirements_empty_env(self): + from gateway.platforms.email import check_email_requirements + self.assertFalse(check_email_requirements()) + + +class TestHelperFunctions(unittest.TestCase): + """Test email parsing helper functions.""" + + def test_decode_header_plain(self): + from gateway.platforms.email import _decode_header_value + self.assertEqual(_decode_header_value("Hello World"), "Hello World") + + def test_decode_header_encoded(self): + from gateway.platforms.email import _decode_header_value + # RFC 2047 encoded subject + encoded = "=?utf-8?B?TWVyaGFiYQ==?=" # "Merhaba" in base64 + result = _decode_header_value(encoded) + self.assertEqual(result, "Merhaba") + + def test_extract_email_address_with_name(self): + from gateway.platforms.email import _extract_email_address + self.assertEqual( + _extract_email_address("John Doe "), + "john@example.com" + ) + + def test_extract_email_address_bare(self): + from gateway.platforms.email import _extract_email_address + self.assertEqual( + _extract_email_address("john@example.com"), + "john@example.com" + ) + + def test_extract_email_address_uppercase(self): + from gateway.platforms.email import _extract_email_address + self.assertEqual( + _extract_email_address("John@Example.COM"), + "john@example.com" + ) + + def test_strip_html_basic(self): + from gateway.platforms.email import _strip_html + html = "

Hello world

" + result = _strip_html(html) + self.assertIn("Hello", result) + self.assertIn("world", result) + self.assertNotIn("

", result) + self.assertNotIn("", result) + + def test_strip_html_br_tags(self): + from gateway.platforms.email import _strip_html + html = "Line 1
Line 2
Line 3" + result = _strip_html(html) + self.assertIn("Line 1", result) + self.assertIn("Line 2", result) + + def test_strip_html_entities(self): + from gateway.platforms.email import _strip_html + html = "a & b < c > d" + result = _strip_html(html) + self.assertIn("a & b", result) + + +class TestExtractTextBody(unittest.TestCase): + """Test email body extraction from different message formats.""" + + def test_plain_text_body(self): + from gateway.platforms.email import _extract_text_body + msg = MIMEText("Hello, this is a test.", "plain", "utf-8") + result = _extract_text_body(msg) + self.assertEqual(result, "Hello, this is a test.") + + def test_html_body_fallback(self): + from gateway.platforms.email import _extract_text_body + msg = MIMEText("

Hello from HTML

", "html", "utf-8") + result = _extract_text_body(msg) + self.assertIn("Hello from HTML", result) + self.assertNotIn("

", result) + + def test_multipart_prefers_plain(self): + from gateway.platforms.email import _extract_text_body + msg = MIMEMultipart("alternative") + msg.attach(MIMEText("

HTML version

", "html", "utf-8")) + msg.attach(MIMEText("Plain version", "plain", "utf-8")) + result = _extract_text_body(msg) + self.assertEqual(result, "Plain version") + + def test_multipart_html_only(self): + from gateway.platforms.email import _extract_text_body + msg = MIMEMultipart("alternative") + msg.attach(MIMEText("

Only HTML

", "html", "utf-8")) + result = _extract_text_body(msg) + self.assertIn("Only HTML", result) + + def test_empty_body(self): + from gateway.platforms.email import _extract_text_body + msg = MIMEText("", "plain", "utf-8") + result = _extract_text_body(msg) + self.assertEqual(result, "") + + +class TestExtractAttachments(unittest.TestCase): + """Test attachment extraction and caching.""" + + def test_no_attachments(self): + from gateway.platforms.email import _extract_attachments + msg = MIMEText("No attachments here.", "plain", "utf-8") + result = _extract_attachments(msg) + self.assertEqual(result, []) + + @patch("gateway.platforms.email.cache_document_from_bytes") + def test_document_attachment(self, mock_cache): + from gateway.platforms.email import _extract_attachments + mock_cache.return_value = "/tmp/cached_doc.pdf" + + msg = MIMEMultipart() + msg.attach(MIMEText("See attached.", "plain", "utf-8")) + + part = MIMEBase("application", "pdf") + part.set_payload(b"%PDF-1.4 fake pdf content") + encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment; filename=report.pdf") + msg.attach(part) + + result = _extract_attachments(msg) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["type"], "document") + self.assertEqual(result[0]["filename"], "report.pdf") + mock_cache.assert_called_once() + + @patch("gateway.platforms.email.cache_image_from_bytes") + def test_image_attachment(self, mock_cache): + from gateway.platforms.email import _extract_attachments + mock_cache.return_value = "/tmp/cached_img.jpg" + + msg = MIMEMultipart() + msg.attach(MIMEText("See photo.", "plain", "utf-8")) + + part = MIMEBase("image", "jpeg") + part.set_payload(b"\xff\xd8\xff\xe0 fake jpg") + encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment; filename=photo.jpg") + msg.attach(part) + + result = _extract_attachments(msg) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["type"], "image") + mock_cache.assert_called_once() + + +class TestAuthorizationMaps(unittest.TestCase): + """Verify email is in authorization maps in gateway/run.py.""" + + def test_email_in_adapter_factory(self): + """Email adapter creation branch should exist.""" + import gateway.run + import inspect + source = inspect.getsource(gateway.run.GatewayRunner._create_adapter) + self.assertIn("Platform.EMAIL", source) + + def test_email_in_allowed_users_map(self): + """EMAIL_ALLOWED_USERS should be in platform_env_map.""" + import gateway.run + import inspect + source = inspect.getsource(gateway.run.GatewayRunner._is_user_authorized) + self.assertIn("EMAIL_ALLOWED_USERS", source) + + def test_email_in_allow_all_map(self): + """EMAIL_ALLOW_ALL_USERS should be in platform_allow_all_map.""" + import gateway.run + import inspect + source = inspect.getsource(gateway.run.GatewayRunner._is_user_authorized) + self.assertIn("EMAIL_ALLOW_ALL_USERS", source) + + +class TestSendMessageToolRouting(unittest.TestCase): + """Verify email routing in send_message_tool.""" + + def test_email_in_platform_map(self): + import tools.send_message_tool as smt + import inspect + source = inspect.getsource(smt._handle_send) + self.assertIn('"email"', source) + + def test_send_to_platform_has_email_branch(self): + import tools.send_message_tool as smt + import inspect + source = inspect.getsource(smt._send_to_platform) + self.assertIn("Platform.EMAIL", source) + + +class TestCronDelivery(unittest.TestCase): + """Verify email in cron scheduler platform_map.""" + + def test_email_in_cron_platform_map(self): + import cron.scheduler + import inspect + source = inspect.getsource(cron.scheduler) + self.assertIn('"email"', source) + + +class TestToolset(unittest.TestCase): + """Verify email toolset is registered.""" + + def test_email_toolset_exists(self): + from toolsets import TOOLSETS + self.assertIn("hermes-email", TOOLSETS) + + def test_email_in_gateway_toolset(self): + from toolsets import TOOLSETS + includes = TOOLSETS["hermes-gateway"]["includes"] + self.assertIn("hermes-email", includes) + + +class TestPlatformHints(unittest.TestCase): + """Verify email platform hint is registered.""" + + def test_email_in_platform_hints(self): + from agent.prompt_builder import PLATFORM_HINTS + self.assertIn("email", PLATFORM_HINTS) + self.assertIn("email", PLATFORM_HINTS["email"].lower()) + + +class TestChannelDirectory(unittest.TestCase): + """Verify email in channel directory session-based discovery.""" + + def test_email_in_session_discovery(self): + import gateway.channel_directory + import inspect + source = inspect.getsource(gateway.channel_directory.build_channel_directory) + self.assertIn('"email"', source) + + +class TestGatewaySetup(unittest.TestCase): + """Verify email in gateway setup wizard.""" + + def test_email_in_platforms_list(self): + from hermes_cli.gateway import _PLATFORMS + keys = [p["key"] for p in _PLATFORMS] + self.assertIn("email", keys) + + def test_email_has_setup_vars(self): + from hermes_cli.gateway import _PLATFORMS + email_platform = next(p for p in _PLATFORMS if p["key"] == "email") + var_names = [v["name"] for v in email_platform["vars"]] + self.assertIn("EMAIL_ADDRESS", var_names) + self.assertIn("EMAIL_PASSWORD", var_names) + self.assertIn("EMAIL_IMAP_HOST", var_names) + self.assertIn("EMAIL_SMTP_HOST", var_names) + + +class TestEnvExample(unittest.TestCase): + """Verify .env.example has email config.""" + + def test_env_example_has_email_vars(self): + env_path = Path(__file__).resolve().parents[2] / ".env.example" + content = env_path.read_text() + self.assertIn("EMAIL_ADDRESS", content) + self.assertIn("EMAIL_PASSWORD", content) + self.assertIn("EMAIL_IMAP_HOST", content) + self.assertIn("EMAIL_SMTP_HOST", content) + + +class TestDispatchMessage(unittest.TestCase): + """Test email message dispatch logic.""" + + def _make_adapter(self): + """Create an EmailAdapter with mocked env vars.""" + from gateway.config import PlatformConfig + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_IMAP_PORT": "993", + "EMAIL_SMTP_HOST": "smtp.test.com", + "EMAIL_SMTP_PORT": "587", + "EMAIL_POLL_INTERVAL": "15", + }): + from gateway.platforms.email import EmailAdapter + adapter = EmailAdapter(PlatformConfig(enabled=True)) + return adapter + + def test_self_message_filtered(self): + """Messages from the agent's own address should be skipped.""" + import asyncio + adapter = self._make_adapter() + adapter._message_handler = MagicMock() + + msg_data = { + "uid": b"1", + "sender_addr": "hermes@test.com", + "sender_name": "Hermes", + "subject": "Test", + "message_id": "", + "in_reply_to": "", + "body": "Self message", + "attachments": [], + "date": "", + } + + asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + adapter._message_handler.assert_not_called() + + def test_subject_included_in_text(self): + """Subject should be prepended to body for non-reply emails.""" + import asyncio + adapter = self._make_adapter() + captured_events = [] + + async def mock_handler(event): + captured_events.append(event) + return None + + adapter._message_handler = mock_handler + # Override handle_message to capture the event directly + original_handle = adapter.handle_message + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg_data = { + "uid": b"2", + "sender_addr": "user@test.com", + "sender_name": "User", + "subject": "Help with Python", + "message_id": "", + "in_reply_to": "", + "body": "How do I use lists?", + "attachments": [], + "date": "", + } + + asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + self.assertEqual(len(captured_events), 1) + self.assertIn("[Subject: Help with Python]", captured_events[0].text) + self.assertIn("How do I use lists?", captured_events[0].text) + + def test_reply_subject_not_duplicated(self): + """Re: subjects should not be prepended to body.""" + import asyncio + adapter = self._make_adapter() + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg_data = { + "uid": b"3", + "sender_addr": "user@test.com", + "sender_name": "User", + "subject": "Re: Help with Python", + "message_id": "", + "in_reply_to": "", + "body": "Thanks for the help!", + "attachments": [], + "date": "", + } + + asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + self.assertEqual(len(captured_events), 1) + self.assertNotIn("[Subject:", captured_events[0].text) + self.assertEqual(captured_events[0].text, "Thanks for the help!") + + def test_empty_body_handled(self): + """Email with no body should dispatch '(empty email)'.""" + import asyncio + adapter = self._make_adapter() + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg_data = { + "uid": b"4", + "sender_addr": "user@test.com", + "sender_name": "User", + "subject": "Re: test", + "message_id": "", + "in_reply_to": "", + "body": "", + "attachments": [], + "date": "", + } + + asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + self.assertEqual(len(captured_events), 1) + self.assertIn("(empty email)", captured_events[0].text) + + def test_image_attachment_sets_photo_type(self): + """Email with image attachment should set message type to PHOTO.""" + import asyncio + from gateway.platforms.base import MessageType + adapter = self._make_adapter() + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg_data = { + "uid": b"5", + "sender_addr": "user@test.com", + "sender_name": "User", + "subject": "Re: photo", + "message_id": "", + "in_reply_to": "", + "body": "Check this photo", + "attachments": [{"path": "/tmp/img.jpg", "filename": "img.jpg", "type": "image", "media_type": "image/jpeg"}], + "date": "", + } + + asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + self.assertEqual(len(captured_events), 1) + self.assertEqual(captured_events[0].message_type, MessageType.PHOTO) + self.assertEqual(captured_events[0].media_urls, ["/tmp/img.jpg"]) + + def test_source_built_correctly(self): + """Session source should have correct chat_id and user info.""" + import asyncio + adapter = self._make_adapter() + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg_data = { + "uid": b"6", + "sender_addr": "john@example.com", + "sender_name": "John Doe", + "subject": "Re: hi", + "message_id": "", + "in_reply_to": "", + "body": "Hello", + "attachments": [], + "date": "", + } + + asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + event = captured_events[0] + self.assertEqual(event.source.chat_id, "john@example.com") + self.assertEqual(event.source.user_id, "john@example.com") + self.assertEqual(event.source.user_name, "John Doe") + self.assertEqual(event.source.chat_type, "dm") + + +class TestThreadContext(unittest.TestCase): + """Test email reply threading logic.""" + + def _make_adapter(self): + from gateway.config import PlatformConfig + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + }): + from gateway.platforms.email import EmailAdapter + adapter = EmailAdapter(PlatformConfig(enabled=True)) + return adapter + + def test_thread_context_stored_after_dispatch(self): + """After dispatching a message, thread context should be stored.""" + import asyncio + adapter = self._make_adapter() + + async def noop_handle(event): + pass + + adapter.handle_message = noop_handle + + msg_data = { + "uid": b"10", + "sender_addr": "user@test.com", + "sender_name": "User", + "subject": "Project question", + "message_id": "", + "in_reply_to": "", + "body": "Hello", + "attachments": [], + "date": "", + } + + asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + ctx = adapter._thread_context.get("user@test.com") + self.assertIsNotNone(ctx) + self.assertEqual(ctx["subject"], "Project question") + self.assertEqual(ctx["message_id"], "") + + def test_reply_uses_re_prefix(self): + """Reply subject should have Re: prefix.""" + adapter = self._make_adapter() + adapter._thread_context["user@test.com"] = { + "subject": "Project question", + "message_id": "", + } + + with patch("smtplib.SMTP") as mock_smtp: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + adapter._send_email("user@test.com", "Here is the answer.", None) + + # Check the sent message + send_call = mock_server.send_message.call_args[0][0] + self.assertEqual(send_call["Subject"], "Re: Project question") + self.assertEqual(send_call["In-Reply-To"], "") + self.assertEqual(send_call["References"], "") + + def test_reply_does_not_double_re(self): + """If subject already has Re:, don't add another.""" + adapter = self._make_adapter() + adapter._thread_context["user@test.com"] = { + "subject": "Re: Project question", + "message_id": "", + } + + with patch("smtplib.SMTP") as mock_smtp: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + adapter._send_email("user@test.com", "Follow up.", None) + + send_call = mock_server.send_message.call_args[0][0] + self.assertEqual(send_call["Subject"], "Re: Project question") + self.assertFalse(send_call["Subject"].startswith("Re: Re:")) + + def test_no_thread_context_uses_default_subject(self): + """Without thread context, subject should be 'Re: Hermes Agent'.""" + adapter = self._make_adapter() + + with patch("smtplib.SMTP") as mock_smtp: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + adapter._send_email("newuser@test.com", "Hello!", None) + + send_call = mock_server.send_message.call_args[0][0] + self.assertEqual(send_call["Subject"], "Re: Hermes Agent") + + +class TestSendMethods(unittest.TestCase): + """Test email send methods.""" + + def _make_adapter(self): + from gateway.config import PlatformConfig + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + }): + from gateway.platforms.email import EmailAdapter + adapter = EmailAdapter(PlatformConfig(enabled=True)) + return adapter + + def test_send_calls_smtp(self): + """send() should use SMTP to deliver email.""" + import asyncio + adapter = self._make_adapter() + + with patch("smtplib.SMTP") as mock_smtp: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + result = asyncio.get_event_loop().run_until_complete( + adapter.send("user@test.com", "Hello from Hermes!") + ) + + self.assertTrue(result.success) + mock_server.starttls.assert_called_once() + mock_server.login.assert_called_once_with("hermes@test.com", "secret") + mock_server.send_message.assert_called_once() + mock_server.quit.assert_called_once() + + def test_send_failure_returns_error(self): + """SMTP failure should return SendResult with error.""" + import asyncio + adapter = self._make_adapter() + + with patch("smtplib.SMTP") as mock_smtp: + mock_smtp.side_effect = Exception("Connection refused") + + result = asyncio.get_event_loop().run_until_complete( + adapter.send("user@test.com", "Hello") + ) + + self.assertFalse(result.success) + self.assertIn("Connection refused", result.error) + + def test_send_image_includes_url(self): + """send_image should include image URL in email body.""" + import asyncio + from unittest.mock import AsyncMock + adapter = self._make_adapter() + + adapter.send = AsyncMock(return_value=SendResult(success=True)) + + asyncio.get_event_loop().run_until_complete( + adapter.send_image("user@test.com", "https://img.com/photo.jpg", "My photo") + ) + + call_args = adapter.send.call_args + body = call_args[0][1] + self.assertIn("https://img.com/photo.jpg", body) + self.assertIn("My photo", body) + + def test_send_document_with_attachment(self): + """send_document should send email with file attachment.""" + import asyncio + import tempfile + adapter = self._make_adapter() + + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + f.write(b"Test document content") + tmp_path = f.name + + try: + with patch("smtplib.SMTP") as mock_smtp: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + result = asyncio.get_event_loop().run_until_complete( + adapter.send_document("user@test.com", tmp_path, "Here is the file") + ) + + self.assertTrue(result.success) + mock_server.send_message.assert_called_once() + sent_msg = mock_server.send_message.call_args[0][0] + # Should be multipart with attachment + parts = list(sent_msg.walk()) + has_attachment = any( + "attachment" in str(p.get("Content-Disposition", "")) + for p in parts + ) + self.assertTrue(has_attachment) + finally: + os.unlink(tmp_path) + + def test_send_typing_is_noop(self): + """send_typing should do nothing for email.""" + import asyncio + adapter = self._make_adapter() + # Should not raise + asyncio.get_event_loop().run_until_complete(adapter.send_typing("user@test.com")) + + def test_get_chat_info(self): + """get_chat_info should return email address as chat info.""" + import asyncio + adapter = self._make_adapter() + adapter._thread_context["user@test.com"] = {"subject": "Test", "message_id": ""} + + info = asyncio.get_event_loop().run_until_complete( + adapter.get_chat_info("user@test.com") + ) + + self.assertEqual(info["name"], "user@test.com") + self.assertEqual(info["type"], "dm") + self.assertEqual(info["subject"], "Test") + + +class TestConnectDisconnect(unittest.TestCase): + """Test IMAP/SMTP connection lifecycle.""" + + def _make_adapter(self): + from gateway.config import PlatformConfig + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + }): + from gateway.platforms.email import EmailAdapter + adapter = EmailAdapter(PlatformConfig(enabled=True)) + return adapter + + def test_connect_success(self): + """Successful IMAP + SMTP connection returns True.""" + import asyncio + adapter = self._make_adapter() + + mock_imap = MagicMock() + mock_imap.search.return_value = ("OK", [b"1 2 3"]) + + with patch("imaplib.IMAP4_SSL", return_value=mock_imap), \ + patch("smtplib.SMTP") as mock_smtp: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + result = asyncio.get_event_loop().run_until_complete(adapter.connect()) + + self.assertTrue(result) + self.assertTrue(adapter._running) + # Should have skipped existing messages + self.assertEqual(len(adapter._seen_uids), 3) + # Cleanup + adapter._running = False + if adapter._poll_task: + adapter._poll_task.cancel() + + def test_connect_imap_failure(self): + """IMAP connection failure returns False.""" + import asyncio + adapter = self._make_adapter() + + with patch("imaplib.IMAP4_SSL", side_effect=Exception("IMAP down")): + result = asyncio.get_event_loop().run_until_complete(adapter.connect()) + self.assertFalse(result) + self.assertFalse(adapter._running) + + def test_connect_smtp_failure(self): + """SMTP connection failure returns False.""" + import asyncio + adapter = self._make_adapter() + + mock_imap = MagicMock() + mock_imap.search.return_value = ("OK", [b""]) + + with patch("imaplib.IMAP4_SSL", return_value=mock_imap), \ + patch("smtplib.SMTP", side_effect=Exception("SMTP down")): + result = asyncio.get_event_loop().run_until_complete(adapter.connect()) + self.assertFalse(result) + + def test_disconnect_cancels_poll(self): + """disconnect() should cancel the polling task.""" + import asyncio + adapter = self._make_adapter() + adapter._running = True + adapter._poll_task = asyncio.ensure_future(asyncio.sleep(100)) + + asyncio.get_event_loop().run_until_complete(adapter.disconnect()) + + self.assertFalse(adapter._running) + self.assertIsNone(adapter._poll_task) + + +class TestFetchNewMessages(unittest.TestCase): + """Test IMAP message fetching logic.""" + + def _make_adapter(self): + from gateway.config import PlatformConfig + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + }): + from gateway.platforms.email import EmailAdapter + adapter = EmailAdapter(PlatformConfig(enabled=True)) + return adapter + + def test_fetch_skips_seen_uids(self): + """Already-seen UIDs should not be fetched again.""" + adapter = self._make_adapter() + adapter._seen_uids = {b"1", b"2"} + + raw_email = MIMEText("Hello", "plain", "utf-8") + raw_email["From"] = "user@test.com" + raw_email["Subject"] = "Test" + raw_email["Message-ID"] = "" + + mock_imap = MagicMock() + mock_imap.search.return_value = ("OK", [b"1 2 3"]) + mock_imap.fetch.return_value = ("OK", [(b"3", raw_email.as_bytes())]) + + with patch("imaplib.IMAP4_SSL", return_value=mock_imap): + results = adapter._fetch_new_messages() + + # Only UID 3 should be fetched (1 and 2 already seen) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["sender_addr"], "user@test.com") + self.assertIn(b"3", adapter._seen_uids) + + def test_fetch_no_unseen_messages(self): + """No unseen messages returns empty list.""" + adapter = self._make_adapter() + + mock_imap = MagicMock() + mock_imap.search.return_value = ("OK", [b""]) + + with patch("imaplib.IMAP4_SSL", return_value=mock_imap): + results = adapter._fetch_new_messages() + + self.assertEqual(results, []) + + def test_fetch_handles_imap_error(self): + """IMAP errors should be caught and return empty list.""" + adapter = self._make_adapter() + + with patch("imaplib.IMAP4_SSL", side_effect=Exception("Network error")): + results = adapter._fetch_new_messages() + + self.assertEqual(results, []) + + def test_fetch_extracts_sender_name(self): + """Sender name should be extracted from 'Name ' format.""" + adapter = self._make_adapter() + + raw_email = MIMEText("Hello", "plain", "utf-8") + raw_email["From"] = '"John Doe" ' + raw_email["Subject"] = "Test" + raw_email["Message-ID"] = "" + + mock_imap = MagicMock() + mock_imap.search.return_value = ("OK", [b"1"]) + mock_imap.fetch.return_value = ("OK", [(b"1", raw_email.as_bytes())]) + + with patch("imaplib.IMAP4_SSL", return_value=mock_imap): + results = adapter._fetch_new_messages() + + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["sender_addr"], "john@test.com") + self.assertEqual(results[0]["sender_name"], "John Doe") + + +class TestPollLoop(unittest.TestCase): + """Test the async polling loop.""" + + def _make_adapter(self): + from gateway.config import PlatformConfig + with patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_IMAP_HOST": "imap.test.com", + "EMAIL_SMTP_HOST": "smtp.test.com", + "EMAIL_POLL_INTERVAL": "1", + }): + from gateway.platforms.email import EmailAdapter + adapter = EmailAdapter(PlatformConfig(enabled=True)) + return adapter + + def test_check_inbox_dispatches_messages(self): + """_check_inbox should fetch and dispatch new messages.""" + import asyncio + adapter = self._make_adapter() + dispatched = [] + + async def mock_dispatch(msg_data): + dispatched.append(msg_data) + + adapter._dispatch_message = mock_dispatch + + raw_email = MIMEText("Test body", "plain", "utf-8") + raw_email["From"] = "sender@test.com" + raw_email["Subject"] = "Inbox Test" + raw_email["Message-ID"] = "" + + mock_imap = MagicMock() + mock_imap.search.return_value = ("OK", [b"1"]) + mock_imap.fetch.return_value = ("OK", [(b"1", raw_email.as_bytes())]) + + with patch("imaplib.IMAP4_SSL", return_value=mock_imap): + asyncio.get_event_loop().run_until_complete(adapter._check_inbox()) + + self.assertEqual(len(dispatched), 1) + self.assertEqual(dispatched[0]["subject"], "Inbox Test") + + +class TestSendEmailStandalone(unittest.TestCase): + """Test the standalone _send_email function in send_message_tool.""" + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_SMTP_HOST": "smtp.test.com", + "EMAIL_SMTP_PORT": "587", + }) + def test_send_email_tool_success(self): + """_send_email should use SMTP to send.""" + import asyncio + from tools.send_message_tool import _send_email + + with patch("smtplib.SMTP") as mock_smtp: + mock_server = MagicMock() + mock_smtp.return_value = mock_server + + result = asyncio.get_event_loop().run_until_complete( + _send_email({"address": "hermes@test.com", "smtp_host": "smtp.test.com"}, "user@test.com", "Hello") + ) + + self.assertTrue(result["success"]) + self.assertEqual(result["platform"], "email") + + @patch.dict(os.environ, { + "EMAIL_ADDRESS": "hermes@test.com", + "EMAIL_PASSWORD": "secret", + "EMAIL_SMTP_HOST": "smtp.test.com", + }) + def test_send_email_tool_failure(self): + """SMTP failure should return error dict.""" + import asyncio + from tools.send_message_tool import _send_email + + with patch("smtplib.SMTP", side_effect=Exception("SMTP error")): + result = asyncio.get_event_loop().run_until_complete( + _send_email({"address": "hermes@test.com", "smtp_host": "smtp.test.com"}, "user@test.com", "Hello") + ) + + self.assertIn("error", result) + self.assertIn("SMTP error", result["error"]) + + @patch.dict(os.environ, {}, clear=True) + def test_send_email_tool_not_configured(self): + """Missing config should return error.""" + import asyncio + from tools.send_message_tool import _send_email + + result = asyncio.get_event_loop().run_until_complete( + _send_email({}, "user@test.com", "Hello") + ) + + self.assertIn("error", result) + self.assertIn("not configured", result["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index f0b1dd27..56176386 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -119,6 +119,7 @@ def _handle_send(args): "slack": Platform.SLACK, "whatsapp": Platform.WHATSAPP, "signal": Platform.SIGNAL, + "email": Platform.EMAIL, } platform = platform_map.get(platform_name) if not platform: @@ -185,6 +186,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None) return await _send_slack(pconfig.token, chat_id, message) elif platform == Platform.SIGNAL: return await _send_signal(pconfig.extra, chat_id, message) + elif platform == Platform.EMAIL: + return await _send_email(pconfig.extra, chat_id, message) return {"error": f"Direct sending not yet implemented for {platform.value}"} @@ -283,6 +286,35 @@ async def _send_signal(extra, chat_id, message): return {"error": f"Signal send failed: {e}"} +async def _send_email(extra, chat_id, message): + """Send via SMTP (one-shot, no persistent connection needed).""" + import smtplib + from email.mime.text import MIMEText + + address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "") + password = os.getenv("EMAIL_PASSWORD", "") + smtp_host = extra.get("smtp_host") or os.getenv("EMAIL_SMTP_HOST", "") + smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587")) + + if not all([address, password, smtp_host]): + return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"} + + try: + msg = MIMEText(message, "plain", "utf-8") + msg["From"] = address + msg["To"] = chat_id + msg["Subject"] = "Hermes Agent" + + server = smtplib.SMTP(smtp_host, smtp_port) + server.starttls() + server.login(address, password) + server.send_message(msg) + server.quit() + return {"success": True, "platform": "email", "chat_id": chat_id} + except Exception as e: + return {"error": f"Email send failed: {e}"} + + def _check_send_message(): """Gate send_message on gateway running (always available on messaging platforms).""" platform = os.getenv("HERMES_SESSION_PLATFORM", "") diff --git a/toolsets.py b/toolsets.py index 87b48c7e..4aa37f87 100644 --- a/toolsets.py +++ b/toolsets.py @@ -267,10 +267,16 @@ TOOLSETS = { "includes": [] }, + "hermes-email": { + "description": "Email bot toolset - interact with Hermes via email (IMAP/SMTP)", + "tools": _HERMES_CORE_TOOLS, + "includes": [] + }, + "hermes-gateway": { "description": "Gateway toolset - union of all messaging platform tools", "tools": [], - "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant"] + "includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email"] } }