"""Benachrichtigungen per E-Mail und Webhook (Teams, Slack). Anlässe: Ein Antrag wartet auf Freigabe (an alle, die ihn entscheiden dürfen, und an den Webhook des Workspace) und ein Antrag wurde entschieden (an die Person, die ihn ausgelöst hat). Zustellung im Worker mit Wiederholung; ohne konfigurierten Mailserver bzw. Webhook werden Nachrichten als „übersprungen“ markiert. Webhooks nur per https an freigegebene Hosts. """ import logging import re import smtplib from datetime import timedelta from email.message import EmailMessage from urllib.parse import urlsplit import httpx from sqlalchemy import func, select from sqlalchemy.orm import Session from app import audit from app.core import metrics from app.core.config import Settings from app.core.crypto import Vault from app.core.database import Database, now from app.core.errors import Invalid from app.identity.models import User from app.notify.models import EMAIL, FAILED, PENDING, SENT, SKIPPED, WEBHOOK, Notification from app.workspaces.models import APPROVER_ROLES, Workspace, WorkspaceMembership log = logging.getLogger(__name__) MAX_ATTEMPTS = 5 BATCH = 20 FOOTER = ("\n\n—\nAutomatische Nachricht von Bifröst (AI Workspace). Benachrichtigungen lassen sich im Profil abschalten.") EMAIL_FORM = re.compile(r"^[^@\s<>]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,}$") def check_email(value: str) -> str | None: value = value.strip() if not value: return None if not EMAIL_FORM.match(value): raise Invalid("Ungültige E-Mail-Adresse.") return value.lower() def save_profile(session: Session, user_id: int, email_address: str, wants: bool) -> User: user = session.get(User, user_id) user.email, user.notify = check_email(email_address), wants audit.record(session, "user.profile_updated", user_id=user_id, target_kind="user", target_id=user_id, email=bool(user.email), notify=wants) return user def test_email(session: Session, user_id: int) -> None: """Test-Mail an die eigene Adresse, über denselben Weg wie echte Nachrichten.""" user = session.get(User, user_id) if not user.email: raise Invalid("Im Profil ist keine E-Mail-Adresse hinterlegt.") session.add(Notification(channel=EMAIL, user_id=user.id, event="test", subject="Test von Bifröst", body="Diese Test-Nachricht zeigt, dass der Versand funktioniert." + FOOTER)) def test_webhook(session: Session, workspace: Workspace) -> None: if not workspace.webhook: raise Invalid("Es ist kein Webhook hinterlegt.") session.add(Notification(channel=WEBHOOK, workspace_id=workspace.id, event="test", subject="Test", body=f"Test-Nachricht aus Bifröst, Workspace „{clean(workspace.name, 80)}“.")) # Platzhalter für die Basis-URL; ersetzt erst beim Zustellen (public_url des Workers) BASE = "[[aiw-base]]" def link(path: str) -> str: return f"{BASE}{path}" def expand(text: str, settings: Settings) -> str: return text.replace(BASE, settings.public_url.rstrip("/")) def clean(text: str, chars: int = 200) -> str: """Vom Modell formulierte Texte einzeilig und ohne spitze Klammern (Link-Syntax in Slack/Teams).""" return " ".join(str(text).replace("<", "").replace(">", "").split())[:chars] def email(session: Session, user: User, event: str, subject: str, body: str, workspace_id: int | None = None) -> None: if not user.active or not user.notify or not user.email: return session.add(Notification(channel=EMAIL, user_id=user.id, workspace_id=workspace_id, event=event, subject=subject[:300], body=body + FOOTER)) def webhook(session: Session, workspace: Workspace, event: str, text: str) -> None: if workspace.webhook: session.add(Notification(channel=WEBHOOK, workspace_id=workspace.id, event=event, subject=text[:300], body=text)) # --- Anlässe --- def deciders(session: Session, approval) -> list[User]: """Wer den Antrag entscheiden darf (ohne die auslösende Person).""" if approval.approval_level == "global_approver": users = session.scalars(select(User).where(User.is_global_approver, User.active)) else: users = session.scalars(select(User).join(WorkspaceMembership, WorkspaceMembership.user_id == User.id) .where(WorkspaceMembership.workspace_id == approval.workspace_id, WorkspaceMembership.role.in_(APPROVER_ROLES), User.active)) return [u for u in users if u.id != approval.requested_by] def approval_requested(session: Session, approval, workspace: Workspace) -> None: requester = session.get(User, approval.requested_by) title = clean(approval.arguments.get("title") or approval.tool) url = link(f"/approvals/{approval.id}") subject = f"Freigabe nötig: {title} ({clean(workspace.name, 80)})" body = (f"{requester.display_name if requester else 'Jemand'} hat im Workspace „{workspace.name}“ eine Aktion " f"ausgelöst, die eine Freigabe braucht.\n\nTool: {approval.tool}\nTitel: {title}\n" f"Läuft ab: {approval.expires_at.astimezone().strftime('%d.%m.%Y %H:%M')}" f"{chr(10) + 'Vier-Augen-Prinzip: Die auslösende Person kann nicht selbst freigeben.' if approval.four_eyes else ''}" f"\n\nZur Entscheidung: {url}") for user in deciders(session, approval): email(session, user, "approval.requested", subject, body, workspace.id) webhook(session, workspace, "approval.requested", f"Freigabe nötig im Workspace {clean(workspace.name, 80)}: " f"{title} ({approval.tool}). {url}") def approval_decided(session: Session, approval, decider_id: int, chat_id: int | None) -> None: requester = session.get(User, approval.requested_by) if requester is None or requester.id == decider_id: return workspace = session.get(Workspace, approval.workspace_id) decider = session.get(User, decider_id) approved = approval.status == "approved" title = clean(approval.arguments.get("title") or approval.tool) subject = f"Freigabe {'erteilt' if approved else 'abgelehnt'}: {title}" body = (f"{decider.display_name if decider else 'Jemand'} hat deinen Antrag im Workspace „{workspace.name}“ " f"{'freigegeben' if approved else 'abgelehnt'}.\n\nTitel: {title}" + (f"\nKommentar: {approval.comment}" if approval.comment else "") + ("\n\nDie Antwort im Chat geht jetzt weiter." if approved else "") + f"\n\nAntrag: {link(f'/approvals/{approval.id}')}" + (f"\nChat: {link(f'/chats/{chat_id}')}" if chat_id else "")) email(session, requester, "approval.decided", subject, body, workspace.id) # --- Webhook-Adresse des Workspace --- def check_webhook(url: str, settings: Settings) -> str: url = url.strip() parts = urlsplit(url) host = (parts.hostname or "").lower() local = host in ("localhost", "127.0.0.1", "::1") if parts.scheme != "https" and not (local and settings.webhook_allow_local and parts.scheme == "http"): raise Invalid("Webhooks nur über https.") if not local and not any(host == h or (h.startswith(".") and host.endswith(h)) for h in settings.allowed_webhook_hosts): raise Invalid(f"Host {host or '?'} ist für Webhooks nicht freigegeben (AIW_WEBHOOK_ALLOWED_HOSTS).") if local and not settings.webhook_allow_local: raise Invalid("Lokale Webhooks sind nicht freigegeben.") if len(url) > 2000: raise Invalid("Die Adresse ist zu lang.") return url def set_webhook(session: Session, settings: Settings, vault: Vault, workspace: Workspace, url: str, remove: bool = False) -> None: if remove: workspace.webhook, workspace.webhook_hint = None, "" return if not url.strip(): return if not vault.ready: raise Invalid("AIW_SECRET_KEY fehlt; die Adresse kann nicht verschlüsselt gespeichert werden.") url = check_webhook(url, settings) workspace.webhook = vault.encrypt(url) workspace.webhook_hint = urlsplit(url).hostname[:40] # --- Zustellung (Worker) --- def send_email(settings: Settings, to: str, subject: str, body: str) -> None: message = EmailMessage() message["From"], message["To"], message["Subject"] = settings.smtp_from, to, subject message.set_content(body) with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=settings.smtp_timeout_seconds) as smtp: if settings.smtp_starttls: smtp.starttls() if settings.smtp_user: smtp.login(settings.smtp_user, settings.smtp_password) smtp.send_message(message) def post_webhook(settings: Settings, url: str, text: str) -> None: # {"text": …} verstehen eingehende Webhooks von Slack und Microsoft Teams response = httpx.post(check_webhook(url, settings), json={"text": text}, timeout=10, follow_redirects=False) response.raise_for_status() def deliver(db: Database, settings: Settings) -> int: """Fällige Nachrichten zustellen; gibt die Zahl der bearbeiteten zurück.""" with db.session() as session: due = list(session.scalars(select(Notification).where(Notification.status == PENDING, Notification.available_at <= func.now()) .order_by(Notification.id).limit(BATCH).with_for_update(skip_locked=True))) for item in due: try: target = address(session, settings, item) if target is None: item.status = SKIPPED continue if item.channel == EMAIL: send_email(settings, target, item.subject, expand(item.body, settings)) else: post_webhook(settings, target, expand(item.body, settings)) item.status, item.sent, item.error = SENT, now(), "" metrics.notifications_sent.labels(item.channel, "sent").inc() except Exception as e: item.attempts += 1 item.error = f"{type(e).__name__}: {e}"[:500] metrics.notifications_sent.labels(item.channel, "error").inc() log.warning("Zustellung fehlgeschlagen", extra={"event": "notification_error", "channel": item.channel, "notification_id": item.id, "attempt": item.attempts, "error": item.error}) if item.attempts >= MAX_ATTEMPTS: item.status = FAILED log.warning("Benachrichtigung %s endgültig fehlgeschlagen: %s", item.id, item.error) else: item.available_at = func.now() + timedelta(seconds=30 * 2 ** item.attempts) return len(due) def address(session: Session, settings: Settings, item: Notification) -> str | None: """Empfänger zum Zeitpunkt der Zustellung; None = überspringen (nicht eingerichtet oder abbestellt).""" if item.channel == EMAIL: user = session.get(User, item.user_id) if item.user_id else None if not settings.smtp_host or not settings.smtp_from or user is None or not user.email \ or not user.notify or not user.active: return None return user.email workspace = session.get(Workspace, item.workspace_id) if item.workspace_id else None if workspace is None or not workspace.webhook or workspace.deleted is not None: return None return Vault(settings.secret_key).decrypt(workspace.webhook) def recent(session: Session, limit: int = 50) -> list[Notification]: return list(session.scalars(select(Notification).order_by(Notification.id.desc()).limit(limit)))