mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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.
This commit is contained in:
parent
b16d7f2da6
commit
bdcf247efe
12 changed files with 1699 additions and 2 deletions
12
.env.example
12
.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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
533
gateway/platforms/email.py
Normal file
533
gateway/platforms/email.py
Normal file
|
|
@ -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"<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()
|
||||
|
||||
|
||||
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"<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)
|
||||
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"<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)
|
||||
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", ""),
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
1034
tests/gateway/test_email.py
Normal file
1034
tests/gateway/test_email.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue