banyaro/backend/routes/outreach.py
rene e79290edb7 Feature: Mailing — Template-Manager, zwei SMTP-Accounts (partner/support)
- email_templates Tabelle (CRUD), Startwert-Vorlage wird einmalig geseedet
- outreach_log.from_account Spalte ergänzt
- Admin-UI: Template-Liste mit Laden/Bearbeiten/Löschen + Modal zum Anlegen
- Compose mit Absender-Auswahl (partner@/support@)
- send_support_mail() intern aufrufbar für Moderations-Trigger
- SW by-v574, APP_VER 551
2026-04-30 19:41:58 +02:00

188 lines
6.1 KiB
Python

"""BAN YARO — Mailing (Admin)"""
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
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import require_admin
from database import db
router = APIRouter()
_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de")
_SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
_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",
},
}
def _send_smtp(to: str, subject: str, body: str, account: str = "partner"):
acc = _ACCOUNTS.get(account) or _ACCOUNTS["partner"]
if not acc["user"] or not acc["pass"]:
raise RuntimeError(f"SMTP-Account '{account}' nicht konfiguriert.")
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"))
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.as_bytes())
# ------------------------------------------------------------------
# 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
# ------------------------------------------------------------------
@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.")
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)
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."""
_send_smtp(to, subject, body, "support")
# ------------------------------------------------------------------
# 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]