feat(P2): Notruf verschickt E-Mail an Kontaktperson (Testphase)

Beim Notruf-Knopf wird – sofern SMTP konfiguriert ist – eine E-Mail an die
Kontaktperson(en) des Nutzers geschickt (Default: dieter.schlueter@linix.de).
Der Nutzer-Hinweis spiegelt ehrlich wider, ob wirklich etwas rausging:
"Kontaktperson per E-Mail benachrichtigt" vs. "noch keine Benachrichtigung".

- config.py: EMERGENCY_CONTACT_EMAIL + SMTP_* (ohne SMTP_HOST kein Versand);
  veralteten LLM-Klassifikator-Kommentar entfernt.
- emergency.py: SMTP-Versand (best effort, im Thread), lokalisierte Hinweise
  für "gesendet"/"nicht gesendet" (10 Sprachen). Pro-Nutzer-Kontakte via
  prefs.emergency_contacts vorbereitet, Fallback auf Config-Default.
- Test mit gemocktem smtplib. env.example + DEPLOYMENT.md 2.5 dokumentiert.

SMS/Anruf-Eskalation folgt später (eigene Planung). 210 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-25 03:37:18 +02:00
commit c35fe6e187
6 changed files with 141 additions and 20 deletions

View file

@ -845,6 +845,16 @@ ADMIN_USERS=dschlueter
DB_PATH=/var/lib/voice-assistant/voice-assistant.db
HF_HOME=/var/lib/voice-assistant/hf-cache
SSO_LOGOUT_URL=https://auth.jamulix.de/?action=logout
# Notruf-Benachrichtigung per E-Mail (Testphase). OHNE SMTP_HOST kein Versand;
# der Nutzer sieht dann "noch keine Benachrichtigung eingebaut". SMS/Anruf folgen.
EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de
SMTP_HOST= # z. B. smtp.linix.de — leer = kein Versand
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM= # leer -> SMTP_USER
SMTP_STARTTLS=true
```
```bash

View file

