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