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
This commit is contained in:
rene 2026-04-30 19:41:58 +02:00
parent b17b061496
commit e79290edb7
6 changed files with 331 additions and 98 deletions

View file

@ -1,4 +1,4 @@
"""BAN YARO — Outreach E-Mail (Admin)"""
"""BAN YARO — Mailing (Admin)"""
import os
import smtplib
@ -7,81 +7,134 @@ 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 typing import List, Optional
from auth import require_admin
from database import db
router = APIRouter()
_SMTP_HOST = os.getenv("SMTP_HOST", "")
_SMTP_HOST = os.getenv("SMTP_HOST", "mail.your-server.de")
_SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
_SMTP_USER = os.getenv("SMTP_USER", "")
_SMTP_PASS = os.getenv("SMTP_PASS", "")
_SMTP_FROM = os.getenv("SMTP_FROM", "partner@banyaro.app")
TEMPLATES = {
"influencer_de": {
"label": "Influencer-Ansprache (DE)",
"subject": "Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community",
"body": """Hallo {name},
ich bin René und habe Ban Yaro gebaut eine Hunde-App für Tagebuch, Gesundheit, Giftköder-Alarm und Community. Kostenlos, ohne App Store, direkt als PWA.
Ich kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot:
Was deine Follower bekommen: Wer sich mit deinem persönlichen Code registriert, sichert sich einen der 100 Gründer-Plätze eine nummerierte Badge ("Gründer #42") die dauerhaft im Profil sichtbar ist. Diese 100 Plätze gibt es genau einmal.
Was du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt.
Kein Geld, kein verpflichtender Post aber eine echte Exklusivität die du deiner Community geben kannst.
Alle Infos: https://banyaro.app/partner
Wenn dich das interessiert, antworte einfach kurz ich richte deinen Code binnen 24h ein.
Viele Grüße,
René
banyaro.app""",
_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
template_name: Optional[str] = None
from_account: str = "partner"
template_id: Optional[int] = None
def _send_smtp(to: str, subject: str, body: str):
if not _SMTP_HOST or not _SMTP_USER:
raise RuntimeError("SMTP nicht konfiguriert.")
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = formataddr(("Ban Yaro Partner", _SMTP_FROM))
msg["To"] = to
msg["Reply-To"] = _SMTP_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(_SMTP_USER, _SMTP_PASS)
s.sendmail(_SMTP_FROM, to, msg.as_bytes())
# ------------------------------------------------------------------
# Templates CRUD
# ------------------------------------------------------------------
@router.get("/templates")
def list_templates(user=Depends(require_admin)):
return [{"id": k, "label": v["label"], "subject": v["subject"], "body": v["body"]}
for k, v in TEMPLATES.items()]
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_outreach(data: SendRequest, user=Depends(require_admin)):
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():
@ -93,15 +146,14 @@ def send_outreach(data: SendRequest, user=Depends(require_admin)):
if not addr:
continue
try:
_send_smtp(addr, data.subject, data.body)
_send_smtp(addr, data.subject, data.body, data.from_account)
sent.append(addr)
# Log in DB
with db() as conn:
conn.execute(
"""INSERT INTO outreach_log
(sent_by, recipient, subject, body, sent_at)
VALUES (?, ?, ?, ?, ?)""",
(user["id"], addr, data.subject, data.body,
(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:
@ -110,14 +162,27 @@ def send_outreach(data: SendRequest, user=Depends(require_admin)):
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(user=Depends(require_admin)):
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,
u.name AS sent_by_name
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 100"""
ORDER BY ol.sent_at DESC LIMIT 200"""
).fetchall()
return [dict(r) for r in rows]