feat(emails): zweisprachige E-Mail-Templates + Notification-Texte org-relativ
- email_utils/templates.magic_link_login_email + incident_notification_email nehmen jetzt lang Parameter (de | en). - routers/auth.request_magic_link zieht Sprache aus der Org des Users und uebergibt sie ans Template. - agents/orchestrator._send_email_notifications_for_incident lokalisiert ebenfalls und gibt lang an incident_notification_email durch. - DB-Notification-Texte (refresh_summary, new_articles) sind in der Pipeline org-sprach-relativ (englische Variante: "3 new articles", etc.). Status-Change-Notifications: Codes (confirmed/contradicted) bleiben, FE uebersetzt sie in Phase 6. Phase 5 von 8 (eng_demo / Org-Sprache).
Dieser Commit ist enthalten in:
@@ -341,6 +341,10 @@ async def _send_email_notifications_for_incident(
|
|||||||
from email_utils.sender import send_email
|
from email_utils.sender import send_email
|
||||||
from email_utils.templates import incident_notification_email
|
from email_utils.templates import incident_notification_email
|
||||||
from config import MAGIC_LINK_BASE_URL
|
from config import MAGIC_LINK_BASE_URL
|
||||||
|
from services.org_settings import get_org_language
|
||||||
|
|
||||||
|
# Sprache der Org bestimmen (die Lage gehoert genau einer Org)
|
||||||
|
org_lang_iso = await get_org_language(db, tenant_id) if tenant_id else "de"
|
||||||
|
|
||||||
# Alle Nutzer mit aktiven Abos fuer diese Lage laden
|
# Alle Nutzer mit aktiven Abos fuer diese Lage laden
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
@@ -386,6 +390,7 @@ async def _send_email_notifications_for_incident(
|
|||||||
notifications=filtered_notifications,
|
notifications=filtered_notifications,
|
||||||
dashboard_url=dashboard_url,
|
dashboard_url=dashboard_url,
|
||||||
incident_type=incident_type,
|
incident_type=incident_type,
|
||||||
|
lang=org_lang_iso,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await send_email(prefs["email"], subject, html)
|
await send_email(prefs["email"], subject, html)
|
||||||
@@ -1753,27 +1758,41 @@ class AgentOrchestrator:
|
|||||||
},
|
},
|
||||||
}, visibility, created_by, tenant_id)
|
}, visibility, created_by, tenant_id)
|
||||||
|
|
||||||
# DB-Notifications erzeugen
|
# DB-Notifications erzeugen (Texte org-sprach-relativ)
|
||||||
|
is_en = output_language_iso == "en"
|
||||||
parts = []
|
parts = []
|
||||||
if new_count > 0:
|
if is_en:
|
||||||
parts.append(f"{new_count} neue Meldung{'en' if new_count != 1 else ''}")
|
if new_count > 0:
|
||||||
if confirmed_count > 0:
|
parts.append(f"{new_count} new article{'s' if new_count != 1 else ''}")
|
||||||
parts.append(f"{confirmed_count} bestätigt")
|
if confirmed_count > 0:
|
||||||
if contradicted_count > 0:
|
parts.append(f"{confirmed_count} confirmed")
|
||||||
parts.append(f"{contradicted_count} widersprochen")
|
if contradicted_count > 0:
|
||||||
summary_text = ", ".join(parts) if parts else "Keine neuen Entwicklungen"
|
parts.append(f"{contradicted_count} contradicted")
|
||||||
|
summary_text = ", ".join(parts) if parts else "No new developments"
|
||||||
|
research_prefix = "Research"
|
||||||
|
new_articles_msg = f"{new_count} new article{'s' if new_count != 1 else ''} found"
|
||||||
|
else:
|
||||||
|
if new_count > 0:
|
||||||
|
parts.append(f"{new_count} neue Meldung{'en' if new_count != 1 else ''}")
|
||||||
|
if confirmed_count > 0:
|
||||||
|
parts.append(f"{confirmed_count} bestätigt")
|
||||||
|
if contradicted_count > 0:
|
||||||
|
parts.append(f"{contradicted_count} widersprochen")
|
||||||
|
summary_text = ", ".join(parts) if parts else "Keine neuen Entwicklungen"
|
||||||
|
research_prefix = "Recherche"
|
||||||
|
new_articles_msg = f"{new_count} neue Meldung{'en' if new_count != 1 else ''} gefunden"
|
||||||
|
|
||||||
db_notifications = [{
|
db_notifications = [{
|
||||||
"type": "refresh_summary",
|
"type": "refresh_summary",
|
||||||
"title": title,
|
"title": title,
|
||||||
"text": f"Recherche: {summary_text}",
|
"text": f"{research_prefix}: {summary_text}",
|
||||||
"icon": "warning" if contradicted_count > 0 else "success",
|
"icon": "warning" if contradicted_count > 0 else "success",
|
||||||
}]
|
}]
|
||||||
if new_count > 0:
|
if new_count > 0:
|
||||||
db_notifications.append({
|
db_notifications.append({
|
||||||
"type": "new_articles",
|
"type": "new_articles",
|
||||||
"title": title,
|
"title": title,
|
||||||
"text": f"{new_count} neue Meldung{'en' if new_count != 1 else ''} gefunden",
|
"text": new_articles_msg,
|
||||||
"icon": "info",
|
"icon": "info",
|
||||||
})
|
})
|
||||||
for sc in status_changes:
|
for sc in status_changes:
|
||||||
|
|||||||
@@ -1,13 +1,40 @@
|
|||||||
"""HTML-E-Mail-Vorlagen für Magic Links, Einladungen und Benachrichtigungen."""
|
"""HTML-E-Mail-Vorlagen für Magic Links, Einladungen und Benachrichtigungen.
|
||||||
|
|
||||||
|
Sprache pro Empfaenger-Org gesteuert (Default 'de').
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def magic_link_login_email(username: str, link: str) -> tuple[str, str]:
|
def magic_link_login_email(username: str, link: str, lang: str = "de") -> tuple[str, str]:
|
||||||
"""Erzeugt Login-E-Mail mit Magic Link.
|
"""Erzeugt Login-E-Mail mit Magic Link.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Empfaenger-Anzeigename
|
||||||
|
link: Magic-Link-URL
|
||||||
|
lang: ISO-Sprachcode ('de' | 'en')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(subject, html_body)
|
(subject, html_body)
|
||||||
"""
|
"""
|
||||||
subject = f"AegisSight Monitor - Anmeldung"
|
if lang == "en":
|
||||||
|
subject = "AegisSight Monitor - Sign in"
|
||||||
|
body = (
|
||||||
|
"Hi {username},",
|
||||||
|
"Click the button below to sign in:",
|
||||||
|
"Sign in",
|
||||||
|
"Or copy this link into your browser:",
|
||||||
|
"This link is valid for 10 minutes. If you did not request this sign-in, simply ignore this email.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subject = "AegisSight Monitor - Anmeldung"
|
||||||
|
body = (
|
||||||
|
"Hallo {username},",
|
||||||
|
"Klicken Sie auf den Button, um sich anzumelden:",
|
||||||
|
"Jetzt anmelden",
|
||||||
|
"Oder kopieren Sie diesen Link in Ihren Browser:",
|
||||||
|
"Dieser Link ist 10 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.",
|
||||||
|
)
|
||||||
|
|
||||||
|
greeting, intro, button_label, copy_hint, validity = body
|
||||||
html = f"""<!DOCTYPE html>
|
html = f"""<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head><meta charset="UTF-8"></head>
|
<head><meta charset="UTF-8"></head>
|
||||||
@@ -15,18 +42,18 @@ def magic_link_login_email(username: str, link: str) -> tuple[str, str]:
|
|||||||
<div style="max-width: 480px; margin: 0 auto; background: #1e293b; border-radius: 12px; padding: 32px; border: 1px solid #334155;">
|
<div style="max-width: 480px; margin: 0 auto; background: #1e293b; border-radius: 12px; padding: 32px; border: 1px solid #334155;">
|
||||||
<h1 style="color: #f0b429; font-size: 20px; margin: 0 0 24px 0;">AegisSight Monitor</h1>
|
<h1 style="color: #f0b429; font-size: 20px; margin: 0 0 24px 0;">AegisSight Monitor</h1>
|
||||||
|
|
||||||
<p style="margin: 0 0 16px 0;">Hallo {username},</p>
|
<p style="margin: 0 0 16px 0;">{greeting.format(username=username)}</p>
|
||||||
|
|
||||||
<p style="margin: 0 0 24px 0;">Klicken Sie auf den Button, um sich anzumelden:</p>
|
<p style="margin: 0 0 24px 0;">{intro}</p>
|
||||||
|
|
||||||
<div style="text-align: center; margin: 0 0 24px 0;">
|
<div style="text-align: center; margin: 0 0 24px 0;">
|
||||||
<a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 14px 40px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
|
<a href="{link}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 14px 40px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 16px;">{button_label}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="color: #94a3b8; font-size: 13px; margin: 0 0 12px 0;">Oder kopieren Sie diesen Link in Ihren Browser:</p>
|
<p style="color: #94a3b8; font-size: 13px; margin: 0 0 12px 0;">{copy_hint}</p>
|
||||||
<p style="color: #64748b; font-size: 11px; word-break: break-all; margin: 0 0 24px 0;">{link}</p>
|
<p style="color: #64748b; font-size: 11px; word-break: break-all; margin: 0 0 24px 0;">{link}</p>
|
||||||
|
|
||||||
<p style="color: #94a3b8; font-size: 13px; margin: 0;">Dieser Link ist 10 Minuten gültig. Falls Sie diese Anmeldung nicht angefordert haben, ignorieren Sie diese E-Mail.</p>
|
<p style="color: #94a3b8; font-size: 13px; margin: 0;">{validity}</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
@@ -39,6 +66,7 @@ def incident_notification_email(
|
|||||||
notifications: list[dict],
|
notifications: list[dict],
|
||||||
dashboard_url: str,
|
dashboard_url: str,
|
||||||
incident_type: str = "adhoc",
|
incident_type: str = "adhoc",
|
||||||
|
lang: str = "de",
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
"""Erzeugt Benachrichtigungs-E-Mail für Lagen-Updates.
|
"""Erzeugt Benachrichtigungs-E-Mail für Lagen-Updates.
|
||||||
|
|
||||||
@@ -48,13 +76,30 @@ def incident_notification_email(
|
|||||||
notifications: Liste von {"text": ..., "icon": ...} Dicts
|
notifications: Liste von {"text": ..., "icon": ...} Dicts
|
||||||
dashboard_url: Link zum Dashboard
|
dashboard_url: Link zum Dashboard
|
||||||
incident_type: "adhoc" oder "research"
|
incident_type: "adhoc" oder "research"
|
||||||
|
lang: ISO-Sprachcode ('de' | 'en')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(subject, html_body)
|
(subject, html_body)
|
||||||
"""
|
"""
|
||||||
is_research = incident_type == "research"
|
is_research = incident_type == "research"
|
||||||
type_label = "Recherche" if is_research else "Lagebild"
|
|
||||||
type_label_lower = "Recherche" if is_research else "Lage"
|
if lang == "en":
|
||||||
|
type_label = "Research" if is_research else "Situation"
|
||||||
|
type_label_lower = "research" if is_research else "situation"
|
||||||
|
notification_word = "notification"
|
||||||
|
greeting = f"Hi {username},"
|
||||||
|
intro = f"There is news on the {type_label_lower}"
|
||||||
|
button_label = "Open in dashboard"
|
||||||
|
footer = "You can disable these notifications in your dashboard settings."
|
||||||
|
else:
|
||||||
|
type_label = "Recherche" if is_research else "Lagebild"
|
||||||
|
type_label_lower = "Recherche" if is_research else "Lage"
|
||||||
|
notification_word = "Benachrichtigung"
|
||||||
|
greeting = f"Hallo {username},"
|
||||||
|
intro = f"es gibt Neuigkeiten zur {type_label_lower}"
|
||||||
|
button_label = "Im Dashboard ansehen"
|
||||||
|
footer = "Diese Benachrichtigung kann in den Einstellungen im Dashboard deaktiviert werden."
|
||||||
|
|
||||||
subject = f"AegisSight - {incident_title}"
|
subject = f"AegisSight - {incident_title}"
|
||||||
|
|
||||||
icon_map = {
|
icon_map = {
|
||||||
@@ -87,20 +132,20 @@ def incident_notification_email(
|
|||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; padding: 40px 20px;">
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; padding: 40px 20px;">
|
||||||
<div style="max-width: 480px; margin: 0 auto; background: #1e293b; border-radius: 12px; padding: 32px; border: 1px solid #334155;">
|
<div style="max-width: 480px; margin: 0 auto; background: #1e293b; border-radius: 12px; padding: 32px; border: 1px solid #334155;">
|
||||||
<h1 style="color: #f0b429; font-size: 20px; margin: 0 0 8px 0;">AegisSight Monitor</h1>
|
<h1 style="color: #f0b429; font-size: 20px; margin: 0 0 8px 0;">AegisSight Monitor</h1>
|
||||||
<p style="color: #94a3b8; font-size: 12px; margin: 0 0 24px 0;">{type_label} - Benachrichtigung</p>
|
<p style="color: #94a3b8; font-size: 12px; margin: 0 0 24px 0;">{type_label} - {notification_word}</p>
|
||||||
|
|
||||||
<p style="margin: 0 0 8px 0;">Hallo {username},</p>
|
<p style="margin: 0 0 8px 0;">{greeting}</p>
|
||||||
<p style="margin: 0 0 20px 0;">es gibt Neuigkeiten zur {type_label_lower} <strong style="color: #f0b429;">{incident_title}</strong>:</p>
|
<p style="margin: 0 0 20px 0;">{intro} <strong style="color: #f0b429;">{incident_title}</strong>:</p>
|
||||||
|
|
||||||
<div style="background: #0f172a; border-radius: 8px; padding: 4px 16px; margin: 0 0 24px 0;">
|
<div style="background: #0f172a; border-radius: 8px; padding: 4px 16px; margin: 0 0 24px 0;">
|
||||||
{items_html}
|
{items_html}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="text-align: center; margin: 0 0 24px 0;">
|
<div style="text-align: center; margin: 0 0 24px 0;">
|
||||||
<a href="{dashboard_url}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">Im Dashboard ansehen</a>
|
<a href="{dashboard_url}" style="display: inline-block; background: #f0b429; color: #0f172a; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600;">{button_label}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="color: #64748b; font-size: 12px; margin: 0;">Diese Benachrichtigung kann in den Einstellungen im Dashboard deaktiviert werden.</p>
|
<p style="color: #64748b; font-size: 12px; margin: 0;">{footer}</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ async def request_magic_link(
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# E-Mail senden
|
# E-Mail senden -- Sprache aus Org-Settings des Users
|
||||||
link = f"{MAGIC_LINK_BASE_URL}/?token={token}"
|
link = f"{MAGIC_LINK_BASE_URL}/?token={token}"
|
||||||
subject, html = magic_link_login_email(user["email"].split("@")[0], link)
|
from services.org_settings import get_org_language
|
||||||
|
org_lang_iso = await get_org_language(db, user["organization_id"])
|
||||||
|
subject, html = magic_link_login_email(user["email"].split("@")[0], link, lang=org_lang_iso)
|
||||||
await send_email(email, subject, html)
|
await send_email(email, subject, html)
|
||||||
|
|
||||||
magic_link_limiter.record(email, ip)
|
magic_link_limiter.record(email, ip)
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren