- email_channel.py: add detect_automated_message() that inspects RFC 3834 Auto-Submitted, mailer-daemon/postmaster sender patterns, X-Loop, multipart/report Content-Type, Precedence, and subject heuristics. fetch_unread_messages() now includes is_automated / automated_reason in every message dict. - main.py: if is_automated is set, call agent.handle_automated_message() instead of process_message() — no reply is ever sent to a bounce source. - agent/core.py: add MAX_USER_MESSAGES = 20 cap; process_message() returns "" without replying once a conversation exceeds the limit and calls notifier.notify_loop_escalation() on first breach. New public method handle_automated_message() records the event and triggers the same one-shot admin alert. - models/conversation.py: add loop_escalated: bool field (persisted) so the admin alert fires at most once per conversation. - notifications/notifier.py: add notify_loop_escalation() which sends a plain-text warning to the admin CC list (Markus Graf / spielgruppen@). https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
381 lines
13 KiB
Python
381 lines
13 KiB
Python
"""IMAP / SMTP email channel adapter.
|
|
|
|
Handles:
|
|
- Polling the inbox for unread messages (IMAP)
|
|
- Conversation matching by sender email address (NOT by thread headers)
|
|
- Sending reply emails (SMTP) with proper threading headers for email clients
|
|
- Stripping quoted reply text so the agent only sees the new content
|
|
|
|
Threading headers (Message-ID, In-Reply-To, References) are preserved for
|
|
outbound replies so messages appear threaded in Gmail/Outlook, but they are
|
|
NOT used to identify which conversation an incoming message belongs to.
|
|
Conversation matching is exclusively by normalized sender email address.
|
|
"""
|
|
|
|
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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Automated / bounce message detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Local parts of sender addresses that are never humans (RFC 5321 §4.5.4, common practice).
|
|
_AUTOMATED_SENDER_RE = re.compile(
|
|
r"^(mailer-daemon|postmaster|noreply|no-reply|no\.reply|do-not-reply|"
|
|
r"donotreply|bounce|bounce\+.*|delivery|mail-delivery|mail\.delivery)$",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Subject lines that indicate delivery failure or automated responses.
|
|
_AUTOMATED_SUBJECT_RE = re.compile(
|
|
r"(undelivered mail|undeliverable|delivery (failed|status|notification)|"
|
|
r"mail delivery (failed|error)|returned to sender|mailer-daemon|"
|
|
r"auto.?reply|out of office|außer haus|abwesenheitsnotiz|automatische antwort)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def detect_automated_message(raw_msg: email.message.Message, from_addr: str) -> tuple[bool, str]:
|
|
"""Detect whether an email was generated by an automated system, not a human.
|
|
|
|
Checks (in order of reliability):
|
|
1. Sender local-part (mailer-daemon, postmaster, noreply, …)
|
|
2. Auto-Submitted header (RFC 3834)
|
|
3. X-Auto-Response-Suppress header (Microsoft Exchange)
|
|
4. Content-Type: multipart/report (RFC 3462 — Delivery Status Notifications)
|
|
5. X-Loop header
|
|
6. Precedence: bulk/junk
|
|
7. Subject-line heuristics
|
|
|
|
Returns:
|
|
(True, reason_string) if automated, (False, "") otherwise.
|
|
"""
|
|
local = from_addr.split("@")[0] if "@" in from_addr else from_addr
|
|
if _AUTOMATED_SENDER_RE.match(local):
|
|
return True, f"sender matches automated address pattern: {from_addr}"
|
|
|
|
# RFC 3834 — Auto-Submitted header
|
|
auto_submitted = raw_msg.get("Auto-Submitted", "").strip().lower()
|
|
if auto_submitted and auto_submitted != "no":
|
|
return True, f"Auto-Submitted: {auto_submitted}"
|
|
|
|
# Microsoft Exchange — suppresses auto-replies
|
|
if raw_msg.get("X-Auto-Response-Suppress"):
|
|
return True, "X-Auto-Response-Suppress header present"
|
|
|
|
# RFC 3462 — multipart/report is used for DSNs and MDNs
|
|
if raw_msg.get_content_type() == "multipart/report":
|
|
return True, "Content-Type: multipart/report (delivery status notification)"
|
|
|
|
# X-Loop — set by mailing-list managers and some MTAs to break loops
|
|
if raw_msg.get("X-Loop"):
|
|
return True, f"X-Loop: {raw_msg.get('X-Loop')}"
|
|
|
|
# Precedence header
|
|
precedence = raw_msg.get("Precedence", "").strip().lower()
|
|
if precedence in ("bulk", "junk", "auto_reply"):
|
|
return True, f"Precedence: {precedence}"
|
|
|
|
# Subject heuristic (weakest — only flag when highly specific)
|
|
subject = _decode_header(raw_msg.get("Subject", ""))
|
|
if _AUTOMATED_SUBJECT_RE.search(subject):
|
|
return True, f"subject matches automated pattern: {subject!r}"
|
|
|
|
return False, ""
|
|
|
|
|
|
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}>"
|
|
|
|
|
|
def _build_quoted_block(original_text: str, from_addr: str) -> str:
|
|
"""Format original_text as a standard email quote block.
|
|
|
|
Produces the classic:
|
|
|
|
On <date>, <from> wrote:
|
|
> line 1
|
|
> line 2
|
|
"""
|
|
date_str = time.strftime("%a, %d %b %Y %H:%M", time.localtime())
|
|
header = f"Am {date_str} schrieb {from_addr}:"
|
|
quoted_lines = "\n".join(
|
|
f"> {line}" for line in original_text.splitlines()
|
|
)
|
|
return f"\n\n{header}\n{quoted_lines}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 (use this as conversation key)
|
|
subject — decoded subject line
|
|
message_id — Message-ID of this inbound email (for reply threading)
|
|
in_reply_to — In-Reply-To header (for reply threading, may be empty)
|
|
references — References header (for reply threading, may be empty)
|
|
body — stripped plain-text body (quoted text removed)
|
|
|
|
Note: ``thread_id`` is no longer returned. Conversation matching is done
|
|
by ``from`` (sender email address), not by threading headers.
|
|
"""
|
|
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()
|
|
|
|
raw_body = _extract_text(msg)
|
|
body = _strip_quoted_text(raw_body)
|
|
|
|
if not body.strip():
|
|
imap.store(num, "+FLAGS", "\\Seen")
|
|
continue
|
|
|
|
is_automated, automated_reason = detect_automated_message(msg, from_addr)
|
|
|
|
messages.append(
|
|
{
|
|
"from": from_addr,
|
|
"subject": subject,
|
|
"message_id": message_id,
|
|
"in_reply_to": in_reply_to,
|
|
"references": references,
|
|
"body": body,
|
|
"raw_body": raw_body,
|
|
"is_automated": is_automated,
|
|
"automated_reason": automated_reason,
|
|
}
|
|
)
|
|
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 = "",
|
|
quoted_text: str = "",
|
|
quoted_from: str = "",
|
|
) -> str:
|
|
"""Send an email reply.
|
|
|
|
If quoted_text is provided it is appended to body as a standard
|
|
``> ``-prefixed quote block so parents can see what they wrote.
|
|
|
|
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)
|
|
|
|
# Append quoted original message
|
|
if quoted_text.strip():
|
|
body = body + _build_quoted_block(quoted_text, quoted_from or to)
|
|
|
|
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
|
|
|