Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 180 additions & 13 deletions gateway/platforms/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import os
import re
import smtplib
import socket
import ssl
import uuid
from email.header import decode_header
Expand All @@ -44,7 +45,80 @@
from gateway.config import Platform, PlatformConfig

logger = logging.getLogger(__name__)
# Automated sender patterns — emails from these are silently ignored

# ── HTML Email Styling ──────────────────────────────────────────────────────
# Inline CSS for maximum email client compatibility (Gmail, Outlook, Apple Mail).

_HERMES_EMAIL_CSS = """\
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f4f4f7;">
<div style="max-width:680px;margin:0 auto;background:#ffffff;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
font-size:15px;line-height:1.6;color:#2d3748;padding:32px;">

"""

_HERMES_EMAIL_FOOTER = """\

<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;
font-size:12px;color:#a0aec0;">
Sent by <strong>Hermes Agent</strong> &middot;
<a href="https://hermes-agent.nousresearch.com" style="color:#667eea;text-decoration:none;">
hermes-agent.nousresearch.com
</a>
</div>
</div>
</body>
</html>
"""

# Pre-process CSS into markdown-compatible HTML wrapper
# We inject styles per-element after markdown conversion for email client compat.
_HERMES_EMAIL_ELEMENT_STYLES = [
("h1", 'style="font-size:24px;font-weight:700;color:#1a202c;margin:24px 0 12px;border-bottom:2px solid #667eea;padding-bottom:8px;"'),
("h2", 'style="font-size:20px;font-weight:700;color:#2d3748;margin:24px 0 10px;border-bottom:1px solid #e2e8f0;padding-bottom:6px;"'),
("h3", 'style="font-size:17px;font-weight:600;color:#4a5568;margin:18px 0 8px;"'),
("h4", 'style="font-size:15px;font-weight:600;color:#718096;margin:14px 0 6px;"'),
("p", 'style="margin:0 0 12px;"'),
("ul", 'style="margin:0 0 12px;padding-left:24px;"'),
("ol", 'style="margin:0 0 12px;padding-left:24px;"'),
("li", 'style="margin-bottom:4px;"'),
("blockquote", 'style="margin:12px 0;padding:12px 16px;border-left:4px solid #667eea;background:#f7fafc;color:#4a5568;font-style:italic;"'),
("table", 'style="border-collapse:collapse;width:100%;margin:12px 0;font-size:14px;"'),
("th", 'style="background:#667eea;color:#fff;padding:8px 12px;text-align:left;font-weight:600;"'),
("td", 'style="padding:8px 12px;border-bottom:1px solid #e2e8f0;"'),
("tr", 'style="border-bottom:1px solid #e2e8f0;"'),
("code", 'style="background:#edf2f7;padding:2px 5px;border-radius:3px;font-size:13px;font-family:Menlo,Monaco,Consolas,monospace;"'),
("pre", 'style="background:#2d3748;color:#e2e8f0;padding:16px;border-radius:6px;overflow-x:auto;font-size:13px;line-height:1.5;"'),
("a", 'style="color:#667eea;text-decoration:none;"'),
("strong", 'style="color:#1a202c;"'),
("em", 'style="color:#4a5568;"'),
("hr", 'style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;"'),
]
Comment on lines +79 to +99


def _style_html_email(html: str) -> str:
"""Inject inline CSS styles into HTML elements for email client compat."""
import re
for tag, style in _HERMES_EMAIL_ELEMENT_STYLES:
# Skip tags that already have a style attribute to avoid duplication
# Use negative lookahead: only match <tag that is NOT followed by ...style=
html = re.sub(
rf'<{tag}(?!\s+style=)(\s|>)',
rf'<{tag} {style}\1',
html,
)
Comment on lines +108 to +112
# Special handling: <code> inside <pre> should be transparent
# Match <pre ...>...<code> and apply override style
html = re.sub(
r'(<pre[^>]*>.*?)<code(?!\s+style=)(\s|>)',
r'\1<code style="background:transparent;padding:0;color:inherit;"\2',
html,
flags=re.DOTALL,
)
return html
_NOREPLY_PATTERNS = (
"noreply", "no-reply", "no_reply", "donotreply", "do-not-reply",
"mailer-daemon", "postmaster", "bounce", "notifications@",
Expand Down Expand Up @@ -263,11 +337,28 @@ def __init__(self, config: PlatformConfig):
extra = config.extra or {}
self._skip_attachments = extra.get("skip_attachments", False)

# HTML email format — converts Markdown bodies to styled HTML.
# platforms:
# email:
# html_format: true # default: true (enabled)
#
# SECURITY NOTE: The Markdown library passes through raw HTML by default.
# We intentionally do NOT sanitize with bleach/allowlists because:
# 1. Hermes generates its own email bodies (LLM output, not user input)
# 2. Emails are sent to the configured address (self-to-self)
# 3. The config toggle provides a kill-switch: set html_format: false
# If the threat model changes (e.g. forwarding untrusted content),
# add bleach.clean() here with an allowed-tags list.
self._html_format = extra.get("html_format", True)
Comment on lines +345 to +352

# Track message IDs we've already processed to avoid duplicates
self._seen_uids: set = set()
self._seen_uids_max: int = 2000 # cap to prevent unbounded memory growth
self._poll_task: Optional[asyncio.Task] = None

# Reusable SSL context for SMTP connections
self._smtp_ssl_ctx: ssl.SSLContext = ssl.create_default_context()

# Map chat_id (sender email) -> last subject + message-id for threading
self._thread_context: Dict[str, Dict[str, str]] = {}

Expand All @@ -293,6 +384,66 @@ def _trim_seen_uids(self) -> None:
# Fallback: just clear old entries if sort fails
self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:])

