diff --git a/backend/database.py b/backend/database.py index 5269002..b6e5d8a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1524,6 +1524,39 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration outreach_log: {e}") + # E-Mail-Vorlagen (DB-gespeichert, CRUD über Admin) + try: + conn.execute(""" + CREATE TABLE IF NOT EXISTS email_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + label TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + from_account TEXT NOT NULL DEFAULT 'partner', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT + ) + """) + # Startwert-Vorlage einspielen wenn Tabelle noch leer + count = conn.execute("SELECT COUNT(*) FROM email_templates").fetchone()[0] + if count == 0: + conn.execute(""" + INSERT INTO email_templates (key, label, subject, body, from_account) VALUES + ('influencer_de', + 'Influencer-Ansprache (DE)', + 'Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community', + 'Hallo {name},\n\nich 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.\n\nIch kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot:\n\nWas 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.\n\nWas du bekommst: Partner-Badge in der App, eigener Code, öffentliches Ranking wer die meisten Gründer bringt.\n\nKein Geld, kein verpflichtender Post — aber eine echte Exklusivität die du deiner Community geben kannst.\n\nAlle Infos: https://banyaro.app/partner\n\nWenn dich das interessiert, antworte einfach kurz — ich richte deinen Code binnen 24h ein.\n\nViele Grüße,\nRené\nbanyaro.app', + 'partner') + """) + except Exception as e: + logger.warning(f"Migration email_templates: {e}") + + # from_account-Spalte in outreach_log nachträglich hinzufügen + existing_ol = [row[1] for row in conn.execute("PRAGMA table_info(outreach_log)").fetchall()] + if 'from_account' not in existing_ol: + conn.execute("ALTER TABLE outreach_log ADD COLUMN from_account TEXT DEFAULT 'partner'") + # js_exercise_id zu training_exercises — verbindet training_exercises mit exercise_progress existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()] if 'js_exercise_id' not in existing_te: diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py index f2ca8ff..11f4152 100644 --- a/backend/routes/outreach.py +++ b/backend/routes/outreach.py @@ -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] diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bf5c33e..3b3a388 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '550'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '551'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index daa83a4..cd34154 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2024,34 +2024,72 @@ window.Page_admin = (() => { API.get('/outreach/log').catch(() => []), ]); + const accountBadge = a => a === 'support' + ? `support@` + : `partner@`; + el.innerHTML = `
Noch keine Vorlagen.
` + : `- Von: partner@banyaro.app via Hetzner SMTP -
- +| Von | Empfänger | Betreff | -Gesendet | +Wer | +Wann |
|---|---|---|---|---|---|
| ${accountBadge(l.from_account)} | ${_esc(l.recipient)} | ${_esc(l.subject)} | -${l.sent_at?.slice(0,16).replace('T',' ')} | +${_esc(l.sent_by_name || '')} | +${(l.sent_at||'').slice(0,16).replace('T',' ')} |