Files
Meister-Eder/openspec/changes/email-loop-prevention/design.md
T
Claude 514d235a2f docs(openspec): add change artifacts for email-loop-prevention
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
2026-02-26 21:06:53 +00:00

5.0 KiB
Raw Blame History

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:

  1. Header-based automated sender detection (catches known patterns immediately)
  2. 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 812 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.