@ -150,4 +150,4 @@ async def trigger_emergency(payload: EmergencyRequest, user: User = Depends(requ
Die eigentliche Benachrichtigung von Angehörigen folgt später.
"""
store = get_store()
return record_manual_emergency(user, store, payload.language)
return await record_manual_emergency(user, store, payload.language)

View file

@ -188,10 +188,16 @@ class Settings(BaseSettings):
llm_fallback: str = ""
tts_fallback: str = ""
daily_request_limit: int = 0 # 0 = unbegrenzt; Anfragen pro Nutzer pro Tag
emergency_webhook_url: str = "" # optionaler Eskalations-Webhook
# LLM-Notfall-Klassifikation (zweite Stufe, faengt was die Stichwoerter verpassen).
# Laeuft als Hintergrund-Task NUR wenn der Keyword-Filter nichts fand -> keine
# zusaetzliche Antwortlatenz. Leerer Provider = Default-LLM-Provider.
emergency_webhook_url: str = "" # optionaler Eskalations-Webhook (später)
# Notruf-Benachrichtigung per E-Mail (Testphase, bis SMS/Anruf gebaut sind).
emergency_contact_email: str = "dieter.schlueter@linix.de" # Default-Kontaktperson
smtp_host: str = "" # ohne Host wird KEINE E-Mail versendet
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "" # Absenderadresse; leer -> smtp_user
smtp_starttls: bool = True
model_config = SettingsConfigDict(
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
)

View file

@ -1,17 +1,25 @@
"""Manuell ausgelöster Notruf — vom Nutzer bewusst bestätigt.
Es gibt KEINE automatische Notfallerkennung (keine Stichwörter, kein LLM): die
wäre unzuverlässig (Fehlalarme wie verpasste Notfälle) und intransparent. Ein
Notruf entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung).
KEINE automatische Erkennung (zu unzuverlässig/intransparent). Ein Notruf
entsteht nur durch eine ausdrückliche Nutzeraktion (Knopf + Bestätigung).
Die tatsächliche Benachrichtigung von Angehörigen (SMS/Anruf/Webhook) ist noch
NICHT implementiert. Bis dahin wird der Notruf protokolliert und der Nutzer
erhält einen klaren Hinweis in seiner Sprache.
Testphase: Bei einem Notruf wird eine E-Mail an die Kontaktperson(en) des
Nutzers geschickt (Default: settings.emergency_contact_email). SMS/Anruf-
Bridges folgen später. Ohne konfigurierten SMTP-Host wird nichts versendet
und der Nutzer erhält einen klaren Hinweis darauf.
"""
import asyncio
import logging
import smtplib
from email.message import EmailMessage
from app.config import settings
from app.metrics import metrics
# Platzhalter-Hinweis je Sprache, solange die Eskalation noch nicht gebaut ist.
logger = logging.getLogger(__name__)
# Hinweis je Sprache, wenn (noch) KEINE Benachrichtigung rausging.
_NO_NOTIFY_NOTICE: dict[str, str] = {
"de": "ACHTUNG: Es ist noch keine Benachrichtigung eingebaut.",
"en": "ATTENTION: No notification has been set up yet.",
@ -25,17 +33,72 @@ _NO_NOTIFY_NOTICE: dict[str, str] = {
"zh": "注意:尚未设置任何通知。",
}
def notify_notice(language: str | None) -> str:
"""Liefert den Platzhalter-Hinweis in der Nutzersprache (Fallback Deutsch)."""
return _NO_NOTIFY_NOTICE.get((language or "de").lower(), _NO_NOTIFY_NOTICE["de"])
# Hinweis je Sprache, wenn die E-Mail rausging.
_SENT_NOTICE: dict[str, str] = {
"de": "Hilfe wurde verständigt. Eine Kontaktperson wurde per E-Mail benachrichtigt.",
"en": "Help has been alerted. A contact person was notified by email.",
"fr": "Les secours ont été alertés. Un proche a été prévenu par e-mail.",
"es": "Se ha avisado a ayuda. Se notificó a un contacto por correo electrónico.",
"it": "L'aiuto è stato allertato. Una persona di contatto è stata avvisata via e-mail.",
"pt": "A ajuda foi avisada. Uma pessoa de contato foi notificada por e-mail.",
"pl": "Pomoc została powiadomiona. Osoba kontaktowa otrzymała e-mail.",
"ar": "تم تنبيه المساعدة. تم إخطار شخص الاتصال عبر البريد الإلكتروني.",
"ru": "Помощь оповещена. Контактное лицо уведомлено по электронной почте.",
"zh": "已通知求助。已通过电子邮件通知联系人。",
}
def record_manual_emergency(user, store, language: str | None = None) -> dict:
"""Protokolliert einen vom Nutzer bestätigten Notruf und liefert den Hinweis.
def notify_notice(language: str | None, sent: bool) -> str:
"""Hinweis in Nutzersprache (Fallback Deutsch), je nachdem ob eine Mail rausging."""
table = _SENT_NOTICE if sent else _NO_NOTIFY_NOTICE
return table.get((language or "de").lower(), table["de"])
Eskalation (Benachrichtigung) folgt später; hier wird nur protokolliert.
"""
def _contacts(user, cfg) -> list[str]:
"""Kontakt-E-Mail(s): pro-Nutzer-Pref (CSV) falls vorhanden, sonst Default aus der Config."""
prefs = getattr(user, "prefs", None) or {}
raw = (prefs.get("emergency_contacts") or "").strip() if isinstance(prefs, dict) else ""
if not raw:
raw = cfg.emergency_contact_email or ""
return [a.strip() for a in raw.replace(";", ",").split(",") if a.strip()]
def _send_email_sync(to: list[str], subject: str, body: str, cfg) -> bool:
"""Versendet eine E-Mail per SMTP. Liefert True bei Erfolg, sonst False (best effort)."""
if not cfg.smtp_host or not to:
return False
msg = EmailMessage()
msg["From"] = cfg.smtp_from or cfg.smtp_user or "voice-assistant@localhost"
msg["To"] = ", ".join(to)
msg["Subject"] = subject
msg.set_content(body)
try:
with smtplib.SMTP(cfg.smtp_host, int(cfg.smtp_port), timeout=10) as s:
if cfg.smtp_starttls:
s.starttls()
if cfg.smtp_user:
s.login(cfg.smtp_user, cfg.smtp_password)
s.send_message(msg)
return True
except Exception:
logger.exception("Notruf-E-Mail konnte nicht versendet werden")
return False
async def record_manual_emergency(user, store, language: str | None = None, cfg=settings) -> dict:
"""Protokolliert einen bestätigten Notruf, benachrichtigt Kontakte und liefert den Hinweis."""
store.log_emergency(user.id, "manual", "Vom Nutzer ausgelöster Notruf")
metrics.inc("emergency_total", {"category": "manual", "source": "user"})
return {"category": "manual", "notice": notify_notice(language)}
to = _contacts(user, cfg)
name = getattr(user, "display_name", None) or getattr(user, "id", "?")
subject = f"🆘 Notruf von {name}"
body = (
f"{name} hat in der Voice-Assistant-App einen Notruf ausgelöst.\n\n"
f"Bitte umgehend Kontakt aufnehmen.\n\n"
f"(Testphase: automatische Eskalation per SMS/Anruf ist noch nicht aktiv.)"
)
sent = await asyncio.to_thread(_send_email_sync, to, subject, body, cfg)
if sent:
metrics.inc("emergency_notify_total", {"channel": "email"})
return {"category": "manual", "notice": notify_notice(language, sent), "email_sent": sent}

View file

@ -20,3 +20,15 @@ SSO_LOGOUT_URL=https://linix.de/yunohost/sso/?action=logout
# Persistente Datenbank (Pfad auf dem Host)
DB_PATH=/var/lib/voice-assistant/voice-assistant.db
# --- Notruf-Benachrichtigung per E-Mail (Testphase) --------------------------
# Beim Notruf-Knopf wird (sofern SMTP gesetzt) eine E-Mail an die Kontaktperson
# geschickt. OHNE SMTP_HOST wird NICHTS versendet und der Nutzer sieht den
# Hinweis "noch keine Benachrichtigung eingebaut". SMS/Anruf folgen später.
EMERGENCY_CONTACT_EMAIL=dieter.schlueter@linix.de
SMTP_HOST= # z. B. smtp.linix.de — leer = kein Versand
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM= # Absender; leer -> SMTP_USER
SMTP_STARTTLS=true

View file

@ -64,3 +64,33 @@ def test_manual_emergency_does_not_count_against_quota(monkeypatch):
monkeypatch.setattr(settings, "daily_request_limit", 1)
for _ in range(3):
assert client.post("/api/emergency", json={"language": "de"}).status_code == 200
def test_manual_emergency_sends_email_when_smtp_configured(monkeypatch):
import app.safety.emergency as em
captured = {}
class FakeSMTP:
def __init__(self, host, port, timeout=10):
captured["host"] = host
def __enter__(self):
return self
def __exit__(self, *a):
return False
def starttls(self):
captured["tls"] = True
def login(self, u, p):
captured["login"] = u
def send_message(self, msg):
captured["to"] = msg["To"]
captured["subject"] = msg["Subject"]
monkeypatch.setattr(em.smtplib, "SMTP", FakeSMTP)
monkeypatch.setattr(settings, "smtp_host", "mail.example.com")
monkeypatch.setattr(settings, "emergency_contact_email", "kontakt@example.com")
data = client.post("/api/emergency", json={"language": "de"}).json()
assert data["email_sent"] is True
assert "benachrichtigt" in data["notice"].lower()
assert captured["to"] == "kontakt@example.com"
assert "Notruf" in captured["subject"]