banyaro/backend/routes/outreach.py
rene 1ff66a7083 Sicherheit + Tests + A11y, SW by-v1118
PYDANTIC max_length (38 Routen, ~400 Field-Constraints):
Schützt vor DoS durch Riesen-Payloads (10MB Thread-Titel etc.).
Pragmatische Limits:
- Titel/Name: 200 · Beschreibung/Body: 10000 · Notiz: 5000
- Email: 254 (RFC 5321) · URL: 500 · Slug/Kategorie: 100
- Hund-Name/Rasse: 80 · Hund-Bio: 2000

Top-betroffen: forum.py, diary.py, health.py, dogs.py, expenses.py,
notes.py, auth.py, profile.py. Manuelle len()-Checks in profile,
chat, ki entfernt (jetzt durch Field abgedeckt).

PYTEST COVERAGE (+19 Tests, 37 grün + 1 xfail):
- test_security.py: require_owner (Places GET/PATCH/DELETE mit
  Fremduser → 403), JWT-Blacklist (Logout invalidiert Token),
  Login-Lockout (5 Fehlversuche → 429 + Retry-After Header)
- test_race.py: Invoice-Counter (20 parallele Threads, alle unique),
  Founder-Number (atomare Vergabe, voll bei 100)
- test_validation.py: Forum-Titel 30k Zeichen → 422, Diary-Text
  50k → 422 (verifiziert Pydantic max_length-Sweep)

A11Y (Tap-Targets ≥44×44 + Dark-Mode-Kontrast):
- #header-user-btn 36→44px, .header-back 40→44, .header-menu-btn 40→44
- dog-profile Wrapped-Slider Prev/Next 40→44
- forum-Lightbox Close 40→44
- --c-text-muted Light: #B0A090 (2.37:1 FAIL) → #7F6B58 (4.74:1 PASS)
- --c-text-muted Dark:  #806A58 (3.58:1 FAIL) → #A08878 (5.46:1 PASS)
- Branding-Farben unangetastet
2026-05-27 13:40:30 +02:00

282 lines
10 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, formatdate
from datetime import datetime
from typing import List, Optional
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
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["Date"] = formatdate(localtime=False) # UTC explizit, Container hat keine lokale TZ
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
_LEGAL_FOOTER = (
"\n\n---\n"
"Ban Yaro | René Degelmann | Ringstr. 26, D-85560 Ebersberg\n"
"Web: https://banyaro.app | Mail: partner@banyaro.app\n\n"
"Datenschutzhinweis: Deine Kontaktdaten stammen aus deinem öffentlichen Profil. "
"Verarbeitung auf Basis berechtigten Interesses (Art. 6 Abs. 1 lit. f DSGVO). "
"Datenschutzerklärung: https://banyaro.app/datenschutz\n"
"Widerspruch/Löschung: Einfach auf diese Mail antworten."
)
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 + _LEGAL_FOOTER, account, html=html)
msg_bytes = msg.as_bytes()
ctx = ssl.create_default_context()
if _SMTP_PORT == 465:
with smtplib.SMTP_SSL(_SMTP_HOST, _SMTP_PORT, context=ctx, timeout=15) as s:
s.login(acc["user"], acc["pass"])
s.sendmail(acc["from"], [to], msg_bytes)
else: # 587 oder 25 mit STARTTLS
with smtplib.SMTP(_SMTP_HOST, _SMTP_PORT, timeout=15) as s:
s.ehlo()
if s.has_extn("starttls"):
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 = Field(..., max_length=100)
label: str = Field(..., max_length=200)
subject: str = Field(..., max_length=500)
body: str = Field(..., max_length=50000)
from_account: str = Field("partner", max_length=50)
class TemplateUpdate(BaseModel):
label: str = Field(..., max_length=200)
subject: str = Field(..., max_length=500)
body: str = Field(..., max_length=50000)
from_account: str = Field("partner", max_length=50)
class SendRequest(BaseModel):
to: List[str]
subject: str = Field(..., max_length=500)
body: str = Field(..., max_length=50000)
from_account: str = Field("partner", max_length=50)
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.body, 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]