Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f509d984ba | ||
|
|
fdf674d8d6 | ||
|
|
514d235a2f | ||
|
|
491c2d3495 | ||
|
|
9dac54fe81 | ||
|
|
fa6b520f19 | ||
|
|
cab16078a2 | ||
|
|
6866d126cb | ||
|
|
e3bdfbcb9e | ||
|
|
91ba940951 | ||
|
|
52901dd7c4 | ||
|
|
8275286647 | ||
|
|
75691879aa | ||
|
|
602b8eddc3 | ||
|
|
14fde88c89 | ||
|
|
f1d0ad045c | ||
|
|
54d88d1d64 | ||
|
|
b2e04fa07b | ||
|
|
1632433b6a | ||
|
|
9981a9121e | ||
|
|
42a01a011e | ||
|
|
e8cf170007 | ||
|
|
c6fd7497aa | ||
|
|
43ec87e151 |
@@ -0,0 +1,22 @@
|
||||
# Secrets — never bake into the image
|
||||
.env
|
||||
|
||||
# Version control
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Python build artefacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
|
||||
# Tests — not needed at runtime
|
||||
tests/
|
||||
|
||||
# Persistent data — mounted as a volume at runtime
|
||||
data/
|
||||
|
||||
# Tool configs — not needed in the container
|
||||
.claude/
|
||||
.gemini/
|
||||
+12
-5
@@ -6,14 +6,21 @@
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# AI Model (via litellm — supports any provider)
|
||||
# AI Models (via litellm — supports any provider)
|
||||
# ---------------------------------------------------------------
|
||||
# Use litellm model strings: "<provider>/<model-name>"
|
||||
# Examples:
|
||||
# anthropic/claude-opus-4-6 (default)
|
||||
# openai/gpt-4o
|
||||
# gemini/gemini-2.0-flash
|
||||
#
|
||||
# AI_MODEL — primary model for conversation with parents (complex reasoning).
|
||||
# SIMPLE_MODEL — lightweight model for simple tasks, e.g. email-label translation.
|
||||
# Can be from a different provider than AI_MODEL.
|
||||
# If unset, falls back to AI_MODEL with a warning in the logs.
|
||||
#
|
||||
# Examples — mixing providers:
|
||||
# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=gemini/gemini-3-flash-preview
|
||||
# AI_MODEL=anthropic/claude-opus-4-6 + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
# AI_MODEL=gemini/gemini-3-pro-preview + SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
AI_MODEL=anthropic/claude-opus-4-6
|
||||
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
|
||||
# Extended thinking — Anthropic models only (leave unset to disable).
|
||||
# Enables a reasoning phase before the model's reply, which improves
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies — cached independently of application code
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# Copy application source
|
||||
COPY src/ ./src/
|
||||
COPY chat_app.py main.py chainlit.toml ./
|
||||
COPY public/ ./public/
|
||||
|
||||
# Make the venv's binaries (chainlit, python, etc.) available directly
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# data/ and openspec/ are expected to be mounted at runtime
|
||||
@@ -44,7 +44,7 @@ cp .env.example .env
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `AI_MODEL` | litellm model string, e.g. `anthropic/claude-opus-4-6` or `openai/gpt-4o` |
|
||||
| `AI_MODEL` | Primary model (litellm string), e.g. `anthropic/claude-opus-4-6` |
|
||||
| `IMAP_HOST` | IMAP server hostname for receiving parent emails |
|
||||
| `IMAP_USERNAME` | Email account username |
|
||||
| `IMAP_PASSWORD` | Email account password |
|
||||
@@ -57,30 +57,37 @@ The API key variable depends on your chosen provider — see [Switching AI provi
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SIMPLE_MODEL` | _(falls back to `AI_MODEL`)_ | Lightweight model for simple tasks (e.g. email-label translation). Can be from a different provider. Logs a warning if unset. |
|
||||
| `THINKING_BUDGET` | _(disabled)_ | Token budget for extended thinking — Anthropic models only. Recommended: `8000`. |
|
||||
| `IMAP_PORT` | `993` | IMAP port |
|
||||
| `IMAP_USE_SSL` | `true` | Use SSL for IMAP |
|
||||
| `SMTP_PORT` | `587` | SMTP port |
|
||||
| `SMTP_USE_TLS` | `true` | Use STARTTLS for SMTP |
|
||||
| `CHAINLIT_HOST` | `localhost` | Host the web chat binds to. Set to `0.0.0.0` to expose externally. |
|
||||
| `DATA_DIR` | `data/` | Directory for conversation state and completed registrations |
|
||||
| `KNOWLEDGE_BASE_DIR` | `openspec/…/knowledge-base` | Path to admin-editable knowledge base markdown files |
|
||||
| `POLL_INTERVAL` | `60` | Seconds between inbox polls (only used when running as a daemon) |
|
||||
|
||||
### Switching AI providers
|
||||
|
||||
`AI_MODEL` uses [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes:
|
||||
Both `AI_MODEL` and `SIMPLE_MODEL` use [litellm](https://docs.litellm.ai/docs/providers) model strings — any supported provider works without code changes. The two models can be from different providers:
|
||||
|
||||
```bash
|
||||
# Anthropic (default)
|
||||
# Anthropic for both (default)
|
||||
AI_MODEL=anthropic/claude-opus-4-6
|
||||
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# OpenAI
|
||||
AI_MODEL=openai/gpt-4o
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Google Gemini
|
||||
AI_MODEL=gemini/gemini-2.0-flash
|
||||
# Google Gemini for both
|
||||
AI_MODEL=gemini/gemini-3-pro-preview
|
||||
SIMPLE_MODEL=gemini/gemini-3-flash-preview
|
||||
GEMINI_API_KEY=...
|
||||
|
||||
# Mixed providers
|
||||
AI_MODEL=gemini/gemini-3-pro-preview
|
||||
SIMPLE_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
GEMINI_API_KEY=...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
## Running
|
||||
@@ -149,6 +156,35 @@ uv run python main.py
|
||||
|
||||
Completed registrations from both channels are stored in the same `DATA_DIR` (default: `data/`) and share the same admin notification configuration.
|
||||
|
||||
### Docker Compose (recommended for production)
|
||||
|
||||
A `docker-compose.yml` is provided that runs both services together with shared persistent storage:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# fill in .env, then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
| Service | What it runs |
|
||||
|---|---|
|
||||
| `web` | Chainlit web chat at `http://localhost:8000` |
|
||||
| `email-worker` | Email polling agent (`main.py`) |
|
||||
|
||||
Both services mount `./data` for shared registration storage and `./openspec` (read-only) for the knowledge base. Restarting a service does not lose conversation state.
|
||||
|
||||
To view logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
To rebuild after a code change:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Running tests
|
||||
@@ -165,4 +201,4 @@ The agent answers parent questions from markdown files in the knowledge base dir
|
||||
|
||||
### Adding a new AI provider
|
||||
|
||||
Set `AI_MODEL` to any [litellm-supported model string](https://docs.litellm.ai/docs/providers) and set the corresponding API key environment variable. No code changes required.
|
||||
Set `AI_MODEL` (and optionally `SIMPLE_MODEL`) to any [litellm-supported model string](https://docs.litellm.ai/docs/providers) and set the corresponding API key environment variable. No code changes required.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+11
-1
@@ -58,6 +58,7 @@ _notifier = AdminNotifier(
|
||||
indoor_email=_config.admin_email_indoor,
|
||||
outdoor_email=_config.admin_email_outdoor,
|
||||
cc_emails=[e.strip() for e in _config.admin_email_cc.split(",") if e.strip()],
|
||||
model=_config.simple_model,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -66,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?"
|
||||
)
|
||||
|
||||
@@ -172,6 +173,15 @@ async def on_message(message: cl.Message) -> None:
|
||||
logger.exception(
|
||||
"Failed to save/notify for session %s", state.conversation_id
|
||||
)
|
||||
try:
|
||||
_notifier.notify_parent(
|
||||
registration=state.registration,
|
||||
language=state.language,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to send parent confirmation for session %s", state.conversation_id
|
||||
)
|
||||
|
||||
# --- Handle post-completion update intent ---
|
||||
if state.completed and intent == "update" and any(v is not None for v in updates.values()):
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
|
||||
# Web chat interface — Chainlit served at http://localhost:8000
|
||||
web:
|
||||
build: .
|
||||
command: chainlit run chat_app.py --host 0.0.0.0 --port 8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file: .env
|
||||
volumes:
|
||||
# Persistent storage for conversations and completed registrations
|
||||
- ./data:/app/data
|
||||
# Knowledge-base markdown files — edit on the host, no rebuild needed
|
||||
- ./openspec:/app/openspec:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# Email polling agent — checks the inbox every POLL_INTERVAL seconds
|
||||
email-worker:
|
||||
build: .
|
||||
command: python main.py
|
||||
env_file: .env
|
||||
volumes:
|
||||
# Shares the same data directory as the web service
|
||||
- ./data:/app/data
|
||||
- ./openspec:/app/openspec:ro
|
||||
restart: unless-stopped
|
||||
@@ -61,6 +61,7 @@ def build_components(config: Config):
|
||||
indoor_email=config.admin_email_indoor,
|
||||
outdoor_email=config.admin_email_outdoor,
|
||||
cc_emails=[e.strip() for e in config.admin_email_cc.split(",") if e.strip()],
|
||||
model=config.simple_model,
|
||||
)
|
||||
|
||||
agent = EmailAgent(
|
||||
@@ -97,6 +98,27 @@ def run_poll_loop(agent: EmailAgent, channel: EmailChannel, poll_interval: int)
|
||||
for msg in messages:
|
||||
logger.info("Processing message from %s", msg["from"])
|
||||
try:
|
||||
# ----------------------------------------------------------
|
||||
# Bounce / automated-sender guard
|
||||
# If the channel layer flagged this as an automated message
|
||||
# (bounce, out-of-office, delivery failure, …) we must NOT
|
||||
# reply — that would create or worsen an email loop.
|
||||
# Instead, alert the admin once and drop the message.
|
||||
# ----------------------------------------------------------
|
||||
if msg.get("is_automated"):
|
||||
logger.warning(
|
||||
"Automated/bounce message from %s — reason: %s — not replying",
|
||||
msg["from"],
|
||||
msg.get("automated_reason", "unknown"),
|
||||
)
|
||||
agent.handle_automated_message(
|
||||
sender_email=msg["from"],
|
||||
subject=msg["subject"],
|
||||
reason=msg.get("automated_reason", "automated sender detected"),
|
||||
inbound_message_id=msg["message_id"],
|
||||
)
|
||||
continue
|
||||
|
||||
# Prepend email headers so the LLM can extract the
|
||||
# sender's address and subject (e.g. to fill in
|
||||
# parentGuardian.email automatically).
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
## Context
|
||||
|
||||
The email poll loop (`main.py`) fetches all unread messages and passes each one to `EmailAgent.process_message()`, which calls the LLM and returns a reply. The reply is then sent via SMTP. There was no check to determine whether the inbound message came from a human or an automated system. Any message that arrived in the inbox — including MAILER-DAEMON bounces triggered by the agent's own previous reply — was processed and replied to, completing the loop.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Prevent the agent from replying to automated/bounce messages
|
||||
- Alert admin once when an automated loop is detected
|
||||
- Enforce a hard upper bound on conversation length as a secondary safety net
|
||||
- Persist escalation state so alerts are not repeated across poll cycles
|
||||
|
||||
**Non-Goals:**
|
||||
- General spam detection
|
||||
- Blocking specific sender addresses permanently
|
||||
- Exposing loop-detection configuration via the admin UI
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Two-layer defence
|
||||
|
||||
**Decision**: Implement two independent checks in sequence:
|
||||
1. Header-based automated sender detection (catches known patterns immediately)
|
||||
2. Message-count cap (catches anything that slips through layer 1)
|
||||
|
||||
**Rationale**: Neither layer is infallible alone. Header-based detection covers RFC-standard signals and common patterns, but edge cases exist (e.g. a forwarding alias that strips headers). The count cap is a last-resort guarantee that no conversation runs forever.
|
||||
|
||||
### 2. Detection at the channel layer, handling in the agent
|
||||
|
||||
**Decision**: `email_channel.py` performs the header analysis and adds `is_automated` / `automated_reason` to the message dict. `main.py` checks the flag and calls `agent.handle_automated_message()` instead of `agent.process_message()`.
|
||||
|
||||
**Rationale**: The channel layer already has access to the raw `email.message.Message` object with all headers. The agent layer has access to conversation state and the notifier. Splitting cleanly at the channel/agent boundary keeps each layer doing what it does best without coupling them further.
|
||||
|
||||
**Alternative considered**: Detecting in the agent by inspecting the message text. Rejected — by that point the raw headers are gone, and text-based detection is less reliable than header-based.
|
||||
|
||||
### 3. Detection signals (in priority order)
|
||||
|
||||
| Signal | Standard | Reliability |
|
||||
|---|---|---|
|
||||
| Sender local-part: `mailer-daemon`, `postmaster`, `noreply`, `no-reply`, `bounce`, … | RFC 5321 §4.5.4 | Very high |
|
||||
| `Auto-Submitted:` ≠ `no` | RFC 3834 | Very high |
|
||||
| `X-Auto-Response-Suppress:` present | MS Exchange | Very high |
|
||||
| `Content-Type: multipart/report` | RFC 3462 | Very high |
|
||||
| `X-Loop:` present | MTA convention | High |
|
||||
| `Precedence: bulk` or `junk` | Common practice | Medium |
|
||||
| Subject heuristics (Undelivered Mail, Out of Office, Abwesenheitsnotiz, …) | — | Medium |
|
||||
|
||||
`Precedence: list` is intentionally excluded — mailing-list messages may be legitimate.
|
||||
|
||||
### 4. Message-count cap set at 20
|
||||
|
||||
**Decision**: `MAX_USER_MESSAGES = 20`. If `process_message()` is called when there are already more than 20 user messages in the history, return `""` (no reply) and escalate to admin.
|
||||
|
||||
**Rationale**: A typical registration takes 8–12 exchanges. 20 gives ample room for slow or verbose conversations while still catching runaway loops. The value is a module-level constant so it can be changed without config infrastructure overhead.
|
||||
|
||||
### 5. One-shot admin alert via `loop_escalated` flag
|
||||
|
||||
**Decision**: Add `loop_escalated: bool` to `ConversationState`. The admin is notified exactly once per conversation. Subsequent automated messages or over-limit polls are silently dropped after the flag is set.
|
||||
|
||||
**Rationale**: The admin needs to know something is wrong, but receiving one alert per bounce (which may arrive many times per minute) would create inbox spam worse than the original problem.
|
||||
|
||||
**Implementation**: The flag is persisted to JSON so it survives agent restarts.
|
||||
|
||||
### 6. Admin notification routed to CC list
|
||||
|
||||
**Decision**: Loop-escalation alerts go to `self._cc_emails` (Markus Graf / `ADMIN_EMAIL_CC`), not to playgroup leaders.
|
||||
|
||||
**Rationale**: This is a system/infrastructure issue, not a registration event. The CC address is the designated admin (Markus Graf) who handles operational issues. Playgroup leaders do not need to see these alerts.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**False positives** → A legitimate parent using a `noreply@` alias could be silently blocked. This is an unlikely edge case; the subject/header checks require multiple signals for ambiguous senders. A missed registration is recoverable — admin gets the alert and can follow up manually.
|
||||
|
||||
**False negatives** → A clever loop that uses a normal-looking sender address and no automated headers would slip past layer 1. The 20-message cap catches it.
|
||||
|
||||
**Completed conversations** → The count cap applies to all conversations, including completed ones with many post-completion Q&A exchanges. A very chatty parent could theoretically hit the cap after registration is done. Acceptable for MVP — the cap is high enough that normal use is unaffected.
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
The email channel had no protection against automated message loops. When the agent sent a reply that bounced (e.g. due to an invalid recipient address or a misconfigured mail server), the bounce message arrived back in the inbox. The agent treated it as a new inbound message, generated another reply, which bounced again — creating an infinite loop.
|
||||
|
||||
A real incident demonstrated this: a `MAILER-DAEMON@tacitus2.sui-inter.net` bounce began accumulating replies indefinitely, consuming LLM quota and filling the inbox with noise while the agent remained unaware it was talking to an automated system.
|
||||
|
||||
Beyond bounce loops, any automated sender — out-of-office replies, mailing-list software, delivery status notifications — can trigger this pattern if the agent replies to them. The system needs to recognise non-human senders and refuse to reply.
|
||||
|
||||
A secondary risk: a legitimate but very slow conversation (or a forwarding-alias loop that bypasses simple bounce detection) could still accumulate messages indefinitely. A hard cap on conversation length provides a safety net.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Detect automated/bounce senders** before the agent replies — inspect email headers and sender address patterns to identify non-human messages
|
||||
- **Skip sending any reply** to automated messages — silence breaks the loop
|
||||
- **Alert the admin once** when an automated sender is detected, so a human can investigate
|
||||
- **Cap conversation length** at 20 inbound messages — if a conversation has not completed after 20 user messages, stop responding and alert the admin
|
||||
- **Track escalation state** per conversation so admin alerts fire at most once
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Spam filtering (automated detection is specific to loop-causing patterns, not general spam)
|
||||
- Automatic unsubscribe/block of senders
|
||||
- Forwarding the original problem email to the admin (admin receives only a warning notification)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `email-channel`: Add automated/bounce sender detection; skip replies for flagged messages
|
||||
- `registration-notifications`: Add loop-escalation alert type sent to admin CC address
|
||||
@@ -0,0 +1,54 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Automated sender detection
|
||||
The system SHALL detect whether an inbound email was sent by an automated system rather than a human, before the message is processed by the agent.
|
||||
|
||||
#### Scenario: MAILER-DAEMON sender
|
||||
- **WHEN** an email arrives with a sender local-part of `mailer-daemon`, `postmaster`, `noreply`, `no-reply`, `donotreply`, or `bounce` (case-insensitive)
|
||||
- **THEN** the system SHALL flag the message as automated with a reason string identifying the sender pattern
|
||||
|
||||
#### Scenario: RFC 3834 Auto-Submitted header
|
||||
- **WHEN** an email contains an `Auto-Submitted` header with any value other than `no`
|
||||
- **THEN** the system SHALL flag the message as automated, citing the header value in the reason
|
||||
|
||||
#### Scenario: Auto-Submitted: no is not automated
|
||||
- **WHEN** an email contains `Auto-Submitted: no`
|
||||
- **THEN** the system SHALL NOT flag the message as automated based on this header
|
||||
|
||||
#### Scenario: Microsoft Exchange auto-reply suppression
|
||||
- **WHEN** an email contains an `X-Auto-Response-Suppress` header (any value)
|
||||
- **THEN** the system SHALL flag the message as automated
|
||||
|
||||
#### Scenario: Delivery Status Notification (RFC 3462)
|
||||
- **WHEN** an email has `Content-Type: multipart/report`
|
||||
- **THEN** the system SHALL flag the message as automated, as this indicates a machine-generated delivery status or read receipt
|
||||
|
||||
#### Scenario: X-Loop header
|
||||
- **WHEN** an email contains an `X-Loop` header (any value)
|
||||
- **THEN** the system SHALL flag the message as automated
|
||||
|
||||
#### Scenario: Bulk or junk precedence
|
||||
- **WHEN** an email has a `Precedence` header with value `bulk` or `junk`
|
||||
- **THEN** the system SHALL flag the message as automated
|
||||
|
||||
#### Scenario: Bounce / OOO subject line
|
||||
- **WHEN** an email subject matches patterns indicating delivery failure or automated response (e.g. "Undelivered Mail", "Mail Delivery Failed", "Out of Office", "Abwesenheitsnotiz", "Automatische Antwort")
|
||||
- **THEN** the system SHALL flag the message as automated
|
||||
|
||||
#### Scenario: Normal parent message
|
||||
- **WHEN** an email has a normal human sender address and no automated-sender headers
|
||||
- **THEN** the system SHALL NOT flag the message as automated
|
||||
|
||||
### Requirement: Automated messages are never replied to
|
||||
The system SHALL NOT send any reply to a message flagged as automated.
|
||||
|
||||
#### Scenario: Bounce message arrives
|
||||
- **WHEN** the system receives a message flagged as automated
|
||||
- **THEN** the system SHALL mark the message as read (IMAP Seen flag)
|
||||
- **AND** the system SHALL call the agent's automated-message handler
|
||||
- **AND** the system SHALL NOT send any outbound email reply
|
||||
|
||||
### Requirement: Message dict includes automation flag
|
||||
Every message returned by `fetch_unread_messages()` SHALL include:
|
||||
- `is_automated` (boolean): whether the message was flagged as automated
|
||||
- `automated_reason` (string): human-readable reason if flagged, empty string otherwise
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Loop escalation alert to admin
|
||||
The system SHALL send a plain-text warning email to the admin when a conversation is stopped due to an automated sender or message-count cap breach.
|
||||
|
||||
#### Scenario: First automated message from a sender
|
||||
- **WHEN** the first automated/bounce message is received from a sender address
|
||||
- **THEN** the system SHALL send one alert email to the admin CC address list
|
||||
- **AND** the subject SHALL begin with `[WARNUNG]` for easy inbox filtering
|
||||
- **AND** the subject SHALL include the sender's email address
|
||||
- **AND** the body SHALL include: sender address, conversation ID, detection reason, and message count
|
||||
- **AND** no further alert SHALL be sent for subsequent automated messages from the same sender
|
||||
|
||||
#### Scenario: Conversation exceeds message-count cap
|
||||
- **WHEN** a conversation accumulates more than 20 inbound user messages without completing
|
||||
- **THEN** the system SHALL send one alert email to the admin CC address list on first breach
|
||||
- **AND** the body SHALL identify the conversation and state that the message limit was exceeded
|
||||
- **AND** no further alert SHALL be sent for subsequent messages in the same capped conversation
|
||||
|
||||
#### Scenario: No admin CC address configured
|
||||
- **WHEN** `ADMIN_EMAIL_CC` is not set and a loop escalation is triggered
|
||||
- **THEN** the system SHALL log a warning
|
||||
- **AND** the system SHALL NOT attempt an SMTP connection
|
||||
|
||||
#### Scenario: No SMTP host configured (dev mode)
|
||||
- **WHEN** `SMTP_HOST` is not set and a loop escalation is triggered
|
||||
- **THEN** the system SHALL log the notification content
|
||||
- **AND** the system SHALL NOT attempt an SMTP connection
|
||||
|
||||
### Requirement: Alert routing
|
||||
Loop escalation alerts SHALL be sent only to the admin CC list (`ADMIN_EMAIL_CC`). They SHALL NOT be sent to playgroup leaders (Andrea Sigrist, Barbara Gross), as loop detection is an operational concern, not a registration event.
|
||||
@@ -0,0 +1,43 @@
|
||||
## 1. Automated Sender Detection (email_channel.py)
|
||||
|
||||
- [x] 1.1 Add `_AUTOMATED_SENDER_RE` regex for known non-human local-parts (mailer-daemon, postmaster, noreply, no-reply, donotreply, bounce, …)
|
||||
- [x] 1.2 Add `_AUTOMATED_SUBJECT_RE` regex for bounce/OOO subject patterns (German + English)
|
||||
- [x] 1.3 Implement `detect_automated_message(raw_msg, from_addr) → (bool, str)` checking all signals in priority order: sender pattern → Auto-Submitted → X-Auto-Response-Suppress → multipart/report → X-Loop → Precedence → subject
|
||||
- [x] 1.4 Add `is_automated` and `automated_reason` fields to the dict returned by `fetch_unread_messages()`
|
||||
|
||||
## 2. Poll Loop Guard (main.py)
|
||||
|
||||
- [x] 2.1 In `run_poll_loop()`, check `msg.get("is_automated")` before calling `agent.process_message()`
|
||||
- [x] 2.2 If automated: log a warning, call `agent.handle_automated_message()`, and `continue` (skip `send_reply`)
|
||||
|
||||
## 3. Agent — Automated Message Handler (agent/core.py)
|
||||
|
||||
- [x] 3.1 Add `MAX_USER_MESSAGES = 20` module-level constant
|
||||
- [x] 3.2 Implement `handle_automated_message(sender_email, subject, reason, inbound_message_id)` method
|
||||
- [x] 3.3 In `handle_automated_message`: load or create state; set `loop_escalated = True`; call `notify_loop_escalation()` once; silently skip if already escalated; save state
|
||||
- [x] 3.4 In `process_message()`, after appending the user message, count user messages; if count > `MAX_USER_MESSAGES` and not escalated: set `loop_escalated = True`, call `notify_loop_escalation()`, return `""`
|
||||
- [x] 3.5 If already escalated and over limit: silently save state and return `""`
|
||||
|
||||
## 4. Conversation State (models/conversation.py)
|
||||
|
||||
- [x] 4.1 Add `loop_escalated: bool = False` field to `ConversationState`
|
||||
- [x] 4.2 Include `loop_escalated` in `to_dict()`
|
||||
- [x] 4.3 Restore `loop_escalated` in `from_dict()` with default `False` for backward compatibility
|
||||
|
||||
## 5. Admin Notification (notifications/notifier.py)
|
||||
|
||||
- [x] 5.1 Implement `notify_loop_escalation(sender_email, conversation_id, reason, message_count)` method
|
||||
- [x] 5.2 Route alert to `self._cc_emails` only (not playgroup leaders)
|
||||
- [x] 5.3 Subject: `[WARNUNG] Automatische E-Mail / Endlosschleife erkannt: {sender_email}`
|
||||
- [x] 5.4 Body: sender, conversation ID, message count, reason, call-to-action in German
|
||||
- [x] 5.5 Guard: if no CC emails configured, log warning and return without SMTP call
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- [x] 6.1 `TestDetectAutomatedMessageBySender` — mailer-daemon, postmaster, noreply, no-reply, donotreply, bounce; normal parent address not flagged
|
||||
- [x] 6.2 `TestDetectAutomatedMessageByHeaders` — Auto-Submitted (auto-replied, auto-generated, no); X-Auto-Response-Suppress; multipart/report; X-Loop; Precedence bulk/junk; Precedence list not flagged
|
||||
- [x] 6.3 `TestDetectAutomatedMessageBySubject` — Undelivered Mail, Mail Delivery Failed, Out of Office, Abwesenheitsnotiz, Automatische Antwort; case-insensitive; normal subject not flagged
|
||||
- [x] 6.4 `TestHandleAutomatedMessage` — sets loop_escalated; calls notifier once; creates state when none exists; drops silently if already escalated; notifier failure does not propagate; inbound message ID stored
|
||||
- [x] 6.5 `TestProcessMessageCountCap` — at limit still processes; over limit returns ""; sets loop_escalated; calls notifier once; no duplicate alert; notifier failure does not propagate; constant equals 20
|
||||
- [x] 6.6 `TestNotifyLoopEscalation` — sends to CC; [WARNUNG] in subject; sender in subject; reason in body; message count in body; no-CC guard; no-SMTP guard
|
||||
- [x] 6.7 `TestConversationStateLoopEscalated` (test_models.py) — default False; to_dict includes key; True round-trip; from_dict backward compatibility
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-22
|
||||
@@ -0,0 +1,101 @@
|
||||
## Context
|
||||
|
||||
When a registration is completed, `AdminNotifier.notify_admin()` sends an email to the relevant playgroup leaders. The parent receives nothing — no acknowledgement, no summary, no payment details.
|
||||
|
||||
The existing `AdminNotifier` in `src/notifications/notifier.py` handles both new-registration and update notifications to admins. It uses `MIMEMultipart("alternative")` and sends via the configured SMTP server. All the SMTP plumbing already works.
|
||||
|
||||
This change adds a parent-facing confirmation email triggered at the same point as the admin notification.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Parent receives an HTML confirmation email immediately after registration is stored
|
||||
- Email contains full registration summary (all 13 fields)
|
||||
- Email contains payment instructions for the CHF 80 registration fee
|
||||
- Email contains a Swiss QR-bill (QR code image) embedded inline so the parent can pay via banking app or print
|
||||
|
||||
**Non-Goals:**
|
||||
- Translating the QR-bill slip labels themselves (the SIX Group standard mandates German/French/Italian for the payment slip fields — surrounding email text is translated, but the slip is not)
|
||||
- Sending a reminder if the parent hasn't paid (payment tracking is out of scope)
|
||||
- Generating a full PDF invoice (QR code embedded in HTML email is sufficient)
|
||||
- Sibling discount handling in the QR-bill amount (CHF 80 is always fixed for the registration fee)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Extend `AdminNotifier` vs. New Class
|
||||
|
||||
**Decision**: Add `notify_parent()` to the existing `AdminNotifier` class (renamed conceptually; kept in the same file for now).
|
||||
|
||||
**Rationale**: The SMTP plumbing (`_send`, `_smtp_host`, credentials) is already there. A `notify_parent` method reuses all of it. Splitting into a separate class would require duplicating constructor parameters and SMTP setup for no structural benefit at this stage.
|
||||
|
||||
**Trade-off**: `AdminNotifier` becomes slightly misnamed. Acceptable — the class handles all outbound notification emails. Rename in a future refactor if needed.
|
||||
|
||||
### 2. HTML Email Format
|
||||
|
||||
**Decision**: Send `multipart/alternative` with both plain-text and HTML parts. The HTML part is the primary view; plain-text is fallback.
|
||||
|
||||
**Rationale**: Matches the existing `_send` method's `MIMEMultipart("alternative")` pattern. HTML is needed to embed the QR-bill image inline.
|
||||
|
||||
**QR image embedding**: Use `multipart/related` wrapping the HTML part, with the QR PNG attached as `Content-ID` (`cid:qrbill`). This is the standard approach for inline images that don't appear as attachments.
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
multipart/mixed
|
||||
└── multipart/alternative
|
||||
├── text/plain (fallback)
|
||||
└── multipart/related
|
||||
├── text/html (references cid:qrbill)
|
||||
└── image/png (Content-ID: qrbill, inline)
|
||||
```
|
||||
|
||||
### 3. Swiss QR-Bill Generation
|
||||
|
||||
**Decision**: Use the `qrbill` Python library to generate the QR code image.
|
||||
|
||||
**Rationale**: `qrbill` implements the Swiss QR-bill standard (SIX Group spec) directly. It accepts IBAN, payee address, amount, and currency, and outputs an SVG or PNG. No external services required.
|
||||
|
||||
**Fixed payment data** (hardcoded in the notifier, not in config — this is stable bank data):
|
||||
- IBAN: `CH14 0900 0000 4930 8018 8`
|
||||
- Payee: Familienverein Fällanden Spielgruppen, c/o Markus Graf, Huebwisstrase 5, 8117 Fällanden
|
||||
- Amount: CHF 80.00
|
||||
- Currency: CHF
|
||||
- Reference type: NON (no structured reference)
|
||||
|
||||
**Output**: PNG bytes, embedded as inline image in HTML email.
|
||||
|
||||
**Dependency**: Add `qrbill` to `pyproject.toml` dependencies.
|
||||
|
||||
### 4. Language
|
||||
|
||||
**Decision**: Add a `language` field to `RegistrationData` (default `"de"`). The agent sets it when it detects the parent's language during conversation. The confirmation email body is rendered in the stored language. The QR-bill slip labels are fixed German/French/Italian per the SIX Group standard and are not translated.
|
||||
|
||||
**Supported values**: `"de"` (German, default) and `"en"` (English). Other values fall back to `"de"`.
|
||||
|
||||
**Where it lives in the model**: A new `metadata` field on `RegistrationData` (a `Metadata` dataclass) with fields `submitted_at`, `channel`, `conversation_id`, and `language`. This also aligns with the JSON schema in `registration-schema.json` which already defines a `metadata` object with those keys. The `language` field is added to both the Python model and the JSON schema.
|
||||
|
||||
**Template strategy**: Two string-template dicts (one per language) for all user-visible strings in the confirmation email. The notifier selects the dict based on `registration.metadata.language`. Admin notifications remain German-only (admins are Swiss German speakers).
|
||||
|
||||
**Rationale**: Parents who conversed in English reasonably expect an English confirmation. Storing language in the model (rather than passing it as a parameter) means it's persisted with the registration and available for future use (e.g. update notifications, reminders).
|
||||
|
||||
### 5. Trigger Point
|
||||
|
||||
**Decision**: Call `notify_parent()` immediately after `notify_admin()` at the same trigger site — wherever `notify_admin` is currently called (in `src/agent/core.py` or equivalent).
|
||||
|
||||
**Rationale**: The parent notification is a direct consequence of the same event (registration completed). No separate trigger or queue needed.
|
||||
|
||||
**Error isolation**: If the parent email fails, log the error but do not fail the registration or block the admin notification. Both notifications are best-effort.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**`qrbill` library maturity**: Actively maintained, used in production Swiss applications. Risk is low. If the library is unavailable, the QR code can be omitted and the plain IBAN text still enables payment.
|
||||
|
||||
**Inline image rendering**: Some email clients block inline images by default (Outlook, some mobile clients). The plain-text fallback and the raw IBAN text in the HTML body ensure the payment info is always readable even if the QR image is blocked.
|
||||
|
||||
**SMTP failure for parent email**: Parent notification is non-critical (the registration is already stored). Failure is logged as a warning, not an exception.
|
||||
|
||||
**Language detection accuracy**: The agent infers language from conversation content. Misdetection is possible but low-risk — a parent who receives a German email when they expected English can still understand the registration summary. The QR-bill is universally recognisable regardless of surrounding language.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the confirmation email also include the monthly subscription fee (in addition to the CHF 80 registration fee), or only the registration fee QR-bill? The CHF 80 one-time fee is the immediate action required; the monthly fee is recurring and not yet payable. **Proposed answer: include both as informational text, but the QR-bill is for CHF 80 only.**
|
||||
- Should the parent's email be CC'd on the admin notification, or kept as a separate send? **Proposed answer: separate send — keeps admin and parent content distinct.**
|
||||
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
When a parent completes registration, they currently receive no confirmation. They have no record of what they submitted, no clarity on next steps, and no way to pay the registration fee without separately asking for bank details. This creates uncertainty for parents and additional back-and-forth for admins.
|
||||
|
||||
A confirmation email closes this gap: the parent gets a clear summary of their registration, knows exactly what they agreed to, and can pay immediately using the included Swiss QR-bill.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Send HTML confirmation email to the parent** immediately when a registration is completed and stored
|
||||
- **Include full registration summary** — all fields the parent filled out, formatted clearly
|
||||
- **Include payment instructions** (German text) for the CHF 80 registration fee with IBAN and payee details
|
||||
- **Include a Swiss QR-bill** (payment QR code) so the parent can pay directly from their banking app or print-to-pay
|
||||
- The confirmation is sent in the **same language** the parent used during the conversation (German or English), but the QR-bill and payment block are always in German (banking standard)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `registration-notifications`: Currently only notifies admins. Extended to also send a confirmation to the parent's email address upon completion.
|
||||
|
||||
### New Capabilities
|
||||
|
||||
*None — this extends an existing capability*
|
||||
|
||||
## Impact
|
||||
|
||||
- **Parents**: Receive immediate, clear confirmation with everything they need — what was registered and how to pay. No need to ask for bank details.
|
||||
- **Admins**: Fewer follow-up inquiries about "did my registration go through?" and "where do I pay?". Payment is initiated earlier.
|
||||
- **Email deliverability**: System must send to parent email, not just admin addresses. Parent email is already a required field in the registration schema.
|
||||
- **Swiss QR-bill generation**: Requires a library to generate the QR code image from the payment data (IBAN, amount, payee address). The QR code is embedded inline in the HTML email.
|
||||
- **Bilingual**: Confirmation body adapts to the parent's language. The payment section uses German regardless (Swiss QR-bill standard).
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Notify parent on completed registration
|
||||
The system SHALL send an HTML confirmation email to the parent immediately after a registration is completed and stored.
|
||||
|
||||
#### Scenario: Confirmation sent to parent email
|
||||
- **WHEN** a registration is completed
|
||||
- **THEN** the system SHALL send a confirmation email to the address in `parentGuardian.email`
|
||||
|
||||
#### Scenario: Confirmation sent before or alongside admin notification
|
||||
- **WHEN** a registration is completed
|
||||
- **THEN** both the admin notification and the parent confirmation SHALL be dispatched in the same completion event; failure of either SHALL be logged but SHALL NOT block the other or fail the registration
|
||||
|
||||
#### Scenario: No confirmation for incomplete registration
|
||||
- **WHEN** a registration is not yet complete (any required field missing)
|
||||
- **THEN** no confirmation email SHALL be sent to the parent
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Confirmation email contains full registration summary
|
||||
The confirmation email SHALL include a summary of all registration data the parent submitted.
|
||||
|
||||
#### Scenario: All required fields present in confirmation
|
||||
- **WHEN** the confirmation email is sent
|
||||
- **THEN** it SHALL include child name, date of birth, special needs, selected playgroup type(s), selected days, parent/guardian contact details, and emergency contact
|
||||
|
||||
#### Scenario: Monthly fee shown as informational text
|
||||
- **WHEN** the confirmation email is sent
|
||||
- **THEN** it SHALL display the calculated monthly subscription fee as informational text (not a payment request)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Confirmation email contains payment instructions for registration fee
|
||||
The confirmation email SHALL include instructions for paying the one-time CHF 80 registration fee.
|
||||
|
||||
#### Scenario: IBAN and payee shown as text
|
||||
- **WHEN** the confirmation email is sent
|
||||
- **THEN** it SHALL display the payee name, IBAN, and amount in plain text so the parent can pay manually if the QR code is not rendered
|
||||
|
||||
#### Scenario: Swiss QR-bill embedded inline
|
||||
- **WHEN** the confirmation email is sent
|
||||
- **THEN** it SHALL include a Swiss QR-bill image (per SIX Group standard) embedded inline as a `Content-ID` referenced image within the HTML part
|
||||
- **AND** the QR-bill SHALL encode: IBAN `CH14 0900 0000 4930 8018 8`, payee Familienverein Fällanden Spielgruppen (Huebwisstrase 5, 8117 Fällanden), amount CHF 80.00, currency CHF, reference type NON
|
||||
|
||||
#### Scenario: QR-bill fallback for non-HTML clients
|
||||
- **WHEN** a parent's email client does not render HTML
|
||||
- **THEN** the plain-text part SHALL include the IBAN and payee details in full so payment is still possible without the QR code
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Confirmation email language matches parent's detected language
|
||||
The confirmation email body SHALL be rendered in the language detected during the conversation.
|
||||
|
||||
#### Scenario: German parent receives German confirmation
|
||||
- **WHEN** the conversation language is `"de"`
|
||||
- **THEN** the confirmation email body SHALL be in German
|
||||
|
||||
#### Scenario: English-speaking parent receives English confirmation
|
||||
- **WHEN** the conversation language is `"en"`
|
||||
- **THEN** the confirmation email body SHALL be in English
|
||||
|
||||
#### Scenario: Unknown language falls back to German
|
||||
- **WHEN** the stored language value is not `"de"` or `"en"`
|
||||
- **THEN** the confirmation email SHALL be sent in German
|
||||
|
||||
#### Scenario: QR-bill slip labels are not translated
|
||||
- **WHEN** the confirmation email is rendered in any language
|
||||
- **THEN** the Swiss QR-bill payment slip labels SHALL remain in German (per SIX Group standard; the slip is internationally recognisable without translation)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Parent's conversation language is persisted in the registration record
|
||||
The language detected during the parent's conversation SHALL be stored in the completed registration record.
|
||||
|
||||
#### Scenario: Language written to registration record
|
||||
- **WHEN** a registration is stored
|
||||
- **THEN** the JSON record SHALL include a `metadata.language` field containing the detected language code (`"de"` or `"en"`)
|
||||
|
||||
#### Scenario: Language defaults to German when not detected
|
||||
- **WHEN** no language was explicitly detected during the conversation
|
||||
- **THEN** `metadata.language` SHALL be `"de"`
|
||||
@@ -0,0 +1,52 @@
|
||||
## 1. Add `qrbill` Dependency
|
||||
|
||||
- [x] 1.1 Add `qrbill` to `[project.dependencies]` in `pyproject.toml`
|
||||
- [x] 1.2 Run `uv lock` to update the lockfile
|
||||
- [x] 1.3 Verify `qrbill` imports successfully in a smoke test or REPL
|
||||
|
||||
## 2. Persist Language in Registration Record
|
||||
|
||||
- [x] 2.1 Update `ConversationStore._build_record()` in `src/storage/json_store.py` to include `language` from `state.language` in the `metadata` dict
|
||||
- [x] 2.2 Update `ConversationStore.save_registration()` and `save_registration_version()` signatures to accept/forward `state` (already does — confirm `_build_record` receives the full state)
|
||||
- [x] 2.3 Add a test in `tests/test_storage.py` asserting that the saved record's `metadata.language` matches `state.language`
|
||||
|
||||
## 3. Add `notify_parent()` to `AdminNotifier`
|
||||
|
||||
- [x] 3.1 Add a `_generate_qr_bill_png()` static/class method to `AdminNotifier` using `qrbill` with fixed payment data:
|
||||
- IBAN: `CH14 0900 0000 4930 8018 8`
|
||||
- Payee: Familienverein Fällanden Spielgruppen, Huebwisstrase 5, 8117 Fällanden
|
||||
- Amount: `80.00`, Currency: `CHF`, Reference type: NON
|
||||
- Returns raw PNG `bytes`
|
||||
- [x] 3.2 Add bilingual string template dicts `_STRINGS_DE` and `_STRINGS_EN` (module-level constants) covering all user-visible strings in the confirmation email (subject, section headers, fee labels, payment instructions text, closing)
|
||||
- [x] 3.3 Add `_build_parent_html()` method: renders full HTML confirmation email body using the appropriate string dict, embedding the QR image via `cid:qrbill`; includes registration summary and both monthly fee (informational) and CHF 80 registration fee (with IBAN text + QR reference)
|
||||
- [x] 3.4 Add `_build_parent_text()` method: renders the plain-text fallback, including all summary fields and IBAN/payee details in plain text (no image)
|
||||
- [x] 3.5 Add `notify_parent()` public method:
|
||||
- Parameters: `registration: RegistrationData`, `language: str = "de"`
|
||||
- Select string dict based on `language`; fall back to `"de"` for unknown values
|
||||
- Call `_generate_qr_bill_png()` to get PNG bytes
|
||||
- Build MIME structure: `multipart/mixed` > `multipart/alternative` > plain text part + `multipart/related` > HTML part + inline PNG (`Content-Disposition: inline`, `Content-ID: <qrbill>`)
|
||||
- Call `_send()` with `to=[registration.parent_guardian.email]`, empty `cc`, localised subject, the assembled MIME message
|
||||
- If `_smtp_host` is empty (dev mode), log and skip as with `notify_admin`
|
||||
|
||||
## 4. Wire `notify_parent()` into Completion Events
|
||||
|
||||
- [x] 4.1 In `src/agent/core.py` `_handle_registration()`: after the existing `notify_admin()` try/except block, add a parallel try/except block calling `self._notifier.notify_parent(registration=state.registration, language=state.language)`
|
||||
- [x] 4.2 In `chat_app.py` `on_message()`: after the existing `notify_admin()` call inside the completion block, add a parallel try/except block calling `_notifier.notify_parent(registration=state.registration, language=state.language)`
|
||||
- [x] 4.3 Verify both call sites log a warning (not an exception) on failure, and the registration completion path continues normally
|
||||
|
||||
## 5. Tests
|
||||
|
||||
- [x] 5.1 Add `tests/test_notifier.py` tests for `notify_parent()`:
|
||||
- `test_notify_parent_calls_send`: mock `_send` and assert it is called with `to=[parent_email]`
|
||||
- `test_notify_parent_german_subject`: assert subject contains German text when `language="de"`
|
||||
- `test_notify_parent_english_subject`: assert subject contains English text when `language="en"`
|
||||
- `test_notify_parent_unknown_language_falls_back_to_de`: assert `language="fr"` produces German subject
|
||||
- `test_notify_parent_no_smtp_skips_send`: when `smtp_host=""`, `_send` is NOT called
|
||||
- [x] 5.2 Add a test asserting that the plain-text body contains the IBAN string `CH14` when `smtp_host` is empty (inspecting log or body build directly)
|
||||
- [x] 5.3 Add a test for `_generate_qr_bill_png()` asserting it returns `bytes` with non-zero length (requires `qrbill` installed)
|
||||
|
||||
## 6. Manual Smoke Test
|
||||
|
||||
- [ ] 6.1 Run `chainlit run chat_app.py` locally (or the email poller), complete a registration end-to-end, and verify the parent confirmation email arrives with the inline QR image rendered correctly
|
||||
- [ ] 6.2 Verify the admin notification still arrives unchanged alongside the parent confirmation
|
||||
- [ ] 6.3 Verify the saved `current.json` for the registration includes `metadata.language`
|
||||
@@ -11,6 +11,10 @@ dependencies = [
|
||||
# Registration schema validation
|
||||
"jsonschema>=4.23.0",
|
||||
"chainlit>=2.9.6",
|
||||
"qrbill>=1.2.0",
|
||||
"pillow>=12.1.1",
|
||||
"jinja2>=3.0.0",
|
||||
"pyyaml>=6.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -129,6 +225,13 @@ class EmailAgent:
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send admin notification for %s", email_key)
|
||||
try:
|
||||
self._notifier.notify_parent(
|
||||
registration=state.registration,
|
||||
language=state.language,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send parent confirmation for %s", email_key)
|
||||
logger.info("Registration complete for %s", state.conversation_id)
|
||||
|
||||
return reply_text
|
||||
|
||||
@@ -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")
|
||||
|
||||
+22
-2
@@ -1,9 +1,12 @@
|
||||
"""Configuration loaded from environment variables."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
@@ -13,10 +16,17 @@ except ImportError:
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
# AI model — litellm format, e.g. "anthropic/claude-opus-4-6" or "openai/gpt-4o".
|
||||
# Primary model for conversation with parents — litellm format.
|
||||
# The matching API key must be set as an env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, …).
|
||||
# Example: "anthropic/claude-opus-4-6", "google/gemini-2.0-flash", "openai/gpt-4o"
|
||||
ai_model: str = "anthropic/claude-opus-4-6"
|
||||
|
||||
# Lightweight model for simple tasks such as email-label translation.
|
||||
# Can be from a different provider than ai_model.
|
||||
# If not configured (SIMPLE_MODEL env var unset), falls back to ai_model with a warning.
|
||||
# Example: "anthropic/claude-haiku-4-5-20251001", "openai/gpt-4o-mini"
|
||||
simple_model: str = ""
|
||||
|
||||
# Email — IMAP (receiving)
|
||||
imap_host: str = ""
|
||||
imap_port: int = 993
|
||||
@@ -57,8 +67,18 @@ class Config:
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
ai_model = os.getenv("AI_MODEL", "anthropic/claude-opus-4-6")
|
||||
simple_model = os.getenv("SIMPLE_MODEL", "")
|
||||
if not simple_model:
|
||||
logger.warning(
|
||||
"SIMPLE_MODEL not configured — falling back to AI_MODEL (%s) for simple tasks "
|
||||
"(set SIMPLE_MODEL to a cheaper model, e.g. anthropic/claude-haiku-4-5-20251001)",
|
||||
ai_model,
|
||||
)
|
||||
simple_model = ai_model
|
||||
return cls(
|
||||
ai_model=os.getenv("AI_MODEL", "anthropic/claude-opus-4-6"),
|
||||
ai_model=ai_model,
|
||||
simple_model=simple_model,
|
||||
imap_host=os.getenv("IMAP_HOST", ""),
|
||||
imap_port=int(os.getenv("IMAP_PORT", "993")),
|
||||
imap_username=os.getenv("IMAP_USERNAME", ""),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Pure functions for building email context dicts from registration data."""
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from ..models.registration import RegistrationData
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Swiss QR-bill payment constants (stable bank details — not in config)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
QR_IBAN = "CH14 0900 0000 4930 8018 8"
|
||||
QR_PAYEE = "Familienverein Fällanden Spielgruppen"
|
||||
QR_STREET = "Huebwisstrase 5"
|
||||
QR_PCODE = "8117"
|
||||
QR_CITY = "Fällanden"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting helpers (pure functions, no side-effects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_dob(dob_str: str) -> str:
|
||||
"""Return DD.MM.YYYY from a YYYY-MM-DD string, or the original on error."""
|
||||
try:
|
||||
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
|
||||
except Exception:
|
||||
return dob_str or ""
|
||||
|
||||
|
||||
def calculate_age(dob_str: str) -> str:
|
||||
"""Return 'X Jahre, Y Monate' from a YYYY-MM-DD string."""
|
||||
try:
|
||||
dob = datetime.strptime(dob_str, "%Y-%m-%d").date()
|
||||
today = date.today()
|
||||
years = today.year - dob.year - (
|
||||
(today.month, today.day) < (dob.month, dob.day)
|
||||
)
|
||||
months = (today.month - dob.month) % 12
|
||||
return f"{years} Jahre, {months} Monate"
|
||||
except Exception:
|
||||
return dob_str
|
||||
|
||||
|
||||
def format_types(types: list[str]) -> str:
|
||||
"""German label for a list of playgroup type keys (admin emails)."""
|
||||
has_indoor = "indoor" in types
|
||||
has_outdoor = "outdoor" in types
|
||||
if has_indoor and has_outdoor:
|
||||
return "Innen- und Waldspielgruppe"
|
||||
if has_indoor:
|
||||
return "Innenspielgruppe"
|
||||
if has_outdoor:
|
||||
return "Waldspielgruppe"
|
||||
return "Spielgruppe"
|
||||
|
||||
|
||||
def format_types_i18n(types: list[str], strings: dict) -> str:
|
||||
"""Localised label for playgroup type keys using the supplied string table."""
|
||||
type_map: dict = strings["types"]
|
||||
labels = [type_map.get(t, t) for t in types]
|
||||
return ", ".join(labels) if labels else ""
|
||||
|
||||
|
||||
def format_days(registration: RegistrationData) -> str:
|
||||
"""German day + type labels for admin emails."""
|
||||
day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
|
||||
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
|
||||
return ", ".join(
|
||||
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
|
||||
for d in registration.booking.selected_days
|
||||
)
|
||||
|
||||
|
||||
def format_days_i18n(registration: RegistrationData, strings: dict) -> str:
|
||||
"""Localised day + type labels using the supplied string table."""
|
||||
day_map: dict = strings["days"]
|
||||
type_map: dict = strings["types"]
|
||||
return ", ".join(
|
||||
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
|
||||
for d in registration.booking.selected_days
|
||||
)
|
||||
|
||||
|
||||
def calculate_monthly_fee(registration: RegistrationData) -> str:
|
||||
"""Compute the monthly fee string from the booking selection."""
|
||||
indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor")
|
||||
outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor")
|
||||
fee = indoor_days * 130 + outdoor_days * 250
|
||||
return f"CHF {fee}.-"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_admin_new_context(
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
channel: str,
|
||||
) -> dict:
|
||||
"""Build the template context for the admin new-registration email."""
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
ec = registration.emergency_contact
|
||||
ch = registration.child
|
||||
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
|
||||
|
||||
return {
|
||||
"submitted_date": now.strftime("%d.%m.%Y"),
|
||||
"submitted_time": now.strftime("%H:%M"),
|
||||
"channel": channel_de,
|
||||
"registration_id": registration_id,
|
||||
"version": version,
|
||||
"child_name": ch.full_name or "",
|
||||
"child_dob": format_dob(ch.date_of_birth or ""),
|
||||
"child_age": calculate_age(ch.date_of_birth or ""),
|
||||
"child_needs": ch.special_needs or "Keine",
|
||||
"playgroup_types": format_types(registration.booking.playgroup_types),
|
||||
"days": format_days(registration),
|
||||
"monthly_fee": calculate_monthly_fee(registration),
|
||||
"parent_name": pg.full_name or "",
|
||||
"parent_street": pg.street_address or "",
|
||||
"parent_postal_code": pg.postal_code or "",
|
||||
"parent_city": pg.city or "",
|
||||
"parent_phone": pg.phone or "",
|
||||
"parent_email": pg.email or "",
|
||||
"emergency_name": ec.full_name or "",
|
||||
"emergency_phone": ec.phone or "",
|
||||
}
|
||||
|
||||
|
||||
def build_admin_update_context(
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
change_summary: dict,
|
||||
) -> dict:
|
||||
"""Build the template context for the admin registration-update email."""
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
|
||||
changes = [
|
||||
{"field": field_path, "old": values["old"], "new": values["new"]}
|
||||
for field_path, values in sorted(change_summary.items())
|
||||
]
|
||||
|
||||
return {
|
||||
"updated_date": now.strftime("%d.%m.%Y"),
|
||||
"updated_time": now.strftime("%H:%M"),
|
||||
"registration_id": registration_id,
|
||||
"version": version,
|
||||
"child_name": registration.child.full_name or "",
|
||||
"parent_email": pg.email or "",
|
||||
"changes": changes,
|
||||
"playgroup_types": format_types(registration.booking.playgroup_types),
|
||||
"days": format_days(registration),
|
||||
"monthly_fee": calculate_monthly_fee(registration),
|
||||
"parent_name": pg.full_name or "",
|
||||
"parent_street": pg.street_address or "",
|
||||
"parent_postal_code": pg.postal_code or "",
|
||||
"parent_city": pg.city or "",
|
||||
"parent_phone": pg.phone or "",
|
||||
}
|
||||
|
||||
|
||||
def build_parent_context(
|
||||
registration: RegistrationData,
|
||||
strings: dict,
|
||||
has_qr: bool = True,
|
||||
) -> dict:
|
||||
"""Build the template context for the parent confirmation email."""
|
||||
pg = registration.parent_guardian
|
||||
ec = registration.emergency_contact
|
||||
ch = registration.child
|
||||
parent_name = pg.full_name or pg.email or ""
|
||||
|
||||
return {
|
||||
"lang": "de" if strings.get("none") == "Keine" else "en",
|
||||
"strings": strings,
|
||||
"greeting": strings["greeting"].format(name=parent_name),
|
||||
"child_name": ch.full_name or "",
|
||||
"child_dob": format_dob(ch.date_of_birth or ""),
|
||||
"child_needs": ch.special_needs or strings["none"],
|
||||
"playgroup_types": format_types_i18n(registration.booking.playgroup_types, strings),
|
||||
"days": format_days_i18n(registration, strings),
|
||||
"monthly_fee": calculate_monthly_fee(registration),
|
||||
"has_indoor": "indoor" in registration.booking.playgroup_types,
|
||||
"has_qr": has_qr,
|
||||
"parent_name": pg.full_name or "",
|
||||
"parent_address": pg.street_address or "",
|
||||
"parent_postal_code": pg.postal_code or "",
|
||||
"parent_city": pg.city or "",
|
||||
"parent_phone": pg.phone or "",
|
||||
"parent_email": pg.email or "",
|
||||
"emergency_name": ec.full_name or "",
|
||||
"emergency_phone": ec.phone or "",
|
||||
"iban": QR_IBAN,
|
||||
"payee": QR_PAYEE,
|
||||
"payee_street": QR_STREET,
|
||||
"payee_postal_code": QR_PCODE,
|
||||
"payee_city": QR_CITY,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""i18n support for parent confirmation emails.
|
||||
|
||||
German is the canonical source language (de.yaml). For any other language
|
||||
the German labels are translated on-demand via an LLM call and cached
|
||||
in-process for the lifetime of the server — no static translation files to
|
||||
maintain, any language the parent writes in is supported automatically.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import litellm
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_I18N_DIR = Path(__file__).parent / "i18n"
|
||||
|
||||
# In-memory translation cache keyed by language code.
|
||||
_cache: dict[str, dict] = {}
|
||||
|
||||
# Pure data values that must never be sent to the LLM for translation.
|
||||
_PASSTHROUGH_KEYS = {"reg_fee_amount", "deposit_amount"}
|
||||
|
||||
_SYSTEM_PROMPT = """\
|
||||
You are a translation assistant for a Swiss playgroup registration system.
|
||||
Translate the following JSON label strings from German into {language}.
|
||||
|
||||
Rules:
|
||||
- Return ONLY a valid JSON object with the exact same keys and structure.
|
||||
- Preserve all {{placeholder}} variables exactly as-is (e.g. {{name}}).
|
||||
- Preserve all HTML tags and entities exactly (e.g. <strong>, ).
|
||||
- Keep proper nouns untranslated: "Spielgruppe Pumuckl", "Familienverein Fällanden".
|
||||
- Do not include any explanation or text outside the JSON."""
|
||||
|
||||
|
||||
def get_strings(language: str, model: str) -> dict:
|
||||
"""Return the label string table for *language*.
|
||||
|
||||
For German, loads directly from de.yaml (no LLM call).
|
||||
For all other languages, translates the German labels via LLM and caches
|
||||
the result in memory. Falls back to German if the LLM call fails.
|
||||
"""
|
||||
if language == "de":
|
||||
return _load_german()
|
||||
|
||||
if language in _cache:
|
||||
return _cache[language]
|
||||
|
||||
german = _load_german()
|
||||
translated = _translate(german, language, model)
|
||||
_cache[language] = translated
|
||||
return translated
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Evict all cached translations (intended for use in tests)."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def _load_german() -> dict:
|
||||
with (_I18N_DIR / "de.yaml").open(encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh)
|
||||
|
||||
|
||||
def _translate(german: dict, language: str, model: str) -> dict:
|
||||
"""Translate the German label dict into *language* via LLM.
|
||||
|
||||
Returns the German dict unchanged if the LLM call fails or returns
|
||||
malformed JSON.
|
||||
"""
|
||||
passthrough = {k: german[k] for k in _PASSTHROUGH_KEYS if k in german}
|
||||
to_translate = {k: v for k, v in german.items() if k not in _PASSTHROUGH_KEYS}
|
||||
|
||||
system = _SYSTEM_PROMPT.format(language=language)
|
||||
payload = json.dumps(to_translate, ensure_ascii=False, indent=2)
|
||||
|
||||
try:
|
||||
response = litellm.completion(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": payload},
|
||||
],
|
||||
max_tokens=2048,
|
||||
)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
|
||||
# Strip markdown code fences that some models add
|
||||
if raw.startswith("```"):
|
||||
raw = raw[raw.index("\n") + 1 :]
|
||||
raw = raw[: raw.rfind("```")]
|
||||
|
||||
translated: dict = json.loads(raw)
|
||||
translated.update(passthrough)
|
||||
return translated
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to translate email labels into %s — falling back to German", language
|
||||
)
|
||||
return german
|
||||
@@ -0,0 +1,57 @@
|
||||
subject: "Anmeldebestätigung – Spielgruppe Pumuckl"
|
||||
greeting: "Guten Tag {name}"
|
||||
intro: >-
|
||||
Deine Anmeldung für die Spielgruppe Pumuckl ist bei uns eingegangen.
|
||||
Hier ist eine Zusammenfassung:
|
||||
|
||||
child_section: "Angaben zum Kind"
|
||||
child_name: "Name"
|
||||
child_dob: "Geburtsdatum"
|
||||
child_needs: "Besondere Bedürfnisse"
|
||||
|
||||
booking_section: "Spielgruppen-Buchung"
|
||||
booking_type: "Art"
|
||||
booking_days: "Tage"
|
||||
|
||||
fees_section: "Kosten"
|
||||
monthly_fee: "Monatlicher Beitrag"
|
||||
reg_fee: "Anmeldegebühr (einmalig, erstes Jahr)"
|
||||
reg_fee_amount: "CHF 80.00"
|
||||
deposit: "Reinigungsdepot Innen (rückerstattbar)"
|
||||
deposit_amount: "CHF 50.00"
|
||||
|
||||
parent_section: "Deine Kontaktdaten"
|
||||
parent_name: "Name"
|
||||
parent_address: "Adresse"
|
||||
parent_phone: "Telefon"
|
||||
parent_email: "E-Mail"
|
||||
|
||||
emergency_section: "Notfallkontakt"
|
||||
emergency_name: "Name"
|
||||
emergency_phone: "Telefon"
|
||||
|
||||
payment_section: "Zahlungsinformationen"
|
||||
payment_intro: >-
|
||||
Bitte überweise die Anmeldegebühr von <strong>CHF 80.00</strong> auf folgendes Konto.
|
||||
Du kannst den QR-Code mit deiner Banking-App scannen:
|
||||
payment_intro_text: "Bitte überweise die Anmeldegebühr von CHF 80.00 auf folgendes Konto:"
|
||||
iban_label: "IBAN"
|
||||
payee_label: "Empfänger"
|
||||
amount_label: "Betrag"
|
||||
|
||||
closing: |
|
||||
Bei Fragen stehen wir dir gerne zur Verfügung. Wir freuen uns auf dein Kind!
|
||||
|
||||
Herzliche Grüsse
|
||||
Spielgruppe Pumuckl
|
||||
|
||||
none: "Keine"
|
||||
|
||||
days:
|
||||
monday: "Montag"
|
||||
wednesday: "Mittwoch"
|
||||
thursday: "Donnerstag"
|
||||
|
||||
types:
|
||||
indoor: "Innenspielgruppe"
|
||||
outdoor: "Waldspielgruppe"
|
||||
+185
-168
@@ -1,12 +1,31 @@
|
||||
"""Admin email notifications — new registrations and registration updates."""
|
||||
"""Admin email notifications — new registrations, updates, and parent confirmations."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import date, datetime
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import qrcode
|
||||
import qrcode.constants
|
||||
from PIL import Image, ImageDraw
|
||||
from qrbill import QRBill
|
||||
|
||||
from ..models.registration import RegistrationData
|
||||
from .context import (
|
||||
QR_CITY,
|
||||
QR_IBAN,
|
||||
QR_PAYEE,
|
||||
QR_PCODE,
|
||||
QR_STREET,
|
||||
build_admin_new_context,
|
||||
build_admin_update_context,
|
||||
build_parent_context,
|
||||
format_types,
|
||||
)
|
||||
from .i18n import get_strings
|
||||
from .renderer import render_template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,6 +51,7 @@ class AdminNotifier:
|
||||
indoor_email: str = "",
|
||||
outdoor_email: str = "",
|
||||
cc_emails: list[str] | None = None,
|
||||
model: str = "anthropic/claude-haiku-4-5-20251001",
|
||||
) -> None:
|
||||
self._smtp_host = smtp_host
|
||||
self._smtp_port = smtp_port
|
||||
@@ -42,6 +62,7 @@ class AdminNotifier:
|
||||
self._indoor_email = indoor_email
|
||||
self._outdoor_email = outdoor_email
|
||||
self._cc_emails: list[str] = cc_emails or []
|
||||
self._model = model
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
@@ -67,9 +88,10 @@ class AdminNotifier:
|
||||
|
||||
subject = (
|
||||
f"Neue Anmeldung: {registration.child.full_name} "
|
||||
f"– {self._format_types(types)}"
|
||||
f"– {format_types(types)}"
|
||||
)
|
||||
body = self._build_new_body(registration, registration_id, version, channel)
|
||||
ctx = build_admin_new_context(registration, registration_id, version, channel)
|
||||
body = render_template("admin_new.txt.j2", ctx)
|
||||
|
||||
self._send(
|
||||
to=to_addresses,
|
||||
@@ -98,7 +120,8 @@ class AdminNotifier:
|
||||
return
|
||||
|
||||
subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
|
||||
body = self._build_update_body(registration, registration_id, version, change_summary)
|
||||
ctx = build_admin_update_context(registration, registration_id, version, change_summary)
|
||||
body = render_template("admin_update.txt.j2", ctx)
|
||||
|
||||
self._send(
|
||||
to=to_addresses,
|
||||
@@ -108,6 +131,120 @@ 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,
|
||||
language: str = "de",
|
||||
) -> None:
|
||||
"""Send an HTML confirmation email to the parent with registration summary and QR-bill."""
|
||||
parent_email = registration.parent_guardian.email
|
||||
if not parent_email:
|
||||
logger.warning("No parent email in registration — confirmation not sent.")
|
||||
return
|
||||
|
||||
strings = get_strings(language, self._model)
|
||||
|
||||
try:
|
||||
qr_png = self._generate_qr_bill_png()
|
||||
except Exception:
|
||||
logger.exception("Failed to generate QR-bill PNG — omitting image from confirmation")
|
||||
qr_png = None
|
||||
|
||||
ctx = build_parent_context(registration, strings, has_qr=qr_png is not None)
|
||||
html_body = render_template("parent_confirmation.html.j2", ctx)
|
||||
text_body = render_template("parent_confirmation.txt.j2", ctx)
|
||||
subject = strings["subject"]
|
||||
|
||||
if not self._smtp_host:
|
||||
logger.warning(
|
||||
"SMTP not configured — parent confirmation NOT sent. Would have emailed %s: %s",
|
||||
parent_email,
|
||||
subject,
|
||||
)
|
||||
logger.debug("Parent confirmation body:\n%s", text_body)
|
||||
return
|
||||
|
||||
# MIME structure:
|
||||
# multipart/mixed
|
||||
# └── multipart/alternative
|
||||
# ├── text/plain (fallback)
|
||||
# └── multipart/related
|
||||
# ├── text/html (references cid:qrbill)
|
||||
# └── image/png (Content-ID: qrbill, inline)
|
||||
msg_outer = MIMEMultipart("mixed")
|
||||
msg_outer["From"] = self._from_email
|
||||
msg_outer["To"] = parent_email
|
||||
msg_outer["Subject"] = subject
|
||||
|
||||
msg_alt = MIMEMultipart("alternative")
|
||||
msg_alt.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
|
||||
if qr_png is not None:
|
||||
msg_related = MIMEMultipart("related")
|
||||
msg_related.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
img_part = MIMEImage(qr_png, "png")
|
||||
img_part.add_header("Content-ID", "<qrbill>")
|
||||
img_part.add_header("Content-Disposition", "inline", filename="qrbill.png")
|
||||
msg_related.attach(img_part)
|
||||
msg_alt.attach(msg_related)
|
||||
else:
|
||||
msg_alt.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
msg_outer.attach(msg_alt)
|
||||
|
||||
try:
|
||||
if self._use_tls:
|
||||
server = smtplib.SMTP(self._smtp_host, self._smtp_port)
|
||||
server.starttls()
|
||||
else:
|
||||
server = smtplib.SMTP_SSL(self._smtp_host, self._smtp_port)
|
||||
server.login(self._username, self._password)
|
||||
server.sendmail(self._from_email, [parent_email], msg_outer.as_string())
|
||||
server.quit()
|
||||
logger.info("Parent confirmation sent to %s", parent_email)
|
||||
except Exception:
|
||||
logger.exception("Failed to send parent confirmation to %s", parent_email)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routing helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -122,178 +259,58 @@ class AdminNotifier:
|
||||
return recipients
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Formatting helpers
|
||||
# QR-bill generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_types(types: list[str]) -> str:
|
||||
has_indoor = "indoor" in types
|
||||
has_outdoor = "outdoor" in types
|
||||
if has_indoor and has_outdoor:
|
||||
return "Innen- und Waldspielgruppe"
|
||||
if has_indoor:
|
||||
return "Innenspielgruppe"
|
||||
if has_outdoor:
|
||||
return "Waldspielgruppe"
|
||||
return "Spielgruppe"
|
||||
def _generate_qr_bill_png() -> bytes:
|
||||
"""Generate a Swiss QR-bill payment QR code as a PNG image.
|
||||
|
||||
@staticmethod
|
||||
def _calculate_age(dob_str: str) -> str:
|
||||
try:
|
||||
dob = datetime.strptime(dob_str, "%Y-%m-%d").date()
|
||||
today = date.today()
|
||||
years = today.year - dob.year - (
|
||||
(today.month, today.day) < (dob.month, dob.day)
|
||||
Uses the fixed registration fee payment data (CHF 80.00).
|
||||
The QR code includes the Swiss cross overlay as required by the SIX Group standard.
|
||||
|
||||
Returns:
|
||||
PNG image bytes of the QR code.
|
||||
"""
|
||||
bill = QRBill(
|
||||
account=QR_IBAN,
|
||||
creditor={
|
||||
"name": QR_PAYEE,
|
||||
"street": QR_STREET,
|
||||
"pcode": QR_PCODE,
|
||||
"city": QR_CITY,
|
||||
"country": "CH",
|
||||
},
|
||||
amount="80.00",
|
||||
currency="CHF",
|
||||
)
|
||||
months = (today.month - dob.month) % 12
|
||||
return f"{years} Jahre, {months} Monate"
|
||||
except Exception:
|
||||
return dob_str
|
||||
payload = bill.qr_data()
|
||||
|
||||
@staticmethod
|
||||
def _format_dob(dob_str: str) -> str:
|
||||
try:
|
||||
return datetime.strptime(dob_str, "%Y-%m-%d").strftime("%d.%m.%Y")
|
||||
except Exception:
|
||||
return dob_str or ""
|
||||
|
||||
@staticmethod
|
||||
def _calculate_monthly_fee(registration: RegistrationData) -> str:
|
||||
indoor_days = sum(1 for d in registration.booking.selected_days if d.type == "indoor")
|
||||
outdoor_days = sum(1 for d in registration.booking.selected_days if d.type == "outdoor")
|
||||
fee = 0
|
||||
if indoor_days == 1:
|
||||
fee += 130
|
||||
elif indoor_days == 2:
|
||||
fee += 260
|
||||
elif indoor_days >= 3:
|
||||
fee += 390
|
||||
if outdoor_days >= 1:
|
||||
fee += 250
|
||||
return f"CHF {fee}.-"
|
||||
|
||||
@staticmethod
|
||||
def _format_days(registration: RegistrationData) -> str:
|
||||
day_map = {"monday": "Montag", "wednesday": "Mittwoch", "thursday": "Donnerstag"}
|
||||
type_map = {"indoor": "Innenspielgruppe", "outdoor": "Waldspielgruppe"}
|
||||
return ", ".join(
|
||||
f"{day_map.get(d.day, d.day.capitalize())} ({type_map.get(d.type, d.type)})"
|
||||
for d in registration.booking.selected_days
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=8,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(payload)
|
||||
qr.make(fit=True)
|
||||
pil_img: Image.Image = qr.make_image(fill_color="black", back_color="white").get_image()
|
||||
pil_img = pil_img.convert("RGB")
|
||||
|
||||
@staticmethod
|
||||
def _format_change_summary(change_summary: dict) -> str:
|
||||
"""Render field changes as a human-readable list."""
|
||||
lines = []
|
||||
for field_path, values in sorted(change_summary.items()):
|
||||
old_val, new_val = values["old"], values["new"]
|
||||
lines.append(f" {field_path}:")
|
||||
lines.append(f" Alt: {old_val}")
|
||||
lines.append(f" Neu: {new_val}")
|
||||
return "\n".join(lines) if lines else " (keine Änderungen erkannt)"
|
||||
# Overlay Swiss cross in center (SIX Group standard)
|
||||
w, h = pil_img.size
|
||||
cross_size = max(int(w * 0.15), 20)
|
||||
cx, cy = w // 2, h // 2
|
||||
half = cross_size // 2
|
||||
bar = cross_size // 5
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
draw.rectangle([cx - half, cy - half, cx + half, cy + half], fill="white")
|
||||
draw.rectangle([cx - bar // 2, cy - half, cx + bar // 2, cy + half], fill="#FF0000")
|
||||
draw.rectangle([cx - half, cy - bar // 2, cx + half, cy + bar // 2], fill="#FF0000")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Email body builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_new_body(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
channel: str,
|
||||
) -> str:
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
ec = registration.emergency_contact
|
||||
channel_de = {"email": "E-Mail", "chat": "Chat"}.get(channel.lower(), channel.title())
|
||||
|
||||
return (
|
||||
"===============================================\n"
|
||||
"NEUE SPIELGRUPPEN-ANMELDUNG\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
f"Eingereicht: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
|
||||
f"Kanal: {channel_de}\n"
|
||||
f"Anmelde-ID: {registration_id} (Version {version})\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"ANGABEN ZUM KIND\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {registration.child.full_name}\n"
|
||||
f"Geburtsdatum: {self._format_dob(registration.child.date_of_birth or '')} "
|
||||
f"(Alter: {self._calculate_age(registration.child.date_of_birth or '')})\n"
|
||||
f"Bes. Bedürfnisse: {registration.child.special_needs or 'Keine'}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"SPIELGRUPPEN-AUSWAHL\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Art: {self._format_types(registration.booking.playgroup_types)}\n"
|
||||
f"Tage: {self._format_days(registration)}\n"
|
||||
"\n"
|
||||
f"Monatlicher Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
||||
"(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"ELTERN / ERZIEHUNGSBERECHTIGTE\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {pg.full_name}\n"
|
||||
f"Adresse: {pg.street_address}\n"
|
||||
f" {pg.postal_code} {pg.city}\n"
|
||||
f"Telefon: {pg.phone}\n"
|
||||
f"E-Mail: {pg.email}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"NOTFALLKONTAKT\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {ec.full_name}\n"
|
||||
f"Telefon: {ec.phone}\n"
|
||||
"\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
"Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
||||
)
|
||||
|
||||
def _build_update_body(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
version: int,
|
||||
change_summary: dict,
|
||||
) -> str:
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
|
||||
return (
|
||||
"===============================================\n"
|
||||
"ANMELDUNGS-AKTUALISIERUNG\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
f"Aktualisiert: {now.strftime('%d.%m.%Y')} um {now.strftime('%H:%M')} Uhr (UTC)\n"
|
||||
f"Anmelde-ID: {registration_id} (Version {version})\n"
|
||||
f"Kind: {registration.child.full_name}\n"
|
||||
f"Eltern-E-Mail: {pg.email}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"WAS HAT SICH GEÄNDERT\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"{self._format_change_summary(change_summary)}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"AKTUELLE ANMELDUNG (nach Aktualisierung)\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Spielgruppe: {self._format_types(registration.booking.playgroup_types)}\n"
|
||||
f"Tage: {self._format_days(registration)}\n"
|
||||
f"Monatl. Beitrag: {self._calculate_monthly_fee(registration)}\n"
|
||||
"\n"
|
||||
f"Elternteil: {pg.full_name}\n"
|
||||
f"Adresse: {pg.street_address}, {pg.postal_code} {pg.city}\n"
|
||||
f"Telefon: {pg.phone}\n"
|
||||
"\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
"Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.\n"
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
pil_img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SMTP dispatch
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Jinja2 template renderer for email notifications."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
_TEMPLATES_DIR = Path(__file__).parent / "templates"
|
||||
|
||||
_env = Environment(
|
||||
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
|
||||
autoescape=select_autoescape(enabled_extensions=["html.j2"]),
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
|
||||
|
||||
def render_template(name: str, context: dict) -> str:
|
||||
"""Render *name* (relative to the templates directory) with *context*."""
|
||||
return _env.get_template(name).render(**context)
|
||||
@@ -0,0 +1,42 @@
|
||||
===============================================
|
||||
NEUE SPIELGRUPPEN-ANMELDUNG
|
||||
===============================================
|
||||
|
||||
Eingereicht: {{ submitted_date }} um {{ submitted_time }} Uhr (UTC)
|
||||
Kanal: {{ channel }}
|
||||
Anmelde-ID: {{ registration_id }} (Version {{ version }})
|
||||
|
||||
-----------------------------------------------
|
||||
ANGABEN ZUM KIND
|
||||
-----------------------------------------------
|
||||
Name: {{ child_name }}
|
||||
Geburtsdatum: {{ child_dob }} (Alter: {{ child_age }})
|
||||
Bes. Bedürfnisse: {{ child_needs }}
|
||||
|
||||
-----------------------------------------------
|
||||
SPIELGRUPPEN-AUSWAHL
|
||||
-----------------------------------------------
|
||||
Art: {{ playgroup_types }}
|
||||
Tage: {{ days }}
|
||||
|
||||
Monatlicher Beitrag: {{ monthly_fee }}
|
||||
(Zzgl. CHF 80 Anmeldegebühr bei Erstanmeldung)
|
||||
|
||||
-----------------------------------------------
|
||||
ELTERN / ERZIEHUNGSBERECHTIGTE
|
||||
-----------------------------------------------
|
||||
Name: {{ parent_name }}
|
||||
Adresse: {{ parent_street }}
|
||||
{{ parent_postal_code }} {{ parent_city }}
|
||||
Telefon: {{ parent_phone }}
|
||||
E-Mail: {{ parent_email }}
|
||||
|
||||
-----------------------------------------------
|
||||
NOTFALLKONTAKT
|
||||
-----------------------------------------------
|
||||
Name: {{ emergency_name }}
|
||||
Telefon: {{ emergency_phone }}
|
||||
|
||||
===============================================
|
||||
|
||||
Diese Anmeldung wurde über den automatischen Anmeldeassistenten eingereicht.
|
||||
@@ -0,0 +1,33 @@
|
||||
===============================================
|
||||
ANMELDUNGS-AKTUALISIERUNG
|
||||
===============================================
|
||||
|
||||
Aktualisiert: {{ updated_date }} um {{ updated_time }} Uhr (UTC)
|
||||
Anmelde-ID: {{ registration_id }} (Version {{ version }})
|
||||
Kind: {{ child_name }}
|
||||
Eltern-E-Mail: {{ parent_email }}
|
||||
|
||||
-----------------------------------------------
|
||||
WAS HAT SICH GEÄNDERT
|
||||
-----------------------------------------------
|
||||
{% for change in changes %}
|
||||
{{ change.field }}:
|
||||
Alt: {{ change.old }}
|
||||
Neu: {{ change.new }}
|
||||
{% else %}
|
||||
(keine Änderungen erkannt)
|
||||
{% endfor %}
|
||||
-----------------------------------------------
|
||||
AKTUELLE ANMELDUNG (nach Aktualisierung)
|
||||
-----------------------------------------------
|
||||
Spielgruppe: {{ playgroup_types }}
|
||||
Tage: {{ days }}
|
||||
Monatl. Beitrag: {{ monthly_fee }}
|
||||
|
||||
Elternteil: {{ parent_name }}
|
||||
Adresse: {{ parent_street }}, {{ parent_postal_code }} {{ parent_city }}
|
||||
Telefon: {{ parent_phone }}
|
||||
|
||||
===============================================
|
||||
|
||||
Diese Aktualisierung wurde über den automatischen Anmeldeassistenten eingereicht.
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ lang }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
</head>
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:0 auto;padding:20px;">
|
||||
|
||||
<div style="background:#2e7d32;color:white;padding:20px;border-radius:8px 8px 0 0;text-align:center;">
|
||||
<h1 style="margin:0;font-size:22px;">Spielgruppe Pumuckl</h1>
|
||||
<p style="margin:4px 0 0;font-size:13px;opacity:.85;">Familienverein Fällanden</p>
|
||||
</div>
|
||||
|
||||
<div style="background:white;border:1px solid #e0e0e0;border-top:none;padding:24px;border-radius:0 0 8px 8px;">
|
||||
|
||||
<p>{{ greeting }},</p>
|
||||
<p>{{ strings.intro }}</p>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.child_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.child_name }}</td><td>{{ child_name }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_dob }}</td><td>{{ child_dob }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.child_needs }}</td><td>{{ child_needs }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.booking_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.booking_type }}</td><td>{{ playgroup_types }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.booking_days }}</td><td>{{ days }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.fees_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.monthly_fee }}</td><td>{{ monthly_fee }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.reg_fee }}</td><td>{{ strings.reg_fee_amount }}</td></tr>
|
||||
{% if has_indoor %}
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.deposit }}</td><td>{{ strings.deposit_amount }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.payment_section }}</h2>
|
||||
<p>{{ strings.payment_intro | safe }}</p>
|
||||
<table style="width:100%;border-collapse:collapse;background:#f8f8f8;border-radius:4px;">
|
||||
<tr>
|
||||
<td style="padding:8px;color:#666;width:40%;border-bottom:1px solid #e0e0e0;">{{ strings.iban_label }}</td>
|
||||
<td style="padding:8px;font-family:monospace;font-weight:bold;border-bottom:1px solid #e0e0e0;">{{ iban }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;color:#666;border-bottom:1px solid #e0e0e0;">{{ strings.payee_label }}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #e0e0e0;">{{ payee }}<br>{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px;color:#666;">{{ strings.amount_label }}</td>
|
||||
<td style="padding:8px;font-weight:bold;">CHF 80.00</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% if has_qr %}
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<img src="cid:qrbill" alt="Swiss QR-Bill" style="max-width:380px;width:100%;border:1px solid #e0e0e0;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.parent_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.parent_name }}</td><td>{{ parent_name }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_address }}</td><td>{{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_phone }}</td><td>{{ parent_phone }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.parent_email }}</td><td>{{ parent_email }}</td></tr>
|
||||
</table>
|
||||
|
||||
<h2 style="color:#2e7d32;border-bottom:2px solid #2e7d32;padding-bottom:4px;font-size:16px;">{{ strings.emergency_section }}</h2>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr><td style="padding:4px 0;color:#666;width:55%;">{{ strings.emergency_name }}</td><td>{{ emergency_name }}</td></tr>
|
||||
<tr><td style="padding:4px 0;color:#666;">{{ strings.emergency_phone }}</td><td>{{ emergency_phone }}</td></tr>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:24px;white-space:pre-line;">{{ strings.closing }}</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{{ greeting }},
|
||||
|
||||
{{ strings.intro }}
|
||||
|
||||
===============================================
|
||||
{{ strings.child_section | upper }}
|
||||
===============================================
|
||||
{{ strings.child_name }}: {{ child_name }}
|
||||
{{ strings.child_dob }}: {{ child_dob }}
|
||||
{{ strings.child_needs }}: {{ child_needs }}
|
||||
|
||||
===============================================
|
||||
{{ strings.booking_section | upper }}
|
||||
===============================================
|
||||
{{ strings.booking_type }}: {{ playgroup_types }}
|
||||
{{ strings.booking_days }}: {{ days }}
|
||||
|
||||
===============================================
|
||||
{{ strings.fees_section | upper }}
|
||||
===============================================
|
||||
{{ strings.monthly_fee }}: {{ monthly_fee }}
|
||||
{{ strings.reg_fee }}: {{ strings.reg_fee_amount }}
|
||||
{% if has_indoor %}{{ strings.deposit }}: {{ strings.deposit_amount }}
|
||||
{% endif %}
|
||||
===============================================
|
||||
{{ strings.payment_section | upper }}
|
||||
===============================================
|
||||
{{ strings.payment_intro_text }}
|
||||
|
||||
{{ strings.iban_label }}: {{ iban }}
|
||||
{{ strings.payee_label }}: {{ payee }}
|
||||
{{ payee_street }}, {{ payee_postal_code }} {{ payee_city }}
|
||||
{{ strings.amount_label }}: CHF 80.00
|
||||
|
||||
===============================================
|
||||
{{ strings.parent_section | upper }}
|
||||
===============================================
|
||||
{{ strings.parent_name }}: {{ parent_name }}
|
||||
{{ strings.parent_address }}: {{ parent_address }}, {{ parent_postal_code }} {{ parent_city }}
|
||||
{{ strings.parent_phone }}: {{ parent_phone }}
|
||||
{{ strings.parent_email }}: {{ parent_email }}
|
||||
|
||||
===============================================
|
||||
{{ strings.emergency_section | upper }}
|
||||
===============================================
|
||||
{{ strings.emergency_name }}: {{ emergency_name }}
|
||||
{{ strings.emergency_phone }}: {{ emergency_phone }}
|
||||
|
||||
-----------------------------------------------
|
||||
|
||||
{{ strings.closing }}
|
||||
@@ -254,6 +254,7 @@ class ConversationStore:
|
||||
"channel": "email",
|
||||
"parentEmail": state.parent_email,
|
||||
"conversationId": state.conversation_id,
|
||||
"language": state.language,
|
||||
}
|
||||
return record
|
||||
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
"""Tests for email loop prevention.
|
||||
|
||||
Covers three layers:
|
||||
- detect_automated_message() — header-based bounce/automated sender detection
|
||||
- EmailAgent.handle_automated_message() — state tracking, one-shot admin alert
|
||||
- EmailAgent.process_message() — hard message-count cap (MAX_USER_MESSAGES)
|
||||
- AdminNotifier.notify_loop_escalation() — escalation email dispatch
|
||||
"""
|
||||
|
||||
import email
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.channels.email_channel import detect_automated_message
|
||||
from src.agent.core import EmailAgent, MAX_USER_MESSAGES
|
||||
from src.models.conversation import ConversationState, ChatMessage
|
||||
from src.notifications.notifier import AdminNotifier
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _decode_body(msg_str: str) -> str:
|
||||
"""Extract the decoded plain-text body from a raw MIME message string."""
|
||||
parsed = email.message_from_string(msg_str)
|
||||
if parsed.is_multipart():
|
||||
for part in parsed.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
return payload.decode(part.get_content_charset() or "utf-8")
|
||||
payload = parsed.get_payload(decode=True)
|
||||
return payload.decode(parsed.get_content_charset() or "utf-8") if payload else ""
|
||||
|
||||
|
||||
def _make_msg(
|
||||
from_addr: str = "parent@example.com",
|
||||
subject: str = "Hallo",
|
||||
extra_headers: dict | None = None,
|
||||
content_type: str = "text/plain",
|
||||
) -> email.message.Message:
|
||||
"""Build a minimal parsed email.message.Message for testing detect_automated_message."""
|
||||
raw = (
|
||||
f"From: {from_addr}\r\n"
|
||||
f"Subject: {subject}\r\n"
|
||||
f"Content-Type: {content_type}\r\n"
|
||||
)
|
||||
for key, value in (extra_headers or {}).items():
|
||||
raw += f"{key}: {value}\r\n"
|
||||
raw += "\r\nBody text"
|
||||
return email.message_from_string(raw)
|
||||
|
||||
|
||||
def _state_with_n_user_messages(n: int, email_addr: str = "loop@example.com") -> ConversationState:
|
||||
"""Return a ConversationState that already has *n* user messages in its history."""
|
||||
state = ConversationState(
|
||||
conversation_id=email_addr,
|
||||
parent_email=email_addr,
|
||||
)
|
||||
for i in range(n):
|
||||
state.messages.append(ChatMessage(role="user", content=f"Message {i + 1}"))
|
||||
state.messages.append(ChatMessage(role="assistant", content=f"Reply {i + 1}"))
|
||||
return state
|
||||
|
||||
|
||||
VALID_LLM_REPLY = json.dumps({
|
||||
"reply": "Wie heisst dein Kind?",
|
||||
"updates": {},
|
||||
"next_step": "child_name",
|
||||
"registration_complete": False,
|
||||
"language": "de",
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def mock_kb():
|
||||
kb = MagicMock()
|
||||
kb.get_all.return_value = "# FAQ\nSome content."
|
||||
return kb
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
store = MagicMock()
|
||||
store.load.return_value = None
|
||||
store.save_registration.return_value = ("loop@example.com", 1)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_notifier():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent(mock_kb, mock_store, mock_notifier):
|
||||
return EmailAgent(
|
||||
model="anthropic/claude-opus-4-6",
|
||||
kb=mock_kb,
|
||||
store=mock_store,
|
||||
notifier=mock_notifier,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier():
|
||||
return AdminNotifier(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
username="agent@example.com",
|
||||
password="secret",
|
||||
use_tls=True,
|
||||
from_email="agent@example.com",
|
||||
indoor_email="andrea@example.com",
|
||||
outdoor_email="barbara@example.com",
|
||||
cc_emails=["markus@example.com"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier_no_cc():
|
||||
"""Notifier without any CC recipients — simulates missing ADMIN_EMAIL_CC."""
|
||||
return AdminNotifier(
|
||||
smtp_host="smtp.example.com",
|
||||
smtp_port=587,
|
||||
username="agent@example.com",
|
||||
password="secret",
|
||||
use_tls=True,
|
||||
from_email="agent@example.com",
|
||||
cc_emails=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier_no_smtp():
|
||||
"""Notifier in dev mode (no SMTP host)."""
|
||||
return AdminNotifier(
|
||||
smtp_host="",
|
||||
smtp_port=587,
|
||||
username="",
|
||||
password="",
|
||||
from_email="agent@example.com",
|
||||
cc_emails=["markus@example.com"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_automated_message — sender address patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectAutomatedMessageBySender:
|
||||
def test_mailer_daemon_is_automated(self):
|
||||
msg = _make_msg(from_addr="MAILER-DAEMON@tacitus2.sui-inter.net")
|
||||
is_auto, reason = detect_automated_message(msg, "MAILER-DAEMON@tacitus2.sui-inter.net")
|
||||
assert is_auto is True
|
||||
assert reason != ""
|
||||
|
||||
def test_mailer_daemon_lowercase_is_automated(self):
|
||||
msg = _make_msg(from_addr="mailer-daemon@example.com")
|
||||
is_auto, _ = detect_automated_message(msg, "mailer-daemon@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_postmaster_is_automated(self):
|
||||
msg = _make_msg(from_addr="postmaster@example.com")
|
||||
is_auto, _ = detect_automated_message(msg, "postmaster@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_noreply_is_automated(self):
|
||||
msg = _make_msg(from_addr="noreply@example.com")
|
||||
is_auto, _ = detect_automated_message(msg, "noreply@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_no_reply_hyphen_is_automated(self):
|
||||
msg = _make_msg(from_addr="no-reply@example.com")
|
||||
is_auto, _ = detect_automated_message(msg, "no-reply@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_donotreply_is_automated(self):
|
||||
msg = _make_msg(from_addr="donotreply@example.com")
|
||||
is_auto, _ = detect_automated_message(msg, "donotreply@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_bounce_is_automated(self):
|
||||
msg = _make_msg(from_addr="bounce@example.com")
|
||||
is_auto, _ = detect_automated_message(msg, "bounce@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_normal_parent_email_is_not_automated(self):
|
||||
msg = _make_msg(from_addr="anna.muster@example.com")
|
||||
is_auto, reason = detect_automated_message(msg, "anna.muster@example.com")
|
||||
assert is_auto is False
|
||||
assert reason == ""
|
||||
|
||||
def test_reason_string_mentions_sender(self):
|
||||
msg = _make_msg(from_addr="MAILER-DAEMON@tacitus2.sui-inter.net")
|
||||
_, reason = detect_automated_message(msg, "MAILER-DAEMON@tacitus2.sui-inter.net")
|
||||
assert "MAILER-DAEMON@tacitus2.sui-inter.net" in reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_automated_message — RFC / header signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectAutomatedMessageByHeaders:
|
||||
def test_auto_submitted_auto_replied(self):
|
||||
msg = _make_msg(extra_headers={"Auto-Submitted": "auto-replied"})
|
||||
is_auto, reason = detect_automated_message(msg, "someone@example.com")
|
||||
assert is_auto is True
|
||||
assert "auto-replied" in reason
|
||||
|
||||
def test_auto_submitted_auto_generated(self):
|
||||
msg = _make_msg(extra_headers={"Auto-Submitted": "auto-generated"})
|
||||
is_auto, _ = detect_automated_message(msg, "someone@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_auto_submitted_no_is_not_automated(self):
|
||||
"""Auto-Submitted: no means the message was composed by a human."""
|
||||
msg = _make_msg(extra_headers={"Auto-Submitted": "no"})
|
||||
is_auto, _ = detect_automated_message(msg, "parent@example.com")
|
||||
assert is_auto is False
|
||||
|
||||
def test_x_auto_response_suppress_is_automated(self):
|
||||
msg = _make_msg(extra_headers={"X-Auto-Response-Suppress": "All"})
|
||||
is_auto, reason = detect_automated_message(msg, "someone@example.com")
|
||||
assert is_auto is True
|
||||
assert "X-Auto-Response-Suppress" in reason
|
||||
|
||||
def test_multipart_report_content_type_is_automated(self):
|
||||
msg = _make_msg(content_type="multipart/report")
|
||||
is_auto, reason = detect_automated_message(msg, "system@example.com")
|
||||
assert is_auto is True
|
||||
assert "multipart/report" in reason
|
||||
|
||||
def test_x_loop_header_is_automated(self):
|
||||
msg = _make_msg(extra_headers={"X-Loop": "spielgruppen@familien-verein.ch"})
|
||||
is_auto, reason = detect_automated_message(msg, "someone@example.com")
|
||||
assert is_auto is True
|
||||
assert "X-Loop" in reason
|
||||
|
||||
def test_precedence_bulk_is_automated(self):
|
||||
msg = _make_msg(extra_headers={"Precedence": "bulk"})
|
||||
is_auto, reason = detect_automated_message(msg, "list@example.com")
|
||||
assert is_auto is True
|
||||
assert "bulk" in reason
|
||||
|
||||
def test_precedence_junk_is_automated(self):
|
||||
msg = _make_msg(extra_headers={"Precedence": "junk"})
|
||||
is_auto, _ = detect_automated_message(msg, "spam@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_precedence_list_is_not_automated(self):
|
||||
"""Mailing list messages (Precedence: list) are not considered automated."""
|
||||
msg = _make_msg(extra_headers={"Precedence": "list"})
|
||||
is_auto, _ = detect_automated_message(msg, "newsletter@example.com")
|
||||
assert is_auto is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_automated_message — subject heuristics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectAutomatedMessageBySubject:
|
||||
def test_undelivered_mail_returned_to_sender(self):
|
||||
msg = _make_msg(subject="Undelivered Mail Returned to Sender")
|
||||
is_auto, reason = detect_automated_message(msg, "mailer@example.com")
|
||||
# Caught by sender pattern first, but subject pattern must also flag it
|
||||
# Test that a neutral sender + bounce subject is still flagged
|
||||
msg2 = _make_msg(
|
||||
from_addr="delivery@isp.example.com",
|
||||
subject="Undelivered Mail Returned to Sender",
|
||||
)
|
||||
is_auto2, _ = detect_automated_message(msg2, "delivery@isp.example.com")
|
||||
assert is_auto2 is True
|
||||
|
||||
def test_delivery_failed_subject(self):
|
||||
msg = _make_msg(
|
||||
from_addr="system@isp.example.com",
|
||||
subject="Mail Delivery Failed",
|
||||
)
|
||||
is_auto, _ = detect_automated_message(msg, "system@isp.example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_out_of_office_subject(self):
|
||||
msg = _make_msg(
|
||||
from_addr="colleague@example.com",
|
||||
subject="Out of Office: Re: Anmeldung",
|
||||
)
|
||||
is_auto, _ = detect_automated_message(msg, "colleague@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_abwesenheitsnotiz_subject(self):
|
||||
msg = _make_msg(
|
||||
from_addr="colleague@example.com",
|
||||
subject="Abwesenheitsnotiz: Anmeldung",
|
||||
)
|
||||
is_auto, _ = detect_automated_message(msg, "colleague@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_automatische_antwort_subject(self):
|
||||
msg = _make_msg(
|
||||
from_addr="colleague@example.com",
|
||||
subject="Automatische Antwort: Ihre Anfrage",
|
||||
)
|
||||
is_auto, _ = detect_automated_message(msg, "colleague@example.com")
|
||||
assert is_auto is True
|
||||
|
||||
def test_normal_registration_subject_is_not_automated(self):
|
||||
msg = _make_msg(
|
||||
from_addr="parent@example.com",
|
||||
subject="Anmeldung meines Kindes",
|
||||
)
|
||||
is_auto, _ = detect_automated_message(msg, "parent@example.com")
|
||||
assert is_auto is False
|
||||
|
||||
def test_case_insensitive_subject_matching(self):
|
||||
msg = _make_msg(
|
||||
from_addr="system@isp.example.com",
|
||||
subject="UNDELIVERED MAIL RETURNED TO SENDER",
|
||||
)
|
||||
is_auto, _ = detect_automated_message(msg, "system@isp.example.com")
|
||||
assert is_auto is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EmailAgent.handle_automated_message — state and escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandleAutomatedMessage:
|
||||
def test_sets_loop_escalated_on_state(self, agent, mock_store):
|
||||
"""Calling handle_automated_message marks loop_escalated = True in state."""
|
||||
agent.handle_automated_message(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
subject="Undelivered Mail Returned to Sender",
|
||||
reason="sender matches automated address pattern",
|
||||
)
|
||||
saved_state = mock_store.save.call_args[0][0]
|
||||
assert saved_state.loop_escalated is True
|
||||
|
||||
def test_calls_notify_loop_escalation(self, agent, mock_notifier):
|
||||
"""Admin is notified once on the first automated message."""
|
||||
agent.handle_automated_message(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
subject="Undelivered Mail Returned to Sender",
|
||||
reason="sender matches automated address pattern",
|
||||
)
|
||||
mock_notifier.notify_loop_escalation.assert_called_once()
|
||||
|
||||
def test_notify_called_with_correct_sender(self, agent, mock_notifier):
|
||||
agent.handle_automated_message(
|
||||
sender_email="MAILER-DAEMON@tacitus2.sui-inter.net",
|
||||
subject="Bounce",
|
||||
reason="sender match",
|
||||
)
|
||||
call_kwargs = mock_notifier.notify_loop_escalation.call_args[1]
|
||||
assert call_kwargs["sender_email"] == "MAILER-DAEMON@tacitus2.sui-inter.net"
|
||||
|
||||
def test_creates_new_state_when_none_exists(self, agent, mock_store):
|
||||
"""When no prior state exists, a new ConversationState is created and saved."""
|
||||
mock_store.load.return_value = None
|
||||
|
||||
agent.handle_automated_message(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
subject="Bounce",
|
||||
reason="automated sender",
|
||||
)
|
||||
|
||||
assert mock_store.save.called
|
||||
saved_state = mock_store.save.call_args[0][0]
|
||||
assert saved_state.parent_email == "mailer-daemon@tacitus2.sui-inter.net"
|
||||
|
||||
def test_subsequent_automated_message_dropped_silently(self, agent, mock_store, mock_notifier):
|
||||
"""If loop_escalated is already True, no further notify call is made."""
|
||||
existing_state = ConversationState(
|
||||
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
|
||||
parent_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
)
|
||||
existing_state.loop_escalated = True
|
||||
mock_store.load.return_value = existing_state
|
||||
|
||||
agent.handle_automated_message(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
subject="Bounce again",
|
||||
reason="automated sender",
|
||||
)
|
||||
|
||||
mock_notifier.notify_loop_escalation.assert_not_called()
|
||||
|
||||
def test_state_still_saved_when_already_escalated(self, agent, mock_store, mock_notifier):
|
||||
"""Even when already escalated, last_activity is updated and state is persisted."""
|
||||
existing_state = ConversationState(
|
||||
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
|
||||
parent_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
)
|
||||
existing_state.loop_escalated = True
|
||||
mock_store.load.return_value = existing_state
|
||||
|
||||
agent.handle_automated_message(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
subject="Bounce again",
|
||||
reason="automated sender",
|
||||
)
|
||||
|
||||
assert mock_store.save.called
|
||||
|
||||
def test_notifier_failure_does_not_propagate(self, agent, mock_store, mock_notifier):
|
||||
"""A failing notifier must not crash the agent — the state is still saved."""
|
||||
mock_notifier.notify_loop_escalation.side_effect = RuntimeError("SMTP error")
|
||||
|
||||
# Should not raise
|
||||
agent.handle_automated_message(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
subject="Bounce",
|
||||
reason="automated sender",
|
||||
)
|
||||
|
||||
assert mock_store.save.called
|
||||
|
||||
def test_inbound_message_id_stored(self, agent, mock_store):
|
||||
"""The inbound Message-ID is persisted for reply-threading purposes."""
|
||||
agent.handle_automated_message(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
subject="Bounce",
|
||||
reason="automated sender",
|
||||
inbound_message_id="<abc123@tacitus2.sui-inter.net>",
|
||||
)
|
||||
saved_state = mock_store.save.call_args[0][0]
|
||||
assert saved_state.last_inbound_message_id == "<abc123@tacitus2.sui-inter.net>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EmailAgent.process_message — hard message-count cap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessMessageCountCap:
|
||||
def test_at_limit_message_still_processed(self, agent, mock_store):
|
||||
"""A conversation with exactly MAX_USER_MESSAGES messages is still replied to."""
|
||||
state = _state_with_n_user_messages(MAX_USER_MESSAGES - 1)
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
reply = agent.process_message("loop@example.com", "Another message")
|
||||
|
||||
assert reply == "Wie heisst dein Kind?"
|
||||
|
||||
def test_over_limit_returns_empty_string(self, agent, mock_store):
|
||||
"""The 21st user message triggers the cap and returns an empty reply."""
|
||||
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY) as mock_llm:
|
||||
reply = agent.process_message("loop@example.com", "One more message")
|
||||
|
||||
assert reply == ""
|
||||
mock_llm.assert_not_called()
|
||||
|
||||
def test_over_limit_sets_loop_escalated(self, agent, mock_store):
|
||||
"""Hitting the cap marks loop_escalated = True in the persisted state."""
|
||||
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
agent.process_message("loop@example.com", "One more message")
|
||||
|
||||
saved_state = mock_store.save.call_args[0][0]
|
||||
assert saved_state.loop_escalated is True
|
||||
|
||||
def test_over_limit_calls_notify_loop_escalation(self, agent, mock_store, mock_notifier):
|
||||
"""Hitting the cap triggers one admin escalation notification."""
|
||||
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
agent.process_message("loop@example.com", "One more message")
|
||||
|
||||
mock_notifier.notify_loop_escalation.assert_called_once()
|
||||
|
||||
def test_over_limit_no_duplicate_notification_when_already_escalated(
|
||||
self, agent, mock_store, mock_notifier
|
||||
):
|
||||
"""If loop_escalated is already True, no second notification is sent."""
|
||||
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
|
||||
state.loop_escalated = True
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
agent.process_message("loop@example.com", "Yet another message")
|
||||
|
||||
mock_notifier.notify_loop_escalation.assert_not_called()
|
||||
|
||||
def test_over_limit_notify_failure_does_not_propagate(self, agent, mock_store, mock_notifier):
|
||||
"""If the notifier raises, the cap still returns '' without crashing."""
|
||||
mock_notifier.notify_loop_escalation.side_effect = RuntimeError("SMTP down")
|
||||
state = _state_with_n_user_messages(MAX_USER_MESSAGES)
|
||||
mock_store.load.return_value = state
|
||||
|
||||
with patch("src.llm.complete", return_value=VALID_LLM_REPLY):
|
||||
reply = agent.process_message("loop@example.com", "One more message")
|
||||
|
||||
assert reply == ""
|
||||
|
||||
def test_max_user_messages_constant_is_twenty(self):
|
||||
"""The agreed-upon limit from the spec is 20 inbound messages."""
|
||||
assert MAX_USER_MESSAGES == 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AdminNotifier.notify_loop_escalation — SMTP dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNotifyLoopEscalation:
|
||||
def test_sends_email_to_cc_recipients(self, notifier, mocker):
|
||||
"""The escalation alert is sent to the admin CC address list."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
mock_server = mock_smtp_cls.return_value
|
||||
|
||||
notifier.notify_loop_escalation(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
|
||||
reason="sender matches automated address pattern",
|
||||
message_count=5,
|
||||
)
|
||||
|
||||
mock_server.sendmail.assert_called_once()
|
||||
call_args = mock_server.sendmail.call_args
|
||||
recipients = call_args[0][1]
|
||||
assert "markus@example.com" in recipients
|
||||
|
||||
def test_subject_contains_warnung_tag(self, notifier, mocker):
|
||||
"""Subject must start with [WARNUNG] for easy filtering in the admin inbox."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
def fake_sendmail(from_, to_, msg_str):
|
||||
captured["msg"] = msg_str
|
||||
|
||||
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
|
||||
|
||||
notifier.notify_loop_escalation(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
|
||||
reason="automated sender",
|
||||
message_count=3,
|
||||
)
|
||||
|
||||
import email as email_mod
|
||||
from email.header import decode_header
|
||||
parsed = email_mod.message_from_string(captured["msg"])
|
||||
raw_subject = parsed.get("Subject", "")
|
||||
parts = decode_header(raw_subject)
|
||||
subject = "".join(
|
||||
chunk.decode(enc or "utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk, enc in parts
|
||||
)
|
||||
assert "[WARNUNG]" in subject
|
||||
|
||||
def test_subject_contains_sender_address(self, notifier, mocker):
|
||||
"""The sender address appears in the subject for quick identification."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
def fake_sendmail(from_, to_, msg_str):
|
||||
captured["msg"] = msg_str
|
||||
|
||||
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
|
||||
|
||||
notifier.notify_loop_escalation(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
|
||||
reason="automated sender",
|
||||
message_count=3,
|
||||
)
|
||||
|
||||
assert "mailer-daemon@tacitus2.sui-inter.net" in captured["msg"]
|
||||
|
||||
def test_body_contains_reason(self, notifier, mocker):
|
||||
"""The email body includes the specific detection reason."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
def fake_sendmail(from_, to_, msg_str):
|
||||
captured["msg"] = msg_str
|
||||
|
||||
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
|
||||
|
||||
notifier.notify_loop_escalation(
|
||||
sender_email="test@example.com",
|
||||
conversation_id="test@example.com",
|
||||
reason="Content-Type: multipart/report (delivery status notification)",
|
||||
message_count=7,
|
||||
)
|
||||
|
||||
body = _decode_body(captured["msg"])
|
||||
assert "multipart/report" in body
|
||||
|
||||
def test_body_contains_message_count(self, notifier, mocker):
|
||||
"""The email body reports the number of messages exchanged."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
captured = {}
|
||||
|
||||
def fake_sendmail(from_, to_, msg_str):
|
||||
captured["msg"] = msg_str
|
||||
|
||||
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
|
||||
|
||||
notifier.notify_loop_escalation(
|
||||
sender_email="test@example.com",
|
||||
conversation_id="test@example.com",
|
||||
reason="automated sender",
|
||||
message_count=12,
|
||||
)
|
||||
|
||||
body = _decode_body(captured["msg"])
|
||||
assert "12" in body
|
||||
|
||||
def test_no_cc_emails_skips_smtp(self, notifier_no_cc, mocker):
|
||||
"""When no admin CC email is configured, no SMTP connection is made."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
|
||||
notifier_no_cc.notify_loop_escalation(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
|
||||
reason="automated sender",
|
||||
message_count=3,
|
||||
)
|
||||
|
||||
mock_smtp_cls.assert_not_called()
|
||||
|
||||
def test_no_smtp_host_skips_send(self, notifier_no_smtp, mocker):
|
||||
"""Dev mode (no SMTP host): email is logged but not dispatched."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
|
||||
notifier_no_smtp.notify_loop_escalation(
|
||||
sender_email="mailer-daemon@tacitus2.sui-inter.net",
|
||||
conversation_id="mailer-daemon@tacitus2.sui-inter.net",
|
||||
reason="automated sender",
|
||||
message_count=3,
|
||||
)
|
||||
|
||||
mock_smtp_cls.assert_not_called()
|
||||
@@ -150,3 +150,45 @@ class TestConversationStateSerialization:
|
||||
d = state_with_messages.to_dict()
|
||||
assert d["messages"][0]["role"] == "user"
|
||||
assert "Hallo" in d["messages"][0]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ConversationState — loop_escalated field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConversationStateLoopEscalated:
|
||||
def test_default_loop_escalated_is_false(self, fresh_state):
|
||||
assert fresh_state.loop_escalated is False
|
||||
|
||||
def test_to_dict_includes_loop_escalated(self, fresh_state):
|
||||
d = fresh_state.to_dict()
|
||||
assert "loop_escalated" in d
|
||||
assert d["loop_escalated"] is False
|
||||
|
||||
def test_to_dict_reflects_true_when_set(self, fresh_state):
|
||||
fresh_state.loop_escalated = True
|
||||
d = fresh_state.to_dict()
|
||||
assert d["loop_escalated"] is True
|
||||
|
||||
def test_from_dict_restores_loop_escalated_true(self, fresh_state):
|
||||
fresh_state.loop_escalated = True
|
||||
restored = ConversationState.from_dict(fresh_state.to_dict())
|
||||
assert restored.loop_escalated is True
|
||||
|
||||
def test_from_dict_defaults_to_false_when_key_missing(self):
|
||||
"""Older persisted conversations without the key deserialise safely."""
|
||||
data = {
|
||||
"conversation_id": "old@example.com",
|
||||
"parent_email": "old@example.com",
|
||||
"language": "de",
|
||||
"flow_step": "greeting",
|
||||
"registration": {},
|
||||
"messages": [],
|
||||
"completed": False,
|
||||
"reminder_count": 0,
|
||||
"last_inbound_message_id": "",
|
||||
# loop_escalated intentionally absent
|
||||
}
|
||||
state = ConversationState.from_dict(data)
|
||||
assert state.loop_escalated is False
|
||||
|
||||
+234
-24
@@ -1,11 +1,42 @@
|
||||
"""Tests for AdminNotifier helper methods."""
|
||||
"""Tests for AdminNotifier and notification helper functions."""
|
||||
|
||||
import email
|
||||
import json
|
||||
from email.header import decode_header
|
||||
|
||||
import pytest
|
||||
|
||||
from src.notifications.notifier import AdminNotifier
|
||||
from src.notifications.context import (
|
||||
calculate_age,
|
||||
calculate_monthly_fee,
|
||||
format_types,
|
||||
build_parent_context,
|
||||
)
|
||||
from src.notifications.i18n import get_strings, clear_cache
|
||||
from src.notifications.renderer import render_template
|
||||
from src.models.registration import RegistrationData, Booking, BookingDay
|
||||
|
||||
|
||||
def _decoded_subject(msg_str: str) -> str:
|
||||
"""Parse a raw MIME message string and return the decoded Subject header."""
|
||||
msg = email.message_from_string(msg_str)
|
||||
raw_subject = msg.get("Subject", "")
|
||||
parts = decode_header(raw_subject)
|
||||
return "".join(
|
||||
chunk.decode(enc or "utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk, enc in parts
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_translation_cache():
|
||||
"""Clear the in-memory translation cache before every test."""
|
||||
clear_cache()
|
||||
yield
|
||||
clear_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier():
|
||||
return AdminNotifier(
|
||||
@@ -21,54 +52,68 @@ def notifier():
|
||||
)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_types
|
||||
# format_types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatTypes:
|
||||
def test_indoor_label(self, notifier):
|
||||
assert "Innen" in notifier._format_types(["indoor"]) or "indoor" in notifier._format_types(["indoor"]).lower()
|
||||
def test_indoor_label(self):
|
||||
result = format_types(["indoor"])
|
||||
assert "Innen" in result or "indoor" in result.lower()
|
||||
|
||||
def test_outdoor_label(self, notifier):
|
||||
assert "Wald" in notifier._format_types(["outdoor"]) or "outdoor" in notifier._format_types(["outdoor"]).lower()
|
||||
def test_outdoor_label(self):
|
||||
result = format_types(["outdoor"])
|
||||
assert "Wald" in result or "outdoor" in result.lower()
|
||||
|
||||
def test_both_labels(self, notifier):
|
||||
result = notifier._format_types(["indoor", "outdoor"])
|
||||
def test_both_labels(self):
|
||||
result = format_types(["indoor", "outdoor"])
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _calculate_age
|
||||
# calculate_age
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateAge:
|
||||
def test_returns_age_string(self, notifier):
|
||||
result = notifier._calculate_age("2022-01-01")
|
||||
def test_returns_age_string(self):
|
||||
result = calculate_age("2022-01-01")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_invalid_dob_returns_original_string(self, notifier):
|
||||
result = notifier._calculate_age("not-a-date")
|
||||
def test_invalid_dob_returns_original_string(self):
|
||||
result = calculate_age("not-a-date")
|
||||
assert result == "not-a-date"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _calculate_monthly_fee
|
||||
# calculate_monthly_fee
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCalculateMonthlyFee:
|
||||
def test_indoor_one_day(self, notifier, complete_registration):
|
||||
def test_indoor_one_day(self, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[BookingDay(day="monday", type="indoor")],
|
||||
)
|
||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||
fee = calculate_monthly_fee(complete_registration)
|
||||
assert "130" in fee
|
||||
|
||||
def test_indoor_two_days(self, notifier, complete_registration):
|
||||
def test_indoor_two_days(self, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[
|
||||
@@ -76,10 +121,10 @@ class TestCalculateMonthlyFee:
|
||||
BookingDay(day="wednesday", type="indoor"),
|
||||
],
|
||||
)
|
||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||
fee = calculate_monthly_fee(complete_registration)
|
||||
assert "260" in fee
|
||||
|
||||
def test_indoor_three_days(self, notifier, complete_registration):
|
||||
def test_indoor_three_days(self, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["indoor"],
|
||||
selected_days=[
|
||||
@@ -88,15 +133,15 @@ class TestCalculateMonthlyFee:
|
||||
BookingDay(day="thursday", type="indoor"),
|
||||
],
|
||||
)
|
||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||
fee = calculate_monthly_fee(complete_registration)
|
||||
assert "390" in fee
|
||||
|
||||
def test_outdoor_one_day(self, notifier, complete_registration):
|
||||
def test_outdoor_one_day(self, complete_registration):
|
||||
complete_registration.booking = Booking(
|
||||
playgroup_types=["outdoor"],
|
||||
selected_days=[BookingDay(day="monday", type="outdoor")],
|
||||
)
|
||||
fee = notifier._calculate_monthly_fee(complete_registration)
|
||||
fee = calculate_monthly_fee(complete_registration)
|
||||
assert "250" in fee
|
||||
|
||||
|
||||
@@ -107,7 +152,6 @@ class TestCalculateMonthlyFee:
|
||||
|
||||
class TestSend:
|
||||
def test_send_calls_smtp(self, notifier, mocker):
|
||||
# _send uses smtplib.SMTP directly (not as context manager)
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
mock_server = mock_smtp_cls.return_value
|
||||
|
||||
@@ -132,6 +176,172 @@ class TestSend:
|
||||
)
|
||||
|
||||
call_args = mock_server.sendmail.call_args
|
||||
recipients = call_args[0][1] # positional arg: to_addrs
|
||||
recipients = call_args[0][1]
|
||||
assert "a@example.com" in recipients
|
||||
assert "b@example.com" in recipients
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_strings — i18n / LLM translation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetStrings:
|
||||
def test_german_loads_from_yaml_without_llm(self, mocker):
|
||||
"""German must never trigger an LLM call."""
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
strings = get_strings("de", "some-model")
|
||||
mock_litellm.assert_not_called()
|
||||
assert strings["subject"] == "Anmeldebestätigung – Spielgruppe Pumuckl"
|
||||
|
||||
def test_other_language_calls_llm(self, mocker):
|
||||
"""Non-German languages should call litellm.completion."""
|
||||
german = get_strings("de", "some-model")
|
||||
translated = {**german, "subject": "Registration Confirmation – Spielgruppe Pumuckl"}
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
mock_litellm.return_value.choices[0].message.content = json.dumps(translated)
|
||||
|
||||
result = get_strings("en", "some-model")
|
||||
|
||||
mock_litellm.assert_called_once()
|
||||
assert result["subject"] == "Registration Confirmation – Spielgruppe Pumuckl"
|
||||
|
||||
def test_result_is_cached(self, mocker):
|
||||
"""The LLM is only called once per language per process lifetime."""
|
||||
german = get_strings("de", "some-model")
|
||||
mock_litellm = mocker.patch("litellm.completion")
|
||||
mock_litellm.return_value.choices[0].message.content = json.dumps(german)
|
||||
|
||||
get_strings("fr", "some-model")
|
||||
get_strings("fr", "some-model")
|
||||
|
||||
assert mock_litellm.call_count == 1
|
||||
|
||||
def test_llm_failure_falls_back_to_german(self, mocker):
|
||||
"""If the LLM raises, the German strings are returned silently."""
|
||||
mocker.patch("litellm.completion", side_effect=RuntimeError("network error"))
|
||||
|
||||
result = get_strings("it", "some-model")
|
||||
|
||||
assert result["subject"] == "Anmeldebestätigung – Spielgruppe Pumuckl"
|
||||
|
||||
def test_passthrough_keys_not_altered(self, mocker):
|
||||
"""reg_fee_amount and deposit_amount must survive translation unchanged."""
|
||||
german = get_strings("de", "some-model")
|
||||
# Return translation that omits passthrough keys (as the LLM would)
|
||||
without_passthrough = {k: v for k, v in german.items()
|
||||
if k not in {"reg_fee_amount", "deposit_amount"}}
|
||||
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
|
||||
json.dumps(without_passthrough)
|
||||
)
|
||||
|
||||
result = get_strings("en", "some-model")
|
||||
|
||||
assert result["reg_fee_amount"] == "CHF 80.00"
|
||||
assert result["deposit_amount"] == "CHF 50.00"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# notify_parent — parent confirmation email
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNotifyParent:
|
||||
def test_notify_parent_calls_send(self, notifier, complete_registration, mocker):
|
||||
"""notify_parent dispatches an email to the parent address."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
mock_server = mock_smtp_cls.return_value
|
||||
|
||||
notifier.notify_parent(complete_registration, language="de")
|
||||
|
||||
mock_server.sendmail.assert_called_once()
|
||||
call_args = mock_server.sendmail.call_args
|
||||
recipients = call_args[0][1]
|
||||
assert "anna.muster@example.com" in recipients
|
||||
|
||||
def test_notify_parent_german_subject(self, notifier, complete_registration, mocker):
|
||||
"""German language produces a German subject line without any LLM call."""
|
||||
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")
|
||||
|
||||
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
|
||||
|
||||
def test_notify_parent_english_subject(self, notifier, complete_registration, mocker):
|
||||
"""English language produces an English subject line via LLM translation."""
|
||||
german = get_strings("de", "some-model")
|
||||
english = {**german, "subject": "Registration Confirmation – Spielgruppe Pumuckl"}
|
||||
mocker.patch("litellm.completion").return_value.choices[0].message.content = (
|
||||
json.dumps(english)
|
||||
)
|
||||
|
||||
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="en")
|
||||
|
||||
assert "Registration Confirmation" in _decoded_subject(captured["msg"])
|
||||
|
||||
def test_notify_parent_unknown_language_falls_back_to_de(
|
||||
self, notifier, complete_registration, mocker
|
||||
):
|
||||
"""When the LLM call fails, the email is sent in German."""
|
||||
mocker.patch("litellm.completion", side_effect=RuntimeError("timeout"))
|
||||
|
||||
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="fr")
|
||||
|
||||
assert "Anmeldebestätigung" in _decoded_subject(captured["msg"])
|
||||
|
||||
def test_notify_parent_no_smtp_skips_send(
|
||||
self, notifier_no_smtp, complete_registration, mocker
|
||||
):
|
||||
"""When SMTP host is empty, no sendmail call is made."""
|
||||
mock_smtp_cls = mocker.patch("smtplib.SMTP")
|
||||
|
||||
notifier_no_smtp.notify_parent(complete_registration, language="de")
|
||||
|
||||
mock_smtp_cls.assert_not_called()
|
||||
|
||||
def test_text_body_contains_iban(self, complete_registration):
|
||||
"""Rendered plain-text body includes the IBAN regardless of language."""
|
||||
strings = get_strings("de", "some-model")
|
||||
ctx = build_parent_context(complete_registration, strings, has_qr=False)
|
||||
text = render_template("parent_confirmation.txt.j2", ctx)
|
||||
assert "CH14" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _generate_qr_bill_png
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenerateQrBillPng:
|
||||
def test_returns_nonempty_bytes(self, notifier):
|
||||
"""_generate_qr_bill_png returns a non-empty bytes object (PNG)."""
|
||||
png = notifier._generate_qr_bill_png()
|
||||
assert isinstance(png, bytes)
|
||||
assert len(png) > 0
|
||||
|
||||
def test_returns_png_signature(self, notifier):
|
||||
"""Output starts with the PNG magic bytes."""
|
||||
png = notifier._generate_qr_bill_png()
|
||||
assert png[:4] == b"\x89PNG"
|
||||
|
||||
@@ -157,3 +157,20 @@ class TestRegistrationVersioning:
|
||||
store.save_registration(fresh_state)
|
||||
registrations = store.list_registrations()
|
||||
assert len(registrations) == 1
|
||||
|
||||
def test_save_registration_persists_language(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
fresh_state.language = "en"
|
||||
store.save_registration(fresh_state)
|
||||
current = store.get_current_registration(fresh_state.parent_email)
|
||||
assert current is not None
|
||||
assert current["metadata"]["language"] == "en"
|
||||
|
||||
def test_save_registration_defaults_language_to_de(self, store, fresh_state, complete_registration):
|
||||
fresh_state.registration = complete_registration
|
||||
fresh_state.completed = True
|
||||
# language defaults to "de" in ConversationState
|
||||
store.save_registration(fresh_state)
|
||||
current = store.get_current_registration(fresh_state.parent_email)
|
||||
assert current["metadata"]["language"] == "de"
|
||||
|
||||
@@ -823,6 +823,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iso3166"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/11/b5023c736a185a88ebd0d38646af6f4d1b4c9b91f2ca84e08e5d2bc7ac3c/iso3166-2.1.1.tar.gz", hash = "sha256:fcd551b8dda66b44e9f9e6d6bbbee3a1145a22447c0a556e5d0fb1ad1e491719", size = 12807, upload-time = "2022-07-12T04:07:57.294Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d0/bf18725b8d47f37858ff801f8e4d40c6982730a899725bdb6ded62199954/iso3166-2.1.1-py3-none-any.whl", hash = "sha256:263660b36f8471c42acd1ff673d28a3715edbce7d24b1550d0cf010f6816c47f", size = 9829, upload-time = "2022-07-12T04:07:55.54Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
@@ -1074,9 +1083,13 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "chainlit" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "litellm" },
|
||||
{ name = "pillow" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "qrbill" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -1089,9 +1102,13 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "chainlit", specifier = ">=2.9.6" },
|
||||
{ name = "jinja2", specifier = ">=3.0.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.23.0" },
|
||||
{ name = "litellm", specifier = ">=1.0.0" },
|
||||
{ name = "pillow", specifier = ">=12.1.1" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
{ name = "qrbill", specifier = ">=1.2.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -1918,6 +1935,64 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -2208,6 +2283,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-stdnum"
|
||||
version = "2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/7f/96c2b9de6024353177dc6139c33730d5ac25877bc33215515d6b95b84555/python_stdnum-2.2.tar.gz", hash = "sha256:e95fcfa858a703d4a40130cb3eaac133c60d8808a7f3c98efeedac968c2479b9", size = 1311813, upload-time = "2026-01-04T19:36:16.753Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/61/aa32d9c79f83a2fae033cd6496fb2a24aba918d31c73704271dfcfb48375/python_stdnum-2.2-py3-none-any.whl", hash = "sha256:bdf98fd117a0ca152e4047aa8ad254bae63853d4e915ddd4e0effb33ba0e9260", size = 1193213, upload-time = "2026-01-04T19:36:14.812Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
@@ -2257,6 +2341,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrbill"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "iso3166" },
|
||||
{ name = "python-stdnum" },
|
||||
{ name = "qrcode" },
|
||||
{ name = "svgwrite" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/38/95a9069070161becc3a7018fc5ee4edbfb973a012b31d4801d20eb30e1d8/qrbill-1.2.0.tar.gz", hash = "sha256:7a2e37940731890fea0f005189464ef5448382fbbee5e1e96bc2ddff86fc8bbe", size = 22357, upload-time = "2025-11-05T07:35:34.798Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/85/1660c9413411248cb30044bf4e2221319a23cd35f1609233d58fb1321708/qrbill-1.2.0-py3-none-any.whl", hash = "sha256:6a20c08c2bf5cd801408253e3d9292668bfbbd4ed50de071fa80a8922f9a41ca", size = 13201, upload-time = "2025-11-05T07:35:33.323Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
@@ -2491,6 +2602,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "svgwrite"
|
||||
version = "1.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/c1/263d4e93b543390d86d8eb4fc23d9ce8a8d6efd146f9427364109004fa9b/svgwrite-1.4.3.zip", hash = "sha256:a8fbdfd4443302a6619a7f76bc937fc683daf2628d9b737c891ec08b8ce524c3", size = 189516, upload-time = "2022-07-14T14:05:26.107Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/15/640e399579024a6875918839454025bb1d5f850bb70d96a11eabb644d11c/svgwrite-1.4.3-py3-none-any.whl", hash = "sha256:bb6b2b5450f1edbfa597d924f9ac2dd099e625562e492021d7dd614f65f8a22d", size = 67122, upload-time = "2022-07-14T14:05:24.459Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syncer"
|
||||
version = "2.0.3"
|
||||
|
||||
Reference in New Issue
Block a user