From bdcf247efedf51e4c3cea82b5ff2ed5136989607 Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:15:38 +0300 Subject: [PATCH] feat: add email gateway platform (IMAP/SMTP) Allow users to interact with Hermes by sending and receiving emails. Uses IMAP polling for incoming messages and SMTP for replies with proper threading (In-Reply-To, References headers). Integrates with all 14 gateway extension points: config, adapter factory, authorization, send_message tool, cron delivery, toolsets, prompt hints, channel directory, setup wizard, status display, and env example. 65 tests covering config, parsing, dispatch, threading, IMAP fetch, SMTP send, attachments, and all integration points. --- .env.example | 12 + agent/prompt_builder.py | 8 + cron/scheduler.py | 1 + gateway/channel_directory.py | 2 +- gateway/config.py | 26 + gateway/platforms/email.py | 533 ++++++++++++++++++ gateway/run.py | 9 + hermes_cli/gateway.py | 35 ++ hermes_cli/status.py | 1 + tests/gateway/test_email.py | 1034 ++++++++++++++++++++++++++++++++++ tools/send_message_tool.py | 32 ++ toolsets.py | 8 +- 12 files changed, 1699 insertions(+), 2 deletions(-) create mode 100644 gateway/platforms/email.py create mode 100644 tests/gateway/test_email.py 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"] } }