Make admin notification recipients configurable via ADMIN_EMAILS

Previously the To/Cc addresses were hardcoded in notifier.py (Andrea,
Barbara, Markus). This caused accidental emails to production contacts
during testing.

Changes:
- New ADMIN_EMAILS env var: comma-separated list of addresses.
  First address → To; remaining addresses → Cc.
- AdminNotifier now accepts admin_emails list; warns and skips if empty.
- Removed hardcoded _INDOOR_EMAIL / _OUTDOOR_EMAIL / _ADMIN_CC_EMAIL
  constants and the _recipients_for() routing method.
- Config.from_env() parses ADMIN_EMAILS into a list.
- main.py passes config.admin_emails to AdminNotifier.
- .env.example documents the new variable with production example.
- Tests: fixture updated; TestRecipientsFor removed (routing gone).

For testing: ADMIN_EMAILS=you@example.com
For production: ADMIN_EMAILS=andrea.sigrist@gmx.net,baba.laeubli@gmail.com,spielgruppen@familien-verein.ch

https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
This commit is contained in:
Claude
2026-02-21 21:23:51 +00:00
parent af96c7a310
commit 7fb1d1fa0f
5 changed files with 34 additions and 43 deletions
+9
View File
@@ -43,6 +43,15 @@ SMTP_USE_TLS=true
# --------------------------------------------------------------- # ---------------------------------------------------------------
REGISTRATION_EMAIL=anmeldung@example.com REGISTRATION_EMAIL=anmeldung@example.com
# ---------------------------------------------------------------
# Admin notification recipients (comma-separated)
# First address → To; remaining addresses → Cc.
# For testing, set this to just your own email address.
# Production example (indoor leader, outdoor leader, admin):
# ADMIN_EMAILS=andrea.sigrist@gmx.net,baba.laeubli@gmail.com,spielgruppen@familien-verein.ch
# ---------------------------------------------------------------
ADMIN_EMAILS=you@example.com
# --------------------------------------------------------------- # ---------------------------------------------------------------
# Storage # Storage
# --------------------------------------------------------------- # ---------------------------------------------------------------
+1
View File
@@ -58,6 +58,7 @@ def build_components(config: Config):
password=config.imap_password, password=config.imap_password,
use_tls=config.smtp_use_tls, use_tls=config.smtp_use_tls,
from_email=config.registration_email, from_email=config.registration_email,
admin_emails=config.admin_emails,
) )
agent = EmailAgent(model=config.ai_model, kb=kb, store=store, notifier=notifier) agent = EmailAgent(model=config.ai_model, kb=kb, store=store, notifier=notifier)
+10
View File
@@ -32,6 +32,11 @@ class Config:
# Registration email address shown to parents # Registration email address shown to parents
registration_email: str = "" registration_email: str = ""
# Admin notification recipients (comma-separated).
# First address → To; remaining addresses → Cc.
# Set to a single address (e.g. your own) during testing.
admin_emails: list = field(default_factory=list)
# Storage # Storage
data_dir: Path = field(default_factory=lambda: Path("data")) data_dir: Path = field(default_factory=lambda: Path("data"))
knowledge_base_dir: Path = field( knowledge_base_dir: Path = field(
@@ -56,6 +61,11 @@ class Config:
smtp_port=int(os.getenv("SMTP_PORT", "587")), smtp_port=int(os.getenv("SMTP_PORT", "587")),
smtp_use_tls=os.getenv("SMTP_USE_TLS", "true").lower() == "true", smtp_use_tls=os.getenv("SMTP_USE_TLS", "true").lower() == "true",
registration_email=os.getenv("REGISTRATION_EMAIL", ""), registration_email=os.getenv("REGISTRATION_EMAIL", ""),
admin_emails=[
e.strip()
for e in os.getenv("ADMIN_EMAILS", "").split(",")
if e.strip()
],
data_dir=Path(os.getenv("DATA_DIR", "data")), data_dir=Path(os.getenv("DATA_DIR", "data")),
knowledge_base_dir=Path( knowledge_base_dir=Path(
os.getenv( os.getenv(
+13 -22
View File
@@ -10,11 +10,6 @@ from ..models.registration import RegistrationData
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Notification routing per spec
_INDOOR_EMAIL = "andrea.sigrist@gmx.net"
_OUTDOOR_EMAIL = "baba.laeubli@gmail.com"
_ADMIN_CC_EMAIL = "spielgruppen@familien-verein.ch"
class AdminNotifier: class AdminNotifier:
"""Sends formatted admin notification emails. """Sends formatted admin notification emails.
@@ -34,6 +29,7 @@ class AdminNotifier:
password: str, password: str,
use_tls: bool = True, use_tls: bool = True,
from_email: str = "", from_email: str = "",
admin_emails: list[str] | None = None,
) -> None: ) -> None:
self._smtp_host = smtp_host self._smtp_host = smtp_host
self._smtp_port = smtp_port self._smtp_port = smtp_port
@@ -41,6 +37,7 @@ class AdminNotifier:
self._password = password self._password = password
self._use_tls = use_tls self._use_tls = use_tls
self._from_email = from_email or username self._from_email = from_email or username
self._admin_emails: list[str] = admin_emails or []
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public API # Public API
@@ -55,9 +52,11 @@ class AdminNotifier:
channel: str, channel: str,
) -> None: ) -> None:
"""Send notification for a newly completed registration (version 1).""" """Send notification for a newly completed registration (version 1)."""
types = registration.booking.playgroup_types if not self._admin_emails:
to_addresses = self._recipients_for(types) logger.warning("ADMIN_EMAILS not configured — new-registration notification skipped.")
return
types = registration.booking.playgroup_types
subject = ( subject = (
f"Neue Anmeldung: {registration.child.full_name} " f"Neue Anmeldung: {registration.child.full_name} "
f" {self._format_types(types)}" f" {self._format_types(types)}"
@@ -65,8 +64,8 @@ class AdminNotifier:
body = self._build_new_body(registration, registration_id, version, channel) body = self._build_new_body(registration, registration_id, version, channel)
self._send( self._send(
to=to_addresses, to=[self._admin_emails[0]],
cc=[_ADMIN_CC_EMAIL], cc=self._admin_emails[1:],
subject=subject, subject=subject,
body=body, body=body,
reply_to=registration.parent_guardian.email or "", reply_to=registration.parent_guardian.email or "",
@@ -81,15 +80,16 @@ class AdminNotifier:
conversation_id: str, conversation_id: str,
) -> None: ) -> None:
"""Send notification when an existing registration is updated.""" """Send notification when an existing registration is updated."""
types = registration.booking.playgroup_types if not self._admin_emails:
to_addresses = self._recipients_for(types) logger.warning("ADMIN_EMAILS not configured — update notification skipped.")
return
subject = f"Anmeldung aktualisiert: {registration.child.full_name}" subject = f"Anmeldung aktualisiert: {registration.child.full_name}"
body = self._build_update_body(registration, registration_id, version, change_summary) body = self._build_update_body(registration, registration_id, version, change_summary)
self._send( self._send(
to=to_addresses, to=[self._admin_emails[0]],
cc=[_ADMIN_CC_EMAIL], cc=self._admin_emails[1:],
subject=subject, subject=subject,
body=body, body=body,
reply_to=registration.parent_guardian.email or "", reply_to=registration.parent_guardian.email or "",
@@ -99,15 +99,6 @@ class AdminNotifier:
# Formatting helpers # Formatting helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod
def _recipients_for(types: list[str]) -> list[str]:
recipients = []
if "indoor" in types:
recipients.append(_INDOOR_EMAIL)
if "outdoor" in types:
recipients.append(_OUTDOOR_EMAIL)
return recipients
@staticmethod @staticmethod
def _format_types(types: list[str]) -> str: def _format_types(types: list[str]) -> str:
has_indoor = "indoor" in types has_indoor = "indoor" in types
+1 -21
View File
@@ -15,30 +15,10 @@ def notifier():
password="secret", password="secret",
use_tls=True, use_tls=True,
from_email="agent@example.com", from_email="agent@example.com",
admin_emails=["to@example.com", "cc1@example.com", "cc2@example.com"],
) )
# ---------------------------------------------------------------------------
# _recipients_for
# ---------------------------------------------------------------------------
class TestRecipientsFor:
def test_indoor_only_recipients(self, notifier):
recipients = notifier._recipients_for(["indoor"])
assert any("andrea" in r.lower() or "sigrist" in r.lower() for r in recipients)
def test_outdoor_only_recipients(self, notifier):
recipients = notifier._recipients_for(["outdoor"])
assert any("baba.laeubli" in r.lower() for r in recipients)
def test_both_includes_both_leaders(self, notifier):
recipients = notifier._recipients_for(["indoor", "outdoor"])
joined = " ".join(recipients).lower()
assert "andrea.sigrist" in joined
assert "baba.laeubli" in joined
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _format_types # _format_types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------