Initial commit – TimeMaster Zeiterfassung & HR-Tool

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>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
"""
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()