"""IMAP / SMTP email channel adapter. Handles: - Polling the inbox for unread messages (IMAP) - Thread tracking via Message-ID / In-Reply-To / References headers - Sending reply emails (SMTP) with proper threading headers - Stripping quoted reply text so the agent only sees the new content """ import email import email.header import email.utils import imaplib import logging import re import smtplib import time from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Optional logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _decode_header(value: str) -> str: """Decode an RFC-2047 encoded email header value.""" parts = email.header.decode_header(value or "") 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(msg: email.message.Message) -> str: """Extract the plain-text body from a (potentially multi-part) message.""" if msg.is_multipart(): for part in msg.walk(): if ( part.get_content_type() == "text/plain" and "attachment" not in str(part.get("Content-Disposition", "")) ): charset = part.get_content_charset() or "utf-8" payload = part.get_payload(decode=True) if payload: return payload.decode(charset, errors="replace") else: charset = msg.get_content_charset() or "utf-8" payload = msg.get_payload(decode=True) if payload: return payload.decode(charset, errors="replace") return "" def _strip_quoted_text(text: str) -> str: """Remove quoted reply text from the email body. Heuristics: - Drop lines starting with ">" - Stop at common reply-separator patterns """ lines = text.splitlines() result: list[str] = [] for line in lines: stripped = line.strip() if stripped.startswith(">"): continue # Common separators used by email clients if re.match(r"^-{3,}|^_{3,}|^={3,}", stripped): break if re.match(r"^On .+ wrote:$", stripped): break if re.match(r"^Am .+ schrieb .+:$", stripped): # German Outlook/Thunderbird break if "-----Original Message-----" in stripped: break result.append(line) return "\n".join(result).strip() def _generate_message_id(from_addr: str) -> str: domain = from_addr.split("@")[-1] if "@" in from_addr else "meister-eder.local" return f"<{time.time():.6f}.{id(from_addr)}@{domain}>" # --------------------------------------------------------------------------- # Main class # --------------------------------------------------------------------------- class EmailChannel: """Wraps IMAP polling and SMTP sending for the email conversation channel.""" def __init__( self, imap_host: str, imap_port: int, smtp_host: str, smtp_port: int, username: str, password: str, use_ssl: bool = True, use_tls: bool = True, registration_email: str = "", ) -> None: self._imap_host = imap_host self._imap_port = imap_port self._smtp_host = smtp_host self._smtp_port = smtp_port self._username = username self._password = password self._use_ssl = use_ssl self._use_tls = use_tls self._from_email = registration_email or username # ------------------------------------------------------------------ # IMAP — receive # ------------------------------------------------------------------ def fetch_unread_messages(self) -> list[dict]: """Poll the inbox and return all unread messages as structured dicts. Each dict contains: from — sender email address subject — decoded subject line message_id — Message-ID header of this email in_reply_to — In-Reply-To header (may be empty) references — References header (may be empty) thread_id — canonical ID for the email thread body — stripped plain-text body (quoted text removed) """ messages: list[dict] = [] try: imap = self._connect_imap() imap.select("INBOX") _, data = imap.search(None, "UNSEEN") msg_nums = data[0].split() for num in msg_nums: try: _, raw_data = imap.fetch(num, "(RFC822)") raw = raw_data[0][1] msg = email.message_from_bytes(raw) from_addr = email.utils.parseaddr(msg.get("From", ""))[1] subject = _decode_header(msg.get("Subject", "(no subject)")) message_id = msg.get("Message-ID", "").strip() in_reply_to = msg.get("In-Reply-To", "").strip() references = msg.get("References", "").strip() body = _extract_text(msg) body = _strip_quoted_text(body) if not body.strip(): imap.store(num, "+FLAGS", "\\Seen") continue thread_id = self._resolve_thread_id(message_id, in_reply_to, references) messages.append( { "from": from_addr, "subject": subject, "message_id": message_id, "in_reply_to": in_reply_to, "references": references, "thread_id": thread_id, "body": body, } ) imap.store(num, "+FLAGS", "\\Seen") except Exception: logger.exception("Error processing IMAP message %s", num) imap.logout() except Exception: logger.exception("IMAP connection/fetch error") return messages # ------------------------------------------------------------------ # SMTP — send # ------------------------------------------------------------------ def send_reply( self, to: str, subject: str, body: str, in_reply_to: str = "", references: str = "", ) -> str: """Send an email reply. Returns the new Message-ID so the caller can track the thread. """ new_message_id = _generate_message_id(self._from_email) # Ensure subject starts with "Re:" if not subject.lower().startswith("re:"): subject = f"Re: {subject}" # Build References chain ref_parts = [r for r in [references, in_reply_to] if r] new_references = " ".join(ref_parts) msg = MIMEMultipart("alternative") msg["From"] = self._from_email msg["To"] = to msg["Subject"] = subject msg["Message-ID"] = new_message_id if in_reply_to: msg["In-Reply-To"] = in_reply_to if new_references: msg["References"] = new_references msg.attach(MIMEText(body, "plain", "utf-8")) if not self._smtp_host: logger.warning("SMTP not configured — reply NOT sent to %s: %s", to, subject) logger.debug("Reply body:\n%s", body) return new_message_id try: if self._use_tls: server = smtplib.SMTP(self._smtp_host, self._smtp_port) server.starttls() else: server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port) server.login(self._username, self._password) server.sendmail(self._from_email, [to], msg.as_string()) server.quit() logger.info("Reply sent to %s (thread %s)", to, in_reply_to or new_message_id) except Exception: logger.exception("Failed to send reply to %s", to) return new_message_id def send_reminder( self, to: str, subject: str, body: str, in_reply_to: str = "", references: str = "", ) -> None: """Send a reminder email for an incomplete registration.""" self.send_reply( to=to, subject=subject, body=body, in_reply_to=in_reply_to, references=references, ) # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _connect_imap(self) -> imaplib.IMAP4: if self._use_ssl: conn = imaplib.IMAP4_SSL(self._imap_host, self._imap_port) else: conn = imaplib.IMAP4(self._imap_host, self._imap_port) conn.login(self._username, self._password) return conn @staticmethod def _resolve_thread_id(message_id: str, in_reply_to: str, references: str) -> str: """Determine the canonical thread ID from email threading headers. The root of the thread is the first message ever sent — its ID is the first token in the References header (oldest first convention). """ if references: first_ref = references.strip().split()[0] return first_ref if in_reply_to: return in_reply_to.strip() return message_id.strip() or f""