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("
", "\n").replace("
", "\n").replace("

", "\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}"