Author SHA1 Message Date
Claude 4dcf90e4f7 Revert openspec notification-template.md changes
The parent confirmation email template belongs only in the code
(Jinja2 templates), not in the openspec content folder.

https://claude.ai/code/session_01Qikq92MrNk95ZXtatPyJJh
2026-02-27 19:21:46 +00:00
Claude aa3686c6a9 Add leader contact section to parent confirmation email templates
Both .html.j2 and .txt.j2 now include a CONTACT section after the
emergency block. It conditionally shows Andrea Sigrist (indoor) and/or
Barbara Gross (outdoor) plus Markus Graf (admin) with phone and email,
rendered as clickable links in HTML.

Adds the required template variables (has_outdoor, leader_indoor_*,
leader_outdoor_*, admin_*) to build_parent_context in context.py, and
the matching label strings to de.yaml (LLM-translated for other langs).

https://claude.ai/code/session_01Qikq92MrNk95ZXtatPyJJh
2026-02-27 19:18:59 +00:00
Claude 55d5567e54 Add parent confirmation email template with leader contact details
Introduces a parent-facing confirmation email template to
notification-template.md. It includes phone and email for the
relevant playgroup leader(s) (Andrea Sigrist for indoor, Barbara
Gross for outdoor), conditionally rendered by registration type,
plus administration contact. Bilingual (German/English).

https://claude.ai/code/session_01Qikq92MrNk95ZXtatPyJJh
2026-02-27 18:01:38 +00:00
Claude aac179de13 Add leader email addresses to registration completion message
Parents now see both phone and email for Andrea Sigrist (indoor)
and Barbara Gross (outdoor) in the confirmation message, in both
English and German.

https://claude.ai/code/session_01Qikq92MrNk95ZXtatPyJJh
2026-02-27 17:57:08 +00:00
Markus GrafandGitHub e076ac9391 Merge pull request #10 from gurix/claude/confirm-email-from-address-Pgg8F
Specify Reply-To headers for confirmation and notification emails
2026-02-27 18:33:25 +01:00
Claude 248d4aa5f7 feat(email): set Reply-To headers on confirmation and notification emails
- notify_parent: adds Reply-To set to the first CC (admin) address so
  parent replies to the completion confirmation reach the admin rather
  than re-entering the agent pipeline
- notify_admin / notify_registration_update: Reply-To = parent email
  was already wired through _send(); confirmed with new tests
- Mid-registration send_reply emails carry no Reply-To (verified by test)
- 5 new tests across TestNotifyParentReplyTo and TestNotifyAdminReplyTo;
  1 new test in TestSendReplyNoAdminReplyTo (174 passing, 0 failing)

https://claude.ai/code/session_014NynTjALA5TeP85mbr5ZA8
2026-02-27 17:29:38 +00:00
Claude f1f00d5617 docs(openspec): add change artifacts for email-reply-to-addresses
Specifies Reply-To header behavior for the two outbound email types:
- Confirmation emails to parents: Reply-To set to admin address (spielgruppen@familien-verein.ch)
- Registration notification emails to leaders/admin: Reply-To set to parent's email

https://claude.ai/code/session_014NynTjALA5TeP85mbr5ZA8
2026-02-27 16:27:33 +00:00
Markus GrafandGitHub 3188106dbf Merge pull request #9 from gurix/claude/remove-contact-info-ykUMh
Replace personal email addresses with generic placeholders
2026-02-27 17:18:36 +01:00
Claude 0829f5e603 Remove personal contact info from .env.example and README
Replace Andrea Sigrist's and Barbara's email addresses and names with
generic placeholders to avoid exposing personal contact information
in the public repository.

https://claude.ai/code/session_01Y6uFfnbjzxHoAYQYd2vyuS
2026-02-27 16:02:00 +00:00
gurixandClaude Sonnet 4.6 f509d984ba docs: add Docker Compose section to README
Documents the docker-compose.yml setup added in PR #7, including how
to start both services, volume mounts for persistent data, and common
operational commands (logs, rebuild).

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

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

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

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

All 168 tests pass.

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

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

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

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

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

