1fedd683e0
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
6.5 KiB
Python
155 lines
6.5 KiB
Python
"""
|
||
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?token={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?token={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_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()
|