feat: enhance email configuration and registration logic
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m54s

- Added SMTP_SSL and SMTP_STARTTLS options to .env.example and docker-compose.yml for improved email sending configuration.
- Updated password reset email logic to utilize a new send_email helper function with SSL and STARTTLS support.
- Implemented role assignment for the first registered user or based on ADMIN_BOOTSTRAP_EMAILS during registration, enhancing user management capabilities.
This commit is contained in:
Lars 2026-04-29 11:28:07 +02:00
parent fae673670a
commit c6569abe1a
4 changed files with 109 additions and 65 deletions

View File

@ -15,6 +15,13 @@ SMTP_PORT=587
SMTP_USER=noreply@jinkendo.de
SMTP_PASS=your_smtp_password
SMTP_FROM=noreply@jinkendo.de
# Port 465 oft SSL; dann z. B. SMTP_SSL=true und SMTP_STARTTLS=false
SMTP_SSL=
SMTP_STARTTLS=
# Bootstrap: erste Registrierung (leere Nutzerliste) erhält Rolle admin; oder feste Admin-Mails:
AUTO_ADMIN_FIRST_USER=true
ADMIN_BOOTSTRAP_EMAILS=
# App
APP_URL=https://shinkan.jinkendo.de

View File

@ -117,7 +117,7 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)):
session = get_session(x_auth_token)
if not session:
raise HTTPException(401, "Nicht eingeloggt")
if session['role'] != 'admin':
if session.get("role") not in ("admin", "superadmin"):
raise HTTPException(403, "Nur für Admins")
return session

View File

