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:
parent
b17b061496
commit
e79290edb7
6 changed files with 331 additions and 98 deletions
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue