1542 lines
60 KiB
Python
1542 lines
60 KiB
Python
"""
|
||
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 html
|
||
import imaplib
|
||
import logging
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import smtplib
|
||
import socket
|
||
import ssl
|
||
import uuid
|
||
from email.header import decode_header, Header
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
from email.mime.base import MIMEBase
|
||
from email.utils import formatdate, formataddr
|
||
from email import encoders
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from gateway.platforms.base import (
|
||
BasePlatformAdapter,
|
||
MessageEvent,
|
||
MessageType,
|
||
SendResult,
|
||
cache_document_from_bytes,
|
||
cache_image_from_bytes,
|
||
)
|
||
from gateway.config import Platform, PlatformConfig
|
||
from utils import env_int, env_bool
|
||
|
||
logger = logging.getLogger(__name__)
|
||
# Automated sender patterns — emails from these are silently ignored
|
||
_NOREPLY_PATTERNS = (
|
||
"noreply", "no-reply", "no_reply", "donotreply", "do-not-reply",
|
||
"mailer-daemon", "postmaster", "bounce", "notifications@",
|
||
"automated@", "auto-confirm", "auto-reply", "automailer",
|
||
)
|
||
|
||
# RFC headers that indicate bulk/automated mail
|
||
_AUTOMATED_HEADERS = {
|
||
"Auto-Submitted": lambda v: v.lower() != "no",
|
||
"Precedence": lambda v: v.lower() in {"bulk", "list", "junk"},
|
||
"X-Auto-Response-Suppress": lambda v: bool(v),
|
||
"List-Unsubscribe": lambda v: bool(v),
|
||
}
|
||
|
||
# Gmail-safe max length per email body
|
||
MAX_MESSAGE_LENGTH = 50_000
|
||
DEFAULT_EMAIL_SENDER_NAME = "不來梅的艾瑪"
|
||
DEFAULT_STANDALONE_SUBJECT = "Hermes Agent"
|
||
|
||
SMTP_CONNECT_TIMEOUT = 30
|
||
|
||
|
||
def _create_ipv4_connection(
|
||
host: str,
|
||
port: int,
|
||
timeout: float,
|
||
source_address: Any = None,
|
||
) -> socket.socket:
|
||
"""Create a TCP connection using only IPv4 addresses.
|
||
|
||
This mirrors ``socket.create_connection`` but constrains DNS resolution to
|
||
``AF_INET``. It avoids mutating process-global socket functions, which
|
||
matters because email sends run in executor threads.
|
||
"""
|
||
last_error: OSError | None = None
|
||
for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(
|
||
host, port, socket.AF_INET, socket.SOCK_STREAM
|
||
):
|
||
sock = socket.socket(family, socktype, proto)
|
||
sock.settimeout(timeout)
|
||
try:
|
||
if source_address:
|
||
sock.bind(source_address)
|
||
sock.connect(sockaddr)
|
||
return sock
|
||
except OSError as exc:
|
||
last_error = exc
|
||
sock.close()
|
||
if last_error is not None:
|
||
raise last_error
|
||
raise OSError(f"No IPv4 address found for {host}:{port}")
|
||
|
||
|
||
class _IPv4SMTP(smtplib.SMTP):
|
||
def _get_socket(self, host, port, timeout): # type: ignore[override]
|
||
return _create_ipv4_connection(
|
||
host,
|
||
port,
|
||
timeout,
|
||
source_address=self.source_address,
|
||
)
|
||
|
||
|
||
class _IPv4SMTP_SSL(smtplib.SMTP_SSL):
|
||
def _get_socket(self, host, port, timeout): # type: ignore[override]
|
||
raw_sock = _create_ipv4_connection(
|
||
host,
|
||
port,
|
||
timeout,
|
||
source_address=self.source_address,
|
||
)
|
||
return self.context.wrap_socket(
|
||
raw_sock,
|
||
server_hostname=getattr(self, "_host", host),
|
||
)
|
||
|
||
# Supported image extensions for inline detection
|
||
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||
|
||
def _send_imap_id(imap: "imaplib.IMAP4") -> None:
|
||
"""Send RFC 2971 IMAP ID command identifying this client.
|
||
|
||
Required by 163/NetEase mailbox after LOGIN: without it, every UID
|
||
SEARCH/FETCH returns ``BYE Unsafe Login`` and disconnects. Other
|
||
IMAP servers either honor it silently or reject the unknown command;
|
||
we swallow failures so non-supporting servers keep working.
|
||
"""
|
||
try:
|
||
try:
|
||
from hermes_cli import __version__ as _hermes_version
|
||
except Exception: # noqa: BLE001 — keep ID best-effort if import fails
|
||
_hermes_version = "0"
|
||
imap.xatom(
|
||
"ID",
|
||
f'("name" "hermes-agent" "version" "{_hermes_version}" '
|
||
'"vendor" "NousResearch" '
|
||
'"support-email" "noreply@nousresearch.com")',
|
||
)
|
||
except Exception as e: # noqa: BLE001 — best-effort, never fatal
|
||
logger.debug("[Email] IMAP ID command not accepted: %s", e)
|
||
|
||
|
||
def _is_automated_sender(address: str, headers: dict) -> bool:
|
||
"""Return True if this email is from an automated/noreply source."""
|
||
addr = address.lower()
|
||
if any(pattern in addr for pattern in _NOREPLY_PATTERNS):
|
||
return True
|
||
for header, check in _AUTOMATED_HEADERS.items():
|
||
value = headers.get(header, "")
|
||
if value and check(value):
|
||
return True
|
||
return False
|
||
|
||
def check_email_requirements() -> bool:
|
||
"""Check if email platform settings are available and non-blank.
|
||
|
||
Treats blank/whitespace-only values as missing so an abandoned setup that
|
||
left empty ``EMAIL_*`` keys in ``.env`` does not enable the platform (#40715).
|
||
"""
|
||
addr = os.getenv("EMAIL_ADDRESS", "").strip()
|
||
pwd = os.getenv("EMAIL_PASSWORD", "").strip()
|
||
imap = os.getenv("EMAIL_IMAP_HOST", "").strip()
|
||
smtp = os.getenv("EMAIL_SMTP_HOST", "").strip()
|
||
return all([addr, pwd, imap, smtp])
|
||
|
||
|
||
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 _extract_latest_reply_text(
|
||
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 _extract_latest_reply_text(_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 _extract_latest_reply_text(_strip_html(text))
|
||
return _extract_latest_reply_text(text)
|
||
return ""
|
||
|
||
|
||
_ORIGINAL_MESSAGE_DELIMS = (
|
||
"-----Original Message-----",
|
||
"---------- Forwarded message ----------",
|
||
"Begin forwarded message:",
|
||
)
|
||
|
||
_REPLY_HEADER_PATTERNS = (
|
||
re.compile(r"^On\s+.+?wrote:\s*$", re.IGNORECASE),
|
||
re.compile(r"^.+?於\s+.+?寫道[::]\s*$", re.IGNORECASE),
|
||
)
|
||
|
||
|
||
def _extract_latest_reply_text(text: str) -> str:
|
||
"""Return only the newest human-authored reply from an email body.
|
||
|
||
Email replies often include the entire quoted thread. For gateway command
|
||
handling (notably `/approve` / `/deny`) we must preserve only the fresh
|
||
user text; otherwise the command may be buried after quoted history or be
|
||
polluted with leading `>` quote markers and fail command detection.
|
||
|
||
This helper is intentionally conservative: stop at common reply separators
|
||
(`On ... wrote:`, `-----Original Message-----`) or the first quoted line
|
||
after collecting fresh content. Preserve blank lines inside the fresh reply,
|
||
then trim edge whitespace.
|
||
"""
|
||
normalized = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
||
lines = normalized.split("\n")
|
||
fresh: list[str] = []
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
is_quoted = stripped.startswith(">")
|
||
candidate = stripped[1:].lstrip() if is_quoted else stripped
|
||
|
||
if candidate in _ORIGINAL_MESSAGE_DELIMS:
|
||
break
|
||
if any(pattern.match(candidate) for pattern in _REPLY_HEADER_PATTERNS):
|
||
break
|
||
|
||
# Some mail clients quote even the user's newest reply lines. Preserve
|
||
# a leading quoted block by unquoting it, but once we already captured
|
||
# fresh non-blank text, any later quoted non-blank line marks history.
|
||
if is_quoted:
|
||
if any(existing.strip() for existing in fresh) and candidate:
|
||
break
|
||
fresh.append(candidate)
|
||
continue
|
||
|
||
fresh.append(line)
|
||
|
||
# Drop leading/trailing blank lines while preserving interior spacing.
|
||
while fresh and not fresh[0].strip():
|
||
fresh.pop(0)
|
||
while fresh and not fresh[-1].strip():
|
||
fresh.pop()
|
||
|
||
return "\n".join(fresh).strip()
|
||
|
||
|
||
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 _split_markdown_table_row(line: str) -> list[str]:
|
||
"""Split a pipe-table row into cells.
|
||
|
||
Keeps the parser intentionally small: Hermes only needs standard GFM-style
|
||
tables generated by the model, not a full Markdown engine.
|
||
"""
|
||
row = line.strip()
|
||
if row.startswith("|"):
|
||
row = row[1:]
|
||
if row.endswith("|"):
|
||
row = row[:-1]
|
||
return [cell.strip() for cell in row.split("|")]
|
||
|
||
|
||
def _is_markdown_table_delimiter(line: str) -> bool:
|
||
stripped = line.strip()
|
||
if not stripped or "|" not in stripped:
|
||
return False
|
||
cells = _split_markdown_table_row(stripped)
|
||
if not cells:
|
||
return False
|
||
for cell in cells:
|
||
normalized = cell.replace(" ", "")
|
||
if not normalized or not set(normalized) <= {":", "-"} or "-" not in normalized:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _markdown_tables_to_html_blocks(lines: list[str]) -> list[str]:
|
||
"""Convert GFM-style Markdown tables into HTML table blocks.
|
||
|
||
Non-table lines are returned escaped / line-oriented so the existing email
|
||
renderer can continue to wrap them with ``<br>``.
|
||
"""
|
||
blocks: list[str] = []
|
||
i = 0
|
||
while i < len(lines):
|
||
current = lines[i].rstrip()
|
||
next_line = lines[i + 1].rstrip() if i + 1 < len(lines) else ""
|
||
if "|" in current and _is_markdown_table_delimiter(next_line):
|
||
header = _split_markdown_table_row(current)
|
||
rows: list[list[str]] = []
|
||
i += 2
|
||
while i < len(lines):
|
||
candidate = lines[i].rstrip()
|
||
if not candidate.strip() or "|" not in candidate:
|
||
break
|
||
rows.append(_split_markdown_table_row(candidate))
|
||
i += 1
|
||
table_parts = [
|
||
'<table style="border-collapse:collapse;width:100%;margin:12px 0;">',
|
||
'<thead><tr>',
|
||
]
|
||
for cell in header:
|
||
table_parts.append(
|
||
'<th style="border:1px solid #d0d7de;padding:8px 10px;text-align:left;background:#f6f8fa;">'
|
||
+ html.escape(cell)
|
||
+ '</th>'
|
||
)
|
||
table_parts.append('</tr></thead><tbody>')
|
||
for row in rows:
|
||
table_parts.append('<tr>')
|
||
padded = row + [""] * max(0, len(header) - len(row))
|
||
for cell in padded[:len(header)]:
|
||
table_parts.append(
|
||
'<td style="border:1px solid #d0d7de;padding:8px 10px;vertical-align:top;">'
|
||
+ html.escape(cell)
|
||
+ '</td>'
|
||
)
|
||
table_parts.append('</tr>')
|
||
table_parts.append('</tbody></table>')
|
||
blocks.append(''.join(table_parts))
|
||
continue
|
||
|
||
stripped = current.strip()
|
||
if not stripped:
|
||
blocks.append("<br>")
|
||
elif stripped.startswith(("http://", "https://")):
|
||
safe_url = html.escape(stripped, quote=True)
|
||
blocks.append(f' <a href="{safe_url}">點我下載</a>')
|
||
else:
|
||
blocks.append(html.escape(current))
|
||
i += 1
|
||
return blocks
|
||
|
||
|
||
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 _domain_of(address: str) -> str:
|
||
"""Return the lowercased domain part of an email address, or ''."""
|
||
_, _, domain = address.rpartition("@")
|
||
return domain.strip().lower()
|
||
|
||
|
||
def _domains_aligned(a: str, b: str) -> bool:
|
||
"""Return True if two domains are equal or in an organizational
|
||
parent/subdomain relationship (relaxed DMARC alignment).
|
||
|
||
DMARC relaxed alignment treats ``mail.example.com`` as aligned with
|
||
``example.com``. We approximate organizational alignment by checking
|
||
exact equality or that one domain is a dot-suffix of the other.
|
||
"""
|
||
a = (a or "").strip().lower().rstrip(".")
|
||
b = (b or "").strip().lower().rstrip(".")
|
||
if not a or not b:
|
||
return False
|
||
if a == b:
|
||
return True
|
||
return a.endswith("." + b) or b.endswith("." + a)
|
||
|
||
|
||
# Match a single "method=result" token in an Authentication-Results header,
|
||
# e.g. ``dmarc=pass`` or ``spf=fail``.
|
||
_AUTH_METHOD_RE = re.compile(
|
||
r"\b(dmarc|dkim|spf)\s*=\s*([a-z]+)", re.IGNORECASE
|
||
)
|
||
# Match a property value like ``header.from=example.com`` or
|
||
# ``smtp.mailfrom=user@example.com``.
|
||
_AUTH_PROP_RE = re.compile(
|
||
r"\b(header\.from|header\.d|smtp\.mailfrom|smtp\.from|envelope-from)\s*=\s*([^\s;]+)",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _verify_sender_authentication(
|
||
msg: email_lib.message.Message,
|
||
from_addr: str,
|
||
*,
|
||
authserv_id: str = "",
|
||
) -> Tuple[bool, str]:
|
||
"""Verify that the message's ``From:`` domain is authenticated.
|
||
|
||
The ``From:`` header is attacker-controlled and is never authenticated by
|
||
IMAP delivery, so an allowlist keyed on ``From:`` alone is trivially
|
||
spoofable (GHSA-rxqh-5572-8m77). The only trustworthy signal is the
|
||
``Authentication-Results`` header that the *receiving* mail server (the one
|
||
we IMAP into) stamps after running SPF/DKIM/DMARC. That header is prepended
|
||
by our own server, so the topmost instance is the one we trust; any
|
||
``Authentication-Results`` an attacker injected into the body of their
|
||
message sorts below it.
|
||
|
||
Returns ``(authenticated, reason)``. ``authenticated`` is True when:
|
||
* a DMARC pass is recorded for the From domain, OR
|
||
* an SPF pass aligned with the From domain, OR
|
||
* a DKIM pass aligned (``header.d``) with the From domain.
|
||
|
||
When no ``Authentication-Results`` header is present at all, we return
|
||
``(False, "no Authentication-Results header")`` — fail-closed. Operators
|
||
whose mail server does not stamp this header can opt out of the check
|
||
(see ``EmailAdapter._require_authenticated_sender``).
|
||
"""
|
||
from_domain = _domain_of(from_addr)
|
||
if not from_domain:
|
||
return False, "missing From domain"
|
||
|
||
# get_all preserves header order; the receiving server prepends its result,
|
||
# so the FIRST Authentication-Results is the trusted one. We pin to the
|
||
# configured authserv-id when provided to defend against an injected header
|
||
# that happens to sort first.
|
||
headers = msg.get_all("Authentication-Results") or []
|
||
if not headers:
|
||
return False, "no Authentication-Results header"
|
||
|
||
trusted = None
|
||
for raw in headers:
|
||
value = " ".join(str(raw).split())
|
||
if authserv_id:
|
||
# authserv-id is the first token before the first ';'
|
||
serv = value.split(";", 1)[0].strip().lower()
|
||
if not _domains_aligned(serv, authserv_id) and serv != authserv_id.lower():
|
||
continue
|
||
trusted = value
|
||
break
|
||
if trusted is None:
|
||
return False, "no Authentication-Results from trusted authserv-id"
|
||
|
||
methods = {m.lower(): r.lower() for m, r in _AUTH_METHOD_RE.findall(trusted)}
|
||
props = {p.lower(): v.strip().strip('"') for p, v in _AUTH_PROP_RE.findall(trusted)}
|
||
|
||
# 1) DMARC pass is the strongest signal — DMARC already enforces From
|
||
# alignment, so a pass means the From domain is authenticated.
|
||
if methods.get("dmarc") == "pass":
|
||
return True, "dmarc=pass"
|
||
|
||
# 2) SPF pass aligned with the From domain (the envelope/MAIL FROM domain
|
||
# must match the From domain).
|
||
if methods.get("spf") == "pass":
|
||
spf_domain = _domain_of(props.get("smtp.mailfrom", "")) or props.get(
|
||
"smtp.from", ""
|
||
) or props.get("envelope-from", "")
|
||
spf_domain = _domain_of(spf_domain) if "@" in spf_domain else spf_domain
|
||
if _domains_aligned(spf_domain, from_domain):
|
||
return True, "spf=pass aligned"
|
||
|
||
# 3) DKIM pass aligned with the From domain (the signing domain header.d
|
||
# must align with the From domain).
|
||
if methods.get("dkim") == "pass":
|
||
dkim_domain = props.get("header.d", "") or _domain_of(props.get("header.from", ""))
|
||
if _domains_aligned(dkim_domain, from_domain):
|
||
return True, "dkim=pass aligned"
|
||
|
||
return False, f"authentication failed ({trusted[:120]})"
|
||
|
||
|
||
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).
|
||
"""
|
||
attachments = []
|
||
if not msg.is_multipart():
|
||
return attachments
|
||
|
||
for part in msg.walk():
|
||
disposition = str(part.get("Content-Disposition", ""))
|
||
if skip_attachments and ("attachment" in disposition or "inline" in disposition):
|
||
continue
|
||
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:
|
||
try:
|
||
cached_path = cache_image_from_bytes(payload, ext)
|
||
except ValueError:
|
||
logger.debug("Skipping non-image attachment %s (invalid magic bytes)", filename)
|
||
continue
|
||
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)
|
||
|
||
# Resolve connection settings from the env vars first, then fall back to
|
||
# PlatformConfig.extra (address/imap_host/smtp_host) — the canonical dict
|
||
# gateway.config populates and that the "connected" check, the
|
||
# send-helper, and `hermes config show` already read. Without the
|
||
# fallback a config.yaml-only setup left these empty. Host/address values
|
||
# are stripped: a stray space or newline made IMAP4_SSL raise the
|
||
# misleading ``[Errno 8] nodename nor servname`` (an unresolvable name)
|
||
# instead of an obvious "host not set" error.
|
||
extra = config.extra or {}
|
||
self._address = (os.getenv("EMAIL_ADDRESS", "") or extra.get("address", "")).strip()
|
||
self._from_name = str(extra.get("from_name", DEFAULT_EMAIL_SENDER_NAME) or DEFAULT_EMAIL_SENDER_NAME).strip()
|
||
self._password = os.getenv("EMAIL_PASSWORD", "")
|
||
self._imap_host = (os.getenv("EMAIL_IMAP_HOST", "") or extra.get("imap_host", "")).strip()
|
||
self._imap_port = env_int("EMAIL_IMAP_PORT", 993)
|
||
self._smtp_host = (os.getenv("EMAIL_SMTP_HOST", "") or extra.get("smtp_host", "")).strip()
|
||
self._smtp_port = env_int("EMAIL_SMTP_PORT", 587)
|
||
self._poll_interval = env_int("EMAIL_POLL_INTERVAL", 15)
|
||
|
||
# Skip attachments — configured via config.yaml:
|
||
# platforms:
|
||
# email:
|
||
# skip_attachments: true
|
||
self._skip_attachments = extra.get("skip_attachments", False)
|
||
|
||
# Require the sender's From: domain to be authenticated (SPF/DKIM/DMARC)
|
||
# before trusting it for authorization. The From: header is
|
||
# attacker-controlled and unauthenticated by IMAP, so an allowlist keyed
|
||
# on it alone is spoofable (GHSA-rxqh-5572-8m77). Default ON (fail-closed).
|
||
#
|
||
# Operators whose receiving mail server does not stamp an
|
||
# Authentication-Results header can opt out via config.yaml:
|
||
# platforms:
|
||
# email:
|
||
# require_authenticated_sender: false
|
||
# or the EMAIL_TRUST_FROM_HEADER=true env mirror (parity with the other
|
||
# EMAIL_* access-control vars). When allow-all is in effect the operator
|
||
# has already chosen to accept any sender, so the check is moot and the
|
||
# gate below is skipped.
|
||
if "require_authenticated_sender" in extra:
|
||
self._require_authenticated_sender = bool(extra["require_authenticated_sender"])
|
||
elif env_bool("EMAIL_TRUST_FROM_HEADER", False):
|
||
self._require_authenticated_sender = False
|
||
else:
|
||
self._require_authenticated_sender = True
|
||
|
||
# Optional authserv-id to pin Authentication-Results to the operator's
|
||
# own receiving server (defends against an injected header that sorts
|
||
# first). Defaults to the From-domain of the agent's own address.
|
||
self._authserv_id = (
|
||
extra.get("authserv_id", "") or os.getenv("EMAIL_AUTHSERV_ID", "")
|
||
).strip().lower()
|
||
|
||
# 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
|
||
|
||
# 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)
|
||
|
||
def _trim_seen_uids(self) -> None:
|
||
"""Keep only the most recent UIDs to prevent unbounded memory growth.
|
||
|
||
IMAP UIDs are monotonically increasing integers. When the set grows
|
||
beyond the cap, we keep only the highest half — old UIDs are safe to
|
||
drop because new messages always have higher UIDs and IMAP's UNSEEN
|
||
flag prevents re-delivery regardless.
|
||
"""
|
||
if len(self._seen_uids) <= self._seen_uids_max:
|
||
return
|
||
try:
|
||
# UIDs are bytes like b'1234' — sort numerically and keep top half
|
||
sorted_uids = sorted(self._seen_uids, key=lambda u: int(u))
|
||
keep = self._seen_uids_max // 2
|
||
self._seen_uids = set(sorted_uids[-keep:])
|
||
logger.debug("[Email] Trimmed seen UIDs to %d entries", len(self._seen_uids))
|
||
except (ValueError, TypeError):
|
||
# 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, selecting the correct protocol for the port.
|
||
|
||
Port 465 uses implicit TLS (``SMTP_SSL``). All other ports use
|
||
``SMTP`` + ``STARTTLS``.
|
||
|
||
When the host resolves to an IPv6 address that is unreachable
|
||
(common on networks without IPv6 routing), the default connection can
|
||
hang until the socket timeout expires. We retry connection-level
|
||
failures through an IPv4-only socket path, without mutating global
|
||
resolver state. TLS verification errors are not retried.
|
||
|
||
Returns a connected SMTP object with TLS established — callers
|
||
can proceed directly to ``login()``.
|
||
"""
|
||
ctx = ssl.create_default_context()
|
||
host = self._smtp_host
|
||
port = self._smtp_port
|
||
|
||
def _connect(*, ipv4_only: bool = False) -> smtplib.SMTP:
|
||
"""Attempt one SMTP connection."""
|
||
smtp_cls = _IPv4SMTP if ipv4_only else smtplib.SMTP
|
||
smtp_ssl_cls = _IPv4SMTP_SSL if ipv4_only else smtplib.SMTP_SSL
|
||
if port == 465:
|
||
return smtp_ssl_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT, context=ctx)
|
||
smtp = smtp_cls(host, port, timeout=SMTP_CONNECT_TIMEOUT)
|
||
try:
|
||
smtp.starttls(context=ctx)
|
||
except Exception:
|
||
smtp.close()
|
||
raise
|
||
return smtp
|
||
|
||
try:
|
||
return _connect()
|
||
except (socket.timeout, TimeoutError, ConnectionError, OSError) as exc:
|
||
if isinstance(exc, ssl.SSLError):
|
||
raise
|
||
# Connection-level failure (may be unreachable IPv6).
|
||
# Retry with IPv4 only.
|
||
return _connect(ipv4_only=True)
|
||
|
||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||
"""Connect to the IMAP server and start polling for new messages."""
|
||
# Validate up front so a missing host surfaces as an actionable config
|
||
# error instead of IMAP4_SSL("") raising the cryptic
|
||
# ``[Errno 8] nodename nor servname provided, or not known``.
|
||
missing = [
|
||
name
|
||
for name, value in (
|
||
("EMAIL_ADDRESS", self._address),
|
||
("EMAIL_PASSWORD", self._password),
|
||
("EMAIL_IMAP_HOST", self._imap_host),
|
||
("EMAIL_SMTP_HOST", self._smtp_host),
|
||
)
|
||
if not value
|
||
]
|
||
if missing:
|
||
message = (
|
||
"Not configured — missing "
|
||
+ ", ".join(missing)
|
||
+ ". Set it via `hermes gateway setup` (env) or platforms.email "
|
||
"in config.yaml."
|
||
)
|
||
logger.error("[Email] %s", message)
|
||
# Mark non-retryable so the gateway does NOT keep reconnecting against
|
||
# an empty host. A blank-but-present env var (e.g. ``EMAIL_IMAP_HOST=``)
|
||
# used to slip past the startup gate and drive an indefinite retry
|
||
# loop that leaked memory until the host OOM-killed (#40715).
|
||
self._set_fatal_error(
|
||
"email_missing_configuration", message, retryable=False
|
||
)
|
||
return False
|
||
|
||
try:
|
||
# Test IMAP connection
|
||
imap = imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30)
|
||
imap.login(self._address, self._password)
|
||
_send_imap_id(imap)
|
||
# Mark all existing messages as seen so we only process new ones
|
||
imap.select("INBOX")
|
||
status, data = imap.uid("search", None, "ALL")
|
||
if status == "OK" and data and data[0]:
|
||
for uid in data[0].split():
|
||
self._seen_uids.add(uid)
|
||
# Keep only the most recent UIDs to prevent unbounded growth
|
||
self._trim_seen_uids()
|
||
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 = self._connect_smtp()
|
||
try:
|
||
smtp.login(self._address, self._password)
|
||
finally:
|
||
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, timeout=30)
|
||
try:
|
||
imap.login(self._address, self._password)
|
||
_send_imap_id(imap)
|
||
imap.select("INBOX")
|
||
|
||
status, data = imap.uid("search", None, "UNSEEN")
|
||
if status != "OK" or not data or not data[0]:
|
||
return results
|
||
|
||
for uid in data[0].split():
|
||
if uid in self._seen_uids:
|
||
continue
|
||
self._seen_uids.add(uid)
|
||
# Trim periodically to prevent unbounded memory growth
|
||
if len(self._seen_uids) > self._seen_uids_max:
|
||
self._trim_seen_uids()
|
||
|
||
status, msg_data = imap.uid("fetch", uid, "(RFC822)")
|
||
if status != "OK":
|
||
continue
|
||
|
||
# IMAP fetch can return unexpected structures (e.g. a
|
||
# single bytes item instead of a list of tuples). Guard
|
||
# against IndexError / TypeError so one malformed response
|
||
# doesn't abort the batch — the UID is already in
|
||
# _seen_uids, so an abort would permanently skip the
|
||
# remaining messages in this batch.
|
||
try:
|
||
raw_email = msg_data[0][1]
|
||
except (IndexError, TypeError):
|
||
logger.warning(
|
||
"[Email] Unexpected IMAP response structure for UID %s, skipping",
|
||
uid,
|
||
)
|
||
continue
|
||
if not isinstance(raw_email, (bytes, bytearray)):
|
||
logger.warning(
|
||
"[Email] Non-bytes IMAP payload for UID %s, skipping", uid
|
||
)
|
||
continue
|
||
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", "")
|
||
# Skip automated/noreply senders before any processing
|
||
msg_headers = dict(msg.items())
|
||
if _is_automated_sender(sender_addr, msg_headers):
|
||
logger.debug("[Email] Skipping automated sender: %s", sender_addr)
|
||
continue
|
||
|
||
# Verify the From: domain is authenticated (SPF/DKIM/DMARC)
|
||
# while the raw message — and its trusted
|
||
# Authentication-Results header — is still in scope. The
|
||
# verdict is consumed at dispatch where authorization is
|
||
# decided. From: is attacker-controlled, so this is the only
|
||
# place a spoof can be caught (GHSA-rxqh-5572-8m77).
|
||
sender_authenticated, auth_reason = _verify_sender_authentication(
|
||
msg, sender_addr, authserv_id=self._authserv_id
|
||
)
|
||
|
||
body = _extract_text_body(msg)
|
||
attachments = _extract_attachments(msg, skip_attachments=self._skip_attachments)
|
||
|
||
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", ""),
|
||
"sender_authenticated": sender_authenticated,
|
||
"auth_reason": auth_reason,
|
||
})
|
||
finally:
|
||
try:
|
||
imap.logout()
|
||
except Exception:
|
||
pass
|
||
except Exception as e:
|
||
logger.error("[Email] IMAP fetch error: %s", e)
|
||
return results
|
||
|
||
@staticmethod
|
||
def _allow_all_senders() -> bool:
|
||
"""Return True when the operator opted into accepting any sender.
|
||
|
||
Mirrors the gateway authz allow-all resolution: the per-platform
|
||
EMAIL_ALLOW_ALL_USERS flag or the global GATEWAY_ALLOW_ALL_USERS flag.
|
||
When either is set, sender identity is moot, so the From: authentication
|
||
gate is skipped.
|
||
"""
|
||
truthy = {"true", "1", "yes"}
|
||
return (
|
||
os.getenv("EMAIL_ALLOW_ALL_USERS", "").strip().lower() in truthy
|
||
or os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in truthy
|
||
)
|
||
|
||
@staticmethod
|
||
def _allowlist_in_effect() -> bool:
|
||
"""Return True when a sender allowlist gates email access.
|
||
|
||
Authorization keys on the From: address only when an allowlist is
|
||
configured — the per-platform EMAIL_ALLOWED_USERS or the global
|
||
GATEWAY_ALLOWED_USERS. When neither is set the gateway default-denies
|
||
every sender regardless, so the spoofable From: identity grants nothing
|
||
and the authentication gate is unnecessary.
|
||
"""
|
||
return bool(
|
||
os.getenv("EMAIL_ALLOWED_USERS", "").strip()
|
||
or os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||
)
|
||
|
||
@staticmethod
|
||
def _allowed_sender_domains() -> set[str]:
|
||
"""Return normalized sender domains allowed to enter verification intake.
|
||
|
||
Unlike ``EMAIL_ALLOWED_USERS`` this list is not an authorization grant by
|
||
itself. It only decides whether an otherwise-unknown company sender may
|
||
reach the runner so ``GatewayUserStore.process_inbound_message()`` can
|
||
start the verified-email flow instead of being dropped at adapter intake.
|
||
"""
|
||
raw = os.getenv("EMAIL_ALLOWED_DOMAINS", "").strip()
|
||
domains: set[str] = set()
|
||
for item in raw.split(","):
|
||
normalized = item.strip().lower().lstrip("@")
|
||
if normalized:
|
||
domains.add(normalized)
|
||
return domains
|
||
|
||
@classmethod
|
||
def _sender_domain_can_start_verification(cls, sender_addr: str) -> bool:
|
||
domains = cls._allowed_sender_domains()
|
||
if not domains:
|
||
return False
|
||
return _domain_of(sender_addr) in domains
|
||
|
||
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
|
||
|
||
# Never reply to automated senders
|
||
if _is_automated_sender(sender_addr, {}):
|
||
logger.debug("[Email] Dropping automated sender at dispatch: %s", sender_addr)
|
||
return
|
||
|
||
# Skip senders not in EMAIL_ALLOWED_USERS — prevents the adapter
|
||
# from creating a MessageEvent (and thus thread context) for senders
|
||
# that the gateway will never authorize. Without this early guard,
|
||
# a race between dispatch and authorization can result in the adapter
|
||
# sending a reply even though the handler returned None.
|
||
allowed_raw = os.getenv("EMAIL_ALLOWED_USERS", "").strip()
|
||
if not allowed_raw:
|
||
if os.getenv("EMAIL_ALLOW_ALL_USERS", "").strip().lower() not in {"true", "1", "yes"} and (
|
||
os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() not in {"true", "1", "yes"}
|
||
) and not self._sender_domain_can_start_verification(sender_addr):
|
||
logger.debug(
|
||
"[Email] Dropping sender at dispatch — EMAIL_ALLOWED_USERS is unset "
|
||
"and open access is not opted in: %s",
|
||
sender_addr,
|
||
)
|
||
return
|
||
else:
|
||
allowed = {addr.strip().lower() for addr in allowed_raw.split(",") if addr.strip()}
|
||
if sender_addr.lower() not in allowed:
|
||
if self._sender_domain_can_start_verification(sender_addr):
|
||
logger.debug(
|
||
"[Email] Sender not in EMAIL_ALLOWED_USERS but domain may enter verification flow: %s",
|
||
sender_addr,
|
||
)
|
||
else:
|
||
logger.debug("[Email] Dropping non-allowlisted sender at dispatch: %s", sender_addr)
|
||
return
|
||
|
||
# Reject spoofed senders. The allowlist (and the gateway's own authz)
|
||
# key on sender_addr, which comes straight from the attacker-controlled
|
||
# From: header — so an attacker can forge From: an-allowlisted@addr to
|
||
# get authorized (GHSA-rxqh-5572-8m77). This only matters when an
|
||
# allowlist is actually being used to GRANT access: if no allowlist is
|
||
# configured the gateway default-denies everyone anyway, and if allow-all
|
||
# is on the operator already accepts any sender. So enforce From:
|
||
# authentication exactly when an allowlist is in effect and allow-all is
|
||
# off. Fail-closed: an unauthenticated From: is dropped before it can be
|
||
# matched against the allowlist.
|
||
if (
|
||
self._require_authenticated_sender
|
||
and self._allowlist_in_effect()
|
||
and not self._allow_all_senders()
|
||
and not msg_data.get("sender_authenticated", False)
|
||
):
|
||
logger.warning(
|
||
"[Email] Dropping sender with unauthenticated From: %s (%s). "
|
||
"If your mail server does not stamp Authentication-Results, set "
|
||
"platforms.email.require_authenticated_sender: false (or "
|
||
"EMAIL_TRUST_FROM_HEADER=true) to accept the risk.",
|
||
sender_addr,
|
||
msg_data.get("auth_reason", "no verdict"),
|
||
)
|
||
return
|
||
|
||
subject = msg_data["subject"]
|
||
body = _extract_latest_reply_text(msg_data["body"])
|
||
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" and msg_type == MessageType.TEXT:
|
||
msg_type = MessageType.PHOTO
|
||
elif att["type"] == "document":
|
||
# Document wins over PHOTO for mixed attachments: run.py's
|
||
# image handling keys off the per-path image/* mime type
|
||
# regardless of message_type, but document-context injection
|
||
# gates strictly on MessageType.DOCUMENT — so DOCUMENT is the
|
||
# only classification that surfaces both.
|
||
msg_type = MessageType.DOCUMENT
|
||
|
||
# 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 _message_id_domain(self) -> str:
|
||
"""Domain part for generated Message-IDs.
|
||
|
||
EMAIL_ADDRESS may lack an ``@`` (misconfiguration); fall back to
|
||
``localhost`` instead of crashing send with an IndexError.
|
||
"""
|
||
if "@" in self._address:
|
||
return self._address.rsplit("@", 1)[-1] or "localhost"
|
||
return "localhost"
|
||
|
||
def _from_header(self) -> str:
|
||
"""Return the RFC 5322 From header with display name when available."""
|
||
sender_name = str(self._from_name or "").strip()
|
||
return formataddr((sender_name, self._address)) if sender_name else self._address
|
||
|
||
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._from_header()
|
||
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}"
|
||
_set_utf8_subject(msg, 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["Date"] = formatdate(localtime=True)
|
||
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._message_id_domain()}>"
|
||
msg["Message-ID"] = msg_id
|
||
|
||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||
|
||
smtp = self._connect_smtp()
|
||
try:
|
||
smtp.login(self._address, self._password)
|
||
smtp.send_message(msg)
|
||
finally:
|
||
try:
|
||
smtp.quit()
|
||
except Exception:
|
||
smtp.close()
|
||
|
||
logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject)
|
||
return msg_id
|
||
|
||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||
"""Email has no typing indicator — no-op."""
|
||
|
||
async def send_image(
|
||
self,
|
||
chat_id: str,
|
||
image_url: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Send an image URL as part of an email body.
|
||
|
||
``metadata`` is accepted to honor the base-class contract; the
|
||
email body send doesn't use it.
|
||
"""
|
||
text = caption or ""
|
||
text += f"\n\nImage: {image_url}"
|
||
return await self.send(chat_id, text.strip(), reply_to)
|
||
|
||
async def send_multiple_images(
|
||
self,
|
||
chat_id: str,
|
||
images: List[Tuple[str, str]],
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
human_delay: float = 0.0,
|
||
) -> None:
|
||
"""Send a batch of images as a single email with multiple MIME attachments.
|
||
|
||
Local files are attached directly. URL images have their URL
|
||
appended to the body (email adapter does not download remote
|
||
images). No hard cap — email clients handle dozens of
|
||
attachments fine, subject to SMTP message size limits.
|
||
"""
|
||
if not images:
|
||
return
|
||
|
||
from urllib.parse import unquote as _unquote
|
||
|
||
body_parts: List[str] = []
|
||
local_paths: List[str] = []
|
||
for image_url, alt_text in images:
|
||
if alt_text:
|
||
body_parts.append(alt_text)
|
||
if image_url.startswith("file://"):
|
||
local_path = _unquote(image_url[7:])
|
||
if Path(local_path).exists():
|
||
local_paths.append(local_path)
|
||
else:
|
||
logger.warning("[Email] Skipping missing image: %s", local_path)
|
||
else:
|
||
# Remote URLs just get linked in the body (parity with send_image)
|
||
body_parts.append(f"Image: {image_url}")
|
||
|
||
if not local_paths and not body_parts:
|
||
return
|
||
|
||
body = "\n\n".join(body_parts)
|
||
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
await loop.run_in_executor(
|
||
None,
|
||
self._send_email_with_attachments,
|
||
chat_id,
|
||
body,
|
||
local_paths,
|
||
)
|
||
except Exception as e:
|
||
logger.error("[Email] Multi-image send failed, falling back: %s", e, exc_info=True)
|
||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||
|
||
def _send_email_with_attachments(
|
||
self,
|
||
to_addr: str,
|
||
body: str,
|
||
file_paths: List[str],
|
||
) -> str:
|
||
"""Send an email with multiple file attachments via SMTP."""
|
||
msg = MIMEMultipart()
|
||
msg["From"] = self._from_header()
|
||
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}"
|
||
_set_utf8_subject(msg, 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["Date"] = formatdate(localtime=True)
|
||
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._message_id_domain()}>"
|
||
msg["Message-ID"] = msg_id
|
||
|
||
if body:
|
||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||
|
||
for file_path in file_paths:
|
||
p = Path(file_path)
|
||
try:
|
||
with open(p, "rb") as f:
|
||
part = MIMEBase("application", "octet-stream")
|
||
part.set_payload(f.read())
|
||
encoders.encode_base64(part)
|
||
_set_utf8_attachment_name(part, p.name)
|
||
msg.attach(part)
|
||
except Exception as e:
|
||
logger.warning("[Email] Failed to attach %s: %s", file_path, e)
|
||
|
||
smtp = self._connect_smtp()
|
||
try:
|
||
smtp.login(self._address, self._password)
|
||
smtp.send_message(msg)
|
||
finally:
|
||
try:
|
||
smtp.quit()
|
||
except Exception:
|
||
smtp.close()
|
||
|
||
logger.info("[Email] Sent multi-attachment email to %s (%d files)", to_addr, len(file_paths))
|
||
return msg_id
|
||
|
||
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,
|
||
**kwargs,
|
||
) -> 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._from_header()
|
||
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}"
|
||
_set_utf8_subject(msg, 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["Date"] = formatdate(localtime=True)
|
||
msg_id = f"<hermes-{uuid.uuid4().hex[:12]}@{self._message_id_domain()}>"
|
||
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)
|
||
_set_utf8_attachment_name(part, fname)
|
||
msg.attach(part)
|
||
|
||
smtp = self._connect_smtp()
|
||
try:
|
||
smtp.login(self._address, self._password)
|
||
smtp.send_message(msg)
|
||
finally:
|
||
try:
|
||
smtp.quit()
|
||
except Exception:
|
||
smtp.close()
|
||
|
||
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", ""),
|
||
}
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
# Plugin migration glue (#41112 / #3823)
|
||
#
|
||
# Added when the Email adapter moved from gateway/platforms/email.py into this
|
||
# bundled plugin. register() exposes the platform via the registry, replacing
|
||
# the Platform.EMAIL elif in gateway/run.py, the _PLATFORM_CONNECTED_CHECKERS
|
||
# entry in gateway/config.py, the _PLATFORMS["email"] static dict in
|
||
# hermes_cli/gateway.py, and the _send_email dispatch in
|
||
# tools/send_message_tool.py. EMAIL_* env→PlatformConfig seeding stays in core.
|
||
# ──────────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _render_email_plain(message: str) -> str:
|
||
"""Render a plain-text body that's friendly to mail clients.
|
||
|
||
Put bare URLs on their own line inside angle brackets so auto-linking stays
|
||
reliable even when the client visually wraps long lines.
|
||
"""
|
||
out_lines = []
|
||
for line in str(message or "").splitlines():
|
||
stripped = line.strip()
|
||
if stripped.startswith(("http://", "https://")):
|
||
out_lines.append(f"<{stripped}>")
|
||
else:
|
||
out_lines.append(line)
|
||
return "\n".join(out_lines)
|
||
|
||
|
||
def _render_email_html(message: str) -> str:
|
||
lines = str(message or "").splitlines()
|
||
html_blocks = _markdown_tables_to_html_blocks(lines)
|
||
body = "<br>\n".join(html_blocks)
|
||
return f"<html><body style=\"font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;line-height:1.6;white-space:normal;\">{body}</body></html>"
|
||
|
||
|
||
def _set_utf8_subject(msg, subject: str) -> None:
|
||
msg["Subject"] = str(Header(str(subject or ""), "utf-8"))
|
||
|
||
|
||
def _set_utf8_attachment_name(part, filename: str) -> None:
|
||
safe_name = str(filename or "attachment")
|
||
part.set_param("name", safe_name, header="Content-Type", charset="utf-8", language="")
|
||
part.add_header(
|
||
"Content-Disposition",
|
||
"attachment",
|
||
filename=("utf-8", "", safe_name),
|
||
)
|
||
|
||
|
||
async def _standalone_send(
|
||
pconfig,
|
||
chat_id,
|
||
message,
|
||
*,
|
||
thread_id=None,
|
||
media_files=None,
|
||
force_document=False,
|
||
subject=None,
|
||
from_name=None,
|
||
):
|
||
"""Out-of-process Email delivery via SMTP (one-shot). Implements the
|
||
standalone_sender_fn contract; replaces the legacy _send_email helper."""
|
||
import smtplib
|
||
import ssl as _ssl
|
||
from email.mime.text import MIMEText
|
||
from email.utils import formatdate
|
||
|
||
extra = getattr(pconfig, "extra", {}) or {}
|
||
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", "")
|
||
sender_name = str(from_name or extra.get("from_name") or "").strip()
|
||
email_subject = str(subject or extra.get("subject") or DEFAULT_STANDALONE_SUBJECT).strip() or DEFAULT_STANDALONE_SUBJECT
|
||
try:
|
||
smtp_port = int(os.getenv("EMAIL_SMTP_PORT", "587"))
|
||
except (ValueError, TypeError):
|
||
smtp_port = 587
|
||
|
||
if not all([address, password, smtp_host]):
|
||
return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"}
|
||
|
||
try:
|
||
media_files = list(media_files or [])
|
||
plain_body = _render_email_plain(message)
|
||
html_body = _render_email_html(message)
|
||
if media_files:
|
||
msg = MIMEMultipart()
|
||
alt = MIMEMultipart("alternative")
|
||
alt.attach(MIMEText(plain_body, "plain", "utf-8"))
|
||
alt.attach(MIMEText(html_body, "html", "utf-8"))
|
||
msg.attach(alt)
|
||
attached_count = 0
|
||
for media_entry in media_files:
|
||
file_path = "<unknown>"
|
||
try:
|
||
file_path = media_entry[0] if isinstance(media_entry, tuple) else media_entry
|
||
p = Path(file_path).expanduser()
|
||
with open(p, "rb") as f:
|
||
content_type, _encoding = mimetypes.guess_type(str(p))
|
||
if not content_type or _encoding:
|
||
content_type = "application/octet-stream"
|
||
maintype, subtype = content_type.split("/", 1)
|
||
part = MIMEBase(maintype, subtype)
|
||
part.set_payload(f.read())
|
||
encoders.encode_base64(part)
|
||
_set_utf8_attachment_name(part, p.name)
|
||
msg.attach(part)
|
||
attached_count += 1
|
||
except Exception as attach_err:
|
||
logger.warning(
|
||
"[Email] standalone attachment failed for %s: %s",
|
||
str(file_path),
|
||
attach_err,
|
||
)
|
||
else:
|
||
msg = MIMEMultipart("alternative")
|
||
msg.attach(MIMEText(plain_body, "plain", "utf-8"))
|
||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||
attached_count = 0
|
||
msg["From"] = formataddr((sender_name, address)) if sender_name else address
|
||
msg["To"] = chat_id
|
||
_set_utf8_subject(msg, email_subject)
|
||
msg["Date"] = formatdate(localtime=True)
|
||
|
||
server = smtplib.SMTP(smtp_host, smtp_port)
|
||
server.starttls(context=_ssl.create_default_context())
|
||
server.login(address, password)
|
||
server.send_message(msg)
|
||
server.quit()
|
||
if attached_count:
|
||
logger.info("[Email] Sent multi-attachment email to %s (%d files)", chat_id, attached_count)
|
||
else:
|
||
logger.info("[Email] Sent reply to %s (subject: %s)", chat_id, email_subject)
|
||
return {"success": True, "platform": "email", "chat_id": chat_id}
|
||
except Exception as e:
|
||
try:
|
||
from tools.send_message_tool import _error as _e
|
||
return _e(f"Email send failed: {e}")
|
||
except Exception:
|
||
return {"error": f"Email send failed: {e}"}
|
||
|
||
|
||
def _is_connected(config) -> bool:
|
||
"""Email is connected when an address is configured (in PlatformConfig.extra
|
||
or via EMAIL_ADDRESS). Mirrors the legacy
|
||
_PLATFORM_CONNECTED_CHECKERS[Platform.EMAIL] = bool(extra.get('address'))."""
|
||
extra = getattr(config, "extra", {}) or {}
|
||
if extra.get("address"):
|
||
return True
|
||
import hermes_cli.gateway as gateway_mod
|
||
return bool((gateway_mod.get_env_value("EMAIL_ADDRESS") or "").strip())
|
||
|
||
|
||
def _build_adapter(config):
|
||
"""Factory wrapper that constructs EmailAdapter from a PlatformConfig."""
|
||
return EmailAdapter(config)
|
||
|
||
|
||
def register(ctx) -> None:
|
||
"""Plugin entry point — called by the Hermes plugin system."""
|
||
ctx.register_platform(
|
||
name="email",
|
||
label="Email",
|
||
adapter_factory=_build_adapter,
|
||
check_fn=check_email_requirements,
|
||
is_connected=_is_connected,
|
||
required_env=["EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_SMTP_HOST"],
|
||
install_hint="Email uses the Python stdlib (smtplib/imaplib) — no extra deps",
|
||
allowed_users_env="EMAIL_ALLOWED_USERS",
|
||
allow_all_env="EMAIL_ALLOW_ALL_USERS",
|
||
cron_deliver_env_var="EMAIL_HOME_ADDRESS",
|
||
standalone_sender_fn=_standalone_send,
|
||
max_message_length=50_000,
|
||
pii_safe=True,
|
||
emoji="📧",
|
||
allow_update_command=True,
|
||
)
|