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