fix: prevent email loop by detecting bounces and capping conversation length

- 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
This commit is contained in:
Claude
2026-02-26 20:46:09 +00:00
parent fa6b520f19
commit 9dac54fe81
5 changed files with 235 additions and 0 deletions
+73
View File
@@ -89,6 +89,75 @@ def _strip_quoted_text(text: str) -> str:
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}>"
@@ -185,6 +254,8 @@ class EmailChannel:
imap.store(num, "+FLAGS", "\\Seen")
continue
is_automated, automated_reason = detect_automated_message(msg, from_addr)
messages.append(
{
"from": from_addr,
@@ -194,6 +265,8 @@ class EmailChannel:
"references": references,
"body": body,
"raw_body": raw_body,
"is_automated": is_automated,
"automated_reason": automated_reason,
}
)
imap.store(num, "+FLAGS", "\\Seen")