Files
timemaster/backend/app/services/email_service.py
T
patrickandClaude Opus 4.8 2b74f95e68 fix(security): K-5 – LDAP/CalDAV/SMTP-Verschlüsselung auf zentralen Datenschlüssel
ldap_service/caldav_service/email_service hatten je eine eigene _fernet()/
_decrypt_password(), die am JWT-SECRET_KEY hing. Damit honorierten sie das in
core/crypto bereits umgesetzte SECRET_KEY_DATA NICHT – eine JWT-Key-Kompromittierung
hätte weiterhin LDAP-/CalDAV-/SMTP-Passwörter offengelegt.

Jetzt delegieren alle drei an core.crypto.get_fernet_key()/decrypt_value. Da auf
beiden Servern SECRET_KEY_DATA nicht gesetzt ist, ist die Ableitung heute identisch
(sha256(SECRET_KEY)) → bestehende verschlüsselte Werte bleiben lesbar, kein Bruch.
Sobald SECRET_KEY_DATA gesetzt wird, gilt die Trennung dann konsistent für ALLE
Datenverschlüsselung (TOTP + LDAP + CalDAV + SMTP).

190/190 Tests grün. Deployed 137 + 164.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:56:01 +02:00

232 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
E-Mail-Versand via SMTP (smtplib + asyncio.to_thread).
Konfiguration pro Firma in smtp_configs. Kein externer Mail-Dienst nötig.
"""
import asyncio
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import TYPE_CHECKING
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.smtp_config import SmtpConfig
if TYPE_CHECKING:
from app.models.user import User
def _html_wrapper(title: str, body: str) -> str:
return f"""
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>{title}</title>
<style>
body {{ font-family: system-ui, sans-serif; background: #f4f4f4; margin: 0; padding: 40px 20px; }}
.card {{ background: white; border-radius: 12px; padding: 40px; max-width: 520px; margin: 0 auto; }}
.logo {{ font-size: 22px; font-weight: 600; color: #2563EB; margin-bottom: 32px; }}
h1 {{ font-size: 20px; font-weight: 600; color: #111; margin: 0 0 12px; }}
p {{ font-size: 15px; color: #555; line-height: 1.6; margin: 0 0 16px; }}
.btn {{ display: inline-block; padding: 12px 28px; background: #2563EB; color: white !important;
text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 15px; margin: 8px 0 24px; }}
.footer {{ font-size: 12px; color: #999; margin-top: 32px; padding-top: 16px; border-top: 1px solid #eee; }}
</style></head>
<body><div class="card">
<div class="logo">⏱ {settings.app_name}</div>
{body}
<div class="footer">Diese E-Mail wurde automatisch von {settings.app_name} gesendet.</div>
</div></body></html>
"""
def _decrypt_password(encrypted: str) -> str:
"""Fernet-Entschlüsselung über den zentralen Datenschlüssel (K-5)."""
from app.core.crypto import decrypt_value
return decrypt_value(encrypted)
def _smtp_send_sync(cfg: SmtpConfig, to: str, subject: str, html: str) -> None:
"""Synchroner SMTP-Versand wird via asyncio.to_thread() aufgerufen."""
password = _decrypt_password(cfg.password_encrypted) if cfg.password_encrypted else None
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{cfg.from_name} <{cfg.from_email}>"
msg["To"] = to
msg.attach(MIMEText(html, "html", "utf-8"))
if cfg.use_tls:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(cfg.host, cfg.port, context=context) as smtp:
if cfg.username and password:
smtp.login(cfg.username, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(cfg.host, cfg.port) as smtp:
if cfg.use_starttls:
smtp.starttls(context=ssl.create_default_context())
if cfg.username and password:
smtp.login(cfg.username, password)
smtp.send_message(msg)
class EmailService:
async def _load_smtp(self, company_id: UUID, db: AsyncSession) -> SmtpConfig | None:
return await db.scalar(
select(SmtpConfig).where(
SmtpConfig.company_id == company_id,
SmtpConfig.is_enabled == True,
)
)
async def _send(self, to: str, subject: str, html: str, cfg: SmtpConfig | None) -> None:
if not cfg:
print(f"\n{'='*60}")
print(f"EMAIL (kein SMTP konfiguriert nicht versendet)")
print(f" To: {to}")
print(f" Subject: {subject}")
print(f"{'='*60}\n")
return
try:
await asyncio.to_thread(_smtp_send_sync, cfg, to, subject, html)
except Exception as exc:
print(f"SMTP Fehler: {exc}")
async def send_welcome(self, user: "User", db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
body = f"""
<h1>Willkommen bei {settings.app_name}, {user.first_name}!</h1>
<p>Dein Firmen-Account wurde erfolgreich erstellt. Du kannst dich ab sofort anmelden.</p>
<a href="{settings.frontend_url}/login" class="btn">Zum Login</a>
"""
await self._send(user.email, f"Willkommen bei {settings.app_name}", _html_wrapper("Willkommen", body), cfg)
async def send_invite(self, user: "User", invited_by: "User", raw_token: str, db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
invite_url = f"{settings.frontend_url}/invite/accept#{raw_token}"
body = f"""
<h1>Du wurdest eingeladen!</h1>
<p><strong>{invited_by.full_name}</strong> hat dich zu <strong>{settings.app_name}</strong> eingeladen.</p>
<p>Klicke auf den Button, um dein Konto zu aktivieren und ein Passwort festzulegen.<br>
Der Link ist <strong>7 Tage</strong> gültig.</p>
<a href="{invite_url}" class="btn">Einladung annehmen</a>
<p style="font-size:13px;color:#999;">Oder kopiere diesen Link: {invite_url}</p>
"""
await self._send(
user.email,
f"{invited_by.full_name} hat dich zu {settings.app_name} eingeladen",
_html_wrapper("Einladung", body),
cfg,
)
async def send_password_reset(self, user: "User", raw_token: str, db: AsyncSession) -> None:
cfg = await self._load_smtp(user.company_id, db)
reset_url = f"{settings.frontend_url}/auth/reset-password#{raw_token}"
body = f"""
<h1>Passwort zurücksetzen</h1>
<p>Hallo {user.first_name},</p>
<p>du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.<br>
Klicke auf den Button der Link ist <strong>1 Stunde</strong> gültig.</p>
<a href="{reset_url}" class="btn">Passwort zurücksetzen</a>
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
"""
await self._send(user.email, "Passwort zurücksetzen", _html_wrapper("Passwort zurücksetzen", body), cfg)
async def send_substitute_notification(
self, substitute: "User", requester: "User", absence, db: AsyncSession
) -> None:
"""Informiert die eingetragene Vertretung über eine genehmigte Abwesenheit."""
cfg = await self._load_smtp(substitute.company_id, db)
start = absence.start_date.strftime("%d.%m.%Y")
end = absence.end_date.strftime("%d.%m.%Y")
zeitraum = start if start == end else f"{start} {end}"
body = f"""
<h1>Du wurdest als Vertretung eingetragen</h1>
<p>Hallo {substitute.first_name},</p>
<p><strong>{requester.full_name}</strong> ist im Zeitraum <strong>{zeitraum}</strong>
abwesend und hat dich als Vertretung benannt.</p>
<a href="{settings.frontend_url}/absences" class="btn">Abwesenheiten ansehen</a>
"""
await self._send(
substitute.email,
f"Vertretung für {requester.full_name} ({zeitraum})",
_html_wrapper("Vertretung", body),
cfg,
)
async def send_pending_approvals_digest(
self, approver: "User", items: list[dict], db: AsyncSession
) -> None:
"""Tägliche Zusammenfassung offener Anträge an eine genehmigende Person."""
cfg = await self._load_smtp(approver.company_id, db)
rows = "".join(
f"<li>{i['user_name']} {i['type_name']} "
f"({i['start']}{' ' + i['end'] if i['end'] != i['start'] else ''}, "
f"{i['working_days']} Tag(e))</li>"
for i in items
)
body = f"""
<h1>Offene Abwesenheitsanträge</h1>
<p>Hallo {approver.first_name}, es warten <strong>{len(items)}</strong> Anträge auf deine Genehmigung:</p>
<ul style="font-size:14px;color:#555;line-height:1.7">{rows}</ul>
<a href="{settings.frontend_url}/absences?status=pending" class="btn">Anträge prüfen</a>
"""
await self._send(
approver.email, f"{len(items)} offene Abwesenheitsanträge",
_html_wrapper("Offene Anträge", body), cfg,
)
async def send_carryover_expiry_reminder(
self, user: "User", remaining: int, expires_at, db: AsyncSession
) -> None:
cfg = await self._load_smtp(user.company_id, db)
datum = expires_at.strftime("%d.%m.%Y")
body = f"""
<h1>Resturlaub verfällt bald</h1>
<p>Hallo {user.first_name}, du hast noch <strong>{remaining} Urlaubstage</strong>,
die am <strong>{datum}</strong> verfallen.</p>
<p>Plane deinen Urlaub rechtzeitig ein, damit nichts verloren geht.</p>
<a href="{settings.frontend_url}/absences" class="btn">Urlaub planen</a>
"""
await self._send(
user.email, f"Dein Resturlaub verfällt am {datum}",
_html_wrapper("Resturlaub", body), cfg,
)
async def send_certificate_overdue_digest(
self, hr_user: "User", items: list[dict], db: AsyncSession
) -> None:
cfg = await self._load_smtp(hr_user.company_id, db)
rows = "".join(
f"<li>{i['user_name']} krank seit {i['start']}, AU fällig seit {i['due']}</li>"
for i in items
)
body = f"""
<h1>Fehlende AU-Bescheinigungen</h1>
<p>Hallo {hr_user.first_name}, für <strong>{len(items)}</strong> Krankmeldung(en)
fehlt die Arbeitsunfähigkeitsbescheinigung:</p>
<ul style="font-size:14px;color:#555;line-height:1.7">{rows}</ul>
<a href="{settings.frontend_url}/absences" class="btn">Abwesenheiten ansehen</a>
"""
await self._send(
hr_user.email, f"{len(items)} fehlende AU-Bescheinigung(en)",
_html_wrapper("AU fehlt", body), cfg,
)
async def send_test(self, cfg: SmtpConfig, to: str) -> None:
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
body = f"""
<h1>SMTP-Test erfolgreich!</h1>
<p>Deine SMTP-Konfiguration für <strong>{settings.app_name}</strong> funktioniert korrekt.</p>
<p>Server: {cfg.host}:{cfg.port}</p>
"""
await self._send(to, f"{settings.app_name} SMTP-Test", _html_wrapper("SMTP-Test", body), cfg)
email_service = EmailService()