Feature: Admin Outreach — E-Mail-Versand via Hetzner SMTP, Vorlagen, Log, SW by-v567

This commit is contained in:
rene 2026-04-30 19:10:54 +02:00
parent 230455c250
commit b6258db6bc
6 changed files with 261 additions and 2 deletions

View file

@ -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:

View file

@ -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"])

123
backend/routes/outreach.py Normal file
View file

@ -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]

View file

@ -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';

View file

@ -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 = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Compose -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-1);font-size:var(--text-base)">E-Mail senden</h3>
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0 0 var(--space-4)">
Von: <strong>partner@banyaro.app</strong> via Hetzner SMTP
</p>
<form id="adm-outreach-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<!-- Vorlage -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Vorlage laden</label>
<select id="adm-outreach-tpl" class="form-control">
<option value=""> Vorlage wählen </option>
${templates.map(t => `<option value="${t.id}">${_esc(t.label)}</option>`).join('')}
</select>
</div>
<!-- Empfänger -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">
Empfänger <span style="color:var(--c-text-muted)">(mehrere mit Komma trennen)</span>
</label>
<input class="form-control" id="adm-outreach-to" type="text"
placeholder="name@example.com, andere@example.com">
</div>
<!-- Betreff -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
<input class="form-control" id="adm-outreach-subject" type="text">
</div>
<!-- Text -->
<div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="adm-outreach-body" class="form-control" rows="12"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea>
</div>
<div style="display:flex;gap:var(--space-3);align-items:center">
<button type="submit" class="btn btn-primary">
${UI.icon('paper-plane-tilt')} Senden
</button>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">
Hinweis: {name} im Text wird nicht automatisch ersetzt bitte manuell anpassen.
</span>
</div>
</form>
</div>
<!-- Versand-Log -->
<div class="by-card" style="padding:var(--space-4)">
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">Versand-Log</h3>
${log.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine E-Mails gesendet.</p>`
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Empfänger</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Betreff</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Gesendet</th>
</tr>
</thead>
<tbody>
${log.map(l => `
<tr style="border-bottom:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${_esc(l.subject)}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${l.sent_at?.slice(0,16).replace('T',' ')}</td>
</tr>`).join('')}
</tbody>
</table>`}
</div>
</div>
`;
// Vorlage laden
el.querySelector('#adm-outreach-tpl')?.addEventListener('change', e => {
const tpl = templates.find(t => t.id === e.target.value);
if (!tpl) return;
el.querySelector('#adm-outreach-subject').value = tpl.subject;
el.querySelector('#adm-outreach-body').value = tpl.body;
});
// Senden
el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = e.target.querySelector('[type="submit"]');
const to = (el.querySelector('#adm-outreach-to').value || '')
.split(',').map(s => s.trim()).filter(Boolean);
const subject = el.querySelector('#adm-outreach-subject').value.trim();
const body = el.querySelector('#adm-outreach-body').value.trim();
if (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; }
if (!subject) { UI.toast.warning('Betreff fehlt.'); return; }
if (!body) { UI.toast.warning('Text fehlt.'); return; }
await UI.asyncButton(btn, async () => {
const res = await API.post('/outreach/send', { to, subject, body });
if (res.sent?.length) UI.toast.success(`${res.sent.length} E-Mail(s) gesendet.`);
if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f=>f.error).join(', ')}`);
await _renderOutreach(el);
});
});
}
async function _renderAudit(el) {
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v566';
const CACHE_VERSION = 'by-v567';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache