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.
2026-03-10 03:15:38 +03:00
|
|
|
"""
|
|
|
|
|
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
|
2026-03-12 03:02:51 +03:00
|
|
|
import ssl
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
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"<br\s*/?>", "\n", html, flags=re.IGNORECASE)
|
|
|
|
|
text = re.sub(r"<p[^>]*>", "\n", text, flags=re.IGNORECASE)
|
|
|
|
|
text = re.sub(r"</p>", "\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 <addr>' format."""
|
|
|
|
|
match = re.search(r"<([^>]+)>", raw)
|
|
|
|
|
if match:
|
|
|
|
|
return match.group(1).strip().lower()
|
|
|
|
|
return raw.strip().lower()
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 05:27:54 -07:00
|
|
|
def _extract_attachments(
|
|
|
|
|
msg: email_lib.message.Message,
|
|
|
|
|
skip_attachments: bool = False,
|
|
|
|
|
) -> List[Dict[str, Any]]:
|
|
|
|
|
"""Extract attachment metadata and cache files locally.
|
|
|
|
|
|
|
|
|
|
When *skip_attachments* is True, all attachment/inline parts are ignored
|
|
|
|
|
(useful for malware protection or bandwidth savings).
|
|
|
|
|
"""
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
attachments = []
|
|
|
|
|
if not msg.is_multipart():
|
|
|
|
|
return attachments
|
|
|
|
|
|
|
|
|
|
for part in msg.walk():
|
|
|
|
|
disposition = str(part.get("Content-Disposition", ""))
|
2026-03-16 05:27:54 -07:00
|
|
|
if skip_attachments and ("attachment" in disposition or "inline" in disposition):
|
|
|
|
|
continue
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
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"))
|
|
|
|
|
|
2026-03-16 05:27:54 -07:00
|
|
|
# Skip attachments — configured via config.yaml:
|
|
|
|
|
# platforms:
|
|
|
|
|
# email:
|
|
|
|
|
# skip_attachments: true
|
|
|
|
|
extra = config.extra or {}
|
|
|
|
|
self._skip_attachments = extra.get("skip_attachments", False)
|
|
|
|
|
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
# 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")
|
2026-03-12 03:02:51 +03:00
|
|
|
status, data = imap.uid("search", None, "ALL")
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
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)
|
2026-03-12 03:02:51 +03:00
|
|
|
smtp.starttls(context=ssl.create_default_context())
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
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")
|
|
|
|
|
|
2026-03-12 03:02:51 +03:00
|
|
|
status, data = imap.uid("search", None, "UNSEEN")
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
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)
|
|
|
|
|
|
2026-03-12 03:02:51 +03:00
|
|
|
status, msg_data = imap.uid("fetch", uid, "(RFC822)")
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
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)
|
2026-03-16 05:27:54 -07:00
|
|
|
attachments = _extract_attachments(msg, skip_attachments=self._skip_attachments)
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
|
|
|
|
|
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"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
|
|
|
|
|
msg["Message-ID"] = msg_id
|
|
|
|
|
|
|
|
|
|
msg.attach(MIMEText(body, "plain", "utf-8"))
|
|
|
|
|
|
|
|
|
|
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
2026-03-12 03:02:51 +03:00
|
|
|
smtp.starttls(context=ssl.create_default_context())
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
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
|
|
|
|
|
|
fix: email send_typing metadata + ☤ Hermes staff symbol (#1431, #1420)
* fix: prevent infinite 400 failure loop on context overflow (#1630)
When a gateway session exceeds the model's context window, Anthropic may
return a generic 400 invalid_request_error with just 'Error' as the
message. This bypassed the phrase-based context-length detection,
causing the agent to treat it as a non-retryable client error. Worse,
the failed user message was still persisted to the transcript, making
the session even larger on each attempt — creating an infinite loop.
Three-layer fix:
1. run_agent.py — Fallback heuristic: when a 400 error has a very short
generic message AND the session is large (>40% of context or >80
messages), treat it as a probable context overflow and trigger
compression instead of aborting.
2. run_agent.py + gateway/run.py — Don't persist failed messages:
when the agent returns failed=True before generating any response,
skip writing the user's message to the transcript/DB. This prevents
the session from growing on each failure.
3. gateway/run.py — Smarter error messages: detect context-overflow
failures and suggest /compact or /reset specifically, instead of a
generic 'try again' that will fail identically.
* fix(skills): detect prompt injection patterns and block cache file reads
Adds two security layers to prevent prompt injection via skills hub
cache files (#1558):
1. read_file: blocks direct reads of ~/.hermes/skills/.hub/ directory
(index-cache, catalog files). The 3.5MB clawhub_catalog_v1.json
was the original injection vector — untrusted skill descriptions
in the catalog contained adversarial text that the model executed.
2. skill_view: warns when skills are loaded from outside the trusted
~/.hermes/skills/ directory, and detects common injection patterns
in skill content ("ignore previous instructions", "<system>", etc.).
Cherry-picked from PR #1562 by ygd58.
* fix(tools): chunk long messages in send_message_tool before dispatch (#1552)
Long messages sent via send_message tool or cron delivery silently
failed when exceeding platform limits. Gateway adapters handle this
via truncate_message(), but the standalone senders in send_message_tool
bypassed that entirely.
- Apply truncate_message() chunking in _send_to_platform() before
dispatching to individual platform senders
- Remove naive message[i:i+2000] character split in _send_discord()
in favor of centralized smart splitting
- Attach media files to last chunk only for Telegram
- Add regression tests for chunking and media placement
Cherry-picked from PR #1557 by llbn.
* fix(approval): show full command in dangerous command approval (#1553)
Previously the command was truncated to 80 chars in CLI (with a
[v]iew full option), 500 chars in Discord embeds, and missing entirely
in Telegram/Slack approval messages. Now the full command is always
displayed everywhere:
- CLI: removed 80-char truncation and [v]iew full menu option
- Gateway (TG/Slack): approval_required message includes full command
in a code block
- Discord: embed shows full command up to 4096-char limit
- Windows: skip SIGALRM-based test timeout (Unix-only)
- Updated tests: replaced view-flow tests with direct approval tests
Cherry-picked from PR #1566 by crazywriter1.
* fix(cli): flush stdout during agent loop to prevent macOS display freeze (#1624)
The interrupt polling loop in chat() waited on the queue without
invalidating the prompt_toolkit renderer. On macOS, the StdoutProxy
buffer only flushed on input events, causing the CLI to appear frozen
during tool execution until the user typed a key.
Fix: call _invalidate() on each queue timeout (every ~100ms, throttled
to 150ms) to force the renderer to flush buffered agent output.
* fix(claw): warn when API keys are skipped during OpenClaw migration (#1580)
When --migrate-secrets is not passed (the default), API keys like
OPENROUTER_API_KEY are silently skipped with no warning. Users don't
realize their keys weren't migrated until the agent fails to connect.
Add a post-migration warning with actionable instructions: either
re-run with --migrate-secrets or add the key manually via
hermes config set.
Cherry-picked from PR #1593 by ygd58.
* fix(security): block sandbox backend creds from subprocess env (#1264)
Add Modal and Daytona sandbox credentials to the subprocess env
blocklist so they're not leaked to agent terminal sessions via
printenv/env.
Cherry-picked from PR #1571 by ygd58.
* fix(gateway): cap interrupt recursion depth to prevent resource exhaustion (#816)
When a user sends multiple messages while the agent keeps failing,
_run_agent() calls itself recursively with no depth limit. This can
exhaust stack/memory if the agent is in a failure loop.
Add _MAX_INTERRUPT_DEPTH = 3. When exceeded, the pending message is
logged and the current result is returned instead of recursing deeper.
The log handler duplication bug described in #816 was already fixed
separately (AIAgent.__init__ deduplicates handlers).
* fix(gateway): /model shows active fallback model instead of config default (#1615)
When the agent falls back to a different model (e.g. due to rate
limiting), /model still showed the config default. Now tracks the
effective model/provider after each agent run and displays it.
Cleared when the primary model succeeds again or the user explicitly
switches via /model.
Cherry-picked from PR #1616 by MaxKerkula. Added hasattr guard for
test compatibility.
* feat(gateway): inject reply-to message context for out-of-session replies (#1594)
When a user replies to a Telegram message, check if the quoted text
exists in the current session transcript. If missing (from cron jobs,
background tasks, or old sessions), prepend [Replying to: "..."] to
the message so the agent has context about what's being referenced.
- Add reply_to_text field to MessageEvent (base.py)
- Populate from Telegram's reply_to_message (text or caption)
- Inject context in _handle_message when not found in history
Based on PR #1596 by anpicasso (cherry-picked reply-to feature only,
excluded unrelated /server command and background delegation changes).
* fix: recognize Claude Code OAuth credentials in startup gate (#1455)
The _has_any_provider_configured() startup check didn't look for
Claude Code OAuth credentials (~/.claude/.credentials.json). Users
with only Claude Code auth got the setup wizard instead of starting.
Cherry-picked from PR #1455 by kshitijk4poor.
* perf: use ripgrep for file search (200x faster than find)
search_files(target='files') now uses rg --files -g instead of find.
Ripgrep respects .gitignore, excludes hidden dirs by default, and has
parallel directory traversal — ~200x faster on wide trees (0.14s vs 34s
benchmarked on 164-repo tree).
Falls back to find when rg is unavailable, preserving hidden-dir
exclusion and BSD find compatibility.
Salvaged from PR #1464 by @light-merlin-dark (Merlin) — adapted to
preserve hidden-dir exclusion added since the original PR.
* refactor(tts): replace NeuTTS optional skill with built-in provider + setup flow
Remove the optional skill (redundant now that NeuTTS is a built-in TTS
provider). Replace neutts_cli dependency with a standalone synthesis
helper (tools/neutts_synth.py) that calls the neutts Python API directly
in a subprocess.
Add TTS provider selection to hermes setup:
- 'hermes setup' now prompts for TTS provider after model selection
- 'hermes setup tts' available as standalone section
- Selecting NeuTTS checks for deps and offers to install:
espeak-ng (system) + neutts[all] (pip)
- ElevenLabs/OpenAI selections prompt for API keys
- Tool status display shows NeuTTS install state
Changes:
- Remove optional-skills/mlops/models/neutts/ (skill + CLI scaffold)
- Add tools/neutts_synth.py (standalone synthesis subprocess helper)
- Move jo.wav/jo.txt to tools/neutts_samples/ (bundled default voice)
- Refactor _generate_neutts() — uses neutts API via subprocess, no
neutts_cli dependency, config-driven ref_audio/ref_text/model/device
- Add TTS setup to hermes_cli/setup.py (SETUP_SECTIONS, tool status)
- Update config.py defaults (ref_audio, ref_text, model, device)
* fix(docker): add explicit env allowlist for container credentials (#1436)
Docker terminal sessions are secret-dark by default. This adds
terminal.docker_forward_env as an explicit allowlist for env vars
that may be forwarded into Docker containers.
Values resolve from the current shell first, then fall back to
~/.hermes/.env. Only variables the user explicitly lists are
forwarded — nothing is auto-exposed.
Cherry-picked from PR #1449 by @teknium1, conflict-resolved onto
current main.
Fixes #1436
Supersedes #1439
* fix: email send_typing metadata param + ☤ Hermes staff symbol
- email.py: add missing metadata parameter to send_typing() to match
BasePlatformAdapter signature (PR #1431 by @ItsChoudhry)
- README.md: ⚕ → ☤ — the caduceus is Hermes's staff, not the
medical Staff of Asclepius (PR #1420 by @rianczerwinski)
---------
Co-authored-by: buray <ygd58@users.noreply.github.com>
Co-authored-by: lbn <llbn@users.noreply.github.com>
Co-authored-by: crazywriter1 <53251494+crazywriter1@users.noreply.github.com>
Co-authored-by: Max K <MaxKerkula@users.noreply.github.com>
Co-authored-by: Angello Picasso <angello.picasso@devsu.com>
Co-authored-by: kshitij <kshitijk4poor@users.noreply.github.com>
2026-03-17 02:37:40 -07:00
|
|
|
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
"""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"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
|
|
|
|
|
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)
|
2026-03-12 03:02:51 +03:00
|
|
|
smtp.starttls(context=ssl.create_default_context())
|
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.
2026-03-10 03:15:38 +03:00
|
|
|
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", ""),
|
|
|
|
|
}
|