Author SHA1 Message Date
gurixandClaude Sonnet 4.6 f509d984ba docs: add Docker Compose section to README
Documents the docker-compose.yml setup added in PR #7, including how
to start both services, volume mounts for persistent data, and common
operational commands (logs, rebuild).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:13:16 +01:00
Markus GrafandGitHub fdf674d8d6 Merge pull request #8 from gurix/claude/fix-email-loop-prevention-bD8vX
Add loop detection and automated sender handling
2026-02-26 22:20:15 +01:00
Claude 514d235a2f docs(openspec): add change artifacts for email-loop-prevention
Creates openspec/changes/email-loop-prevention/ with all artifacts:

- proposal.md: Why (MAILER-DAEMON bounce loop incident), what changes
  (automated sender detection + message-count cap), capabilities affected
- design.md: Two-layer defence rationale, detection signal table, decisions
  on channel/agent boundary split, 20-message cap, one-shot alert, CC-only
  routing; risks and trade-offs documented
- specs/email-channel/spec.md: ADDED requirements for detect_automated_message(),
  is_automated/automated_reason message dict fields, no-reply guarantee
- specs/registration-notifications/spec.md: ADDED requirements for
  notify_loop_escalation() — alert content, one-shot guarantee, CC-only
  routing, dev-mode guard
- tasks.md: All 6 sections fully checked (implementation already complete)

https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
2026-02-26 21:06:53 +00:00
Claude 491c2d3495 test: add tests for email loop prevention
Add tests/test_email_loop_prevention.py (47 tests) covering:
- detect_automated_message(): sender patterns (mailer-daemon, postmaster,
  noreply, …), RFC headers (Auto-Submitted, X-Auto-Response-Suppress,
  multipart/report, X-Loop, Precedence), subject heuristics (German and
  English bounce/OOO phrases), and negative cases for normal senders
- EmailAgent.handle_automated_message(): state creation, loop_escalated
  flag, one-shot admin alert, silent drop on repeat, notifier failure safety
- EmailAgent.process_message() message-count cap: at-limit still processes,
  over-limit returns "", escalation called once, duplicate prevention,
  notifier failure safety, constant value assertion
- AdminNotifier.notify_loop_escalation(): SMTP dispatch, [WARNUNG] subject,
  body content, no-CC guard, no-SMTP dev-mode guard

Add TestConversationStateLoopEscalated to test_models.py (5 tests):
default value, to_dict inclusion, True round-trip, from_dict backward
compatibility with persisted conversations missing the key.

All 168 tests pass.

https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
2026-02-26 21:03:01 +00:00
Claude 9dac54fe81 fix: prevent email loop by detecting bounces and capping conversation length
- email_channel.py: add detect_automated_message() that inspects RFC 3834
  Auto-Submitted, mailer-daemon/postmaster sender patterns, X-Loop,
  multipart/report Content-Type, Precedence, and subject heuristics.
  fetch_unread_messages() now includes is_automated / automated_reason
  in every message dict.

- main.py: if is_automated is set, call agent.handle_automated_message()
  instead of process_message() — no reply is ever sent to a bounce source.

- agent/core.py: add MAX_USER_MESSAGES = 20 cap; process_message() returns
  "" without replying once a conversation exceeds the limit and calls
  notifier.notify_loop_escalation() on first breach.  New public method
  handle_automated_message() records the event and triggers the same
  one-shot admin alert.

- models/conversation.py: add loop_escalated: bool field (persisted) so
  the admin alert fires at most once per conversation.

- notifications/notifier.py: add notify_loop_escalation() which sends a
  plain-text warning to the admin CC list (Markus Graf / spielgruppen@).

https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
2026-02-26 20:46:09 +00:00
Markus GrafandGitHub fa6b520f19 Merge pull request #7 from gurix/claude/add-docker-compose-setup-PoDxl
Add Docker support with Compose configuration
2026-02-26 21:32:19 +01:00
gurixandClaude Sonnet 4.6 cab16078a2 document SIMPLE_MODEL and THINKING_BUDGET env vars in README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:43:35 +01:00
gurix e3bdfbcb9e Merge remote-tracking branch 'origin/claude/registration-confirmation-email-9c8Uh' 2026-02-23 22:54:20 +01:00
gurixandClaude Sonnet 4.6 91ba940951 update language hint to reflect any-language support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:52:57 +01:00
15 changed files with 1211 additions and 12 deletions
+46 -10
View File
@@ -44,7 +44,7 @@ cp .env.example .env
| Variable | Description |
|---|---|
| `AI_MODEL` | litellm model string, e.g. `anthropic/claude-opus-4-6` or `openai/gpt-4o` |
| `AI_MODEL` | Primary model (litellm string), e.g. `anthropic/claude-opus-4-6` |
| `IMAP_HOST` | IMAP server hostname for receiving parent emails |
| `IMAP_USERNAME` | Email account username |
| `IMAP_PASSWORD` | Email account password |
@@ -57,30 +57,37 @@ The API key variable depends on your chosen provider — see [Switching AI provi
| Variable | Default | Description |
|---|---|---|
| `SIMPLE_MODEL` | _(falls back to `AI_MODEL`)_ | Lightweight model for simple tasks (e.g. email-label translation). Can be from a different provider. Logs a warning if unset. |
| `THINKING_BUDGET` | _(disabled)_ | Token budget for extended thinking — Anthropic models only. Recommended: `8000`. |
| `IMAP_PORT` | `993` | IMAP port |
| `IMAP_USE_SSL` | `true` | Use SSL for IMAP |
| `SMTP_PORT` | `587` | SMTP port |
| `SMTP_USE_TLS` | `true` | Use STARTTLS for SMTP |
| `CHAINLIT_HOST` | `localhost` | Host the web chat binds to. Set to `0.0.0.0` to expose externally. |
| `DATA_DIR` | `data/` | Directory for conversation state and completed registrations |
| `KNOWLEDGE_BASE_DIR` | `openspec/…/knowledge-base` | Path to admin-editable knowledge base markdown files |
| `POLL_INTERVAL` | `60` | Seconds between inbox polls (only used when running as a daemon) |
### Switching AI providers
`AI_MODEL` uses [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes:
Both `AI_MODEL` and `SIMPLE_MODEL` use [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes. The two models can be from different providers:
```bash
# Anthropic (default)
# Anthropic for both (default)
AI_MODEL=anthropic/claude-opus-4-6
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
ANTHROPIC_API_KEY=sk-ant-...
# OpenAI
AI_MODEL=openai/gpt-4o
OPENAI_API_KEY=sk-...
# Google Gemini
AI_MODEL=gemini/gemini-2.0-flash
# Google Gemini for both
AI_MODEL=gemini/gemini-3-pro-preview
SIMPLE_MODEL=gemini/gemini-3-flash-preview
GEMINI_API_KEY=...
# Mixed providers
AI_MODEL=gemini/gemini-3-pro-preview
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
GEMINI_API_KEY=...
ANTHROPIC_API_KEY=sk-ant-...
```
## Running
@@ -149,6 +156,35 @@ uv run python main.py
Completed registrations from both channels are stored in the same `DATA_DIR` (default: `data/`) and share the same admin notification configuration.
### Docker Compose (recommended for production)
A `docker-compose.yml` is provided that runs both services together with shared persistent storage:
```bash
cp .env.example .env
# fill in .env, then:
docker compose up -d
```
| Service | What it runs |
|---|---|
| `web` | Chainlit web chat at `http://localhost:8000` |
| `email-worker` | Email polling agent (`main.py`) |
Both services mount `./data` for shared registration storage and `./openspec` (read-only) for the knowledge base. Restarting a service does not lose conversation state.
To view logs:
```bash
docker compose logs -f
```
To rebuild after a code change:
```bash
docker compose up -d --build
```
## Development
### Running tests
@@ -165,4 +201,4 @@ The agent answers parent questions from markdown files in the knowledge base dir
### Adding a new AI provider
Set `AI_MODEL` to any [litellm-supported model string](https://docs.litellm.ai/docs/providers) and set the corresponding API key environment variable. No code changes required.
Set `AI_MODEL` (and optionally `SIMPLE_MODEL`) to any [litellm-supported model string](https://docs.litellm.ai/docs/providers) and set the corresponding API key environment variable. No code changes required.
+1 -1
View File
@@ -8,7 +8,7 @@ Ich helfe dir, dein Kind für die Spielgruppe anzumelden — schnell und unkompl
- **Anmeldung**: Ich führe dich Schritt für Schritt durch die Anmeldung
- **Fragen beantworten**: Preise, Zeiten, Reglement — frag einfach
- **Deutsch oder Englisch**: Schreib in der Sprache, die dir lieber ist
- **Deutsch oder jede andere Sprache**: Schreib in der Sprache, die dir lieber ist
## Spielgruppen
+1 -1
View File
@@ -67,7 +67,7 @@ _notifier = AdminNotifier(
_WELCOME_DE = (
"Hallo! Ich bin der Anmeldeassistent der Spielgruppe Pumuckl. "
"Ich kann dir helfen, dein Kind anzumelden, oder deine Fragen zur Spielgruppe beantworten.\n\n"
"Du kannst mir auf Deutsch oder Englisch schreiben — ich antworte in derselben Sprache.\n\n"
"Du kannst mir auf die Sprache schreiben die du am besten kannst — ich antworte in derselben Sprache.\n\n"
"Womit kann ich dir helfen?"
)
+21
View File
@@ -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).
@@ -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
+96
View File
@@ -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
# ------------------------------------------------------------------
+73
View File
@@ -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")
+5
View File
@@ -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
+40
View File
@@ -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,
+652
View File
@@ -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="<abc123@tacitus2.sui-inter.net>",
)
saved_state = mock_store.save.call_args[0][0]
assert saved_state.last_inbound_message_id == "<abc123@tacitus2.sui-inter.net>"
# ---------------------------------------------------------------------------
# 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()
+42
View File
@@ -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