Security + E-Mail-HTML + Quartalsbericht + Registrierungspflicht
Registrierung & Login: - E-Mail-Verifikation jetzt Pflicht vor erstem Login - Register gibt keinen Token mehr zurück → "Postfach prüfen"-Screen - Login blockt mit EMAIL_NOT_VERIFIED (403) wenn unverifiziert - Resend-Verification ohne Auth (email-basiert) - Frontend: _renderVerifyPending() nach Register und Login-Fehler - Account-Lockout: 5 Fehlversuche → 15 Min gesperrt (ratelimit.py) - Login Rate-Limit zusätzlich per E-Mail-Adresse (5/5 Min) - Fehler-Tracking wird bei erfolgreichem Login zurückgesetzt E-Mail-Templates (alle Mails jetzt HTML): - email_html() Shared-Template in mailer.py (Gradient-Header, Warm-Beige) - Verifikations-Mail, Passwort-Reset → HTML mit CTA-Button - Admin-Outreach: plain text auto-wrapped in HTML - Züchter-Mails (Antrag/Genehmigung/Ablehnung) → Template - Tierschutz-Alert (litters.py) → Template - send_support_mail → HTML - outreach._build_message() + _send_smtp() unterstützen jetzt html= Parameter Forum-Schutz: - Post-Cooldown: 30 Sek zwischen beliebigen Posts (DB-Check) - Stunden-Limit: 5 Threads / 20 Antworten pro User/Stunde - Duplikat-Erkennung: gleicher Text in 5 Min blockiert (in-memory) - content_filter.py: Spam-Keywords, URL-Sperre für Accounts < 7 Tage, Sonderzeichen-Ratio-Check Security-Headers: - HSTS: max-age=31536000; includeSubDomains - Content-Security-Policy: frame-ancestors none, base-uri self, … - X-Frame-Options entfernt (CSP frame-ancestors ist moderner) Honeypot-Fallen (13 Scanner-Pfade → 24h IP-Sperre): - /api/admin/users, /api/v1/users, /api/.env, /api/config, /api/setup, /api/install, /api/phpinfo, /api/debug, /api/actuator, /api/swagger, /api/graphql u.a. Quartalsbericht-System: - backend/scripts/generate_reports.py: 6 Sections (Sicherheit, Funktionsumfang, Dateien, Nutzer, Partner, Server) - make reports: generiert alle Berichte aus dem Container, committed - Scheduler: quarterly_report Job (1. Feb/Mai/Aug/Nov 07:00) → vollständige HTML-Mail an ADMIN_EMAIL - quarterly_report erscheint im täglichen Status-Report Admin-Panel: - "Forum & Meldungen" → "Forum"
This commit is contained in:
parent
c1bb728153
commit
de1677154f
15 changed files with 1363 additions and 141 deletions
|
|
@ -15,7 +15,7 @@ from auth import (
|
|||
get_current_user
|
||||
)
|
||||
from username_blocklist import is_username_blocked
|
||||
from ratelimit import check as rl_check
|
||||
from ratelimit import check as rl_check, record_login_failure, is_account_locked, clear_login_failures
|
||||
|
||||
router = APIRouter()
|
||||
COOKIE_NAME = "by_token"
|
||||
|
|
@ -27,17 +27,22 @@ def _send_verification_email(email: str, name: str, token: str):
|
|||
if not _SMTP_READY:
|
||||
return
|
||||
from routes.outreach import _send_smtp
|
||||
from mailer import email_html
|
||||
url = f"{_APP_URL}/api/auth/verify-email/{token}"
|
||||
subject = "Ban Yaro — bitte bestätige deine E-Mail-Adresse"
|
||||
body = (
|
||||
f"Hallo {name},\n\n"
|
||||
"willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse mit diesem Link:\n\n"
|
||||
f"{_APP_URL}/api/auth/verify-email/{token}\n\n"
|
||||
"Der Link ist 7 Tage gültig.\n\n"
|
||||
"Falls du dich nicht bei Ban Yaro registriert hast, kannst du diese Mail ignorieren.\n\n"
|
||||
"Viele Grüße,\nDas Ban Yaro Team\nbanyaro.app"
|
||||
)
|
||||
body_html = f"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{name}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
willkommen bei Ban Yaro! Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiv wird.
|
||||
</p>
|
||||
<p style="margin:0;font-size:13px;color:#888">Der Link ist 7 Tage gültig.</p>
|
||||
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
|
||||
Falls du dich nicht bei Ban Yaro registriert hast, ignoriere diese E-Mail einfach.
|
||||
</p>"""
|
||||
html = email_html(body_html, cta_url=url, cta_label="E-Mail bestätigen")
|
||||
plain = f"Hallo {name},\n\nbitte bestätige deine E-Mail:\n{url}\n\nLink ist 7 Tage gültig.\n"
|
||||
try:
|
||||
_send_smtp(email, subject, body, "support")
|
||||
_send_smtp(email, subject, plain, "support", html=html)
|
||||
except Exception:
|
||||
pass # Nicht blockieren wenn SMTP fehlschlägt
|
||||
|
||||
|
|
@ -139,24 +144,32 @@ async def register(data: RegisterRequest, response: Response, request: Request):
|
|||
conn.execute("UPDATE users SET referred_by=? WHERE id=?",
|
||||
(referrer['id'], new_user_id))
|
||||
|
||||
token = create_token(user["id"], user["rolle"])
|
||||
_set_cookie(response, token)
|
||||
_send_verification_email(data.email, name, verify_token)
|
||||
return {"token": token, "name": name, "email_verified": 0}
|
||||
return {"pending_verification": True}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(data: LoginRequest, response: Response, request: Request):
|
||||
rl_check(request, max_requests=10, window_seconds=300, key="login")
|
||||
rl_check(request, max_requests=5, window_seconds=300, key=f"login_email:{data.email.lower()}")
|
||||
|
||||
if is_account_locked(data.email):
|
||||
raise HTTPException(429, "Zu viele Fehlversuche. Bitte warte 15 Minuten und versuche es erneut.")
|
||||
|
||||
with db() as conn:
|
||||
user = conn.execute(
|
||||
"SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?",
|
||||
"SELECT id, pw_hash, name, rolle, is_premium, email_verified FROM users WHERE email=?",
|
||||
(data.email,)
|
||||
).fetchone()
|
||||
|
||||
if not user or not verify_password(data.password, user["pw_hash"]):
|
||||
record_login_failure(data.email)
|
||||
raise HTTPException(401, "E-Mail oder Passwort falsch.")
|
||||
|
||||
if not user["email_verified"]:
|
||||
raise HTTPException(403, "EMAIL_NOT_VERIFIED")
|
||||
|
||||
clear_login_failures(data.email)
|
||||
token = create_token(user["id"], user["rolle"])
|
||||
_set_cookie(response, token)
|
||||
|
||||
|
|
@ -249,23 +262,24 @@ async def verify_email(token: str):
|
|||
return RedirectResponse(f"{_APP_URL}/#settings?verified=1", status_code=302)
|
||||
|
||||
|
||||
class ResendVerificationRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
@router.post("/resend-verification")
|
||||
async def resend_verification(request: Request, user=Depends(get_current_user)):
|
||||
rl_check(request, max_requests=3, window_seconds=3600, key="resend_verify")
|
||||
async def resend_verification(data: ResendVerificationRequest, request: Request):
|
||||
rl_check(request, max_requests=3, window_seconds=3600, key=f"resend_verify_{data.email}")
|
||||
with db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT email, name, email_verified FROM users WHERE id=?", (user["id"],)
|
||||
"SELECT id, name, email_verified FROM users WHERE email=?", (data.email,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404)
|
||||
if row["email_verified"]:
|
||||
return {"ok": True, "already_verified": True}
|
||||
if not row or row["email_verified"]:
|
||||
return {"ok": True}
|
||||
token = secrets.token_urlsafe(32)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE users SET verification_token=? WHERE id=?", (token, user["id"])
|
||||
"UPDATE users SET verification_token=? WHERE id=?", (token, row["id"])
|
||||
)
|
||||
_send_verification_email(row["email"], row["name"], token)
|
||||
_send_verification_email(data.email, row["name"], token)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
|
@ -293,18 +307,23 @@ async def forgot_password(data: ForgotPasswordRequest, request: Request):
|
|||
(token, expires, user["id"])
|
||||
)
|
||||
app_url = os.getenv("APP_URL", "https://banyaro.app")
|
||||
url = f"{app_url}/#reset-password?token={token}"
|
||||
subject = "Ban Yaro — Passwort zurücksetzen"
|
||||
body = (
|
||||
f"Hallo {user['name']},\n\n"
|
||||
"du hast eine Passwort-Zurücksetzen-Anfrage gestellt.\n\n"
|
||||
f"Klicke hier um ein neues Passwort zu setzen:\n"
|
||||
f"{app_url}/#reset-password?token={token}\n\n"
|
||||
"Der Link ist 2 Stunden gültig. Falls du keine Anfrage gestellt hast, ignoriere diese Mail.\n\n"
|
||||
"Viele Grüße,\nDas Ban Yaro Team"
|
||||
)
|
||||
from routes.outreach import _send_smtp
|
||||
from mailer import email_html
|
||||
body_html = f"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
du hast eine Passwort-Zurücksetzen-Anfrage gestellt. Klicke auf den Button, um ein neues Passwort zu setzen.
|
||||
</p>
|
||||
<p style="margin:0;font-size:13px;color:#888">Der Link ist 2 Stunden gültig.</p>
|
||||
<p style="margin:12px 0 0;font-size:12px;color:#bbb">
|
||||
Falls du keine Anfrage gestellt hast, ignoriere diese E-Mail einfach.
|
||||
</p>"""
|
||||
html = email_html(body_html, cta_url=url, cta_label="Passwort zurücksetzen")
|
||||
plain = f"Hallo {user['name']},\n\nPasswort zurücksetzen:\n{url}\n\nLink ist 2h gültig.\n"
|
||||
try:
|
||||
_send_smtp(data.email, subject, body, "support")
|
||||
_send_smtp(data.email, subject, plain, "support", html=html)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from typing import Optional
|
|||
|
||||
from database import db
|
||||
from auth import get_current_user, require_premium
|
||||
from mailer import send_email
|
||||
from mailer import send_email, email_html
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -131,21 +131,21 @@ async def breeder_apply(
|
|||
)
|
||||
|
||||
# Admin benachrichtigen
|
||||
admin_html = f"""
|
||||
<h2>Neuer Züchter-Antrag</h2>
|
||||
<p><b>Von:</b> {user['name']} ({user['email']})</p>
|
||||
<p><b>Zwingername:</b> {zwingername}</p>
|
||||
<p><b>Rasse:</b> {rasse_text}</p>
|
||||
<p><b>Verein:</b> {verein}</p>
|
||||
<p><b>VDH:</b> {'Ja' if vdh_mitglied else 'Nein'}</p>
|
||||
<p><b>Stadt:</b> {stadt}</p>
|
||||
<p><a href="{APP_URL}/admin">Im Admin-Bereich prüfen</a></p>
|
||||
"""
|
||||
admin_body = f"""
|
||||
<p style="margin:0 0 12px"><b>Neuer Züchter-Antrag eingegangen:</b></p>
|
||||
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Von</td><td style="padding:5px 0"><b>{user['name']}</b> ({user['email']})</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwingername</td><td style="padding:5px 0">{zwingername}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Rasse</td><td style="padding:5px 0">{rasse_text}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Verein</td><td style="padding:5px 0">{verein}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">VDH</td><td style="padding:5px 0">{'Ja' if vdh_mitglied else 'Nein'}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Stadt</td><td style="padding:5px 0">{stadt}</td></tr>
|
||||
</table>"""
|
||||
try:
|
||||
await send_email(
|
||||
ADMIN_EMAIL,
|
||||
f"[Banyaro] Neuer Züchter-Antrag — {zwingername}",
|
||||
admin_html,
|
||||
email_html(admin_body, cta_url=f"{APP_URL}/#admin", cta_label="Im Admin-Bereich prüfen"),
|
||||
f"Neuer Züchter-Antrag von {user['name']} ({user['email']}): {zwingername}, {rasse_text}, {verein}",
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -233,18 +233,17 @@ async def admin_approve_breeder(user_id: int, admin=Depends(require_admin)):
|
|||
)
|
||||
|
||||
# Bestätigungs-Mail
|
||||
html = f"""
|
||||
<h2>Willkommen als Züchter bei Banyaro!</h2>
|
||||
<p>Hallo {user['name']},</p>
|
||||
<p>dein Züchter-Profil wurde erfolgreich verifiziert.</p>
|
||||
<p>Ab sofort hast du Zugang zu allen Züchter-Features.</p>
|
||||
<p><a href="{APP_URL}">Zur App</a></p>
|
||||
"""
|
||||
approve_body = f"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
dein Züchter-Profil bei Ban Yaro wurde erfolgreich verifiziert. 🎉<br>
|
||||
Ab sofort hast du Zugang zu allen Züchter-Features.
|
||||
</p>"""
|
||||
try:
|
||||
await send_email(
|
||||
user["email"],
|
||||
"Willkommen als Züchter bei Banyaro!",
|
||||
html,
|
||||
"Willkommen als Züchter bei Ban Yaro!",
|
||||
email_html(approve_body, cta_url=APP_URL, cta_label="Zur App"),
|
||||
f"Hallo {user['name']}, dein Züchter-Profil wurde verifiziert.",
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -274,19 +273,25 @@ async def admin_reject_breeder(user_id: int, body: RejectBody, admin=Depends(req
|
|||
)
|
||||
|
||||
# Ablehnungs-Mail
|
||||
html = f"""
|
||||
<h2>Dein Züchter-Antrag bei Banyaro</h2>
|
||||
<p>Hallo {user['name']},</p>
|
||||
<p>leider konnten wir deinen Antrag aktuell nicht bestätigen.</p>
|
||||
<p><b>Grund:</b> {body.grund}</p>
|
||||
<p>Du kannst jederzeit einen neuen Antrag stellen.</p>
|
||||
<p>Bei Fragen: <a href="mailto:{ADMIN_EMAIL}">{ADMIN_EMAIL}</a></p>
|
||||
"""
|
||||
import html as _h
|
||||
reject_body = f"""
|
||||
<p style="margin:0 0 16px">Hallo <b>{user['name']}</b>,</p>
|
||||
<p style="margin:0 0 16px">
|
||||
leider konnten wir deinen Züchter-Antrag aktuell nicht bestätigen.
|
||||
</p>
|
||||
<div style="background:#fdf6ef;border-left:3px solid #C4843A;padding:12px 16px;
|
||||
border-radius:0 8px 8px 0;margin:0 0 16px;font-size:14px">
|
||||
<b>Grund:</b> {_h.escape(body.grund)}
|
||||
</div>
|
||||
<p style="margin:0;color:#666;font-size:14px">
|
||||
Du kannst jederzeit einen neuen Antrag stellen. Bei Fragen erreichst du uns unter
|
||||
<a href="mailto:{ADMIN_EMAIL}" style="color:#C4843A">{ADMIN_EMAIL}</a>.
|
||||
</p>"""
|
||||
try:
|
||||
await send_email(
|
||||
user["email"],
|
||||
"Dein Züchter-Antrag bei Banyaro",
|
||||
html,
|
||||
"Dein Züchter-Antrag bei Ban Yaro",
|
||||
email_html(reject_body),
|
||||
f"Hallo {user['name']}, dein Antrag wurde abgelehnt. Grund: {body.grund}",
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ from typing import Optional
|
|||
from database import db
|
||||
from auth import get_current_user, get_current_user_optional
|
||||
from timeutils import safe_client_time
|
||||
from ratelimit import is_duplicate_post, record_post
|
||||
from content_filter import check_forum_content
|
||||
from routes.push import send_push_to_user
|
||||
from media_utils import convert_media, extract_video_thumb, generate_preview, preview_url_from
|
||||
|
||||
|
|
@ -164,6 +166,50 @@ async def list_threads(
|
|||
# ------------------------------------------------------------------
|
||||
# POST /api/forum/threads
|
||||
# ------------------------------------------------------------------
|
||||
def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False):
|
||||
"""Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts."""
|
||||
# 30-Sekunden-Cooldown zwischen beliebigen Posts
|
||||
last = conn.execute(
|
||||
"""SELECT MAX(created_at) AS last FROM (
|
||||
SELECT created_at FROM forum_threads WHERE user_id=?
|
||||
UNION ALL
|
||||
SELECT created_at FROM forum_posts WHERE user_id=?
|
||||
)""",
|
||||
(user_id, user_id),
|
||||
).fetchone()["last"]
|
||||
if last:
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds()
|
||||
if diff < 30:
|
||||
raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Stunden-Limit
|
||||
if is_thread:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')",
|
||||
(user_id,),
|
||||
).fetchone()[0]
|
||||
if count >= 5:
|
||||
raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.")
|
||||
else:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')",
|
||||
(user_id,),
|
||||
).fetchone()[0]
|
||||
if count >= 20:
|
||||
raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.")
|
||||
|
||||
# Duplikat-Check
|
||||
if is_duplicate_post(user_id, text):
|
||||
raise HTTPException(400, "Dieser Beitrag wurde bereits kürzlich gepostet.")
|
||||
|
||||
# Content-Filter
|
||||
check_forum_content(text, user_created_at)
|
||||
|
||||
|
||||
@router.post("/threads", status_code=201)
|
||||
async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
|
||||
if not user.get("email_verified"):
|
||||
|
|
@ -177,6 +223,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
|
|||
if data.kategorie not in KATEGORIEN:
|
||||
raise HTTPException(400, "Ungültige Kategorie.")
|
||||
with db() as conn:
|
||||
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True)
|
||||
ct = safe_client_time(data.client_time)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
|
||||
|
|
@ -194,6 +241,7 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
|
|||
t = dict(row)
|
||||
t['foto_urls'] = _parse_foto_urls(t.get('foto_urls'))
|
||||
t['user_liked'] = False
|
||||
record_post(user["id"], data.text.strip())
|
||||
return t
|
||||
|
||||
|
||||
|
|
@ -322,6 +370,8 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
|
|||
if thread['is_deleted']:
|
||||
raise HTTPException(404, "Thread nicht gefunden.")
|
||||
|
||||
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False)
|
||||
|
||||
ct = safe_client_time(data.client_time)
|
||||
cur = conn.execute(
|
||||
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
|
||||
|
|
@ -347,6 +397,7 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
|
|||
pd = dict(row)
|
||||
pd['foto_urls'] = []
|
||||
pd['user_liked'] = False
|
||||
record_post(user["id"], data.text.strip())
|
||||
|
||||
# Push-Notification an Thread-Owner (nicht an sich selbst)
|
||||
if owner_id and owner_id != user['id']:
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ async def create_litter(body: LitterCreate, user=Depends(_require_breeder)):
|
|||
# ------------------------------------------------------------------
|
||||
@router.post("/litters/{litter_id}/welfare-confirm")
|
||||
async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
|
||||
from mailer import send_email
|
||||
from mailer import send_email, email_html
|
||||
import os, logging as _log
|
||||
_logger = _log.getLogger(__name__)
|
||||
|
||||
|
|
@ -265,19 +265,20 @@ async def welfare_confirm(litter_id: int, user=Depends(_require_breeder)):
|
|||
eltern = conn.execute(
|
||||
"SELECT vater_name, mutter_name FROM litters WHERE id=?", (litter_id,)
|
||||
).fetchone()
|
||||
html = f"""
|
||||
<h2>Tierschutz-Hinweis bestätigt</h2>
|
||||
<p>Züchter <b>{zuechter}</b> (Zwinger: {zwinger}) hat einen Wurf mit
|
||||
kritischen Tierschutz-Hinweisen trotzdem angelegt.</p>
|
||||
<p>Vater: {eltern['vater_name'] or '—'} · Mutter: {eltern['mutter_name'] or '—'}</p>
|
||||
<p>Wurf-ID: {litter_id}</p>
|
||||
<p><a href="{app_url}/admin">Im Admin-Bereich prüfen</a></p>
|
||||
"""
|
||||
welfare_body = f"""
|
||||
<p style="margin:0 0 12px"><b>Kritischer Tierschutz-Hinweis bestätigt</b></p>
|
||||
<table style="font-size:14px;border-collapse:collapse;width:100%">
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888;white-space:nowrap">Züchter</td><td style="padding:5px 0"><b>{zuechter}</b></td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Zwinger</td><td style="padding:5px 0">{zwinger}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Vater</td><td style="padding:5px 0">{eltern['vater_name'] or '—'}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Mutter</td><td style="padding:5px 0">{eltern['mutter_name'] or '—'}</td></tr>
|
||||
<tr><td style="padding:5px 12px 5px 0;color:#888">Wurf-ID</td><td style="padding:5px 0">#{litter_id}</td></tr>
|
||||
</table>"""
|
||||
try:
|
||||
await send_email(
|
||||
admin_email,
|
||||
f"[Banyaro Tierschutz] Kritischer Hinweis bestätigt — {zwinger}",
|
||||
html,
|
||||
email_html(welfare_body, cta_url=f"{app_url}/#admin", cta_label="Im Admin-Bereich prüfen"),
|
||||
f"Züchter {zuechter} hat Wurf #{litter_id} trotz kritischer Tierschutz-Hinweise angelegt.",
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ def _imap_save_sent(msg_bytes: bytes, account: str):
|
|||
_log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e)
|
||||
|
||||
|
||||
def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultipart:
|
||||
def _build_message(to: str, subject: str, body: str, account: str, html: str = None) -> MIMEMultipart:
|
||||
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
|
|
@ -92,14 +92,16 @@ def _build_message(to: str, subject: str, body: str, account: str) -> MIMEMultip
|
|||
msg["To"] = to
|
||||
msg["Reply-To"] = acc["from"]
|
||||
msg.attach(MIMEText(body, "plain", "utf-8"))
|
||||
if html:
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
return msg
|
||||
|
||||
|
||||
def _send_smtp(to: str, subject: str, body: str, account: str = "partner"):
|
||||
def _send_smtp(to: str, subject: str, body: str, account: str = "partner", html: str = None):
|
||||
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
|
||||
if not acc["user"] or not acc["pass"]:
|
||||
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
|
||||
msg = _build_message(to, subject, body, account)
|
||||
msg = _build_message(to, subject, body, account, html=html)
|
||||
msg_bytes = msg.as_bytes()
|
||||
ctx = ssl.create_default_context()
|
||||
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
|
||||
|
|
@ -189,6 +191,16 @@ def delete_template(tpl_id: int, user=Depends(require_admin)):
|
|||
# Senden
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _plain_to_html_body(text: str) -> str:
|
||||
import html as h
|
||||
paragraphs = text.strip().split("\n\n")
|
||||
parts = []
|
||||
for p in paragraphs:
|
||||
escaped = h.escape(p).replace("\n", "<br>")
|
||||
parts.append(f'<p style="margin:0 0 14px;color:#444">{escaped}</p>')
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
def send_mail(data: SendRequest, user=Depends(require_admin)):
|
||||
if not data.to:
|
||||
|
|
@ -196,13 +208,19 @@ def send_mail(data: SendRequest, user=Depends(require_admin)):
|
|||
if not data.subject.strip() or not data.body.strip():
|
||||
raise HTTPException(400, "Betreff und Text dürfen nicht leer sein.")
|
||||
|
||||
from mailer import email_html
|
||||
html = email_html(
|
||||
_plain_to_html_body(data.body),
|
||||
footer_text=f"Ban Yaro · banyaro.app · {data.subject}",
|
||||
)
|
||||
|
||||
sent, failed = [], []
|
||||
for addr in data.to:
|
||||
addr = addr.strip()
|
||||
if not addr:
|
||||
continue
|
||||
try:
|
||||
_send_smtp(addr, data.subject, data.body, data.from_account)
|
||||
_send_smtp(addr, data.subject, data.body, data.from_account, html=html)
|
||||
sent.append(addr)
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
|
|
@ -224,7 +242,9 @@ def send_mail(data: SendRequest, user=Depends(require_admin)):
|
|||
|
||||
def send_support_mail(to: str, subject: str, body: str):
|
||||
"""Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik."""
|
||||
_send_smtp(to, subject, body, "support")
|
||||
from mailer import email_html
|
||||
html = email_html(_plain_to_html_body(body))
|
||||
_send_smtp(to, subject, body, "support", html=html)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue