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
This commit is contained in:
Claude
2026-02-26 21:06:53 +00:00
parent 491c2d3495
commit 514d235a2f
5 changed files with 234 additions and 0 deletions
@@ -0,0 +1,76 @@
## 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.
@@ -0,0 +1,30 @@
## Why
The email channel had no protection against automated message loops. When the agent sent a reply that bounced (e.g. due to an invalid recipient address or a misconfigured mail server), the bounce message arrived back in the inbox. The agent treated it as a new inbound message, generated another reply, which bounced again — creating an infinite loop.
A real incident demonstrated this: a `MAILER-DAEMON@tacitus2.sui-inter.net` bounce began accumulating replies indefinitely, consuming LLM quota and filling the inbox with noise while the agent remained unaware it was talking to an automated system.
Beyond bounce loops, any automated sender — out-of-office replies, mailing-list software, delivery status notifications — can trigger this pattern if the agent replies to them. The system needs to recognise non-human senders and refuse to reply.
A secondary risk: a legitimate but very slow conversation (or a forwarding-alias loop that bypasses simple bounce detection) could still accumulate messages indefinitely. A hard cap on conversation length provides a safety net.
## What Changes
- **Detect automated/bounce senders** before the agent replies — inspect email headers and sender address patterns to identify non-human messages
- **Skip sending any reply** to automated messages — silence breaks the loop
- **Alert the admin once** when an automated sender is detected, so a human can investigate
- **Cap conversation length** at 20 inbound messages — if a conversation has not completed after 20 user messages, stop responding and alert the admin
- **Track escalation state** per conversation so admin alerts fire at most once
### Non-Goals
- Spam filtering (automated detection is specific to loop-causing patterns, not general spam)
- Automatic unsubscribe/block of senders
- Forwarding the original problem email to the admin (admin receives only a warning notification)
## Capabilities
### Modified Capabilities
- `email-channel`: Add automated/bounce sender detection; skip replies for flagged messages
- `registration-notifications`: Add loop-escalation alert type sent to admin CC address
@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: Automated sender detection
The system SHALL detect whether an inbound email was sent by an automated system rather than a human, before the message is processed by the agent.
#### Scenario: MAILER-DAEMON sender
- **WHEN** an email arrives with a sender local-part of `mailer-daemon`, `postmaster`, `noreply`, `no-reply`, `donotreply`, or `bounce` (case-insensitive)
- **THEN** the system SHALL flag the message as automated with a reason string identifying the sender pattern
#### Scenario: RFC 3834 Auto-Submitted header
- **WHEN** an email contains an `Auto-Submitted` header with any value other than `no`
- **THEN** the system SHALL flag the message as automated, citing the header value in the reason
#### Scenario: Auto-Submitted: no is not automated
- **WHEN** an email contains `Auto-Submitted: no`
- **THEN** the system SHALL NOT flag the message as automated based on this header
#### Scenario: Microsoft Exchange auto-reply suppression
- **WHEN** an email contains an `X-Auto-Response-Suppress` header (any value)
- **THEN** the system SHALL flag the message as automated
#### Scenario: Delivery Status Notification (RFC 3462)
- **WHEN** an email has `Content-Type: multipart/report`
- **THEN** the system SHALL flag the message as automated, as this indicates a machine-generated delivery status or read receipt
#### Scenario: X-Loop header
- **WHEN** an email contains an `X-Loop` header (any value)
- **THEN** the system SHALL flag the message as automated
#### Scenario: Bulk or junk precedence
- **WHEN** an email has a `Precedence` header with value `bulk` or `junk`
- **THEN** the system SHALL flag the message as automated
#### Scenario: Bounce / OOO subject line
- **WHEN** an email subject matches patterns indicating delivery failure or automated response (e.g. "Undelivered Mail", "Mail Delivery Failed", "Out of Office", "Abwesenheitsnotiz", "Automatische Antwort")
- **THEN** the system SHALL flag the message as automated
#### Scenario: Normal parent message
- **WHEN** an email has a normal human sender address and no automated-sender headers
- **THEN** the system SHALL NOT flag the message as automated
### Requirement: Automated messages are never replied to
The system SHALL NOT send any reply to a message flagged as automated.
#### Scenario: Bounce message arrives
- **WHEN** the system receives a message flagged as automated
- **THEN** the system SHALL mark the message as read (IMAP Seen flag)
- **AND** the system SHALL call the agent's automated-message handler
- **AND** the system SHALL NOT send any outbound email reply
### Requirement: Message dict includes automation flag
Every message returned by `fetch_unread_messages()` SHALL include:
- `is_automated` (boolean): whether the message was flagged as automated
- `automated_reason` (string): human-readable reason if flagged, empty string otherwise
@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: Loop escalation alert to admin
The system SHALL send a plain-text warning email to the admin when a conversation is stopped due to an automated sender or message-count cap breach.
#### Scenario: First automated message from a sender
- **WHEN** the first automated/bounce message is received from a sender address
- **THEN** the system SHALL send one alert email to the admin CC address list
- **AND** the subject SHALL begin with `[WARNUNG]` for easy inbox filtering
- **AND** the subject SHALL include the sender's email address
- **AND** the body SHALL include: sender address, conversation ID, detection reason, and message count
- **AND** no further alert SHALL be sent for subsequent automated messages from the same sender
#### Scenario: Conversation exceeds message-count cap
- **WHEN** a conversation accumulates more than 20 inbound user messages without completing
- **THEN** the system SHALL send one alert email to the admin CC address list on first breach
- **AND** the body SHALL identify the conversation and state that the message limit was exceeded
- **AND** no further alert SHALL be sent for subsequent messages in the same capped conversation
#### Scenario: No admin CC address configured
- **WHEN** `ADMIN_EMAIL_CC` is not set and a loop escalation is triggered
- **THEN** the system SHALL log a warning
- **AND** the system SHALL NOT attempt an SMTP connection
#### Scenario: No SMTP host configured (dev mode)
- **WHEN** `SMTP_HOST` is not set and a loop escalation is triggered
- **THEN** the system SHALL log the notification content
- **AND** the system SHALL NOT attempt an SMTP connection
### Requirement: Alert routing
Loop escalation alerts SHALL be sent only to the admin CC list (`ADMIN_EMAIL_CC`). They SHALL NOT be sent to playgroup leaders (Andrea Sigrist, Barbara Gross), as loop detection is an operational concern, not a registration event.
@@ -0,0 +1,43 @@
## 1. Automated Sender Detection (email_channel.py)
- [x] 1.1 Add `_AUTOMATED_SENDER_RE` regex for known non-human local-parts (mailer-daemon, postmaster, noreply, no-reply, donotreply, bounce, …)
- [x] 1.2 Add `_AUTOMATED_SUBJECT_RE` regex for bounce/OOO subject patterns (German + English)
- [x] 1.3 Implement `detect_automated_message(raw_msg, from_addr) → (bool, str)` checking all signals in priority order: sender pattern → Auto-Submitted → X-Auto-Response-Suppress → multipart/report → X-Loop → Precedence → subject
- [x] 1.4 Add `is_automated` and `automated_reason` fields to the dict returned by `fetch_unread_messages()`
## 2. Poll Loop Guard (main.py)
- [x] 2.1 In `run_poll_loop()`, check `msg.get("is_automated")` before calling `agent.process_message()`
- [x] 2.2 If automated: log a warning, call `agent.handle_automated_message()`, and `continue` (skip `send_reply`)
## 3. Agent — Automated Message Handler (agent/core.py)
- [x] 3.1 Add `MAX_USER_MESSAGES = 20` module-level constant
- [x] 3.2 Implement `handle_automated_message(sender_email, subject, reason, inbound_message_id)` method
- [x] 3.3 In `handle_automated_message`: load or create state; set `loop_escalated = True`; call `notify_loop_escalation()` once; silently skip if already escalated; save state
- [x] 3.4 In `process_message()`, after appending the user message, count user messages; if count > `MAX_USER_MESSAGES` and not escalated: set `loop_escalated = True`, call `notify_loop_escalation()`, return `""`
- [x] 3.5 If already escalated and over limit: silently save state and return `""`
## 4. Conversation State (models/conversation.py)
- [x] 4.1 Add `loop_escalated: bool = False` field to `ConversationState`
- [x] 4.2 Include `loop_escalated` in `to_dict()`
- [x] 4.3 Restore `loop_escalated` in `from_dict()` with default `False` for backward compatibility
## 5. Admin Notification (notifications/notifier.py)
- [x] 5.1 Implement `notify_loop_escalation(sender_email, conversation_id, reason, message_count)` method
- [x] 5.2 Route alert to `self._cc_emails` only (not playgroup leaders)
- [x] 5.3 Subject: `[WARNUNG] Automatische E-Mail / Endlosschleife erkannt: {sender_email}`
- [x] 5.4 Body: sender, conversation ID, message count, reason, call-to-action in German
- [x] 5.5 Guard: if no CC emails configured, log warning and return without SMTP call
## 6. Tests
- [x] 6.1 `TestDetectAutomatedMessageBySender` — mailer-daemon, postmaster, noreply, no-reply, donotreply, bounce; normal parent address not flagged
- [x] 6.2 `TestDetectAutomatedMessageByHeaders` — Auto-Submitted (auto-replied, auto-generated, no); X-Auto-Response-Suppress; multipart/report; X-Loop; Precedence bulk/junk; Precedence list not flagged
- [x] 6.3 `TestDetectAutomatedMessageBySubject` — Undelivered Mail, Mail Delivery Failed, Out of Office, Abwesenheitsnotiz, Automatische Antwort; case-insensitive; normal subject not flagged
- [x] 6.4 `TestHandleAutomatedMessage` — sets loop_escalated; calls notifier once; creates state when none exists; drops silently if already escalated; notifier failure does not propagate; inbound message ID stored
- [x] 6.5 `TestProcessMessageCountCap` — at limit still processes; over limit returns ""; sets loop_escalated; calls notifier once; no duplicate alert; notifier failure does not propagate; constant equals 20
- [x] 6.6 `TestNotifyLoopEscalation` — sends to CC; [WARNUNG] in subject; sender in subject; reason in body; message count in body; no-CC guard; no-SMTP guard
- [x] 6.7 `TestConversationStateLoopEscalated` (test_models.py) — default False; to_dict includes key; True round-trip; from_dict backward compatibility