import smtplib from dataclasses import dataclass from datetime import UTC, datetime from email.message import EmailMessage from html import escape from sqlalchemy.orm import Session from starlette.requests import Request from app.core.config import settings from app.models.repair import Repair, RepairNotificationEvent from app.models.user import User from app.repositories.repair_repository import RepairRepository from app.schemas.repair import RepairNotificationOverviewResponse, RepairNotificationTemplateResponse, RepairStatus from app.services.audit_service import write_audit_log from app.services.repair_public_link_service import RepairPublicLinkService from app.services.repair_status_labels import MAIL_STATUS_LABELS, STATUS_LABELS @dataclass(frozen=True) class RepairNotificationTemplate: event_type: str status: RepairStatus | None subject: str text: str html: str | None = None TEMPLATES: tuple[RepairNotificationTemplate, ...] = ( RepairNotificationTemplate( event_type="repair_created", status="new", subject="Reparatur {repair_number} wurde erfasst", text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} für {device} wurde erfasst. Den aktuellen Status finden Sie später unter {public_status_url}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="device_accepted", status="accepted", subject="Gerät zu Reparatur {repair_number} angenommen", text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde angenommen. Aktueller Status: {status_label}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="diagnosis_started", status="diagnosis", subject="Diagnose für Reparatur {repair_number} läuft", text="Guten Tag {customer_name},\n\nwir prüfen Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="estimate_created", status="estimate", subject="Kostenvoranschlag zu Reparatur {repair_number}", text="Guten Tag {customer_name},\n\nfür Ihr Gerät {device} wurde ein Kostenvoranschlag vorbereitet. Aktueller Status: {status_label}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="waiting_for_customer", status="waiting_for_customer", subject="Freigabe für Reparatur {repair_number} erforderlich", text="Guten Tag {customer_name},\n\nfür Ihre Reparatur {repair_number} warten wir auf Ihre Rückmeldung. Aktueller Status: {status_label}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="repair_started", status="repair", subject="Reparatur {repair_number} läuft", text="Guten Tag {customer_name},\n\nwir bearbeiten Ihr Gerät {device}. Aktueller Status: {status_label}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="final_test", status="final_test", subject="Endprüfung für Reparatur {repair_number}", text="Guten Tag {customer_name},\n\nIhr Gerät {device} befindet sich in der Endprüfung. Aktueller Status: {status_label}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="ready_for_pickup", status="ready_for_pickup", subject="Reparatur {repair_number} ist abholbereit", text="Guten Tag {customer_name},\n\nIhr Gerät {device} ist abholbereit. Aktueller Status: {status_label}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="shipped", status="shipped", subject="Reparatur {repair_number} wurde versendet", text="Guten Tag {customer_name},\n\nIhr Gerät {device} wurde versendet. Aktueller Status: {status_label}.\n\n{company_name}", ), RepairNotificationTemplate( event_type="completed", status="completed", subject="Reparatur {repair_number} abgeschlossen", text="Guten Tag {customer_name},\n\nIhre Reparatur {repair_number} wurde abgeschlossen. Vielen Dank.\n\n{company_name}", ), ) def _device_label(repair: Repair) -> str: return f"{repair.device_manufacturer} {repair.device_model}".strip() def _repair_label(repair: Repair) -> str: return f"{repair.repair_number} · {repair.customer_name}" def _smtp_configured() -> bool: return bool(settings.smtp_host and settings.smtp_from_email) def _from_header() -> str: if settings.smtp_from_name: return f"{settings.smtp_from_name} <{settings.smtp_from_email}>" return str(settings.smtp_from_email) def _plain_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str: return ( f"Hallo {repair.customer_name},\n\n" "der Status Ihrer Reparatur wurde aktualisiert.\n\n" f"Reparaturnummer:\n{repair.repair_number}\n\n" f"Gerät:\n{_device_label(repair)}\n\n" f"Neuer Status:\n{status_label}\n\n" f"Status online ansehen:\n{public_status_url}\n\n" "Hinweis:\nBitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.\n\n" "Funktechnik Schubert" ) def _html_status_mail(repair: Repair, *, status_label: str, public_status_url: str) -> str: return f"""
Olympus CRM
Funktechnik Schubert

Hallo {escape(repair.customer_name)},

der Status Ihrer Reparatur wurde aktualisiert.

Reparaturnummer {escape(repair.repair_number)}
Gerät {escape(_device_label(repair))}
Neuer Status {escape(status_label)}

Reparaturstatus ansehen

Bitte antworten Sie nicht direkt auf diese automatisch erzeugte E-Mail.

Funktechnik Schubert
""" class RepairNotificationService: @staticmethod def render_templates(repair: Repair, *, public_status_url: str = "") -> list[RepairNotificationTemplateResponse]: placeholders = { "repair_number": repair.repair_number, "customer_name": repair.customer_name, "device": _device_label(repair), "status_label": STATUS_LABELS.get(repair.status, repair.status), "public_status_url": public_status_url or "wird später bereitgestellt", "company_name": "Olympus CRM", } return [ RepairNotificationTemplateResponse( event_type=template.event_type, status=template.status, subject=template.subject.format(**placeholders), text=template.text.format(**placeholders), html=template.html.format(**placeholders) if template.html else None, ) for template in TEMPLATES ] @staticmethod def overview(db: Session, repair: Repair, *, public_status_url: str = "") -> RepairNotificationOverviewResponse: return RepairNotificationOverviewResponse( templates=RepairNotificationService.render_templates(repair, public_status_url=public_status_url), events=RepairRepository.list_notification_events(db, repair.id), ) @staticmethod def send_status_mail( db: Session, repair: Repair, *, actor: User | None, request: Request | None, manual: bool = False, ) -> RepairNotificationEvent: recipient = repair.customer_email.strip() subject = f"Reparaturstatus {repair.repair_number} wurde aktualisiert" status_label = MAIL_STATUS_LABELS.get(repair.status, repair.status) template = "repair_status_update" if not recipient: event = RepairRepository.create_notification_event( db, repair_id=repair.id, event_type="repair_status_mail", channel="email", recipient="", subject=subject, template=template, status="skipped", success=False, error_message="Keine Kunden-E-Mail hinterlegt", ) RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="missing_recipient") return event link_action = "repairs.public_link.regenerate" if manual else "repairs.public_link.auto_create" public_link = RepairPublicLinkService.create_with_audit(db, repair, actor=actor, request=request, audit_action=link_action) public_status_url = public_link.public_status_path text = _plain_status_mail(repair, status_label=status_label, public_status_url=public_status_url) html = _html_status_mail(repair, status_label=status_label, public_status_url=public_status_url) if not _smtp_configured(): event = RepairRepository.create_notification_event( db, repair_id=repair.id, event_type="repair_status_mail", channel="email", recipient=recipient, subject=subject, template=template, status="skipped", success=False, error_message="SMTP nicht konfiguriert", ) RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_not_configured") return event try: RepairNotificationService._send_email(recipient=recipient, subject=subject, text=text, html=html) except Exception: event = RepairRepository.create_notification_event( db, repair_id=repair.id, event_type="repair_status_mail", channel="email", recipient=recipient, subject=subject, template=template, status="failed", success=False, error_message="Statusmail konnte nicht versendet werden", ) RepairNotificationService._audit_failure(db, repair, actor=actor, request=request, reason="smtp_send_failed") return event event = RepairRepository.create_notification_event( db, repair_id=repair.id, event_type="repair_status_mail", channel="email", recipient=recipient, subject=subject, template=template, status="sent", success=True, sent_at=datetime.now(UTC), ) write_audit_log( db, action="repairs.status_mail.sent", entity_type="repairs", entity_id=repair.id, entity_label=_repair_label(repair), actor=actor, request=request, metadata={"notification_event_id": event.id}, ) return event @staticmethod def _send_email(*, recipient: str, subject: str, text: str, html: str) -> None: message = EmailMessage() message["Subject"] = subject message["From"] = _from_header() message["To"] = recipient message.set_content(text) message.add_alternative(html, subtype="html") with smtplib.SMTP(str(settings.smtp_host), settings.smtp_port, timeout=15) as smtp: if settings.smtp_use_tls: smtp.starttls() if settings.smtp_username and settings.smtp_password: smtp.login(settings.smtp_username, settings.smtp_password) smtp.send_message(message) @staticmethod def _audit_failure(db: Session, repair: Repair, *, actor: User | None, request: Request | None, reason: str) -> None: write_audit_log( db, action="repairs.status_mail.failed", entity_type="repairs", entity_id=repair.id, entity_label=_repair_label(repair), actor=actor, request=request, metadata={"reason": reason}, )