diff --git a/openspec/changes/email-loop-prevention/design.md b/openspec/changes/email-loop-prevention/design.md new file mode 100644 index 0000000..c7ae40a --- /dev/null +++ b/openspec/changes/email-loop-prevention/design.md @@ -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. diff --git a/openspec/changes/email-loop-prevention/proposal.md b/openspec/changes/email-loop-prevention/proposal.md new file mode 100644 index 0000000..674dbe3 --- /dev/null +++ b/openspec/changes/email-loop-prevention/proposal.md @@ -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 \ No newline at end of file diff --git a/openspec/changes/email-loop-prevention/specs/email-channel/spec.md b/openspec/changes/email-loop-prevention/specs/email-channel/spec.md new file mode 100644 index 0000000..0a7518b --- /dev/null +++ b/openspec/changes/email-loop-prevention/specs/email-channel/spec.md @@ -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 diff --git a/openspec/changes/email-loop-prevention/specs/registration-notifications/spec.md b/openspec/changes/email-loop-prevention/specs/registration-notifications/spec.md new file mode 100644 index 0000000..ca69319 --- /dev/null +++ b/openspec/changes/email-loop-prevention/specs/registration-notifications/spec.md @@ -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. diff --git a/openspec/changes/email-loop-prevention/tasks.md b/openspec/changes/email-loop-prevention/tasks.md new file mode 100644 index 0000000..ec0e507 --- /dev/null +++ b/openspec/changes/email-loop-prevention/tasks.md @@ -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