Author SHA1 Message Date
Claude 248d4aa5f7 feat(email): set Reply-To headers on confirmation and notification emails
- notify_parent: adds Reply-To set to the first CC (admin) address so
  parent replies to the completion confirmation reach the admin rather
  than re-entering the agent pipeline
- notify_admin / notify_registration_update: Reply-To = parent email
  was already wired through _send(); confirmed with new tests
- Mid-registration send_reply emails carry no Reply-To (verified by test)
- 5 new tests across TestNotifyParentReplyTo and TestNotifyAdminReplyTo;
  1 new test in TestSendReplyNoAdminReplyTo (174 passing, 0 failing)

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:13:16 +01:00
Markus GrafandGitHub fdf674d8d6 Merge pull request #8 from gurix/claude/fix-email-loop-prevention-bD8vX
Add loop detection and automated sender handling
2026-02-26 22:20:15 +01:00
11 changed files with 341 additions and 8 deletions
+2 -2
View File
@@ -62,8 +62,8 @@ REGISTRATION_EMAIL=anmeldung@example.com
# ADMIN_EMAIL_CC is always included as Cc (comma-separated for multiple). # ADMIN_EMAIL_CC is always included as Cc (comma-separated for multiple).
# For testing, point all three to your own email address. # For testing, point all three to your own email address.
# --------------------------------------------------------------- # ---------------------------------------------------------------
ADMIN_EMAIL_INDOOR=andrea.sigrist@gmx.net ADMIN_EMAIL_INDOOR=indoor-leader@example.com
ADMIN_EMAIL_OUTDOOR=baba.laeubli@gmail.com ADMIN_EMAIL_OUTDOOR=outdoor-leader@example.com
ADMIN_EMAIL_CC=spielgruppen@familien-verein.ch ADMIN_EMAIL_CC=spielgruppen@familien-verein.ch
# --------------------------------------------------------------- # ---------------------------------------------------------------
+32 -3
View File
@@ -116,9 +116,9 @@ uv run chainlit run chat_app.py --port 8080 --host 0.0.0.0
| `ANTHROPIC_API_KEY` | (or the key for your chosen provider) | | `ANTHROPIC_API_KEY` | (or the key for your chosen provider) |
| `SMTP_HOST` / `SMTP_PORT` | For admin notification emails on registration completion | | `SMTP_HOST` / `SMTP_PORT` | For admin notification emails on registration completion |
| `IMAP_USERNAME` / `IMAP_PASSWORD` | Used as SMTP credentials | | `IMAP_USERNAME` / `IMAP_PASSWORD` | Used as SMTP credentials |
| `ADMIN_EMAIL_INDOOR` | Andrea Sigrist — notified when indoor group is booked | | `ADMIN_EMAIL_INDOOR` | Indoor group leader — notified when indoor group is booked |
| `ADMIN_EMAIL_OUTDOOR` | Barbara Gross — notified when outdoor group is booked | | `ADMIN_EMAIL_OUTDOOR` | Outdoor group leader — notified when outdoor group is booked |
| `ADMIN_EMAIL_CC` | Markus Graf — always CC'd on notifications | | `ADMIN_EMAIL_CC` | Admin — always CC'd on notifications |
IMAP variables (`IMAP_HOST`, etc.) are not required for the web chat — only for the email channel. IMAP variables (`IMAP_HOST`, etc.) are not required for the web chat — only for the email channel.
@@ -156,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. 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 ## Development
### Running tests ### Running tests
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-27
@@ -0,0 +1,47 @@
## Context
The system sends emails in two directions with two different purposes:
- **Outbound to parents**: The AI agent sends conversational replies and, upon registration completion, a confirmation email summarising the registration and next steps.
- **Outbound to admins/leaders**: The system sends a notification email to the relevant playgroup leader(s) and Markus Graf (CC) immediately after a registration is completed.
Email clients use the `Reply-To` header (falling back to `From`) to determine where a reply is directed. Without explicit `Reply-To` headers, all replies from both parents and admins flow back to the registration system's inbox — which is correct for ongoing conversation but wrong for post-completion follow-up.
## Goals / Non-Goals
**Goals:**
- Ensure parent replies to completion confirmation emails reach the admin (Markus Graf) rather than the agent pipeline
- Ensure admin/leader replies to registration notification emails reach the registering parent directly
- Formally specify `Reply-To` behavior in the relevant capability specs
**Non-Goals:**
- Changing the `From` address of any email
- Modifying conversational email behavior (mid-registration agent ↔ parent exchanges — these correctly use the registration address as both From and effective reply target)
- Introducing any new email addresses beyond what is already configured
## Decisions
### 1. Confirmation Email Reply-To: Admin Address
**Decision**: Set `Reply-To: spielgruppen@familien-verein.ch` (Markus Graf) on all confirmation emails sent to parents after registration completion.
**Rationale**: Once registration is complete, the conversation is over. Any parent reply is a human follow-up question — it should reach a human admin, not re-enter the agent pipeline. Markus Graf is the designated central admin contact and is already CC'd on all notifications.
**Alternatives considered**:
- No Reply-To (default to From): Parent replies re-enter the agent inbox and may trigger unwanted agent responses post-completion.
- Reply-To the relevant playgroup leader (Andrea/Barbara): More targeted, but leaders vary by registration type and parents may not know who they're reaching. The central admin address is simpler and consistent.
### 2. Notification Email Reply-To: Parent Address
**Decision**: Set `Reply-To: <parent email>` on all registration notification emails sent to admins/leaders.
**Rationale**: The primary reason admins reply to a notification is to contact the parent (e.g., to confirm a spot, ask a clarifying question, or provide further instructions). Pre-filling Reply-To with the parent's address eliminates a copy-paste step and reduces errors. This is already noted informally in `notification-template.md` — this change formalises it as a spec requirement.
**Alternatives considered**:
- No Reply-To (default to From/registration inbox): Admins must manually copy the parent's email to reply, adding friction.
## Risks / Trade-offs
**Admin confirmation email replies go to Markus, not directly to the leader**: For outdoor registrations, the leader is Barbara Gross, but parent replies to confirmation go to Markus Graf. This is acceptable — Markus can forward as needed, and having a single consistent Reply-To is simpler than routing by playgroup type.
**Mid-registration vs. post-registration distinction**: Conversational emails (mid-registration) should NOT set Reply-To to the admin — they must continue flowing back to the registration inbox so the agent can process them. The implementation must apply the admin Reply-To only to the final confirmation email, not to all outbound agent emails.
@@ -0,0 +1,21 @@
## Why
The system sends two distinct types of emails to different audiences with different needs for follow-up communication. Currently, the Reply-To behavior for these emails is either unspecified or inconsistent:
1. **Confirmation emails to parents** — sent by the agent after registration is complete. If a parent replies to this email, that reply should reach the admin (Markus Graf), not bounce back into the agent's inbox for further automated processing.
2. **Registration notification emails to playgroup leaders/admins** — sent to Andrea Sigrist, Barbara Gross, and Markus Graf when a new registration is submitted. If an admin wants to follow up with the parent directly, their reply must go to the parent's email, not back to the registration system.
Without explicit Reply-To configuration, email clients will default to replying to the From address (the registration system's email). This creates confusion: parent replies to confirmation emails enter the agent pipeline instead of reaching a human admin, and admin replies to notification emails go to the registration inbox rather than the parent.
## What Changes
- **Confirmation emails**: Add a `Reply-To` header set to the admin email address (`spielgruppen@familien-verein.ch`) so parent replies reach a human directly
- **Admin notification emails**: Confirm and formally specify that `Reply-To` is set to the parent's email address so admins can respond to parents directly from their email client
## Capabilities
### Modified Capabilities
- `email-channel`: Add Reply-To specification for confirmation emails sent to parents after registration completion
- `registration-notifications`: Formally specify Reply-To for admin notification emails (parent's email address)
@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: Confirmation email sets Reply-To to admin address
The system SHALL set the `Reply-To` header to the admin email address (`spielgruppen@familien-verein.ch`) on the registration completion confirmation email sent to the parent.
#### Scenario: Parent replies to confirmation email
- **WHEN** the agent sends a registration completion confirmation email to a parent
- **THEN** the email SHALL include a `Reply-To` header set to `spielgruppen@familien-verein.ch`
- **AND** a parent reply SHALL be delivered to the admin, not to the registration system's inbox
#### Scenario: Mid-registration emails are unaffected
- **WHEN** the agent sends a conversational email during an ongoing registration (not the final confirmation)
- **THEN** the email SHALL NOT set `Reply-To` to the admin address
- **AND** parent replies SHALL continue to be routed back to the registration system for processing
@@ -0,0 +1,18 @@
## MODIFIED Requirements
### Requirement: Notification email sets Reply-To to parent address
The system SHALL set the `Reply-To` header to the registering parent's email address on all registration notification emails sent to playgroup leaders and the admin.
#### Scenario: Leader replies to notification email
- **WHEN** a registration notification email is sent to a playgroup leader (Andrea Sigrist or Barbara Gross)
- **THEN** the email SHALL include a `Reply-To` header set to the parent's email address (`registration.parentGuardian.email`)
- **AND** a leader reply SHALL be delivered directly to the parent
#### Scenario: Admin (CC) replies to notification email
- **WHEN** Markus Graf replies to a registration notification email (received as CC)
- **THEN** the reply SHALL be delivered directly to the parent's email address
#### Scenario: Reply-To applies to all notification routing types
- **WHEN** a notification is sent for an indoor-only, outdoor-only, or both registration
- **THEN** all recipient copies (To and CC) SHALL have `Reply-To` set to the parent's email address
@@ -0,0 +1,19 @@
## 1. Confirmation Email Reply-To (Email Channel)
- [x] 1.1 Identify where the registration completion confirmation email is constructed in `src/` (email channel adapter / agent core)
- [x] 1.2 Add `Reply-To: spielgruppen@familien-verein.ch` header to the confirmation email only (not to mid-registration conversational emails)
- [x] 1.3 Add a unit test verifying the `Reply-To` header is present on the confirmation email
- [x] 1.4 Add a unit test verifying mid-registration emails do NOT carry the admin `Reply-To` header
## 2. Notification Email Reply-To (Registration Notifications)
- [x] 2.1 Identify where registration notification emails are constructed and sent
- [x] 2.2 Set `Reply-To: <parent email>` header on all outgoing notification emails (indoor, outdoor, and both routing types)
- [x] 2.3 Add a unit test verifying the `Reply-To` header equals the parent's email for indoor-only notification
- [x] 2.4 Add a unit test verifying the `Reply-To` header equals the parent's email for outdoor-only notification
- [x] 2.5 Add a unit test verifying the `Reply-To` header equals the parent's email when both leaders are notified
## 3. Verification
- [x] 3.1 Run the full test suite and confirm all tests pass
- [ ] 3.2 Manually send a test registration through the email channel and verify reply routing behaves correctly
+2
View File
@@ -215,6 +215,8 @@ class AdminNotifier:
msg_outer["From"] = self._from_email msg_outer["From"] = self._from_email
msg_outer["To"] = parent_email msg_outer["To"] = parent_email
msg_outer["Subject"] = subject msg_outer["Subject"] = subject
if self._cc_emails:
msg_outer["Reply-To"] = self._cc_emails[0]
msg_alt = MIMEMultipart("alternative") msg_alt = MIMEMultipart("alternative")
msg_alt.attach(MIMEText(text_body, "plain", "utf-8")) msg_alt.attach(MIMEText(text_body, "plain", "utf-8"))
+43 -3
View File
@@ -1,10 +1,11 @@
"""Tests for email loop prevention. """Tests for email loop prevention and email channel reply headers.
Covers three layers: Covers four areas:
- detect_automated_message() — header-based bounce/automated sender detection - detect_automated_message() — header-based bounce/automated sender detection
- EmailAgent.handle_automated_message() — state tracking, one-shot admin alert - EmailAgent.handle_automated_message() — state tracking, one-shot admin alert
- EmailAgent.process_message() — hard message-count cap (MAX_USER_MESSAGES) - EmailAgent.process_message() — hard message-count cap (MAX_USER_MESSAGES)
- AdminNotifier.notify_loop_escalation() — escalation email dispatch - AdminNotifier.notify_loop_escalation() — escalation email dispatch
- EmailChannel.send_reply() — mid-registration emails must not carry admin Reply-To
""" """
import email import email
@@ -13,7 +14,7 @@ import json
import pytest import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from src.channels.email_channel import detect_automated_message from src.channels.email_channel import detect_automated_message, EmailChannel
from src.agent.core import EmailAgent, MAX_USER_MESSAGES from src.agent.core import EmailAgent, MAX_USER_MESSAGES
from src.models.conversation import ConversationState, ChatMessage from src.models.conversation import ConversationState, ChatMessage
from src.notifications.notifier import AdminNotifier from src.notifications.notifier import AdminNotifier
@@ -650,3 +651,42 @@ class TestNotifyLoopEscalation:
) )
mock_smtp_cls.assert_not_called() mock_smtp_cls.assert_not_called()
# ---------------------------------------------------------------------------
# EmailChannel.send_reply — mid-registration emails must not have admin Reply-To
# (task 1.4)
# ---------------------------------------------------------------------------
class TestSendReplyNoAdminReplyTo:
def test_send_reply_has_no_reply_to_header(self, mocker):
"""Mid-registration conversational emails must not carry a Reply-To header."""
channel = EmailChannel(
imap_host="imap.example.com",
imap_port=993,
smtp_host="smtp.example.com",
smtp_port=587,
username="agent@example.com",
password="secret",
use_ssl=True,
use_tls=True,
registration_email="agent@example.com",
)
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
channel.send_reply(
to="parent@example.com",
subject="Re: Anmeldung",
body="Wie heisst dein Kind?",
)
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") is None
+140
View File
@@ -345,3 +345,143 @@ class TestGenerateQrBillPng:
"""Output starts with the PNG magic bytes.""" """Output starts with the PNG magic bytes."""
png = notifier._generate_qr_bill_png() png = notifier._generate_qr_bill_png()
assert png[:4] == b"\x89PNG" assert png[:4] == b"\x89PNG"
# ---------------------------------------------------------------------------
# Reply-To header — confirmation email to parent (task 1.3)
# ---------------------------------------------------------------------------
class TestNotifyParentReplyTo:
def test_confirmation_email_has_reply_to_admin(
self, notifier, complete_registration, mocker
):
"""Confirmation email sets Reply-To to the first CC (admin) address."""
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier.notify_parent(complete_registration, language="de")
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") == "markus@example.com"
def test_confirmation_email_no_reply_to_when_no_cc(
self, complete_registration, mocker
):
"""When no CC emails are configured, no Reply-To header is set."""
notifier_no_cc = AdminNotifier(
smtp_host="smtp.example.com",
smtp_port=587,
username="agent@example.com",
password="secret",
from_email="agent@example.com",
cc_emails=[],
)
mock_smtp_cls = mocker.patch("smtplib.SMTP")
captured = {}
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
notifier_no_cc.notify_parent(complete_registration, language="de")
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") is None
# ---------------------------------------------------------------------------
# Reply-To header — admin notification emails (tasks 2.3, 2.4, 2.5)
# ---------------------------------------------------------------------------
class TestNotifyAdminReplyTo:
def _capture_msg(self, mocker):
"""Return a side-effect function and a dict that captures the raw MIME string."""
captured = {}
mock_smtp_cls = mocker.patch("smtplib.SMTP")
def fake_sendmail(from_, to_, msg_str):
captured["msg"] = msg_str
mock_smtp_cls.return_value.sendmail.side_effect = fake_sendmail
return captured
def test_indoor_notification_reply_to_is_parent_email(
self, notifier, complete_registration, mocker
):
"""Indoor-only notification sets Reply-To to the parent's email."""
from src.models.registration import Booking, BookingDay
complete_registration.booking = Booking(
playgroup_types=["indoor"],
selected_days=[BookingDay(day="monday", type="indoor")],
)
captured = self._capture_msg(mocker)
notifier.notify_admin(
complete_registration,
registration_id="reg-001",
version=1,
conversation_id="conv-001",
channel="email",
)
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") == "anna.muster@example.com"
def test_outdoor_notification_reply_to_is_parent_email(
self, notifier, complete_registration, mocker
):
"""Outdoor-only notification sets Reply-To to the parent's email."""
from src.models.registration import Booking, BookingDay
complete_registration.booking = Booking(
playgroup_types=["outdoor"],
selected_days=[BookingDay(day="monday", type="outdoor")],
)
captured = self._capture_msg(mocker)
notifier.notify_admin(
complete_registration,
registration_id="reg-002",
version=1,
conversation_id="conv-002",
channel="email",
)
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") == "anna.muster@example.com"
def test_both_types_notification_reply_to_is_parent_email(
self, notifier, complete_registration, mocker
):
"""Both-types notification sets Reply-To to the parent's email."""
from src.models.registration import Booking, BookingDay
complete_registration.booking = Booking(
playgroup_types=["indoor", "outdoor"],
selected_days=[
BookingDay(day="monday", type="indoor"),
BookingDay(day="monday", type="outdoor"),
],
)
captured = self._capture_msg(mocker)
notifier.notify_admin(
complete_registration,
registration_id="reg-003",
version=1,
conversation_id="conv-003",
channel="email",
)
parsed = email.message_from_string(captured["msg"])
assert parsed.get("Reply-To") == "anna.muster@example.com"