Files
OpenOPC/opc/channels/email.py
T
2026-07-01 17:56:31 +08:00

268 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import html
import imaplib
import smtplib
import ssl
from email import policy
from email.header import decode_header, make_header
from email.message import EmailMessage
from email.parser import BytesParser
from email.utils import parseaddr
from typing import Any
from loguru import logger
from opc.channels.provider_base import PollingChannel
from opc.core.models import SystemMessage
from opc.layer4_tools.output_budget import clip_text, persist_tool_result
class EmailChannel(PollingChannel):
name = "email"
required_package = None
def __init__(self, config: Any, bus: Any):
super().__init__(config, bus)
self._processed_uids: set[str] = set()
self._last_subject_by_chat: dict[str, str] = {}
self._last_message_id_by_chat: dict[str, str] = {}
def get_required_config_fields(self) -> list[str]:
return [
"imap_host",
"imap_username",
"imap_password",
"smtp_host",
"smtp_username",
"smtp_password",
]
def get_poll_interval_seconds(self) -> float:
return max(5.0, float(self.config.poll_interval_seconds))
async def start(self) -> None:
if not self.config.consent_granted:
raise RuntimeError("email channel requires `consent_granted: true` before polling or sending")
await super().start()
def normalize_email(self, payload: dict[str, Any]) -> dict[str, Any]:
sender = str(payload.get("from", "") or "")
thread_id = str(payload.get("thread_id", "") or "")
return {
"sender_id": sender,
"chat_id": thread_id or sender,
"content": str(payload.get("body", "") or payload.get("subject", "")),
"thread_id": thread_id,
"reply_to": str(payload.get("message_id", "") or ""),
"metadata": {
"email_subject": str(payload.get("subject", "") or ""),
"email_from": sender,
"body_truncated": bool(payload.get("body_truncated", False)),
"body_omitted_chars": int(payload.get("body_omitted_chars", 0) or 0),
"full_body_path": str(payload.get("full_body_path", "") or ""),
},
}
async def poll_once(self) -> None:
messages = await asyncio.to_thread(self._fetch_new_messages)
for item in messages:
sender = str(item.get("from", "") or "")
if sender:
subject = str(item.get("subject", "") or "")
if subject:
self._last_subject_by_chat[sender] = subject
message_id = str(item.get("message_id", "") or "")
if message_id:
self._last_message_id_by_chat[sender] = message_id
await self.publish_normalized(self.normalize_email(item))
async def send(self, message: SystemMessage) -> None:
await super().send(message)
if not self.config.consent_granted:
logger.warning("skip email send because consent_granted is false")
return
to_addr = str((message.metadata or {}).get("chat_id") or message.session_id or "").strip()
if not to_addr:
logger.warning("email outbound missing recipient address")
return
metadata = dict(message.metadata or {})
is_reply = to_addr in self._last_subject_by_chat
force_send = bool(metadata.get("force_send"))
if is_reply and not self.config.auto_reply_enabled and not force_send:
logger.info("skip automatic email reply to {} because auto_reply_enabled is false", to_addr)
return
base_subject = self._last_subject_by_chat.get(to_addr, "OpenOPC reply")
subject = str(metadata.get("subject", "") or "").strip() or self._reply_subject(base_subject)
email_msg = EmailMessage()
email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username
email_msg["To"] = to_addr
email_msg["Subject"] = subject
email_msg.set_content(message.content or "")
in_reply_to = str(metadata.get("reply_to") or self._last_message_id_by_chat.get(to_addr, "") or "")
if in_reply_to:
email_msg["In-Reply-To"] = in_reply_to
email_msg["References"] = in_reply_to
await asyncio.to_thread(self._smtp_send, email_msg)
def _smtp_send(self, email_msg: EmailMessage) -> None:
timeout = 30
if self.config.smtp_use_ssl:
with smtplib.SMTP_SSL(self.config.smtp_host, self.config.smtp_port, timeout=timeout) as smtp:
smtp.login(self.config.smtp_username, self.config.smtp_password)
smtp.send_message(email_msg)
return
with smtplib.SMTP(self.config.smtp_host, self.config.smtp_port, timeout=timeout) as smtp:
if self.config.smtp_use_tls:
smtp.starttls(context=ssl.create_default_context())
smtp.login(self.config.smtp_username, self.config.smtp_password)
smtp.send_message(email_msg)
def _connect_imap(self) -> Any:
if self.config.imap_use_ssl:
return imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
return imaplib.IMAP4(self.config.imap_host, self.config.imap_port)
def _fetch_new_messages(self) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
client = self._connect_imap()
try:
client.login(self.config.imap_username, self.config.imap_password)
status, _ = client.select(self.config.imap_mailbox or "INBOX")
if status != "OK":
return messages
status, data = client.search(None, "UNSEEN")
if status != "OK" or not data:
return messages
for imap_id in data[0].split():
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
if status != "OK" or not fetched:
continue
uid = self._extract_uid(fetched)
if uid and uid in self._processed_uids:
continue
raw_bytes = self._extract_message_bytes(fetched)
if not raw_bytes:
continue
parsed = self._parse_message_bytes(raw_bytes)
if not parsed:
continue
if uid:
self._processed_uids.add(uid)
messages.append(parsed)
if self.config.mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
finally:
try:
client.logout()
except Exception:
pass
return messages
@staticmethod
def _extract_uid(fetched: list[Any]) -> str:
for item in fetched:
if not isinstance(item, tuple):
continue
header = item[0]
if isinstance(header, bytes):
text = header.decode("utf-8", errors="ignore")
if "UID " in text:
return text.split("UID ", 1)[1].split(")", 1)[0].strip()
return ""
@staticmethod
def _extract_message_bytes(fetched: list[Any]) -> bytes:
for item in fetched:
if isinstance(item, tuple) and isinstance(item[1], bytes):
return item[1]
return b""
def _parse_message_bytes(self, raw_bytes: bytes) -> dict[str, Any] | None:
message = BytesParser(policy=policy.default).parsebytes(raw_bytes)
sender = parseaddr(message.get("From", ""))[1]
if not sender:
return None
subject = self._decode_header_value(message.get("Subject", "")) or "(no subject)"
body = self._extract_text_body(message).strip()
if not body:
body = subject
body_clip = clip_text(
body,
limit=max(1, int(self.config.max_body_chars)),
marker="email body preview truncated",
)
persisted = {}
if body_clip.truncated:
persisted = persist_tool_result(body, tool_name="email_body", extension="txt")
thread_id = (
str(message.get("Thread-Index", "") or "")
or str(message.get("References", "") or "").split()[-1] if str(message.get("References", "") or "").split() else ""
)
if not thread_id:
thread_id = str(message.get("Message-ID", "") or sender)
return {
"from": sender,
"subject": subject,
"body": body_clip.text,
"body_truncated": body_clip.truncated,
"body_omitted_chars": body_clip.omitted_chars,
"full_body_path": persisted.get("full_output_path", ""),
"message_id": str(message.get("Message-ID", "") or ""),
"thread_id": thread_id,
}
@staticmethod
def _decode_header_value(value: str) -> str:
try:
return str(make_header(decode_header(value)))
except Exception:
return value or ""
def _extract_text_body(self, message: Any) -> str:
if message.is_multipart():
for part in message.walk():
disposition = str(part.get_content_disposition() or "")
content_type = str(part.get_content_type() or "")
if disposition == "attachment":
continue
if content_type == "text/plain":
payload = part.get_content()
return payload if isinstance(payload, str) else str(payload)
if content_type == "text/html":
payload = part.get_content()
if isinstance(payload, str):
return self._html_to_text(payload)
return ""
payload = message.get_content()
if isinstance(payload, str) and message.get_content_type() == "text/html":
return self._html_to_text(payload)
return payload if isinstance(payload, str) else str(payload)
@staticmethod
def _html_to_text(value: str) -> str:
text = value.replace("<br>", "\n").replace("<br/>", "\n").replace("</p>", "\n")
text = html.unescape(text)
inside = False
out: list[str] = []
for char in text:
if char == "<":
inside = True
continue
if char == ">":
inside = False
continue
if not inside:
out.append(char)
return "".join(out).strip()
def _reply_subject(self, value: str) -> str:
lowered = value.lower()
prefix = self.config.subject_prefix or "Re: "
return value if lowered.startswith(prefix.lower()) else f"{prefix}{value}"