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
282 lines
10 KiB
Python
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]
|