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"
264 lines
8.9 KiB
Python
264 lines
8.9 KiB
Python
"""BAN YARO — Mailing (Admin)"""
|
|
|
|
import imaplib
|
|
import os
|
|
import smtplib
|
|
import ssl
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.utils import formataddr
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from auth import require_admin
|
|
from database import db
|
|
|
|
router = APIRouter()
|
|
_log = logging.getLogger(__name__)
|
|
|
|
_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de")
|
|
_SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|
_IMAP_HOST = os.getenv("IMAP_HOST", "mail.your-server.de")
|
|
_IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
|
|
|
|
_ACCOUNTS = {
|
|
"partner": {
|
|
"user": os.getenv("SMTP_USER", ""),
|
|
"pass": os.getenv("SMTP_PASS", ""),
|
|
"from": "partner@banyaro.app",
|
|
"name": "Ban Yaro Partner",
|
|
},
|
|
"support": {
|
|
"user": os.getenv("SMTP_SUPPORT_USER", "support@banyaro.de"),
|
|
"pass": os.getenv("SMTP_SUPPORT_PASS", ""),
|
|
"from": "support@banyaro.app",
|
|
"name": "Ban Yaro Support",
|
|
},
|
|
}
|
|
|
|
# Mögliche Namen für den Sent-Ordner (Hetzner/Dovecot)
|
|
_SENT_CANDIDATES = ["Sent", "Sent Messages", "Sent Items", "INBOX.Sent", "Gesendete Objekte"]
|
|
|
|
|
|
def _imap_save_sent(msg_bytes: bytes, account: str):
|
|
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
|
|
if not acc["user"] or not acc["pass"]:
|
|
_log.warning("IMAP: Account '%s' nicht konfiguriert, überspringe.", account)
|
|
return
|
|
try:
|
|
ctx = ssl.create_default_context()
|
|
with imaplib.IMAP4_SSL(_IMAP_HOST, _IMAP_PORT, ssl_context=ctx) as imap:
|
|
imap.login(acc["user"], acc["pass"])
|
|
_, raw_folders = imap.list()
|
|
available = [f.decode(errors="replace") for f in (raw_folders or [])]
|
|
_log.info("IMAP Ordner (%s): %s", account, available)
|
|
|
|
# Echten Ordnernamen aus LIST-Antwort extrahieren
|
|
# Format: '(\Flags) "." INBOX.Sent' → letztes Token
|
|
folder = None
|
|
for line in available:
|
|
name = line.rsplit('"." ', 1)[-1].strip().strip('"')
|
|
for candidate in _SENT_CANDIDATES:
|
|
if candidate.lower() in name.lower():
|
|
folder = name
|
|
break
|
|
if folder:
|
|
break
|
|
if not folder:
|
|
folder = "INBOX.Sent"
|
|
_log.info("IMAP: speichere in Ordner '%s' (%s)", folder, account)
|
|
|
|
typ, data = imap.append(
|
|
folder,
|
|
r"\Seen",
|
|
imaplib.Time2Internaldate(datetime.now().timestamp()),
|
|
msg_bytes,
|
|
)
|
|
_log.info("IMAP append: %s %s", typ, data)
|
|
except Exception as e:
|
|
_log.error("IMAP Sent-Speicherung fehlgeschlagen (%s): %s", account, e)
|
|
|
|
|
|
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
|
|
msg["From"] = formataddr((acc["name"], acc["from"]))
|
|
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", 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, html=html)
|
|
msg_bytes = msg.as_bytes()
|
|
ctx = ssl.create_default_context()
|
|
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
|
|
s.ehlo()
|
|
s.starttls(context=ctx)
|
|
s.login(acc["user"], acc["pass"])
|
|
s.sendmail(acc["from"], [to], msg_bytes)
|
|
_imap_save_sent(msg_bytes, account)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Schemas
|
|
# ------------------------------------------------------------------
|
|
|
|
class TemplateIn(BaseModel):
|
|
key: str
|
|
label: str
|
|
subject: str
|
|
body: str
|
|
from_account: str = "partner"
|
|
|
|
|
|
class TemplateUpdate(BaseModel):
|
|
label: str
|
|
subject: str
|
|
body: str
|
|
from_account: str = "partner"
|
|
|
|
|
|
class SendRequest(BaseModel):
|
|
to: List[str]
|
|
subject: str
|
|
body: str
|
|
from_account: str = "partner"
|
|
template_id: Optional[int] = None
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Templates CRUD
|
|
# ------------------------------------------------------------------
|
|
|
|
@router.get("/templates")
|
|
def list_templates(user=Depends(require_admin)):
|
|
with db() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, key, label, subject, body, from_account FROM email_templates ORDER BY id"
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
@router.post("/templates", status_code=201)
|
|
def create_template(data: TemplateIn, user=Depends(require_admin)):
|
|
try:
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"""INSERT INTO email_templates (key, label, subject, body, from_account, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING id""",
|
|
(data.key, data.label, data.subject, data.body, data.from_account,
|
|
datetime.utcnow().isoformat())
|
|
).fetchone()
|
|
return {"id": row["id"]}
|
|
except Exception as e:
|
|
raise HTTPException(400, f"Vorlage konnte nicht angelegt werden: {e}")
|
|
|
|
|
|
@router.put("/templates/{tpl_id}")
|
|
def update_template(tpl_id: int, data: TemplateUpdate, user=Depends(require_admin)):
|
|
with db() as conn:
|
|
conn.execute(
|
|
"""UPDATE email_templates
|
|
SET label=?, subject=?, body=?, from_account=?, updated_at=?
|
|
WHERE id=?""",
|
|
(data.label, data.subject, data.body, data.from_account,
|
|
datetime.utcnow().isoformat(), tpl_id)
|
|
)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/templates/{tpl_id}")
|
|
def delete_template(tpl_id: int, user=Depends(require_admin)):
|
|
with db() as conn:
|
|
conn.execute("DELETE FROM email_templates WHERE id=?", (tpl_id,))
|
|
return {"ok": True}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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:
|
|
raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.")
|
|
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, html=html)
|
|
sent.append(addr)
|
|
with db() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO outreach_log
|
|
(sent_by, recipient, subject, body, from_account, sent_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(user["id"], addr, data.subject, data.body, data.from_account,
|
|
datetime.utcnow().isoformat())
|
|
)
|
|
except Exception as e:
|
|
failed.append({"addr": addr, "error": str(e)})
|
|
|
|
return {"sent": sent, "failed": failed}
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Support-Versand (intern, ohne Admin-Auth — für Moderatoren-Trigger)
|
|
# ------------------------------------------------------------------
|
|
|
|
def send_support_mail(to: str, subject: str, body: str):
|
|
"""Intern aufrufbar ohne HTTP-Request — z.B. aus Moderations-Logik."""
|
|
from mailer import email_html
|
|
html = email_html(_plain_to_html_body(body))
|
|
_send_smtp(to, subject, body, "support", html=html)
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# Log
|
|
# ------------------------------------------------------------------
|
|
|
|
@router.get("/log")
|
|
def outreach_log_endpoint(user=Depends(require_admin)):
|
|
with db() as conn:
|
|
rows = conn.execute(
|
|
"""SELECT ol.id, ol.recipient, ol.subject, ol.sent_at,
|
|
ol.from_account, u.name AS sent_by_name
|
|
FROM outreach_log ol
|
|
JOIN users u ON u.id = ol.sent_by
|
|
ORDER BY ol.sent_at DESC LIMIT 200"""
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|