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:
@@ -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 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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user