Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dcf90e4f7 | ||
|
|
aa3686c6a9 | ||
|
|
55d5567e54 | ||
|
|
aac179de13 | ||
|
|
e076ac9391 | ||
|
|
248d4aa5f7 | ||
|
|
f1f00d5617 | ||
|
|
3188106dbf | ||
|
|
0829f5e603 | ||
|
|
f509d984ba | ||
|
|
fdf674d8d6 | ||
|
|
514d235a2f | ||
|
|
491c2d3495 | ||
|
|
9dac54fe81 | ||
|
|
fa6b520f19 | ||
|
|
cab16078a2 | ||
|
|
e3bdfbcb9e | ||
|
|
91ba940951 |
+2
-2
@@ -62,8 +62,8 @@ REGISTRATION_EMAIL=anmeldung@example.com
|
||||
# ADMIN_EMAIL_CC is always included as Cc (comma-separated for multiple).
|
||||
# For testing, point all three to your own email address.
|
||||
# ---------------------------------------------------------------
|
||||
ADMIN_EMAIL_INDOOR=andrea.sigrist@gmx.net
|
||||
ADMIN_EMAIL_OUTDOOR=baba.laeubli@gmail.com
|
||||
ADMIN_EMAIL_INDOOR=indoor-leader@example.com
|
||||
ADMIN_EMAIL_OUTDOOR=outdoor-leader@example.com
|
||||
ADMIN_EMAIL_CC=spielgruppen@familien-verein.ch
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -109,9 +116,9 @@ uv run chainlit run chat_app.py --port 8080 --host 0.0.0.0
|
||||
| `ANTHROPIC_API_KEY` | (or the key for your chosen provider) |
|
||||
| `SMTP_HOST` / `SMTP_PORT` | For admin notification emails on registration completion |
|
||||
| `IMAP_USERNAME` / `IMAP_PASSWORD` | Used as SMTP credentials |
|
||||
| `ADMIN_EMAIL_INDOOR` | Andrea Sigrist — notified when indoor group is booked |
|
||||
| `ADMIN_EMAIL_OUTDOOR` | Barbara Gross — notified when outdoor group is booked |
|
||||
| `ADMIN_EMAIL_CC` | Markus Graf — always CC'd on notifications |
|
||||
| `ADMIN_EMAIL_INDOOR` | Indoor group leader — notified when indoor group is booked |
|
||||
| `ADMIN_EMAIL_OUTDOOR` | Outdoor group leader — notified when outdoor group is booked |
|
||||
| `ADMIN_EMAIL_CC` | Admin — always CC'd on notifications |
|
||||
|
||||
IMAP variables (`IMAP_HOST`, etc.) are not required for the web chat — only for the email channel.
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
> - Registration fee: CHF 80 (one-time)
|
||||
> - Monthly fee: [amount based on selection]
|
||||
>
|
||||
> The playgroup team will be in touch soon. If you have any questions, feel free to contact Andrea (indoor) at 079 674 99 92 or Barbara (outdoor) at 078 761 19 64.
|
||||
> The playgroup team will be in touch soon. If you have any questions, feel free to contact Andrea (indoor) at 079 674 99 92 or andrea.sigrist@gmx.net, or Barbara (outdoor) at 078 761 19 64 or baba.laeubli@gmail.com.
|
||||
>
|
||||
> We look forward to welcoming [child's name]!
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
> - Einschreibegebühr: CHF 80 (einmalig)
|
||||
> - Monatsbeitrag: [amount based on selection]
|
||||
>
|
||||
> Das Spielgruppen-Team wird sich bald melden. Bei Fragen kannst du Andrea (drinnen) unter 079 674 99 92 oder Barbara (draussen) unter 078 761 19 64 erreichen.
|
||||
> Das Spielgruppen-Team wird sich bald melden. Bei Fragen kannst du Andrea (drinnen) unter 079 674 99 92 oder andrea.sigrist@gmx.net, oder Barbara (draussen) unter 078 761 19 64 oder baba.laeubli@gmail.com erreichen.
|
||||
>
|
||||
> Wir freuen uns auf [child's name]!
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-27
|
||||
@@ -0,0 +1,47 @@
|
||||
## Context
|
||||
|
||||
The system sends emails in two directions with two different purposes:
|
||||
|
||||
- **Outbound to parents**: The AI agent sends conversational replies and, upon registration completion, a confirmation email summarising the registration and next steps.
|
||||
- **Outbound to admins/leaders**: The system sends a notification email to the relevant playgroup leader(s) and Markus Graf (CC) immediately after a registration is completed.
|
||||
|
||||
Email clients use the `Reply-To` header (falling back to `From`) to determine where a reply is directed. Without explicit `Reply-To` headers, all replies from both parents and admins flow back to the registration system's inbox — which is correct for ongoing conversation but wrong for post-completion follow-up.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Ensure parent replies to completion confirmation emails reach the admin (Markus Graf) rather than the agent pipeline
|
||||
- Ensure admin/leader replies to registration notification emails reach the registering parent directly
|
||||
- Formally specify `Reply-To` behavior in the relevant capability specs
|
||||
|
||||
**Non-Goals:**
|
||||
- Changing the `From` address of any email
|
||||
- Modifying conversational email behavior (mid-registration agent ↔ parent exchanges — these correctly use the registration address as both From and effective reply target)
|
||||
- Introducing any new email addresses beyond what is already configured
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Confirmation Email Reply-To: Admin Address
|
||||
|
||||
**Decision**: Set `Reply-To: spielgruppen@familien-verein.ch` (Markus Graf) on all confirmation emails sent to parents after registration completion.
|
||||
|
||||
**Rationale**: Once registration is complete, the conversation is over. Any parent reply is a human follow-up question — it should reach a human admin, not re-enter the agent pipeline. Markus Graf is the designated central admin contact and is already CC'd on all notifications.
|
||||
|
||||
**Alternatives considered**:
|
||||
- No Reply-To (default to From): Parent replies re-enter the agent inbox and may trigger unwanted agent responses post-completion.
|
||||
- Reply-To the relevant playgroup leader (Andrea/Barbara): More targeted, but leaders vary by registration type and parents may not know who they're reaching. The central admin address is simpler and consistent.
|
||||
|
||||
### 2. Notification Email Reply-To: Parent Address
|
||||
|
||||
**Decision**: Set `Reply-To: <parent email>` on all registration notification emails sent to admins/leaders.
|
||||
|
||||
**Rationale**: The primary reason admins reply to a notification is to contact the parent (e.g., to confirm a spot, ask a clarifying question, or provide further instructions). Pre-filling Reply-To with the parent's address eliminates a copy-paste step and reduces errors. This is already noted informally in `notification-template.md` — this change formalises it as a spec requirement.
|
||||
|
||||
**Alternatives considered**:
|
||||
- No Reply-To (default to From/registration inbox): Admins must manually copy the parent's email to reply, adding friction.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**Admin confirmation email replies go to Markus, not directly to the leader**: For outdoor registrations, the leader is Barbara Gross, but parent replies to confirmation go to Markus Graf. This is acceptable — Markus can forward as needed, and having a single consistent Reply-To is simpler than routing by playgroup type.
|
||||
|
||||
**Mid-registration vs. post-registration distinction**: Conversational emails (mid-registration) should NOT set Reply-To to the admin — they must continue flowing back to the registration inbox so the agent can process them. The implementation must apply the admin Reply-To only to the final confirmation email, not to all outbound agent emails.
|
||||
@@ -0,0 +1,21 @@
|
||||
## Why
|
||||
|
||||
The system sends two distinct types of emails to different audiences with different needs for follow-up communication. Currently, the Reply-To behavior for these emails is either unspecified or inconsistent:
|
||||
|
||||
1. **Confirmation emails to parents** — sent by the agent after registration is complete. If a parent replies to this email, that reply should reach the admin (Markus Graf), not bounce back into the agent's inbox for further automated processing.
|
||||
|
||||
2. **Registration notification emails to playgroup leaders/admins** — sent to Andrea Sigrist, Barbara Gross, and Markus Graf when a new registration is submitted. If an admin wants to follow up with the parent directly, their reply must go to the parent's email, not back to the registration system.
|
||||
|
||||
Without explicit Reply-To configuration, email clients will default to replying to the From address (the registration system's email). This creates confusion: parent replies to confirmation emails enter the agent pipeline instead of reaching a human admin, and admin replies to notification emails go to the registration inbox rather than the parent.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Confirmation emails**: Add a `Reply-To` header set to the admin email address (`spielgruppen@familien-verein.ch`) so parent replies reach a human directly
|
||||
- **Admin notification emails**: Confirm and formally specify that `Reply-To` is set to the parent's email address so admins can respond to parents directly from their email client
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `email-channel`: Add Reply-To specification for confirmation emails sent to parents after registration completion
|
||||
- `registration-notifications`: Formally specify Reply-To for admin notification emails (parent's email address)
|
||||
@@ -0,0 +1,15 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Confirmation email sets Reply-To to admin address
|
||||
|
||||
The system SHALL set the `Reply-To` header to the admin email address (`spielgruppen@familien-verein.ch`) on the registration completion confirmation email sent to the parent.
|
||||
|
||||
#### Scenario: Parent replies to confirmation email
|
||||
- **WHEN** the agent sends a registration completion confirmation email to a parent
|
||||
- **THEN** the email SHALL include a `Reply-To` header set to `spielgruppen@familien-verein.ch`
|
||||
- **AND** a parent reply SHALL be delivered to the admin, not to the registration system's inbox
|
||||
|
||||
#### Scenario: Mid-registration emails are unaffected
|
||||
- **WHEN** the agent sends a conversational email during an ongoing registration (not the final confirmation)
|
||||
- **THEN** the email SHALL NOT set `Reply-To` to the admin address
|
||||
- **AND** parent replies SHALL continue to be routed back to the registration system for processing
|
||||
@@ -0,0 +1,18 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Notification email sets Reply-To to parent address
|
||||
|
||||
The system SHALL set the `Reply-To` header to the registering parent's email address on all registration notification emails sent to playgroup leaders and the admin.
|
||||
|
||||
#### Scenario: Leader replies to notification email
|
||||
- **WHEN** a registration notification email is sent to a playgroup leader (Andrea Sigrist or Barbara Gross)
|
||||
- **THEN** the email SHALL include a `Reply-To` header set to the parent's email address (`registration.parentGuardian.email`)
|
||||
- **AND** a leader reply SHALL be delivered directly to the parent
|
||||
|
||||
#### Scenario: Admin (CC) replies to notification email
|
||||
- **WHEN** Markus Graf replies to a registration notification email (received as CC)
|
||||
- **THEN** the reply SHALL be delivered directly to the parent's email address
|
||||
|
||||
#### Scenario: Reply-To applies to all notification routing types
|
||||
- **WHEN** a notification is sent for an indoor-only, outdoor-only, or both registration
|
||||
- **THEN** all recipient copies (To and CC) SHALL have `Reply-To` set to the parent's email address
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. Confirmation Email Reply-To (Email Channel)
|
||||
|
||||
- [x] 1.1 Identify where the registration completion confirmation email is constructed in `src/` (email channel adapter / agent core)
|
||||
- [x] 1.2 Add `Reply-To: spielgruppen@familien-verein.ch` header to the confirmation email only (not to mid-registration conversational emails)
|
||||
- [x] 1.3 Add a unit test verifying the `Reply-To` header is present on the confirmation email
|
||||
- [x] 1.4 Add a unit test verifying mid-registration emails do NOT carry the admin `Reply-To` header
|
||||
|
||||
## 2. Notification Email Reply-To (Registration Notifications)
|
||||
|
||||
- [x] 2.1 Identify where registration notification emails are constructed and sent
|
||||
- [x] 2.2 Set `Reply-To: <parent email>` header on all outgoing notification emails (indoor, outdoor, and both routing types)
|
||||
- [x] 2.3 Add a unit test verifying the `Reply-To` header equals the parent's email for indoor-only notification
|
||||
- [x] 2.4 Add a unit test verifying the `Reply-To` header equals the parent's email for outdoor-only notification
|
||||
- [x] 2.5 Add a unit test verifying the `Reply-To` header equals the parent's email when both leaders are notified
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Run the full test suite and confirm all tests pass
|
||||
- [ ] 3.2 Manually send a test registration through the email channel and verify reply routing behaves correctly
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,18 @@ QR_STREET = "Huebwisstrase 5"
|
||||
QR_PCODE = "8117"
|
||||
QR_CITY = "Fällanden"
|
||||
|
||||
LEADER_INDOOR_NAME = "Andrea Sigrist"
|
||||
LEADER_INDOOR_PHONE = "079 674 99 92"
|
||||
LEADER_INDOOR_EMAIL = "andrea.sigrist@gmx.net"
|
||||
|
||||
LEADER_OUTDOOR_NAME = "Barbara Gross"
|
||||
LEADER_OUTDOOR_PHONE = "078 761 19 64"
|
||||
LEADER_OUTDOOR_EMAIL = "baba.laeubli@gmail.com"
|
||||
|
||||
ADMIN_NAME = "Markus Graf"
|
||||
ADMIN_PHONE = "079 261 16 37"
|
||||
ADMIN_EMAIL = "spielgruppen@familien-verein.ch"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting helpers (pure functions, no side-effects)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -187,7 +199,17 @@ def build_parent_context(
|
||||
"days": format_days_i18n(registration, strings),
|
||||
"monthly_fee": calculate_monthly_fee(registration),
|
||||
"has_indoor": "indoor" in registration.booking.playgroup_types,
|
||||
"has_outdoor": "outdoor" in registration.booking.playgroup_types,
|
||||
"has_qr": has_qr,
|
||||
"leader_indoor_name": LEADER_INDOOR_NAME,
|
||||
"leader_indoor_phone": LEADER_INDOOR_PHONE,
|
||||
"leader_indoor_email": LEADER_INDOOR_EMAIL,
|
||||
"leader_outdoor_name": LEADER_OUTDOOR_NAME,
|
||||
"leader_outdoor_phone": LEADER_OUTDOOR_PHONE,
|
||||
"leader_outdoor_email": LEADER_OUTDOOR_EMAIL,
|
||||
"admin_name": ADMIN_NAME,
|
||||
"admin_phone": ADMIN_PHONE,
|
||||
"admin_email": ADMIN_EMAIL,
|
||||
"parent_name": pg.full_name or "",
|
||||
"parent_address": pg.street_address or "",
|
||||
"parent_postal_code": pg.postal_code or "",
|
||||
|
||||
@@ -39,8 +39,17 @@ iban_label: "IBAN"
|
||||
payee_label: "Empfänger"
|
||||
amount_label: "Betrag"
|
||||
|
||||
contact_section: "Deine Ansprechpersonen"
|
||||
contact_intro: "Bei Fragen kannst du dich direkt an die zuständige Spielgruppenleiterin wenden:"
|
||||
contact_indoor_label: "Innenspielgruppe"
|
||||
contact_outdoor_label: "Waldspielgruppe"
|
||||
contact_admin_label: "Administration"
|
||||
contact_name: "Name"
|
||||
contact_phone: "Telefon"
|
||||
contact_email: "E-Mail"
|
||||
|
||||
closing: |
|
||||
Bei Fragen stehen wir dir gerne zur Verfügung. Wir freuen uns auf dein Kind!
|
||||
Wir freuen uns auf dein Kind!
|
||||
|
||||
Herzliche Grüsse
|
||||
Spielgruppe Pumuckl
|
||||
|
||||
@@ -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,
|
||||
@@ -175,6 +215,8 @@ class AdminNotifier:
|
||||
msg_outer["From"] = self._from_email
|
||||
msg_outer["To"] = parent_email
|
||||
msg_outer["Subject"] = subject
|
||||
if self._cc_emails:
|
||||
msg_outer["Reply-To"] = self._cc_emails[0]
|
||||
|
||||
msg_alt = MIMEMultipart("alternative")
|
||||
msg_alt.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
|
||||
@@ -74,6 +74,27 @@
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.emergency_phone }}</td><td>{{ emergency_phone }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.contact_section }}</h2>
|
||||
<p>{{ strings.contact_intro }}</p>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
{% if has_indoor %}
|
||||
<tr><td colspan="2" style="padding:8px 0 2px;font-weight:bold;">{{ strings.contact_indoor_label }}</td></tr>
|
||||
<tr><td style="padding:2px 0 2px 16px;color:#666;width:55%;">{{ strings.contact_name }}</td><td>{{ leader_indoor_name }}</td></tr>
|
||||
<tr><td style="padding:2px 0 2px 16px;color:#666;">{{ strings.contact_phone }}</td><td><a href="tel:{{ leader_indoor_phone }}" style="color:#2e7d32;">{{ leader_indoor_phone }}</a></td></tr>
|
||||
<tr><td style="padding:2px 0 8px 16px;color:#666;">{{ strings.contact_email }}</td><td><a href="mailto:{{ leader_indoor_email }}" style="color:#2e7d32;">{{ leader_indoor_email }}</a></td></tr>
|
||||
{% endif %}
|
||||
{% if has_outdoor %}
|
||||
<tr><td colspan="2" style="padding:8px 0 2px;font-weight:bold;">{{ strings.contact_outdoor_label }}</td></tr>
|
||||
<tr><td style="padding:2px 0 2px 16px;color:#666;width:55%;">{{ strings.contact_name }}</td><td>{{ leader_outdoor_name }}</td></tr>
|
||||
<tr><td style="padding:2px 0 2px 16px;color:#666;">{{ strings.contact_phone }}</td><td><a href="tel:{{ leader_outdoor_phone }}" style="color:#2e7d32;">{{ leader_outdoor_phone }}</a></td></tr>
|
||||
<tr><td style="padding:2px 0 8px 16px;color:#666;">{{ strings.contact_email }}</td><td><a href="mailto:{{ leader_outdoor_email }}" style="color:#2e7d32;">{{ leader_outdoor_email }}</a></td></tr>
|
||||
{% endif %}
|
||||
<tr><td colspan="2" style="padding:8px 0 2px;font-weight:bold;">{{ strings.contact_admin_label }}</td></tr>
|
||||
<tr><td style="padding:2px 0 2px 16px;color:#666;width:55%;">{{ strings.contact_name }}</td><td>{{ admin_name }}</td></tr>
|
||||
<tr><td style="padding:2px 0 2px 16px;color:#666;">{{ strings.contact_phone }}</td><td><a href="tel:{{ admin_phone }}" style="color:#2e7d32;">{{ admin_phone }}</a></td></tr>
|
||||
<tr><td style="padding:2px 0 8px 16px;color:#666;">{{ strings.contact_email }}</td><td><a href="mailto:{{ admin_email }}" style="color:#2e7d32;">{{ admin_email }}</a></td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:24px;white-space:pre-line;">{{ strings.closing }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,27 @@
|
||||
{{ strings.emergency_name }}: {{ emergency_name }}
|
||||
{{ strings.emergency_phone }}: {{ emergency_phone }}
|
||||
|
||||
===============================================
|
||||
{{ strings.contact_section | upper }}
|
||||
===============================================
|
||||
{{ strings.contact_intro }}
|
||||
{% if has_indoor %}
|
||||
{{ strings.contact_indoor_label }}
|
||||
{{ strings.contact_name }}: {{ leader_indoor_name }}
|
||||
{{ strings.contact_phone }}: {{ leader_indoor_phone }}
|
||||
{{ strings.contact_email }}: {{ leader_indoor_email }}
|
||||
{% endif %}
|
||||
{% if has_outdoor %}
|
||||
{{ strings.contact_outdoor_label }}
|
||||
{{ strings.contact_name }}: {{ leader_outdoor_name }}
|
||||
{{ strings.contact_phone }}: {{ leader_outdoor_phone }}
|
||||
{{ strings.contact_email }}: {{ leader_outdoor_email }}
|
||||
{% endif %}
|
||||
{{ strings.contact_admin_label }}
|
||||
{{ strings.contact_name }}: {{ admin_name }}
|
||||
{{ strings.contact_phone }}: {{ admin_phone }}
|
||||
{{ strings.contact_email }}: {{ admin_email }}
|
||||
|
||||
-----------------------------------------------
|
||||
|
||||
{{ strings.closing }}
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
"""Tests for email loop prevention and email channel reply headers.
|
||||
|
||||
Covers four areas:
|
||||
- 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
|
||||
- EmailChannel.send_reply() — mid-registration emails must not carry admin Reply-To
|
||||
"""
|
||||
|
||||
import email
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.channels.email_channel import detect_automated_message, EmailChannel
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EmailChannel.send_reply — mid-registration emails must not have admin Reply-To
|
||||
# (task 1.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSendReplyNoAdminReplyTo:
|
||||
def test_send_reply_has_no_reply_to_header(self, mocker):
|
||||
"""Mid-registration conversational emails must not carry a Reply-To header."""
|
||||
channel = EmailChannel(
|
||||
imap_host="imap.example.com",
|
||||
imap_port=993,
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
username="agent@example.com",
|
||||
password="secret",
|
||||
use_ssl=True,
|
||||
use_tls=True,
|
||||
registration_email="agent@example.com",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
channel.send_reply(
|
||||
to="parent@example.com",
|
||||
subject="Re: Anmeldung",
|
||||
body="Wie heisst dein Kind?",
|
||||
)
|
||||
|
||||
parsed = email.message_from_string(captured["msg"])
|
||||
assert parsed.get("Reply-To") is None
|
||||
@@ -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
|
||||
|
||||
@@ -345,3 +345,143 @@ class TestGenerateQrBillPng:
|
||||
"""Output starts with the PNG magic bytes."""
|
||||
png = notifier._generate_qr_bill_png()
|
||||
assert png[:4] == b"\x89PNG"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reply-To header — confirmation email to parent (task 1.3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNotifyParentReplyTo:
|
||||
def test_confirmation_email_has_reply_to_admin(
|
||||
self, notifier, complete_registration, mocker
|
||||
):
|
||||
"""Confirmation email sets Reply-To to the first CC (admin) address."""
|
||||
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_parent(complete_registration, language="de")
|
||||
|
||||
parsed = email.message_from_string(captured["msg"])
|
||||
assert parsed.get("Reply-To") == "markus@example.com"
|
||||
|
||||
def test_confirmation_email_no_reply_to_when_no_cc(
|
||||
self, complete_registration, mocker
|
||||
):
|
||||
"""When no CC emails are configured, no Reply-To header is set."""
|
||||
notifier_no_cc = AdminNotifier(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
username="agent@example.com",
|
||||
password="secret",
|
||||
from_email="agent@example.com",
|
||||
cc_emails=[],
|
||||
)
|
||||
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_no_cc.notify_parent(complete_registration, language="de")
|
||||
|
||||
parsed = email.message_from_string(captured["msg"])
|
||||
assert parsed.get("Reply-To") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reply-To header — admin notification emails (tasks 2.3, 2.4, 2.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNotifyAdminReplyTo:
|
||||
def _capture_msg(self, mocker):
|
||||
"""Return a side-effect function and a dict that captures the raw MIME string."""
|
||||
captured = {}
|
||||
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
|
||||
def fake_sendmail(from_, to_, msg_str):
|
||||
captured["msg"] = msg_str
|
||||
|
||||
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
|
||||
return captured
|
||||
|
||||
def test_indoor_notification_reply_to_is_parent_email(
|
||||
self, notifier, complete_registration, mocker
|
||||
):
|
||||
"""Indoor-only notification sets Reply-To to the parent's email."""
|
||||
from src.models.registration import Booking, BookingDay
|
||||
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[BookingDay(day="monday", type="indoor")],
|
||||
)
|
||||
captured = self._capture_msg(mocker)
|
||||
|
||||
notifier.notify_admin(
|
||||
complete_registration,
|
||||
registration_id="reg-001",
|
||||
version=1,
|
||||
conversation_id="conv-001",
|
||||
channel="email",
|
||||
)
|
||||
|
||||
parsed = email.message_from_string(captured["msg"])
|
||||
assert parsed.get("Reply-To") == "anna.muster@example.com"
|
||||
|
||||
def test_outdoor_notification_reply_to_is_parent_email(
|
||||
self, notifier, complete_registration, mocker
|
||||
):
|
||||
"""Outdoor-only notification sets Reply-To to the parent's email."""
|
||||
from src.models.registration import Booking, BookingDay
|
||||
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["outdoor"],
|
||||
selected_days=[BookingDay(day="monday", type="outdoor")],
|
||||
)
|
||||
captured = self._capture_msg(mocker)
|
||||
|
||||
notifier.notify_admin(
|
||||
complete_registration,
|
||||
registration_id="reg-002",
|
||||
version=1,
|
||||
conversation_id="conv-002",
|
||||
channel="email",
|
||||
)
|
||||
|
||||
parsed = email.message_from_string(captured["msg"])
|
||||
assert parsed.get("Reply-To") == "anna.muster@example.com"
|
||||
|
||||
def test_both_types_notification_reply_to_is_parent_email(
|
||||
self, notifier, complete_registration, mocker
|
||||
):
|
||||
"""Both-types notification sets Reply-To to the parent's email."""
|
||||
from src.models.registration import Booking, BookingDay
|
||||
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor", "outdoor"],
|
||||
selected_days=[
|
||||
BookingDay(day="monday", type="indoor"),
|
||||
BookingDay(day="monday", type="outdoor"),
|
||||
],
|
||||
)
|
||||
captured = self._capture_msg(mocker)
|
||||
|
||||
notifier.notify_admin(
|
||||
complete_registration,
|
||||
registration_id="reg-003",
|
||||
version=1,
|
||||
conversation_id="conv-003",
|
||||
channel="email",
|
||||
)
|
||||
|
||||
parsed = email.message_from_string(captured["msg"])
|
||||
assert parsed.get("Reply-To") == "anna.muster@example.com"
|
||||
|
||||
Reference in New Issue
Block a user