Files
timemaster/backend/app/services/email_service.py
T
patrickandClaude Opus 4.8 3b2df1c978
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
feat: agent-11 PR1 – Vertretung, Storno-Re-Genehmigung, Kommentare
Abwesenheits-Modul abgerundet (Feature-Parität mit Urlaubsverwaltung):

- Vertretung: Overlap-Warnung beim Anlegen, E-Mail an Vertretung bei
  Genehmigung, GET /absences/?as_substitute=true, neuer schlanker
  GET /users/colleagues (alle Rollen, RLS-gefenced) für die Auswahl;
  Vertreter-Dropdown + Anzeige in der Liste.
- Stornierung mit Re-Genehmigung: neuer Status CANCELLATION_REQUESTED,
  POST /absences/{id}/request-cancellation; Manager genehmigt/lehnt über
  bestehende approve/reject ab (Urlaub + FZA-Rückbuchung via _apply_cancellation).
- Kommentare: Model AbsenceComment (company_id-RLS), GET/POST comments,
  System-Kommentare bei Statuswechsel, AbsenceCommentsModal.
- Fix: CalDAV fire-and-forget nutzte die Request-Session weiter (in Tests
  geteilt -> "another operation in progress"); jetzt sync_*_bg mit eigener
  Session + RLS-Bypass.

Migration 0035. 178/178 Tests grün. Deployed auf 137 + 164.

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

177 lines
7.5 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 (gleiche Implementierung wie ldap_service)."""
import base64
import hashlib
from cryptography.fernet import Fernet
key = hashlib.sha256(settings.secret_key.encode()).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.decrypt(encrypted.encode()).decode()
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_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()