diff --git a/backend/database.py b/backend/database.py index 9822d42..5269002 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1509,6 +1509,21 @@ def _migrate(conn_factory): except Exception as e: logger.warning(f"Migration partner_codes: {e}") + # Outreach-Log (Admin-E-Mail-Versand) + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS outreach_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sent_by INTEGER REFERENCES users(id), + recipient TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + except Exception as e: + logger.warning(f"Migration outreach_log: {e}") + # 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/main.py b/backend/main.py index fb55815..ddb4acc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -163,6 +163,7 @@ from routes.zucht_hunde import router as zucht_hunde_router from routes.breeder_export import router as breeder_export_router from routes.zucht_ki import router as zucht_ki_router from routes.partner import router as partner_router +from routes.outreach import router as outreach_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -195,6 +196,7 @@ app.include_router(zucht_hunde_router, prefix="/api", tags=["Zuchtkart app.include_router(breeder_export_router, prefix="/api", tags=["Export"]) app.include_router(zucht_ki_router, prefix="/api", tags=["Züchter-KI"]) app.include_router(partner_router, prefix="/api", tags=["Partner"]) +app.include_router(outreach_router, prefix="/api/outreach", tags=["Outreach"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) app.include_router(import_router, prefix="/api/import", tags=["Import"]) diff --git a/backend/routes/outreach.py b/backend/routes/outreach.py new file mode 100644 index 0000000..f2ca8ff --- /dev/null +++ b/backend/routes/outreach.py @@ -0,0 +1,123 @@ +"""BAN YARO — Outreach E-Mail (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 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_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""", + }, +} + + +class SendRequest(BaseModel): + to: List[str] + subject: str + body: str + template_name: Optional[str] = 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()) + + +@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()] + + +@router.post("/send") +def send_outreach(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) + 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, + datetime.utcnow().isoformat()) + ) + except Exception as e: + failed.append({"addr": addr, "error": str(e)}) + + return {"sent": sent, "failed": failed} + + +@router.get("/log") +def outreach_log(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 + FROM outreach_log ol + JOIN users u ON u.id = ol.sent_by + ORDER BY ol.sent_at DESC LIMIT 100""" + ).fetchall() + return [dict(r) for r in rows] diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3680a86..74f5a94 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 = '543'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '544'; // ← 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 69ed773..b05c6f6 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -20,6 +20,7 @@ window.Page_admin = (() => { { id: 'system', label: 'System', icon: 'gear' }, { id: 'jobs', label: 'Jobs', icon: 'clock' }, { id: 'partner', label: 'Partner', icon: 'handshake' }, + { id: 'outreach', label: 'Outreach', icon: 'envelope' }, { id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' }, ]; @@ -90,6 +91,7 @@ window.Page_admin = (() => { case 'system': await _renderSystem(el); break; case 'jobs': await _renderJobs(el); break; case 'partner': await _renderPartner(el); break; + case 'outreach': await _renderOutreach(el); break; case 'audit': await _renderAudit(el); break; } } catch (e) { @@ -2016,6 +2018,123 @@ window.Page_admin = (() => { }); } + async function _renderOutreach(el) { + const [templates, log] = await Promise.all([ + API.get('/outreach/templates').catch(() => []), + API.get('/outreach/log').catch(() => []), + ]); + + el.innerHTML = ` +
+ Von: partner@banyaro.app via Hetzner SMTP +
+ + +Noch keine E-Mails gesendet.
` + : `| Empfänger | +Betreff | +Gesendet | +
|---|---|---|
| ${_esc(l.recipient)} | +${_esc(l.subject)} | +${l.sent_at?.slice(0,16).replace('T',' ')} | +