Implement Python email agent with multi-model AI support
Adds a complete email-based registration agent for Spielgruppe Pumuckl based on the OpenSpec define-project-scope specifications. Architecture - Channel-agnostic EmailAgent core — no email-specific code in business logic - Pluggable AI provider layer: Anthropic (Claude) and OpenAI (GPT) supported via a shared LLMProvider interface; switch with AI_PROVIDER env var - IMAP polling for inbound emails with thread-tracking via email headers (Message-ID / In-Reply-To / References) - SMTP for outbound replies and admin notifications - File-based JSON storage for conversation state and completed registrations - Admin-editable knowledge-base loaded from markdown files at startup Key files src/config.py — env-var configuration src/providers/base.py — abstract LLMProvider src/providers/anthropic_provider.py — Claude backend src/providers/openai_provider.py — OpenAI backend src/agent/core.py — EmailAgent orchestrator src/agent/prompts.py — system prompt builder (KB + registration state) src/models/registration.py — RegistrationData matching the JSON schema src/models/conversation.py — ConversationState persisted per thread src/channels/email_channel.py — IMAP/SMTP I/O + quoted-text stripping src/storage/json_store.py — conversation & registration persistence src/notifications/notifier.py — admin notification routing by playgroup type main.py — polling entry point requirements.txt — anthropic, openai, python-dotenv, jsonschema .env.example — configuration template https://claude.ai/code/session_01HaUFs7SaLD5SoiuGCY27Tw
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"""Admin email notifications sent when a registration is completed."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import date, datetime
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from ..models.registration import RegistrationData
|
||||
|
||||
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:
|
||||
"""Sends formatted admin notification emails upon registration completion.
|
||||
|
||||
The SMTP credentials are re-used from the agent's outbound email config.
|
||||
When *smtp_host* is empty the notifier logs the notification and skips
|
||||
sending (useful for local development / testing).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
use_tls: bool = True,
|
||||
from_email: str = "",
|
||||
) -> None:
|
||||
self._smtp_host = smtp_host
|
||||
self._smtp_port = smtp_port
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._use_tls = use_tls
|
||||
self._from_email = from_email or username
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def notify_admin(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
conversation_id: str,
|
||||
channel: str,
|
||||
) -> None:
|
||||
"""Send notification email(s) for a completed registration."""
|
||||
types = registration.booking.playgroup_types
|
||||
|
||||
to_addresses: list[str] = []
|
||||
if "indoor" in types:
|
||||
to_addresses.append(_INDOOR_EMAIL)
|
||||
if "outdoor" in types:
|
||||
to_addresses.append(_OUTDOOR_EMAIL)
|
||||
|
||||
subject = (
|
||||
f"New Registration: {registration.child.full_name} "
|
||||
f"for {self._format_types(types)}"
|
||||
)
|
||||
body = self._build_body(registration, registration_id, channel)
|
||||
|
||||
self._send(
|
||||
to=to_addresses,
|
||||
cc=[_ADMIN_CC_EMAIL],
|
||||
subject=subject,
|
||||
body=body,
|
||||
reply_to=registration.parent_guardian.email or "",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Formatting helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@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 "Indoor + Outdoor Playgroup"
|
||||
if has_indoor:
|
||||
return "Indoor Playgroup"
|
||||
if has_outdoor:
|
||||
return "Outdoor Playgroup"
|
||||
return "Playgroup"
|
||||
|
||||
@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)
|
||||
)
|
||||
months = (today.month - dob.month) % 12
|
||||
return f"{years} years, {months} months"
|
||||
except Exception:
|
||||
return dob_str
|
||||
|
||||
@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": "Monday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
}
|
||||
return ", ".join(
|
||||
f"{day_map.get(d.day, d.day.capitalize())} ({d.type})"
|
||||
for d in registration.booking.selected_days
|
||||
)
|
||||
|
||||
def _build_body(
|
||||
self,
|
||||
registration: RegistrationData,
|
||||
registration_id: str,
|
||||
channel: str,
|
||||
) -> str:
|
||||
now = datetime.utcnow()
|
||||
pg = registration.parent_guardian
|
||||
ec = registration.emergency_contact
|
||||
|
||||
return (
|
||||
"===============================================\n"
|
||||
"NEW PLAYGROUP REGISTRATION\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
f"Submitted: {now.strftime('%d.%m.%Y')} at {now.strftime('%H:%M')} UTC\n"
|
||||
f"Channel: {channel.title()}\n"
|
||||
f"Registration ID: {registration_id}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"CHILD INFORMATION\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {registration.child.full_name}\n"
|
||||
f"Date of Birth: {self._format_dob(registration.child.date_of_birth or '')} "
|
||||
f"(Age: {self._calculate_age(registration.child.date_of_birth or '')})\n"
|
||||
f"Special Needs: {registration.child.special_needs or 'None'}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"PLAYGROUP SELECTION\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Type: {self._format_types(registration.booking.playgroup_types)}\n"
|
||||
f"Days: {self._format_days(registration)}\n"
|
||||
"\n"
|
||||
f"Monthly Fee: {self._calculate_monthly_fee(registration)}\n"
|
||||
"(Plus CHF 80 registration fee if first enrolment)\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"PARENT / GUARDIAN\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {pg.full_name}\n"
|
||||
f"Address: {pg.street_address}\n"
|
||||
f" {pg.postal_code} {pg.city}\n"
|
||||
f"Phone: {pg.phone}\n"
|
||||
f"Email: {pg.email}\n"
|
||||
"\n"
|
||||
"-----------------------------------------------\n"
|
||||
"EMERGENCY CONTACT\n"
|
||||
"-----------------------------------------------\n"
|
||||
f"Name: {ec.full_name}\n"
|
||||
f"Phone: {ec.phone}\n"
|
||||
"\n"
|
||||
"===============================================\n"
|
||||
"\n"
|
||||
"This registration was submitted via the automated registration assistant.\n"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SMTP dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _send(
|
||||
self,
|
||||
to: list[str],
|
||||
cc: list[str],
|
||||
subject: str,
|
||||
body: str,
|
||||
reply_to: str = "",
|
||||
) -> None:
|
||||
if not self._smtp_host:
|
||||
logger.warning(
|
||||
"SMTP not configured — notification NOT sent. Would have emailed %s (CC: %s): %s",
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
)
|
||||
logger.debug("Notification body:\n%s", body)
|
||||
return
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = self._from_email
|
||||
msg["To"] = ", ".join(to)
|
||||
msg["CC"] = ", ".join(cc)
|
||||
msg["Subject"] = subject
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
all_recipients = to + cc
|
||||
|
||||
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, all_recipients, msg.as_string())
|
||||
server.quit()
|
||||
logger.info("Admin notification sent to %s", all_recipients)
|
||||
except Exception:
|
||||
logger.exception("Failed to send admin notification to %s", all_recipients)
|
||||
Reference in New Issue
Block a user