https://claude.ai/code/session_01KwvR5hDPjSuJg4kvw5b5e5
2026-02-26 20:46:09 +00:00
Markus GrafandGitHub fa6b520f19 Merge pull request #7 from gurix/claude/add-docker-compose-setup-PoDxl
Add Docker support with Compose configuration
2026-02-26 21:32:19 +01:00
gurixandClaude Sonnet 4.6 cab16078a2 document SIMPLE_MODEL and THINKING_BUDGET env vars in README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 17:43:35 +01:00
gurix e3bdfbcb9e Merge remote-tracking branch 'origin/claude/registration-confirmation-email-9c8Uh' 2026-02-23 22:54:20 +01:00
gurixandClaude Sonnet 4.6 91ba940951 update language hint to reflect any-language support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:52:57 +01:00
28 changed files with 1596 additions and 20 deletions
+2 -2
View File
@@ -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
# ---------------------------------------------------------------
+49 -13
View File
@@ -44,7 +44,7 @@ cp .env.example .env
| Variable | Description |
|---|---|
| `AI_MODEL` | litellm model string, e.g. `anthropic/claude-opus-4-6` or `openai/gpt-4o` |
| `AI_MODEL` | Primary model (litellm string), e.g. `anthropic/claude-opus-4-6` |
| `IMAP_HOST` | IMAP server hostname for receiving parent emails |
| `IMAP_USERNAME` | Email account username |
| `IMAP_PASSWORD` | Email account password |
@@ -57,30 +57,37 @@ The API key variable depends on your chosen provider — see [Switching AI provi
| Variable | Default | Description |
|---|---|---|
| `SIMPLE_MODEL` | _(falls back to `AI_MODEL`)_ | Lightweight model for simple tasks (e.g. email-label translation). Can be from a different provider. Logs a warning if unset. |
| `THINKING_BUDGET` | _(disabled)_ | Token budget for extended thinking — Anthropic models only. Recommended: `8000`. |
| `IMAP_PORT` | `993` | IMAP port |
| `IMAP_USE_SSL` | `true` | Use SSL for IMAP |
| `SMTP_PORT` | `587` | SMTP port |
| `SMTP_USE_TLS` | `true` | Use STARTTLS for SMTP |
| `CHAINLIT_HOST` | `localhost` | Host the web chat binds to. Set to `0.0.0.0` to expose externally. |
| `DATA_DIR` | `data/` | Directory for conversation state and completed registrations |
| `KNOWLEDGE_BASE_DIR` | `openspec/…/knowledge-base` | Path to admin-editable knowledge base markdown files |
| `POLL_INTERVAL` | `60` | Seconds between inbox polls (only used when running as a daemon) |
### Switching AI providers
`AI_MODEL` uses [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes:
Both `AI_MODEL` and `SIMPLE_MODEL` use [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes. The two models can be from different providers:
```bash
# Anthropic (default)
# Anthropic for both (default)
AI_MODEL=anthropic/claude-opus-4-6
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
ANTHROPIC_API_KEY=sk-ant-...
# OpenAI
AI_MODEL=openai/gpt-4o
OPENAI_API_KEY=sk-...
# Google Gemini
AI_MODEL=gemini/gemini-2.0-flash
# Google Gemini for both
AI_MODEL=gemini/gemini-3-pro-preview
SIMPLE_MODEL=gemini/gemini-3-flash-preview
GEMINI_API_KEY=...
# Mixed providers
AI_MODEL=gemini/gemini-3-pro-preview
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
GEMINI_API_KEY=...
ANTHROPIC_API_KEY=sk-ant-...
```
## Running
@@ -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
View File
@@ -8,7 +8,7 @@ Ich helfe dir, dein Kind für die Spielgruppe anzumelden — schnell und unkompl
- **Anmeldung**: Ich führe dich Schritt für Schritt durch die Anmeldung
- **Fragen beantworten**: Preise, Zeiten, Reglement — frag einfach
- **Deutsch oder Englisch**: Schreib in der Sprache, die dir lieber ist
- **Deutsch oder jede andere Sprache**: Schreib in der Sprache, die dir lieber ist
## Spielgruppen
+1 -1
View File
@@ -67,7 +67,7 @@ _notifier = AdminNotifier(
_WELCOME_DE = (
"Hallo! Ich bin der Anmeldeassistent der Spielgruppe Pumuckl. "
"Ich kann dir helfen, dein Kind anzumelden, oder deine Fragen zur Spielgruppe beantworten.\n\n"
"Du kannst mir auf Deutsch oder Englisch schreiben — ich antworte in derselben Sprache.\n\n"
"Du kannst mir auf die Sprache schreiben die du am besten kannst — ich antworte in derselben Sprache.\n\n"
"Womit kann ich dir helfen?"
)
+21
View File
@@ -98,6 +98,27 @@ def run_poll_loop(agent: EmailAgent, channel: EmailChannel, poll_interval: int)
for msg in messages:
logger.info("Processing message from %s", msg["from"])
try:
# ----------------------------------------------------------
# Bounce / automated-sender guard
# If the channel layer flagged this as an automated message
# (bounce, out-of-office, delivery failure, …) we must NOT
# reply — that would create or worsen an email loop.
# Instead, alert the admin once and drop the message.
# ----------------------------------------------------------
if msg.get("is_automated"):
logger.warning(
"Automated/bounce message from %s — reason: %s — not replying",
msg["from"],
msg.get("automated_reason", "unknown"),
)
agent.handle_automated_message(
sender_email=msg["from"],
subject=msg["subject"],
reason=msg.get("automated_reason", "automated sender detected"),
inbound_message_id=msg["message_id"],
)
continue
# Prepend email headers so the LLM can extract the
# sender's address and subject (e.g. to fill in
# parentGuardian.email automatically).
@@ -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 812 exchanges. 20 gives ample room for slow or verbose conversations while still catching runaway loops. The value is a module-level constant so it can be changed without config infrastructure overhead.
### 5. One-shot admin alert via `loop_escalated` flag
**Decision**: Add `loop_escalated: bool` to `ConversationState`. The admin is notified exactly once per conversation. Subsequent automated messages or over-limit polls are silently dropped after the flag is set.
**Rationale**: The admin needs to know something is wrong, but receiving one alert per bounce (which may arrive many times per minute) would create inbox spam worse than the original problem.
**Implementation**: The flag is persisted to JSON so it survives agent restarts.
### 6. Admin notification routed to CC list
**Decision**: Loop-escalation alerts go to `self._cc_emails` (Markus Graf / `ADMIN_EMAIL_CC`), not to playgroup leaders.
**Rationale**: This is a system/infrastructure issue, not a registration event. The CC address is the designated admin (Markus Graf) who handles operational issues. Playgroup leaders do not need to see these alerts.
## Risks / Trade-offs
**False positives** → A legitimate parent using a `noreply@` alias could be silently blocked. This is an unlikely edge case; the subject/header checks require multiple signals for ambiguous senders. A missed registration is recoverable — admin gets the alert and can follow up manually.
**False negatives** → A clever loop that uses a normal-looking sender address and no automated headers would slip past layer 1. The 20-message cap catches it.
**Completed conversations** → The count cap applies to all conversations, including completed ones with many post-completion Q&A exchanges. A very chatty parent could theoretically hit the cap after registration is done. Acceptable for MVP — the cap is high enough that normal use is unaffected.
@@ -0,0 +1,30 @@
## Why
The email channel had no protection against automated message loops. When the agent sent a reply that bounced (e.g. due to an invalid recipient address or a misconfigured mail server), the bounce message arrived back in the inbox. The agent treated it as a new inbound message, generated another reply, which bounced again — creating an infinite loop.
A real incident demonstrated this: a `MAILER-DAEMON@tacitus2.sui-inter.net` bounce began accumulating replies indefinitely, consuming LLM quota and filling the inbox with noise while the agent remained unaware it was talking to an automated system.
Beyond bounce loops, any automated sender — out-of-office replies, mailing-list software, delivery status notifications — can trigger this pattern if the agent replies to them. The system needs to recognise non-human senders and refuse to reply.
A secondary risk: a legitimate but very slow conversation (or a forwarding-alias loop that bypasses simple bounce detection) could still accumulate messages indefinitely. A hard cap on conversation length provides a safety net.
## What Changes
- **Detect automated/bounce senders** before the agent replies — inspect email headers and sender address patterns to identify non-human messages
- **Skip sending any reply** to automated messages — silence breaks the loop
- **Alert the admin once** when an automated sender is detected, so a human can investigate
- **Cap conversation length** at 20 inbound messages — if a conversation has not completed after 20 user messages, stop responding and alert the admin
- **Track escalation state** per conversation so admin alerts fire at most once
### Non-Goals
- Spam filtering (automated detection is specific to loop-causing patterns, not general spam)
- Automatic unsubscribe/block of senders
- Forwarding the original problem email to the admin (admin receives only a warning notification)
## Capabilities
### Modified Capabilities
- `email-channel`: Add automated/bounce sender detection; skip replies for flagged messages
- `registration-notifications`: Add loop-escalation alert type sent to admin CC address
@@ -0,0 +1,54 @@
## ADDED Requirements
### Requirement: Automated sender detection
The system SHALL detect whether an inbound email was sent by an automated system rather than a human, before the message is processed by the agent.
#### Scenario: MAILER-DAEMON sender
- **WHEN** an email arrives with a sender local-part of `mailer-daemon`, `postmaster`, `noreply`, `no-reply`, `donotreply`, or `bounce` (case-insensitive)
- **THEN** the system SHALL flag the message as automated with a reason string identifying the sender pattern
#### Scenario: RFC 3834 Auto-Submitted header
- **WHEN** an email contains an `Auto-Submitted` header with any value other than `no`
- **THEN** the system SHALL flag the message as automated, citing the header value in the reason
#### Scenario: Auto-Submitted: no is not automated
- **WHEN** an email contains `Auto-Submitted: no`
- **THEN** the system SHALL NOT flag the message as automated based on this header
#### Scenario: Microsoft Exchange auto-reply suppression
- **WHEN** an email contains an `X-Auto-Response-Suppress` header (any value)
- **THEN** the system SHALL flag the message as automated
#### Scenario: Delivery Status Notification (RFC 3462)
- **WHEN** an email has `Content-Type: multipart/report`
- **THEN** the system SHALL flag the message as automated, as this indicates a machine-generated delivery status or read receipt
#### Scenario: X-Loop header
- **WHEN** an email contains an `X-Loop` header (any value)
- **THEN** the system SHALL flag the message as automated
#### Scenario: Bulk or junk precedence
- **WHEN** an email has a `Precedence` header with value `bulk` or `junk`
- **THEN** the system SHALL flag the message as automated
#### Scenario: Bounce / OOO subject line
- **WHEN** an email subject matches patterns indicating delivery failure or automated response (e.g. "Undelivered Mail", "Mail Delivery Failed", "Out of Office", "Abwesenheitsnotiz", "Automatische Antwort")
- **THEN** the system SHALL flag the message as automated
#### Scenario: Normal parent message
- **WHEN** an email has a normal human sender address and no automated-sender headers
- **THEN** the system SHALL NOT flag the message as automated
### Requirement: Automated messages are never replied to
The system SHALL NOT send any reply to a message flagged as automated.
#### Scenario: Bounce message arrives
- **WHEN** the system receives a message flagged as automated
- **THEN** the system SHALL mark the message as read (IMAP Seen flag)
- **AND** the system SHALL call the agent's automated-message handler
- **AND** the system SHALL NOT send any outbound email reply
### Requirement: Message dict includes automation flag
Every message returned by `fetch_unread_messages()` SHALL include:
- `is_automated` (boolean): whether the message was flagged as automated
- `automated_reason` (string): human-readable reason if flagged, empty string otherwise
@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: Loop escalation alert to admin
The system SHALL send a plain-text warning email to the admin when a conversation is stopped due to an automated sender or message-count cap breach.
#### Scenario: First automated message from a sender
- **WHEN** the first automated/bounce message is received from a sender address
- **THEN** the system SHALL send one alert email to the admin CC address list
- **AND** the subject SHALL begin with `[WARNUNG]` for easy inbox filtering
- **AND** the subject SHALL include the sender's email address
- **AND** the body SHALL include: sender address, conversation ID, detection reason, and message count
- **AND** no further alert SHALL be sent for subsequent automated messages from the same sender
#### Scenario: Conversation exceeds message-count cap
- **WHEN** a conversation accumulates more than 20 inbound user messages without completing
- **THEN** the system SHALL send one alert email to the admin CC address list on first breach
- **AND** the body SHALL identify the conversation and state that the message limit was exceeded
- **AND** no further alert SHALL be sent for subsequent messages in the same capped conversation
#### Scenario: No admin CC address configured
- **WHEN** `ADMIN_EMAIL_CC` is not set and a loop escalation is triggered
- **THEN** the system SHALL log a warning
- **AND** the system SHALL NOT attempt an SMTP connection
#### Scenario: No SMTP host configured (dev mode)
- **WHEN** `SMTP_HOST` is not set and a loop escalation is triggered
- **THEN** the system SHALL log the notification content
- **AND** the system SHALL NOT attempt an SMTP connection
### Requirement: Alert routing
Loop escalation alerts SHALL be sent only to the admin CC list (`ADMIN_EMAIL_CC`). They SHALL NOT be sent to playgroup leaders (Andrea Sigrist, Barbara Gross), as loop detection is an operational concern, not a registration event.
@@ -0,0 +1,43 @@
## 1. Automated Sender Detection (email_channel.py)
- [x] 1.1 Add `_AUTOMATED_SENDER_RE` regex for known non-human local-parts (mailer-daemon, postmaster, noreply, no-reply, donotreply, bounce, …)
- [x] 1.2 Add `_AUTOMATED_SUBJECT_RE` regex for bounce/OOO subject patterns (German + English)
- [x] 1.3 Implement `detect_automated_message(raw_msg, from_addr) → (bool, str)` checking all signals in priority order: sender pattern → Auto-Submitted → X-Auto-Response-Suppress → multipart/report → X-Loop → Precedence → subject
- [x] 1.4 Add `is_automated` and `automated_reason` fields to the dict returned by `fetch_unread_messages()`
## 2. Poll Loop Guard (main.py)
- [x] 2.1 In `run_poll_loop()`, check `msg.get("is_automated")` before calling `agent.process_message()`
- [x] 2.2 If automated: log a warning, call `agent.handle_automated_message()`, and `continue` (skip `send_reply`)
## 3. Agent — Automated Message Handler (agent/core.py)
- [x] 3.1 Add `MAX_USER_MESSAGES = 20` module-level constant
- [x] 3.2 Implement `handle_automated_message(sender_email, subject, reason, inbound_message_id)` method
- [x] 3.3 In `handle_automated_message`: load or create state; set `loop_escalated = True`; call `notify_loop_escalation()` once; silently skip if already escalated; save state
- [x] 3.4 In `process_message()`, after appending the user message, count user messages; if count > `MAX_USER_MESSAGES` and not escalated: set `loop_escalated = True`, call `notify_loop_escalation()`, return `""`
- [x] 3.5 If already escalated and over limit: silently save state and return `""`
## 4. Conversation State (models/conversation.py)
- [x] 4.1 Add `loop_escalated: bool = False` field to `ConversationState`
- [x] 4.2 Include `loop_escalated` in `to_dict()`
- [x] 4.3 Restore `loop_escalated` in `from_dict()` with default `False` for backward compatibility
## 5. Admin Notification (notifications/notifier.py)
- [x] 5.1 Implement `notify_loop_escalation(sender_email, conversation_id, reason, message_count)` method
- [x] 5.2 Route alert to `self._cc_emails` only (not playgroup leaders)
- [x] 5.3 Subject: `[WARNUNG] Automatische E-Mail / Endlosschleife erkannt: {sender_email}`
- [x] 5.4 Body: sender, conversation ID, message count, reason, call-to-action in German
- [x] 5.5 Guard: if no CC emails configured, log warning and return without SMTP call
## 6. Tests
- [x] 6.1 `TestDetectAutomatedMessageBySender` — mailer-daemon, postmaster, noreply, no-reply, donotreply, bounce; normal parent address not flagged
- [x] 6.2 `TestDetectAutomatedMessageByHeaders` — Auto-Submitted (auto-replied, auto-generated, no); X-Auto-Response-Suppress; multipart/report; X-Loop; Precedence bulk/junk; Precedence list not flagged
- [x] 6.3 `TestDetectAutomatedMessageBySubject` — Undelivered Mail, Mail Delivery Failed, Out of Office, Abwesenheitsnotiz, Automatische Antwort; case-insensitive; normal subject not flagged
- [x] 6.4 `TestHandleAutomatedMessage` — sets loop_escalated; calls notifier once; creates state when none exists; drops silently if already escalated; notifier failure does not propagate; inbound message ID stored
- [x] 6.5 `TestProcessMessageCountCap` — at limit still processes; over limit returns ""; sets loop_escalated; calls notifier once; no duplicate alert; notifier failure does not propagate; constant equals 20
- [x] 6.6 `TestNotifyLoopEscalation` — sends to CC; [WARNUNG] in subject; sender in subject; reason in body; message count in body; no-CC guard; no-SMTP guard
- [x] 6.7 `TestConversationStateLoopEscalated` (test_models.py) — default False; to_dict includes key; True round-trip; from_dict backward compatibility
@@ -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
+96
View File
@@ -14,6 +14,11 @@ from .response_parser import apply_updates, fallback_message, parse_llm_response
logger = logging.getLogger(__name__)
# Maximum number of inbound user messages before the conversation is stopped and
# escalated to the admin. This prevents runaway loops that slip through automated
# sender detection (e.g. a forwarding alias that bounces the agent's own replies).
MAX_USER_MESSAGES = 20
class EmailAgent:
"""Processes one inbound email and returns the agent's reply text.
@@ -78,6 +83,43 @@ class EmailAgent:
# Append the user's message to history
state.messages.append(ChatMessage(role="user", content=message_text))
# ------------------------------------------------------------------
# Hard message-count cap — stop conversations that have gone on too
# long without completing (covers loops that bypass automated-sender
# detection, e.g. a broken forwarding alias).
# ------------------------------------------------------------------
user_msg_count = sum(1 for m in state.messages if m.role == "user")
if user_msg_count > MAX_USER_MESSAGES:
if not state.loop_escalated:
state.loop_escalated = True
state.updated_at = now
self._store.save(state)
reason = (
f"conversation exceeded {MAX_USER_MESSAGES} inbound messages "
f"without completing"
)
logger.warning(
"Conversation %s exceeded message limit (%d user messages) — escalating",
email_key,
user_msg_count,
)
try:
self._notifier.notify_loop_escalation(
sender_email=parent_email,
conversation_id=email_key,
reason=reason,
message_count=user_msg_count,
)
except Exception:
logger.exception("Failed to send loop escalation notification for %s", email_key)
else:
logger.warning(
"Conversation %s still exceeding message limit — already escalated, ignoring",
email_key,
)
self._store.save(state)
return ""
# Route to the appropriate handler
if state.completed:
reply_text = self._handle_post_completion(state)
@@ -91,6 +133,60 @@ class EmailAgent:
return reply_text
def handle_automated_message(
self,
sender_email: str,
subject: str,
reason: str,
inbound_message_id: str = "",
) -> None:
"""Handle an inbound message detected as automated/bounce.
Does NOT send any reply (to avoid looping). Alerts the admin once per
conversation — subsequent automated messages from the same sender are
silently dropped after the first alert.
"""
email_key = normalize_email(sender_email)
state = self._store.load(email_key)
if state is None:
state = ConversationState(
conversation_id=email_key,
parent_email=email_key,
)
now = datetime.now(timezone.utc).isoformat()
state.last_activity = now
if inbound_message_id:
state.last_inbound_message_id = inbound_message_id
message_count = sum(1 for m in state.messages if m.role == "user")
if state.loop_escalated:
logger.info(
"Automated message from %s (already escalated) — dropping silently", sender_email
)
self._store.save(state)
return
state.loop_escalated = True
state.updated_at = now
self._store.save(state)
logger.warning(
"Automated/bounce message from %s — reason: %s — alerting admin", sender_email, reason
)
try:
self._notifier.notify_loop_escalation(
sender_email=sender_email,
conversation_id=email_key,
reason=reason,
message_count=message_count,
)
except Exception:
logger.exception(
"Failed to send loop escalation notification for automated sender %s", email_key
)
# ------------------------------------------------------------------
# Registration flow
# ------------------------------------------------------------------
+73
View File
@@ -89,6 +89,75 @@ def _strip_quoted_text(text: str) -> str:
return "\n".join(result).strip()
# ---------------------------------------------------------------------------
# Automated / bounce message detection
# ---------------------------------------------------------------------------
# Local parts of sender addresses that are never humans (RFC 5321 §4.5.4, common practice).
_AUTOMATED_SENDER_RE = re.compile(
r"^(mailer-daemon|postmaster|noreply|no-reply|no\.reply|do-not-reply|"
r"donotreply|bounce|bounce\+.*|delivery|mail-delivery|mail\.delivery)$",
re.IGNORECASE,
)
# Subject lines that indicate delivery failure or automated responses.
_AUTOMATED_SUBJECT_RE = re.compile(
r"(undelivered mail|undeliverable|delivery (failed|status|notification)|"
r"mail delivery (failed|error)|returned to sender|mailer-daemon|"
r"auto.?reply|out of office|außer haus|abwesenheitsnotiz|automatische antwort)",
re.IGNORECASE,
)
def detect_automated_message(raw_msg: email.message.Message, from_addr: str) -> tuple[bool, str]:
"""Detect whether an email was generated by an automated system, not a human.
Checks (in order of reliability):
1. Sender local-part (mailer-daemon, postmaster, noreply, …)
2. Auto-Submitted header (RFC 3834)
3. X-Auto-Response-Suppress header (Microsoft Exchange)
4. Content-Type: multipart/report (RFC 3462 — Delivery Status Notifications)
5. X-Loop header
6. Precedence: bulk/junk
7. Subject-line heuristics
Returns:
(True, reason_string) if automated, (False, "") otherwise.
"""
local = from_addr.split("@")[0] if "@" in from_addr else from_addr
if _AUTOMATED_SENDER_RE.match(local):
return True, f"sender matches automated address pattern: {from_addr}"
# RFC 3834 — Auto-Submitted header
auto_submitted = raw_msg.get("Auto-Submitted", "").strip().lower()
if auto_submitted and auto_submitted != "no":
return True, f"Auto-Submitted: {auto_submitted}"
# Microsoft Exchange — suppresses auto-replies
if raw_msg.get("X-Auto-Response-Suppress"):
return True, "X-Auto-Response-Suppress header present"
# RFC 3462 — multipart/report is used for DSNs and MDNs
if raw_msg.get_content_type() == "multipart/report":
return True, "Content-Type: multipart/report (delivery status notification)"
# X-Loop — set by mailing-list managers and some MTAs to break loops
if raw_msg.get("X-Loop"):
return True, f"X-Loop: {raw_msg.get('X-Loop')}"
# Precedence header
precedence = raw_msg.get("Precedence", "").strip().lower()
if precedence in ("bulk", "junk", "auto_reply"):
return True, f"Precedence: {precedence}"
# Subject heuristic (weakest — only flag when highly specific)
subject = _decode_header(raw_msg.get("Subject", ""))
if _AUTOMATED_SUBJECT_RE.search(subject):
return True, f"subject matches automated pattern: {subject!r}"
return False, ""
def _generate_message_id(from_addr: str) -> str:
domain = from_addr.split("@")[-1] if "@" in from_addr else "meister-eder.local"
return f"<{time.time():.6f}.{id(from_addr)}@{domain}>"
@@ -185,6 +254,8 @@ class EmailChannel:
imap.store(num, "+FLAGS", "\\Seen")
continue
is_automated, automated_reason = detect_automated_message(msg, from_addr)
messages.append(
{
"from": from_addr,
@@ -194,6 +265,8 @@ class EmailChannel:
"references": references,
"body": body,
"raw_body": raw_body,
"is_automated": is_automated,
"automated_reason": automated_reason,
}
)
imap.store(num, "+FLAGS", "\\Seen")
+5
View File
@@ -35,6 +35,9 @@ class ConversationState:
# Most recent inbound Message-ID — used for reply threading headers only,
# NOT for conversation matching (which is always by email address).
last_inbound_message_id: str = ""
# Loop / automated-sender prevention.
# Set to True once the admin has been notified; prevents repeated alerts.
loop_escalated: bool = False
def to_dict(self) -> dict:
return {
@@ -54,6 +57,7 @@ class ConversationState:
"completed": self.completed,
"reminder_count": self.reminder_count,
"last_inbound_message_id": self.last_inbound_message_id,
"loop_escalated": self.loop_escalated,
}
@classmethod
@@ -78,4 +82,5 @@ class ConversationState:
state.completed = data.get("completed", False)
state.reminder_count = data.get("reminder_count", 0)
state.last_inbound_message_id = data.get("last_inbound_message_id", "")
state.loop_escalated = data.get("loop_escalated", False)
return state
+22
View File
@@ -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 "",
+10 -1
View File
@@ -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
+42
View File
@@ -131,6 +131,46 @@ class AdminNotifier:
reply_to=registration.parent_guardian.email or "",
)
def notify_loop_escalation(
self,
sender_email: str,
conversation_id: str,
reason: str,
message_count: int,
) -> None:
"""Alert the admin that a conversation was stopped due to a loop or automated sender.
Sent to the CC list (Markus Graf / admin) only — no playgroup leader routing needed.
"""
if not self._cc_emails:
logger.warning(
"No admin CC email configured — loop escalation NOT sent for %s", conversation_id
)
return
subject = f"[WARNUNG] Automatische E-Mail / Endlosschleife erkannt: {sender_email}"
body = (
f"Das Anmeldungssystem hat eine Konversation automatisch gestoppt.\n\n"
f"Absender: {sender_email}\n"
f"Konversations-ID: {conversation_id}\n"
f"Nachrichten: {message_count}\n"
f"Grund: {reason}\n\n"
f"Es wurde keine weitere Antwort gesendet. Bitte prüfen Sie den Sachverhalt "
f"manuell und leiten Sie die Konversation bei Bedarf weiter.\n\n"
f"---\nMeister-Eder Anmeldungssystem"
)
self._send(
to=self._cc_emails,
cc=[],
subject=subject,
body=body,
)
logger.info(
"Loop escalation notification sent to admin for conversation %s (reason: %s)",
conversation_id,
reason,
)
def notify_parent(
self,
registration: RegistrationData,
@@ -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 }}
+692
View File
@@ -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
+42
View File
@@ -150,3 +150,45 @@ class TestConversationStateSerialization:
d = state_with_messages.to_dict()
assert d["messages"][0]["role"] == "user"
assert "Hallo" in d["messages"][0]["content"]
# ---------------------------------------------------------------------------
# ConversationState — loop_escalated field
# ---------------------------------------------------------------------------
class TestConversationStateLoopEscalated:
def test_default_loop_escalated_is_false(self, fresh_state):
assert fresh_state.loop_escalated is False
def test_to_dict_includes_loop_escalated(self, fresh_state):
d = fresh_state.to_dict()
assert "loop_escalated" in d
assert d["loop_escalated"] is False
def test_to_dict_reflects_true_when_set(self, fresh_state):
fresh_state.loop_escalated = True
d = fresh_state.to_dict()
assert d["loop_escalated"] is True
def test_from_dict_restores_loop_escalated_true(self, fresh_state):
fresh_state.loop_escalated = True
restored = ConversationState.from_dict(fresh_state.to_dict())
assert restored.loop_escalated is True
def test_from_dict_defaults_to_false_when_key_missing(self):
"""Older persisted conversations without the key deserialise safely."""
data = {
"conversation_id": "old@example.com",
"parent_email": "old@example.com",
"language": "de",
"flow_step": "greeting",
"registration": {},
"messages": [],
"completed": False,
"reminder_count": 0,
"last_inbound_message_id": "",
# loop_escalated intentionally absent
}
state = ConversationState.from_dict(data)
assert state.loop_escalated is False
+140
View File
@@ -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"