A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
252 lines
8.0 KiB
Python
252 lines
8.0 KiB
Python
"""
|
|
Email Action für Alerts Agent.
|
|
|
|
Sendet E-Mail-Benachrichtigungen für Alerts.
|
|
"""
|
|
import logging
|
|
from typing import Dict, Any, List
|
|
from datetime import datetime
|
|
|
|
from .base import ActionHandler, ActionResult, ActionType, AlertContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# HTML-Template für Alert-E-Mails
|
|
EMAIL_TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<style>
|
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: #4A90E2; color: white; padding: 20px; border-radius: 8px 8px 0 0; }}
|
|
.content {{ background: #f9f9f9; padding: 20px; border: 1px solid #ddd; border-top: none; }}
|
|
.alert-card {{ background: white; padding: 15px; margin: 10px 0; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }}
|
|
.alert-title {{ font-size: 16px; font-weight: 600; color: #1a1a1a; margin-bottom: 8px; }}
|
|
.alert-title a {{ color: #4A90E2; text-decoration: none; }}
|
|
.alert-snippet {{ font-size: 14px; color: #666; margin-bottom: 8px; }}
|
|
.alert-meta {{ font-size: 12px; color: #999; }}
|
|
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; }}
|
|
.badge-keep {{ background: #d4edda; color: #155724; }}
|
|
.badge-review {{ background: #fff3cd; color: #856404; }}
|
|
.footer {{ padding: 15px; text-align: center; font-size: 12px; color: #999; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h2 style="margin: 0;">BreakPilot Alert</h2>
|
|
<p style="margin: 5px 0 0 0; opacity: 0.9;">{topic_name}</p>
|
|
</div>
|
|
<div class="content">
|
|
<div class="alert-card">
|
|
<div class="alert-title">
|
|
<a href="{url}">{title}</a>
|
|
</div>
|
|
<div class="alert-snippet">{snippet}</div>
|
|
<div class="alert-meta">
|
|
{decision_badge}
|
|
{score_display}
|
|
{rule_display}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="footer">
|
|
Gesendet von BreakPilot Alerts Agent<br>
|
|
<a href="{dashboard_url}" style="color: #4A90E2;">Zur Inbox</a>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
class EmailAction(ActionHandler):
|
|
"""
|
|
E-Mail-Benachrichtigungen für Alerts.
|
|
|
|
Konfiguration:
|
|
- to: E-Mail-Adresse(n) des Empfängers
|
|
- subject_prefix: Optionaler Betreff-Prefix
|
|
- include_snippet: Snippet einbinden (default: true)
|
|
"""
|
|
|
|
@property
|
|
def action_type(self) -> ActionType:
|
|
return ActionType.EMAIL
|
|
|
|
def get_required_config_fields(self) -> List[str]:
|
|
return ["to"]
|
|
|
|
def validate_config(self, config: Dict[str, Any]) -> bool:
|
|
to = config.get("to")
|
|
if not to:
|
|
return False
|
|
if isinstance(to, str):
|
|
return "@" in to
|
|
if isinstance(to, list):
|
|
return all("@" in email for email in to)
|
|
return False
|
|
|
|
async def execute(
|
|
self,
|
|
context: AlertContext,
|
|
config: Dict[str, Any],
|
|
) -> ActionResult:
|
|
"""
|
|
Sendet eine E-Mail-Benachrichtigung.
|
|
|
|
Args:
|
|
context: Alert-Kontext
|
|
config: E-Mail-Konfiguration (to, subject_prefix, etc.)
|
|
|
|
Returns:
|
|
ActionResult
|
|
"""
|
|
try:
|
|
# Empfänger
|
|
to = config.get("to")
|
|
if isinstance(to, str):
|
|
recipients = [to]
|
|
else:
|
|
recipients = to
|
|
|
|
# Betreff
|
|
subject_prefix = config.get("subject_prefix", "[BreakPilot Alert]")
|
|
subject = f"{subject_prefix} {context.title[:50]}"
|
|
|
|
# HTML-Body generieren
|
|
html_body = self._render_email(context, config)
|
|
|
|
# E-Mail senden
|
|
sent = await self._send_email(
|
|
recipients=recipients,
|
|
subject=subject,
|
|
html_body=html_body,
|
|
)
|
|
|
|
if sent:
|
|
return ActionResult(
|
|
success=True,
|
|
action_type=self.action_type,
|
|
message=f"E-Mail an {len(recipients)} Empfänger gesendet",
|
|
details={"recipients": recipients, "subject": subject},
|
|
)
|
|
else:
|
|
return ActionResult(
|
|
success=False,
|
|
action_type=self.action_type,
|
|
message="E-Mail konnte nicht gesendet werden",
|
|
error="SMTP-Fehler",
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Email action error: {e}")
|
|
return ActionResult(
|
|
success=False,
|
|
action_type=self.action_type,
|
|
message="E-Mail-Fehler",
|
|
error=str(e),
|
|
)
|
|
|
|
def _render_email(
|
|
self,
|
|
context: AlertContext,
|
|
config: Dict[str, Any],
|
|
) -> str:
|
|
"""Rendert das E-Mail-Template."""
|
|
|
|
# Decision Badge
|
|
decision_badge = ""
|
|
if context.relevance_decision:
|
|
badge_class = "badge-keep" if context.relevance_decision == "KEEP" else "badge-review"
|
|
decision_badge = f'<span class="badge {badge_class}">{context.relevance_decision}</span>'
|
|
|
|
# Score
|
|
score_display = ""
|
|
if context.relevance_score is not None:
|
|
score_display = f' | Score: {context.relevance_score:.0%}'
|
|
|
|
# Matched Rule
|
|
rule_display = ""
|
|
if context.matched_rule:
|
|
rule_display = f' | Regel: {context.matched_rule}'
|
|
|
|
# Snippet
|
|
snippet = context.snippet[:200] if context.snippet else ""
|
|
if config.get("include_snippet", True) is False:
|
|
snippet = ""
|
|
|
|
# Dashboard URL
|
|
dashboard_url = config.get("dashboard_url", "http://localhost:8000/studio#alerts")
|
|
|
|
return EMAIL_TEMPLATE.format(
|
|
topic_name=context.topic_name,
|
|
title=context.title,
|
|
url=context.url,
|
|
snippet=snippet,
|
|
decision_badge=decision_badge,
|
|
score_display=score_display,
|
|
rule_display=rule_display,
|
|
dashboard_url=dashboard_url,
|
|
)
|
|
|
|
async def _send_email(
|
|
self,
|
|
recipients: List[str],
|
|
subject: str,
|
|
html_body: str,
|
|
) -> bool:
|
|
"""
|
|
Sendet die E-Mail über SMTP.
|
|
|
|
Verwendet aiosmtplib für async SMTP.
|
|
"""
|
|
import os
|
|
|
|
smtp_host = os.getenv("SMTP_HOST", "localhost")
|
|
smtp_port = int(os.getenv("SMTP_PORT", "587"))
|
|
smtp_user = os.getenv("SMTP_USER", "")
|
|
smtp_pass = os.getenv("SMTP_PASS", "")
|
|
smtp_from = os.getenv("SMTP_FROM", "alerts@breakpilot.de")
|
|
|
|
try:
|
|
import aiosmtplib
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
|
# E-Mail erstellen
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = smtp_from
|
|
msg["To"] = ", ".join(recipients)
|
|
|
|
# HTML-Teil
|
|
html_part = MIMEText(html_body, "html", "utf-8")
|
|
msg.attach(html_part)
|
|
|
|
# Senden
|
|
await aiosmtplib.send(
|
|
msg,
|
|
hostname=smtp_host,
|
|
port=smtp_port,
|
|
username=smtp_user if smtp_user else None,
|
|
password=smtp_pass if smtp_pass else None,
|
|
start_tls=True if smtp_port == 587 else False,
|
|
)
|
|
|
|
logger.info(f"Email sent to {recipients}")
|
|
return True
|
|
|
|
except ImportError:
|
|
logger.warning("aiosmtplib not installed. Email not sent.")
|
|
# Im Dev-Modus: Erfolg simulieren
|
|
logger.info(f"[DEV] Would send email to {recipients}: {subject}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"SMTP error: {e}")
|
|
return False
|