def _connect_smtp(self) -> smtplib.SMTP:
"""Create an SMTP connection, using SMTP_SSL for port 465 (implicit TLS).

Tries IPv4 first to avoid hanging on unreachable AAAA records,
then falls back to default resolution (which includes IPv6) if
no A records are found. This is thread-safe — no global state
is mutated.
"""
sock = self._connect_smtp_socket()
if self._smtp_port == 465:
# SMTP_SSL wraps the socket in _get_socket() during connect(),
# but we bypass connect() by providing a pre-connected socket,
# so we wrap and initialize manually.
ssl_sock = self._smtp_ssl_ctx.wrap_socket(
sock, server_hostname=self._smtp_host,
)
smtp: smtplib.SMTP = smtplib.SMTP_SSL(context=self._smtp_ssl_ctx)
smtp.sock = ssl_sock
smtp.file = ssl_sock.makefile("rb")
smtp.getreply() # read server greeting banner
else:
smtp = smtplib.SMTP()
smtp.sock = sock
smtp.file = sock.makefile("rb")
smtp.getreply() # read server greeting banner
smtp.ehlo()
if self._smtp_port != 465:
smtp.starttls(context=self._smtp_ssl_ctx)
smtp.ehlo()
return smtp

def _connect_smtp_socket(self, timeout: float = 30) -> socket.socket:
"""Resolve SMTP host preferring IPv4, then connect.

Tries IPv4 (A records) first so hosts with broken IPv6 don't
hang. Falls back to default resolution if no A records exist.
Thread-safe — no global state is mutated.
"""
# Try IPv4 first — gaierror means no A records (IPv6-only host)
try:
addrs = socket.getaddrinfo(
self._smtp_host, self._smtp_port, socket.AF_INET, socket.SOCK_STREAM,
)
except socket.gaierror:
addrs = []
if not addrs:
# No A records — fall back to default (may include IPv6)
addrs = socket.getaddrinfo(
self._smtp_host, self._smtp_port, type=socket.SOCK_STREAM,
)
for family, type_, proto, _, addr in addrs:
sock = socket.socket(family, type_, proto)
sock.settimeout(timeout)
try:
sock.connect(addr)
return sock
except OSError:
sock.close()
raise OSError(f"Cannot connect to {self._smtp_host}:{self._smtp_port}")

async def connect(self) -> bool:
"""Connect to the IMAP server and start polling for new messages."""
try:
Expand All @@ -316,10 +467,14 @@ async def connect(self) -> bool:

try:
# Test SMTP connection
smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.quit()
smtp = self._connect_smtp()
try:
smtp.login(self._address, self._password)
finally:
try:
smtp.quit()
except Exception:
smtp.close()
logger.info("[Email] SMTP connection test passed.")
except Exception as e:
logger.error("[Email] SMTP connection failed: %s", e)
Expand Down Expand Up @@ -531,8 +686,8 @@ def _send_email(
body: str,
reply_to_msg_id: Optional[str] = None,
) -> str:
"""Send an email via SMTP. Runs in executor thread."""
msg = MIMEMultipart()
"""Send an email via SMTP with HTML formatting. Runs in executor thread."""
msg = MIMEMultipart("alternative")
msg["From"] = self._address
msg["To"] = to_addr

Expand All @@ -553,11 +708,25 @@ def _send_email(
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._address.split('@')[1]}>"
msg["Message-ID"] = msg_id

# Plain text fallback
msg.attach(MIMEText(body, "plain", "utf-8"))

smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
# HTML version with inline CSS (if enabled)
if self._html_format:
try:
import markdown as _md
html_body = _md.markdown(
body,
extensions=["tables", "fenced_code", "nl2br"],
)
Comment on lines +715 to +721
html_body = _style_html_email(html_body)
html_email = _HERMES_EMAIL_CSS + html_body + _HERMES_EMAIL_FOOTER
msg.attach(MIMEText(html_email, "html", "utf-8"))
except Exception as e:
logger.warning("[Email] HTML conversion failed, sending plain only: %s", e)

smtp = self._connect_smtp()
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
Expand Down Expand Up @@ -677,9 +846,8 @@ def _send_email_with_attachments(
except Exception as e:
logger.warning("[Email] Failed to attach %s: %s", file_path, e)

smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp = self._connect_smtp()
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
Expand Down Expand Up @@ -756,9 +924,8 @@ def _send_email_with_attachment(
part.add_header("Content-Disposition", f"attachment; filename={fname}")
msg.attach(part)

smtp = smtplib.SMTP(self._smtp_host, self._smtp_port, timeout=30)
smtp = self._connect_smtp()
try:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self._address, self._password)
smtp.send_message(msg)
finally:
Expand Down