@ -6,6 +6,7 @@ Handles login, logout, password reset, and profile authentication.
import os
import secrets
import smtplib
import ssl
from typing import Optional
from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText
@ -123,38 +124,21 @@ async def password_reset_request(req: PasswordResetRequest, request: Request):
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
(f"reset_{token}", prof['id'], expires.isoformat()))
# Send email
try:
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", 587))
smtp_user = os.getenv("SMTP_USER")
smtp_pass = os.getenv("SMTP_PASS")
smtp_from = os.getenv("SMTP_FROM")
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
if smtp_host and smtp_user and smtp_pass:
msg = MIMEText(f"""Hallo {prof['name']},
app_base = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
reset_body = f"""Hallo {prof['name']},
Du hast einen Passwort-Reset angefordert.
Reset-Link: {app_url}/reset-password?token={token}
Reset-Link: {app_base}/reset-password?token={token}
Der Link ist 1 Stunde gültig.
Falls du diese Anfrage nicht gestellt hast, ignoriere diese E-Mail.
Dein Mitai Jinkendo Team
""")
msg['Subject'] = "Passwort zurücksetzen Mitai Jinkendo"
msg['From'] = smtp_from
msg['To'] = email
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
except Exception as e:
print(f"Email error: {e}")
Dein Shinkan Jinkendo Team
"""
if not send_email(email, "Passwort zurücksetzen Shinkan Jinkendo", reset_body):
print("[SMTP] Passwort-Reset konnte nicht gesendet werden (SMTP prüfen).")
return {"ok": True, "message": "Falls die E-Mail existiert, wurde ein Reset-Link gesendet."}
@ -177,34 +161,89 @@ def password_reset_confirm(req: PasswordResetConfirm):
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
# ── Helpers (Registrierung / E-Mail-Links) ─────────────────────────────────────
def _public_app_base() -> str:
return (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
def verification_link(token: str) -> str:
"""Öffentlicher Link zur Bestätigung (FastAPI unter /api/auth)."""
return f"{_public_app_base()}/api/auth/verify/{token}"
def registration_role(cur, email_lower: str) -> str:
"""
bootstrap: erste Registrierung in leerer DB admin,
oder E-Mail ADMIN_BOOTSTRAP_EMAILS (kommasepariert, Groß/Klein egal).
Um alle Self-Regs als Trainer zu haben: AUTO_ADMIN_FIRST_USER=false und keine ADMIN_BOOTSTRAP_EMAILS.
"""
bootstrap = {
x.strip().lower()
for x in os.getenv("ADMIN_BOOTSTRAP_EMAILS", "").split(",")
if x.strip()
}
if email_lower in bootstrap:
return "admin"
if os.getenv("AUTO_ADMIN_FIRST_USER", "true").strip().lower() not in ("1", "true", "yes"):
return "trainer"
cur.execute("SELECT COUNT(*) AS n FROM profiles")
row = cur.fetchone()
try:
n = int(row["n"]) if row is not None else 0
except (KeyError, TypeError, ValueError):
n = 0
return "admin" if n == 0 else "trainer"
# ── Helper: Send Email ────────────────────────────────────────────────────────
def send_email(to_email: str, subject: str, body: str):
"""Send email via SMTP (reusable helper)."""
"""Send mail via SMTP. Port 465 → SSL; sonst SMTP + optional STARTTLS (587)."""
try:
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", 587))
smtp_user = os.getenv("SMTP_USER")
smtp_host = (os.getenv("SMTP_HOST") or "").strip()
smtp_port_raw = os.getenv("SMTP_PORT") or "587"
try:
smtp_port = int(str(smtp_port_raw).strip())
except ValueError:
smtp_port = 587
smtp_user = (os.getenv("SMTP_USER") or "").strip()
smtp_pass = os.getenv("SMTP_PASS")
smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de")
if not smtp_host or not smtp_user or not smtp_pass:
print("SMTP not configured, skipping email")
if not smtp_host or not smtp_user or smtp_pass is None or smtp_pass == "":
print("[SMTP] Nicht konfiguriert — setze SMTP_HOST, SMTP_USER, SMTP_PASS (und ggf. SMTP_PORT)")
return False
msg = MIMEText(body)
msg['Subject'] = subject
msg['From'] = smtp_from
msg['To'] = to_email
msg["Subject"] = subject
msg["From"] = smtp_from
msg["To"] = to_email
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
use_tls_env = (os.getenv("SMTP_STARTTLS", "true").strip().lower() not in ("0", "false", "no"))
force_ssl = (os.getenv("SMTP_SSL", "").strip().lower() in ("1", "true", "yes")) or smtp_port == 465
ctx = ssl.create_default_context()
if force_ssl:
print(f"[SMTP] Versand (SSL) an …@{to_email.split('@')[-1]} über {smtp_host}:{smtp_port}")
with smtplib.SMTP_SSL(smtp_host, smtp_port, context=ctx, timeout=30) as server:
server.login(smtp_user, smtp_pass)
server.send_message(msg)
else:
print(f"[SMTP] Versand (STARTTLS={use_tls_env}) an …@{to_email.split('@')[-1]} über {smtp_host}:{smtp_port}")
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
server.ehlo()
if use_tls_env:
server.starttls(context=ctx)
server.ehlo()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
print("[SMTP] Nachricht erfolgreich akzeptiert (Server-Queue)")
return True
except Exception as e:
print(f"Email error: {e}")
print(f"[SMTP] Fehler: {e}")
return False
@ -237,6 +276,9 @@ async def register(req: RegisterRequest, request: Request):
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → admin
role = registration_role(cur, email)
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
pin_hash = hash_pin(password)
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
@ -246,27 +288,28 @@ async def register(req: RegisterRequest, request: Request):
name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
""", (name, email, pin_hash, verification_token, verification_expires, trial_ends))
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
# Send verification email
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
verify_url = f"{app_url}/verify?token={verification_token}"
verify_url = verification_link(verification_token)
email_body = f"""Hallo {name},
willkommen bei Mitai Jinkendo!
willkommen bei Shinkan Jinkendo!
Bitte bestätige deine E-Mail-Adresse um die Registrierung abzuschließen:
Bitte bestätige deine E-Mail-Adresse, um die Registrierung abzuschließen:
{verify_url}
Der Link ist 24 Stunden gültig.
Dein Mitai Jinkendo Team
Nach dem Aufruf des Links bist du bestätigt (Browser zeigt eine kurze JSON-Antwort das ist in Ordnung).
Dein Shinkan Jinkendo Team
"""
send_email(email, "Willkommen bei Mitai Jinkendo E-Mail bestätigen", email_body)
if not send_email(email, "Shinkan Jinkendo E-Mail bestätigen", email_body):
print("[SMTP] Verifizierungs-Mail konnte nicht gesendet werden — Logs prüfen, SMTP_* in der Laufzeitumgebung.")
return {
"ok": True,
@ -366,34 +409,22 @@ async def resend_verification(req: dict, request: Request):
WHERE id=%s
""", (verification_token, verification_expires, prof['id']))
# Send verification email
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
verify_url = f"{app_url}/verify?token={verification_token}"
verify_url = verification_link(verification_token)
email_body = f"""Hallo {prof['name']},
du hast eine neue Bestätigungs-E-Mail angefordert.
Bitte bestätige deine E-Mail-Adresse, indem du auf folgenden Link klickst:
Bitte bestätige deine E-Mail-Adresse mit diesem Link:
{verify_url}
Dieser Link ist 24 Stunden gültig.
Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren.
Viele Grüße
Dein Mitai Jinkendo Team
Dein Shinkan Jinkendo Team
"""
try:
send_email(
to=email,
subject="Neue Bestätigungs-E-Mail - Mitai Jinkendo",
body=email_body
)
except Exception as e:
print(f"Failed to send verification email: {e}")
raise HTTPException(500, "E-Mail konnte nicht versendet werden")
if not send_email(email, "Shinkan Jinkendo E-Mail bestätigen", email_body):
raise HTTPException(502, "E-Mail konnte nicht versendet werden. SMTP-Einstellungen und Container-Logs prüfen.")
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}

View File

@ -34,6 +34,12 @@ services:
SMTP_USER: ${SMTP_USER}
SMTP_PASS: ${SMTP_PASS}
SMTP_FROM: ${SMTP_FROM}
# SMTP_STARTTLS=false | SMTP_SSL=true nach Anbieter (z. B. Port 465)
SMTP_SSL: ${SMTP_SSL:-}
SMTP_STARTTLS: ${SMTP_STARTTLS:-}
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-true}"
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
APP_URL: https://shinkan.jinkendo.de
ALLOWED_ORIGINS: https://shinkan.jinkendo.de
ENVIRONMENT: production