Creates openspec/changes/email-loop-prevention/ with all artifacts: - proposal.md: Why (MAILER-DAEMON bounce loop incident), what changes (automated sender detection + message-count cap), capabilities affected - design.md: Two-layer defence rationale, detection signal table, decisions on channel/agent boundary split, 20-message cap, one-shot alert, CC-only routing; risks and trade-offs documented - specs/email-channel/spec.md: ADDED requirements for detect_automated_message(), is_automated/automated_reason message dict fields, no-reply guarantee - specs/registration-notifications/spec.md: ADDED requirements for notify_loop_escalation() — alert content, one-shot guarantee, CC-only routing, dev-mode guard - tasks.md: All 6 sections fully checked (implementation already complete) https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
5.0 KiB
Context
The email poll loop (main.py) fetches all unread messages and passes each one to EmailAgent.process_message(), which calls the LLM and returns a reply. The reply is then sent via SMTP. There was no check to determine whether the inbound message came from a human or an automated system. Any message that arrived in the inbox — including MAILER-DAEMON bounces triggered by the agent's own previous reply — was processed and replied to, completing the loop.
Goals / Non-Goals
Goals:
- Prevent the agent from replying to automated/bounce messages
- Alert admin once when an automated loop is detected
- Enforce a hard upper bound on conversation length as a secondary safety net
- Persist escalation state so alerts are not repeated across poll cycles
Non-Goals:
- General spam detection
- Blocking specific sender addresses permanently
- Exposing loop-detection configuration via the admin UI
Decisions
1. Two-layer defence
Decision: Implement two independent checks in sequence:
- Header-based automated sender detection (catches known patterns immediately)
- Message-count cap (catches anything that slips through layer 1)
Rationale: Neither layer is infallible alone. Header-based detection covers RFC-standard signals and common patterns, but edge cases exist (e.g. a forwarding alias that strips headers). The count cap is a last-resort guarantee that no conversation runs forever.
2. Detection at the channel layer, handling in the agent
Decision: email_channel.py performs the header analysis and adds is_automated / automated_reason to the message dict. main.py checks the flag and calls agent.handle_automated_message() instead of agent.process_message().
Rationale: The channel layer already has access to the raw email.message.Message object with all headers. The agent layer has access to conversation state and the notifier. Splitting cleanly at the channel/agent boundary keeps each layer doing what it does best without coupling them further.
Alternative considered: Detecting in the agent by inspecting the message text. Rejected — by that point the raw headers are gone, and text-based detection is less reliable than header-based.
3. Detection signals (in priority order)
| Signal | Standard | Reliability |
|---|---|---|
Sender local-part: mailer-daemon, postmaster, noreply, no-reply, bounce, … |
RFC 5321 §4.5.4 | Very high |
Auto-Submitted: ≠ no |
RFC 3834 | Very high |
X-Auto-Response-Suppress: present |
MS Exchange | Very high |
Content-Type: multipart/report |
RFC 3462 | Very high |
X-Loop: present |
MTA convention | High |
Precedence: bulk or junk |
Common practice | Medium |
| Subject heuristics (Undelivered Mail, Out of Office, Abwesenheitsnotiz, …) | — | Medium |
Precedence: list is intentionally excluded — mailing-list messages may be legitimate.
4. Message-count cap set at 20
Decision: MAX_USER_MESSAGES = 20. If process_message() is called when there are already more than 20 user messages in the history, return "" (no reply) and escalate to admin.
Rationale: A typical registration takes 8–12 exchanges. 20 gives ample room for slow or verbose conversations while still catching runaway loops. The value is a module-level constant so it can be changed without config infrastructure overhead.
5. One-shot admin alert via loop_escalated flag
Decision: Add loop_escalated: bool to ConversationState. The admin is notified exactly once per conversation. Subsequent automated messages or over-limit polls are silently dropped after the flag is set.
Rationale: The admin needs to know something is wrong, but receiving one alert per bounce (which may arrive many times per minute) would create inbox spam worse than the original problem.
Implementation: The flag is persisted to JSON so it survives agent restarts.
6. Admin notification routed to CC list
Decision: Loop-escalation alerts go to self._cc_emails (Markus Graf / ADMIN_EMAIL_CC), not to playgroup leaders.
Rationale: This is a system/infrastructure issue, not a registration event. The CC address is the designated admin (Markus Graf) who handles operational issues. Playgroup leaders do not need to see these alerts.
Risks / Trade-offs
False positives → A legitimate parent using a noreply@ alias could be silently blocked. This is an unlikely edge case; the subject/header checks require multiple signals for ambiguous senders. A missed registration is recoverable — admin gets the alert and can follow up manually.
False negatives → A clever loop that uses a normal-looking sender address and no automated headers would slip past layer 1. The 20-message cap catches it.
Completed conversations → The count cap applies to all conversations, including completed ones with many post-completion Q&A exchanges. A very chatty parent could theoretically hit the cap after registration is done. Acceptable for MVP — the cap is high enough that normal use is unaffected.