diff --git a/main.py b/main.py index c432fb8..23519e2 100644 --- a/main.py +++ b/main.py @@ -98,6 +98,27 @@ def run_poll_loop(agent: EmailAgent, channel: EmailChannel, poll_interval: int) for msg in messages: logger.info("Processing message from %s", msg["from"]) try: + # ---------------------------------------------------------- + # Bounce / automated-sender guard + # If the channel layer flagged this as an automated message + # (bounce, out-of-office, delivery failure, …) we must NOT + # reply — that would create or worsen an email loop. + # Instead, alert the admin once and drop the message. + # ---------------------------------------------------------- + if msg.get("is_automated"): + logger.warning( + "Automated/bounce message from %s — reason: %s — not replying", + msg["from"], + msg.get("automated_reason", "unknown"), + ) + agent.handle_automated_message( + sender_email=msg["from"], + subject=msg["subject"], + reason=msg.get("automated_reason", "automated sender detected"), + inbound_message_id=msg["message_id"], + ) + continue + # Prepend email headers so the LLM can extract the # sender's address and subject (e.g. to fill in # parentGuardian.email automatically). 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 diff --git a/src/agent/core.py b/src/agent/core.py index 91cad5f..3e61c88 100644 --- a/src/agent/core.py +++ b/src/agent/core.py @@ -14,6 +14,11 @@ from .response_parser import apply_updates, fallback_message, parse_llm_response logger = logging.getLogger(__name__) +# Maximum number of inbound user messages before the conversation is stopped and +# escalated to the admin. This prevents runaway loops that slip through automated +# sender detection (e.g. a forwarding alias that bounces the agent's own replies). +MAX_USER_MESSAGES = 20 + class EmailAgent: """Processes one inbound email and returns the agent's reply text. @@ -78,6 +83,43 @@ class EmailAgent: # Append the user's message to history state.messages.append(ChatMessage(role="user", content=message_text)) + # ------------------------------------------------------------------ + # Hard message-count cap — stop conversations that have gone on too + # long without completing (covers loops that bypass automated-sender + # detection, e.g. a broken forwarding alias). + # ------------------------------------------------------------------ + user_msg_count = sum(1 for m in state.messages if m.role == "user") + if user_msg_count > MAX_USER_MESSAGES: + if not state.loop_escalated: + state.loop_escalated = True + state.updated_at = now + self._store.save(state) + reason = ( + f"conversation exceeded {MAX_USER_MESSAGES} inbound messages " + f"without completing" + ) + logger.warning( + "Conversation %s exceeded message limit (%d user messages) — escalating", + email_key, + user_msg_count, + ) + try: + self._notifier.notify_loop_escalation( + sender_email=parent_email, + conversation_id=email_key, + reason=reason, + message_count=user_msg_count, + ) + except Exception: + logger.exception("Failed to send loop escalation notification for %s", email_key) + else: + logger.warning( + "Conversation %s still exceeding message limit — already escalated, ignoring", + email_key, + ) + self._store.save(state) + return "" + # Route to the appropriate handler if state.completed: reply_text = self._handle_post_completion(state) @@ -91,6 +133,60 @@ class EmailAgent: return reply_text + def handle_automated_message( + self, + sender_email: str, + subject: str, + reason: str, + inbound_message_id: str = "", + ) -> None: + """Handle an inbound message detected as automated/bounce. + + Does NOT send any reply (to avoid looping). Alerts the admin once per + conversation — subsequent automated messages from the same sender are + silently dropped after the first alert. + """ + email_key = normalize_email(sender_email) + state = self._store.load(email_key) + if state is None: + state = ConversationState( + conversation_id=email_key, + parent_email=email_key, + ) + + now = datetime.now(timezone.utc).isoformat() + state.last_activity = now + if inbound_message_id: + state.last_inbound_message_id = inbound_message_id + + message_count = sum(1 for m in state.messages if m.role == "user") + + if state.loop_escalated: + logger.info( + "Automated message from %s (already escalated) — dropping silently", sender_email + ) + self._store.save(state) + return + + state.loop_escalated = True + state.updated_at = now + self._store.save(state) + + logger.warning( + "Automated/bounce message from %s — reason: %s — alerting admin", sender_email, reason + ) + try: + self._notifier.notify_loop_escalation( + sender_email=sender_email, + conversation_id=email_key, + reason=reason, + message_count=message_count, + ) + except Exception: + logger.exception( + "Failed to send loop escalation notification for automated sender %s", email_key + ) + # ------------------------------------------------------------------ # Registration flow # ------------------------------------------------------------------ diff --git a/src/channels/email_channel.py b/src/channels/email_channel.py index 42e7ee8..59002d1 100644 --- a/src/channels/email_channel.py +++ b/src/channels/email_channel.py @@ -89,6 +89,75 @@ def _strip_quoted_text(text: str) -> str: return "\n".join(result).strip() +# --------------------------------------------------------------------------- +# Automated / bounce message detection +# --------------------------------------------------------------------------- + +# Local parts of sender addresses that are never humans (RFC 5321 §4.5.4, common practice). +_AUTOMATED_SENDER_RE = re.compile( + r"^(mailer-daemon|postmaster|noreply|no-reply|no\.reply|do-not-reply|" + r"donotreply|bounce|bounce\+.*|delivery|mail-delivery|mail\.delivery)$", + re.IGNORECASE, +) + +# Subject lines that indicate delivery failure or automated responses. +_AUTOMATED_SUBJECT_RE = re.compile( + r"(undelivered mail|undeliverable|delivery (failed|status|notification)|" + r"mail delivery (failed|error)|returned to sender|mailer-daemon|" + r"auto.?reply|out of office|außer haus|abwesenheitsnotiz|automatische antwort)", + re.IGNORECASE, +) + + +def detect_automated_message(raw_msg: email.message.Message, from_addr: str) -> tuple[bool, str]: + """Detect whether an email was generated by an automated system, not a human. + + Checks (in order of reliability): + 1. Sender local-part (mailer-daemon, postmaster, noreply, …) + 2. Auto-Submitted header (RFC 3834) + 3. X-Auto-Response-Suppress header (Microsoft Exchange) + 4. Content-Type: multipart/report (RFC 3462 — Delivery Status Notifications) + 5. X-Loop header + 6. Precedence: bulk/junk + 7. Subject-line heuristics + + Returns: + (True, reason_string) if automated, (False, "") otherwise. + """ + local = from_addr.split("@")[0] if "@" in from_addr else from_addr + if _AUTOMATED_SENDER_RE.match(local): + return True, f"sender matches automated address pattern: {from_addr}" + + # RFC 3834 — Auto-Submitted header + auto_submitted = raw_msg.get("Auto-Submitted", "").strip().lower() + if auto_submitted and auto_submitted != "no": + return True, f"Auto-Submitted: {auto_submitted}" + + # Microsoft Exchange — suppresses auto-replies + if raw_msg.get("X-Auto-Response-Suppress"): + return True, "X-Auto-Response-Suppress header present" + + # RFC 3462 — multipart/report is used for DSNs and MDNs + if raw_msg.get_content_type() == "multipart/report": + return True, "Content-Type: multipart/report (delivery status notification)" + + # X-Loop — set by mailing-list managers and some MTAs to break loops + if raw_msg.get("X-Loop"): + return True, f"X-Loop: {raw_msg.get('X-Loop')}" + + # Precedence header + precedence = raw_msg.get("Precedence", "").strip().lower() + if precedence in ("bulk", "junk", "auto_reply"): + return True, f"Precedence: {precedence}" + + # Subject heuristic (weakest — only flag when highly specific) + subject = _decode_header(raw_msg.get("Subject", "")) + if _AUTOMATED_SUBJECT_RE.search(subject): + return True, f"subject matches automated pattern: {subject!r}" + + return False, "" + + def _generate_message_id(from_addr: str) -> str: domain = from_addr.split("@")[-1] if "@" in from_addr else "meister-eder.local" return f"<{time.time():.6f}.{id(from_addr)}@{domain}>" @@ -185,6 +254,8 @@ class EmailChannel: imap.store(num, "+FLAGS", "\\Seen") continue + is_automated, automated_reason = detect_automated_message(msg, from_addr) + messages.append( { "from": from_addr, @@ -194,6 +265,8 @@ class EmailChannel: "references": references, "body": body, "raw_body": raw_body, + "is_automated": is_automated, + "automated_reason": automated_reason, } ) imap.store(num, "+FLAGS", "\\Seen") diff --git a/src/models/conversation.py b/src/models/conversation.py index db8a565..d71eb38 100644 --- a/src/models/conversation.py +++ b/src/models/conversation.py @@ -35,6 +35,9 @@ class ConversationState: # Most recent inbound Message-ID — used for reply threading headers only, # NOT for conversation matching (which is always by email address). last_inbound_message_id: str = "" + # Loop / automated-sender prevention. + # Set to True once the admin has been notified; prevents repeated alerts. + loop_escalated: bool = False def to_dict(self) -> dict: return { @@ -54,6 +57,7 @@ class ConversationState: "completed": self.completed, "reminder_count": self.reminder_count, "last_inbound_message_id": self.last_inbound_message_id, + "loop_escalated": self.loop_escalated, } @classmethod @@ -78,4 +82,5 @@ class ConversationState: state.completed = data.get("completed", False) state.reminder_count = data.get("reminder_count", 0) state.last_inbound_message_id = data.get("last_inbound_message_id", "") + state.loop_escalated = data.get("loop_escalated", False) return state diff --git a/src/notifications/notifier.py b/src/notifications/notifier.py index 6a89c1c..0e2eda4 100644 --- a/src/notifications/notifier.py +++ b/src/notifications/notifier.py @@ -131,6 +131,46 @@ class AdminNotifier: reply_to=registration.parent_guardian.email or "", ) + def notify_loop_escalation( + self, + sender_email: str, + conversation_id: str, + reason: str, + message_count: int, + ) -> None: + """Alert the admin that a conversation was stopped due to a loop or automated sender. + + Sent to the CC list (Markus Graf / admin) only — no playgroup leader routing needed. + """ + if not self._cc_emails: + logger.warning( + "No admin CC email configured — loop escalation NOT sent for %s", conversation_id + ) + return + + subject = f"[WARNUNG] Automatische E-Mail / Endlosschleife erkannt: {sender_email}" + body = ( + f"Das Anmeldungssystem hat eine Konversation automatisch gestoppt.\n\n" + f"Absender: {sender_email}\n" + f"Konversations-ID: {conversation_id}\n" + f"Nachrichten: {message_count}\n" + f"Grund: {reason}\n\n" + f"Es wurde keine weitere Antwort gesendet. Bitte prüfen Sie den Sachverhalt " + f"manuell und leiten Sie die Konversation bei Bedarf weiter.\n\n" + f"---\nMeister-Eder Anmeldungssystem" + ) + self._send( + to=self._cc_emails, + cc=[], + subject=subject, + body=body, + ) + logger.info( + "Loop escalation notification sent to admin for conversation %s (reason: %s)", + conversation_id, + reason, + ) + def notify_parent( self, registration: RegistrationData, diff --git a/tests/test_email_loop_prevention.py b/tests/test_email_loop_prevention.py new file mode 100644 index 0000000..f25b27e --- /dev/null +++ b/tests/test_email_loop_prevention.py @@ -0,0 +1,652 @@ +"""Tests for email loop prevention. + +Covers three layers: +- detect_automated_message() — header-based bounce/automated sender detection +- EmailAgent.handle_automated_message() — state tracking, one-shot admin alert +- EmailAgent.process_message() — hard message-count cap (MAX_USER_MESSAGES) +- AdminNotifier.notify_loop_escalation() — escalation email dispatch +""" + +import email +import json + +import pytest +from unittest.mock import MagicMock, patch + +from src.channels.email_channel import detect_automated_message +from src.agent.core import EmailAgent, MAX_USER_MESSAGES +from src.models.conversation import ConversationState, ChatMessage +from src.notifications.notifier import AdminNotifier + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _decode_body(msg_str: str) -> str: + """Extract the decoded plain-text body from a raw MIME message string.""" + parsed = email.message_from_string(msg_str) + if parsed.is_multipart(): + for part in parsed.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + if payload: + return payload.decode(part.get_content_charset() or "utf-8") + payload = parsed.get_payload(decode=True) + return payload.decode(parsed.get_content_charset() or "utf-8") if payload else "" + + +def _make_msg( + from_addr: str = "parent@example.com", + subject: str = "Hallo", + extra_headers: dict | None = None, + content_type: str = "text/plain", +) -> email.message.Message: + """Build a minimal parsed email.message.Message for testing detect_automated_message.""" + raw = ( + f"From: {from_addr}\r\n" + f"Subject: {subject}\r\n" + f"Content-Type: {content_type}\r\n" + ) + for key, value in (extra_headers or {}).items(): + raw += f"{key}: {value}\r\n" + raw += "\r\nBody text" + return email.message_from_string(raw) + + +def _state_with_n_user_messages(n: int, email_addr: str = "loop@example.com") -> ConversationState: + """Return a ConversationState that already has *n* user messages in its history.""" + state = ConversationState( + conversation_id=email_addr, + parent_email=email_addr, + ) + for i in range(n): + state.messages.append(ChatMessage(role="user", content=f"Message {i + 1}")) + state.messages.append(ChatMessage(role="assistant", content=f"Reply {i + 1}")) + return state + + +VALID_LLM_REPLY = json.dumps({ + "reply": "Wie heisst dein Kind?", + "updates": {}, + "next_step": "child_name", + "registration_complete": False, + "language": "de", +}) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_kb(): + kb = MagicMock() + kb.get_all.return_value = "# FAQ\nSome content." + return kb + + +@pytest.fixture +def mock_store(): + store = MagicMock() + store.load.return_value = None + store.save_registration.return_value = ("loop@example.com", 1) + return store + + +@pytest.fixture +def mock_notifier(): + return MagicMock() + + +@pytest.fixture +def agent(mock_kb, mock_store, mock_notifier): + return EmailAgent( + model="anthropic/claude-opus-4-6", + kb=mock_kb, + store=mock_store, + notifier=mock_notifier, + ) + + +@pytest.fixture +def notifier(): + return AdminNotifier( + smtp_host="smtp.example.com", + smtp_port=587, + username="agent@example.com", + password="secret", + use_tls=True, + from_email="agent@example.com", + indoor_email="andrea@example.com", + outdoor_email="barbara@example.com", + cc_emails=["markus@example.com"], + ) + + +@pytest.fixture +def notifier_no_cc(): + """Notifier without any CC recipients — simulates missing ADMIN_EMAIL_CC.""" + return AdminNotifier( + smtp_host="smtp.example.com", + smtp_port=587, + username="agent@example.com", + password="secret", + use_tls=True, + from_email="agent@example.com", + cc_emails=[], + ) + + +@pytest.fixture +def notifier_no_smtp(): + """Notifier in dev mode (no SMTP host).""" + return AdminNotifier( + smtp_host="", + smtp_port=587, + username="", + password="", + from_email="agent@example.com", + cc_emails=["markus@example.com"], + ) + + +# --------------------------------------------------------------------------- +# detect_automated_message — sender address patterns +# --------------------------------------------------------------------------- + + +class TestDetectAutomatedMessageBySender: + def test_mailer_daemon_is_automated(self): + msg = _make_msg(from_addr="MAILER-DAEMON@tacitus2.sui-inter.net") + is_auto, reason = detect_automated_message(msg, "MAILER-DAEMON@tacitus2.sui-inter.net") + assert is_auto is True + assert reason != "" + + def test_mailer_daemon_lowercase_is_automated(self): + msg = _make_msg(from_addr="mailer-daemon@example.com") + is_auto, _ = detect_automated_message(msg, "mailer-daemon@example.com") + assert is_auto is True + + def test_postmaster_is_automated(self): + msg = _make_msg(from_addr="postmaster@example.com") + is_auto, _ = detect_automated_message(msg, "postmaster@example.com") + assert is_auto is True + + def test_noreply_is_automated(self): + msg = _make_msg(from_addr="noreply@example.com") + is_auto, _ = detect_automated_message(msg, "noreply@example.com") + assert is_auto is True + + def test_no_reply_hyphen_is_automated(self): + msg = _make_msg(from_addr="no-reply@example.com") + is_auto, _ = detect_automated_message(msg, "no-reply@example.com") + assert is_auto is True + + def test_donotreply_is_automated(self): + msg = _make_msg(from_addr="donotreply@example.com") + is_auto, _ = detect_automated_message(msg, "donotreply@example.com") + assert is_auto is True + + def test_bounce_is_automated(self): + msg = _make_msg(from_addr="bounce@example.com") + is_auto, _ = detect_automated_message(msg, "bounce@example.com") + assert is_auto is True + + def test_normal_parent_email_is_not_automated(self): + msg = _make_msg(from_addr="anna.muster@example.com") + is_auto, reason = detect_automated_message(msg, "anna.muster@example.com") + assert is_auto is False + assert reason == "" + + def test_reason_string_mentions_sender(self): + msg = _make_msg(from_addr="MAILER-DAEMON@tacitus2.sui-inter.net") + _, reason = detect_automated_message(msg, "MAILER-DAEMON@tacitus2.sui-inter.net") + assert "MAILER-DAEMON@tacitus2.sui-inter.net" in reason + + +# --------------------------------------------------------------------------- +# detect_automated_message — RFC / header signals +# --------------------------------------------------------------------------- + + +class TestDetectAutomatedMessageByHeaders: + def test_auto_submitted_auto_replied(self): + msg = _make_msg(extra_headers={"Auto-Submitted": "auto-replied"}) + is_auto, reason = detect_automated_message(msg, "someone@example.com") + assert is_auto is True + assert "auto-replied" in reason + + def test_auto_submitted_auto_generated(self): + msg = _make_msg(extra_headers={"Auto-Submitted": "auto-generated"}) + is_auto, _ = detect_automated_message(msg, "someone@example.com") + assert is_auto is True + + def test_auto_submitted_no_is_not_automated(self): + """Auto-Submitted: no means the message was composed by a human.""" + msg = _make_msg(extra_headers={"Auto-Submitted": "no"}) + is_auto, _ = detect_automated_message(msg, "parent@example.com") + assert is_auto is False + + def test_x_auto_response_suppress_is_automated(self): + msg = _make_msg(extra_headers={"X-Auto-Response-Suppress": "All"}) + is_auto, reason = detect_automated_message(msg, "someone@example.com") + assert is_auto is True + assert "X-Auto-Response-Suppress" in reason + + def test_multipart_report_content_type_is_automated(self): + msg = _make_msg(content_type="multipart/report") + is_auto, reason = detect_automated_message(msg, "system@example.com") + assert is_auto is True + assert "multipart/report" in reason + + def test_x_loop_header_is_automated(self): + msg = _make_msg(extra_headers={"X-Loop": "spielgruppen@familien-verein.ch"}) + is_auto, reason = detect_automated_message(msg, "someone@example.com") + assert is_auto is True + assert "X-Loop" in reason + + def test_precedence_bulk_is_automated(self): + msg = _make_msg(extra_headers={"Precedence": "bulk"}) + is_auto, reason = detect_automated_message(msg, "list@example.com") + assert is_auto is True + assert "bulk" in reason + + def test_precedence_junk_is_automated(self): + msg = _make_msg(extra_headers={"Precedence": "junk"}) + is_auto, _ = detect_automated_message(msg, "spam@example.com") + assert is_auto is True + + def test_precedence_list_is_not_automated(self): + """Mailing list messages (Precedence: list) are not considered automated.""" + msg = _make_msg(extra_headers={"Precedence": "list"}) + is_auto, _ = detect_automated_message(msg, "newsletter@example.com") + assert is_auto is False + + +# --------------------------------------------------------------------------- +# detect_automated_message — subject heuristics +# --------------------------------------------------------------------------- + + +class TestDetectAutomatedMessageBySubject: + def test_undelivered_mail_returned_to_sender(self): + msg = _make_msg(subject="Undelivered Mail Returned to Sender") + is_auto, reason = detect_automated_message(msg, "mailer@example.com") + # Caught by sender pattern first, but subject pattern must also flag it + # Test that a neutral sender + bounce subject is still flagged + msg2 = _make_msg( + from_addr="delivery@isp.example.com", + subject="Undelivered Mail Returned to Sender", + ) + is_auto2, _ = detect_automated_message(msg2, "delivery@isp.example.com") + assert is_auto2 is True + + def test_delivery_failed_subject(self): + msg = _make_msg( + from_addr="system@isp.example.com", + subject="Mail Delivery Failed", + ) + is_auto, _ = detect_automated_message(msg, "system@isp.example.com") + assert is_auto is True + + def test_out_of_office_subject(self): + msg = _make_msg( + from_addr="colleague@example.com", + subject="Out of Office: Re: Anmeldung", + ) + is_auto, _ = detect_automated_message(msg, "colleague@example.com") + assert is_auto is True + + def test_abwesenheitsnotiz_subject(self): + msg = _make_msg( + from_addr="colleague@example.com", + subject="Abwesenheitsnotiz: Anmeldung", + ) + is_auto, _ = detect_automated_message(msg, "colleague@example.com") + assert is_auto is True + + def test_automatische_antwort_subject(self): + msg = _make_msg( + from_addr="colleague@example.com", + subject="Automatische Antwort: Ihre Anfrage", + ) + is_auto, _ = detect_automated_message(msg, "colleague@example.com") + assert is_auto is True + + def test_normal_registration_subject_is_not_automated(self): + msg = _make_msg( + from_addr="parent@example.com", + subject="Anmeldung meines Kindes", + ) + is_auto, _ = detect_automated_message(msg, "parent@example.com") + assert is_auto is False + + def test_case_insensitive_subject_matching(self): + msg = _make_msg( + from_addr="system@isp.example.com", + subject="UNDELIVERED MAIL RETURNED TO SENDER", + ) + is_auto, _ = detect_automated_message(msg, "system@isp.example.com") + assert is_auto is True + + +# --------------------------------------------------------------------------- +# EmailAgent.handle_automated_message — state and escalation +# --------------------------------------------------------------------------- + + +class TestHandleAutomatedMessage: + def test_sets_loop_escalated_on_state(self, agent, mock_store): + """Calling handle_automated_message marks loop_escalated = True in state.""" + agent.handle_automated_message( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + subject="Undelivered Mail Returned to Sender", + reason="sender matches automated address pattern", + ) + saved_state = mock_store.save.call_args[0][0] + assert saved_state.loop_escalated is True + + def test_calls_notify_loop_escalation(self, agent, mock_notifier): + """Admin is notified once on the first automated message.""" + agent.handle_automated_message( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + subject="Undelivered Mail Returned to Sender", + reason="sender matches automated address pattern", + ) + mock_notifier.notify_loop_escalation.assert_called_once() + + def test_notify_called_with_correct_sender(self, agent, mock_notifier): + agent.handle_automated_message( + sender_email="MAILER-DAEMON@tacitus2.sui-inter.net", + subject="Bounce", + reason="sender match", + ) + call_kwargs = mock_notifier.notify_loop_escalation.call_args[1] + assert call_kwargs["sender_email"] == "MAILER-DAEMON@tacitus2.sui-inter.net" + + def test_creates_new_state_when_none_exists(self, agent, mock_store): + """When no prior state exists, a new ConversationState is created and saved.""" + mock_store.load.return_value = None + + agent.handle_automated_message( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + subject="Bounce", + reason="automated sender", + ) + + assert mock_store.save.called + saved_state = mock_store.save.call_args[0][0] + assert saved_state.parent_email == "mailer-daemon@tacitus2.sui-inter.net" + + def test_subsequent_automated_message_dropped_silently(self, agent, mock_store, mock_notifier): + """If loop_escalated is already True, no further notify call is made.""" + existing_state = ConversationState( + conversation_id="mailer-daemon@tacitus2.sui-inter.net", + parent_email="mailer-daemon@tacitus2.sui-inter.net", + ) + existing_state.loop_escalated = True + mock_store.load.return_value = existing_state + + agent.handle_automated_message( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + subject="Bounce again", + reason="automated sender", + ) + + mock_notifier.notify_loop_escalation.assert_not_called() + + def test_state_still_saved_when_already_escalated(self, agent, mock_store, mock_notifier): + """Even when already escalated, last_activity is updated and state is persisted.""" + existing_state = ConversationState( + conversation_id="mailer-daemon@tacitus2.sui-inter.net", + parent_email="mailer-daemon@tacitus2.sui-inter.net", + ) + existing_state.loop_escalated = True + mock_store.load.return_value = existing_state + + agent.handle_automated_message( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + subject="Bounce again", + reason="automated sender", + ) + + assert mock_store.save.called + + def test_notifier_failure_does_not_propagate(self, agent, mock_store, mock_notifier): + """A failing notifier must not crash the agent — the state is still saved.""" + mock_notifier.notify_loop_escalation.side_effect = RuntimeError("SMTP error") + + # Should not raise + agent.handle_automated_message( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + subject="Bounce", + reason="automated sender", + ) + + assert mock_store.save.called + + def test_inbound_message_id_stored(self, agent, mock_store): + """The inbound Message-ID is persisted for reply-threading purposes.""" + agent.handle_automated_message( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + subject="Bounce", + reason="automated sender", + inbound_message_id="", + ) + saved_state = mock_store.save.call_args[0][0] + assert saved_state.last_inbound_message_id == "" + + +# --------------------------------------------------------------------------- +# EmailAgent.process_message — hard message-count cap +# --------------------------------------------------------------------------- + + +class TestProcessMessageCountCap: + def test_at_limit_message_still_processed(self, agent, mock_store): + """A conversation with exactly MAX_USER_MESSAGES messages is still replied to.""" + state = _state_with_n_user_messages(MAX_USER_MESSAGES - 1) + mock_store.load.return_value = state + + with patch("src.llm.complete", return_value=VALID_LLM_REPLY): + reply = agent.process_message("loop@example.com", "Another message") + + assert reply == "Wie heisst dein Kind?" + + def test_over_limit_returns_empty_string(self, agent, mock_store): + """The 21st user message triggers the cap and returns an empty reply.""" + state = _state_with_n_user_messages(MAX_USER_MESSAGES) + mock_store.load.return_value = state + + with patch("src.llm.complete", return_value=VALID_LLM_REPLY) as mock_llm: + reply = agent.process_message("loop@example.com", "One more message") + + assert reply == "" + mock_llm.assert_not_called() + + def test_over_limit_sets_loop_escalated(self, agent, mock_store): + """Hitting the cap marks loop_escalated = True in the persisted state.""" + state = _state_with_n_user_messages(MAX_USER_MESSAGES) + mock_store.load.return_value = state + + with patch("src.llm.complete", return_value=VALID_LLM_REPLY): + agent.process_message("loop@example.com", "One more message") + + saved_state = mock_store.save.call_args[0][0] + assert saved_state.loop_escalated is True + + def test_over_limit_calls_notify_loop_escalation(self, agent, mock_store, mock_notifier): + """Hitting the cap triggers one admin escalation notification.""" + state = _state_with_n_user_messages(MAX_USER_MESSAGES) + mock_store.load.return_value = state + + with patch("src.llm.complete", return_value=VALID_LLM_REPLY): + agent.process_message("loop@example.com", "One more message") + + mock_notifier.notify_loop_escalation.assert_called_once() + + def test_over_limit_no_duplicate_notification_when_already_escalated( + self, agent, mock_store, mock_notifier + ): + """If loop_escalated is already True, no second notification is sent.""" + state = _state_with_n_user_messages(MAX_USER_MESSAGES) + state.loop_escalated = True + mock_store.load.return_value = state + + with patch("src.llm.complete", return_value=VALID_LLM_REPLY): + agent.process_message("loop@example.com", "Yet another message") + + mock_notifier.notify_loop_escalation.assert_not_called() + + def test_over_limit_notify_failure_does_not_propagate(self, agent, mock_store, mock_notifier): + """If the notifier raises, the cap still returns '' without crashing.""" + mock_notifier.notify_loop_escalation.side_effect = RuntimeError("SMTP down") + state = _state_with_n_user_messages(MAX_USER_MESSAGES) + mock_store.load.return_value = state + + with patch("src.llm.complete", return_value=VALID_LLM_REPLY): + reply = agent.process_message("loop@example.com", "One more message") + + assert reply == "" + + def test_max_user_messages_constant_is_twenty(self): + """The agreed-upon limit from the spec is 20 inbound messages.""" + assert MAX_USER_MESSAGES == 20 + + +# --------------------------------------------------------------------------- +# AdminNotifier.notify_loop_escalation — SMTP dispatch +# --------------------------------------------------------------------------- + + +class TestNotifyLoopEscalation: + def test_sends_email_to_cc_recipients(self, notifier, mocker): + """The escalation alert is sent to the admin CC address list.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + mock_server = mock_smtp_cls.return_value + + notifier.notify_loop_escalation( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + conversation_id="mailer-daemon@tacitus2.sui-inter.net", + reason="sender matches automated address pattern", + message_count=5, + ) + + mock_server.sendmail.assert_called_once() + call_args = mock_server.sendmail.call_args + recipients = call_args[0][1] + assert "markus@example.com" in recipients + + def test_subject_contains_warnung_tag(self, notifier, mocker): + """Subject must start with [WARNUNG] for easy filtering in the admin inbox.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + captured = {} + + def fake_sendmail(from_, to_, msg_str): + captured["msg"] = msg_str + + mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail + + notifier.notify_loop_escalation( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + conversation_id="mailer-daemon@tacitus2.sui-inter.net", + reason="automated sender", + message_count=3, + ) + + import email as email_mod + from email.header import decode_header + parsed = email_mod.message_from_string(captured["msg"]) + raw_subject = parsed.get("Subject", "") + parts = decode_header(raw_subject) + subject = "".join( + chunk.decode(enc or "utf-8") if isinstance(chunk, bytes) else chunk + for chunk, enc in parts + ) + assert "[WARNUNG]" in subject + + def test_subject_contains_sender_address(self, notifier, mocker): + """The sender address appears in the subject for quick identification.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + captured = {} + + def fake_sendmail(from_, to_, msg_str): + captured["msg"] = msg_str + + mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail + + notifier.notify_loop_escalation( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + conversation_id="mailer-daemon@tacitus2.sui-inter.net", + reason="automated sender", + message_count=3, + ) + + assert "mailer-daemon@tacitus2.sui-inter.net" in captured["msg"] + + def test_body_contains_reason(self, notifier, mocker): + """The email body includes the specific detection reason.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + captured = {} + + def fake_sendmail(from_, to_, msg_str): + captured["msg"] = msg_str + + mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail + + notifier.notify_loop_escalation( + sender_email="test@example.com", + conversation_id="test@example.com", + reason="Content-Type: multipart/report (delivery status notification)", + message_count=7, + ) + + body = _decode_body(captured["msg"]) + assert "multipart/report" in body + + def test_body_contains_message_count(self, notifier, mocker): + """The email body reports the number of messages exchanged.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + captured = {} + + def fake_sendmail(from_, to_, msg_str): + captured["msg"] = msg_str + + mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail + + notifier.notify_loop_escalation( + sender_email="test@example.com", + conversation_id="test@example.com", + reason="automated sender", + message_count=12, + ) + + body = _decode_body(captured["msg"]) + assert "12" in body + + def test_no_cc_emails_skips_smtp(self, notifier_no_cc, mocker): + """When no admin CC email is configured, no SMTP connection is made.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + + notifier_no_cc.notify_loop_escalation( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + conversation_id="mailer-daemon@tacitus2.sui-inter.net", + reason="automated sender", + message_count=3, + ) + + mock_smtp_cls.assert_not_called() + + def test_no_smtp_host_skips_send(self, notifier_no_smtp, mocker): + """Dev mode (no SMTP host): email is logged but not dispatched.""" + mock_smtp_cls = mocker.patch("smtplib.SMTP") + + notifier_no_smtp.notify_loop_escalation( + sender_email="mailer-daemon@tacitus2.sui-inter.net", + conversation_id="mailer-daemon@tacitus2.sui-inter.net", + reason="automated sender", + message_count=3, + ) + + mock_smtp_cls.assert_not_called() diff --git a/tests/test_models.py b/tests/test_models.py index fe9157f..436ae73 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -150,3 +150,45 @@ class TestConversationStateSerialization: d = state_with_messages.to_dict() assert d["messages"][0]["role"] == "user" assert "Hallo" in d["messages"][0]["content"] + + +# --------------------------------------------------------------------------- +# ConversationState — loop_escalated field +# --------------------------------------------------------------------------- + + +class TestConversationStateLoopEscalated: + def test_default_loop_escalated_is_false(self, fresh_state): + assert fresh_state.loop_escalated is False + + def test_to_dict_includes_loop_escalated(self, fresh_state): + d = fresh_state.to_dict() + assert "loop_escalated" in d + assert d["loop_escalated"] is False + + def test_to_dict_reflects_true_when_set(self, fresh_state): + fresh_state.loop_escalated = True + d = fresh_state.to_dict() + assert d["loop_escalated"] is True + + def test_from_dict_restores_loop_escalated_true(self, fresh_state): + fresh_state.loop_escalated = True + restored = ConversationState.from_dict(fresh_state.to_dict()) + assert restored.loop_escalated is True + + def test_from_dict_defaults_to_false_when_key_missing(self): + """Older persisted conversations without the key deserialise safely.""" + data = { + "conversation_id": "old@example.com", + "parent_email": "old@example.com", + "language": "de", + "flow_step": "greeting", + "registration": {}, + "messages": [], + "completed": False, + "reminder_count": 0, + "last_inbound_message_id": "", + # loop_escalated intentionally absent + } + state = ConversationState.from_dict(data) + assert state.loop_escalated is False