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 = `
+ +
+
+

Vorlagen

+ +
+ ${templates.length === 0 + ? `

Noch keine Vorlagen.

` + : `
+ ${templates.map(t => ` +
+
+
+ ${_esc(t.label)} + ${accountBadge(t.from_account)} +
+
+ ${_esc(t.subject)} +
+
+
+ + + +
+
`).join('')} +
`} +
+
-

E-Mail senden

-

- Von: partner@banyaro.app via Hetzner SMTP -

- +

E-Mail senden

- -
- - -
- - -
- - + +
+
+ + +
+
+ + +
@@ -2063,7 +2101,7 @@ window.Page_admin = (() => {
-
@@ -2072,7 +2110,7 @@ window.Page_admin = (() => { ${UI.icon('paper-plane-tilt')} Senden - Hinweis: {name} im Text wird nicht automatisch ersetzt — bitte manuell anpassen. + {name} wird nicht automatisch ersetzt — bitte manuell anpassen.
@@ -2086,17 +2124,21 @@ window.Page_admin = (() => { : ` + - + + ${log.map(l => ` + - + + `).join('')}
Von Empfänger BetreffGesendetWerWann
${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',' ')}
`} @@ -2105,36 +2147,127 @@ window.Page_admin = (() => {
`; - // Vorlage laden - el.querySelector('#adm-outreach-tpl')?.addEventListener('change', e => { - const tpl = templates.find(t => t.id === e.target.value); + // Vorlage in Compose laden + function _loadTplIntoCompose(id) { + const tpl = templates.find(t => t.id === id); if (!tpl) return; + el.querySelector('#adm-outreach-from').value = tpl.from_account || 'partner'; el.querySelector('#adm-outreach-subject').value = tpl.subject; - el.querySelector('#adm-outreach-body').value = tpl.body; + el.querySelector('#adm-outreach-body').value = tpl.body; + } + + el.querySelectorAll('.adm-tpl-load').forEach(btn => { + btn.addEventListener('click', () => _loadTplIntoCompose(Number(btn.dataset.id))); }); + // Vorlage löschen + el.querySelectorAll('.adm-tpl-del').forEach(btn => { + btn.addEventListener('click', async () => { + if (!window.confirm('Vorlage löschen?')) return; + await API.del(`/outreach/templates/${btn.dataset.id}`); + await _renderOutreach(el); + }); + }); + + // Vorlage bearbeiten + el.querySelectorAll('.adm-tpl-edit').forEach(btn => { + btn.addEventListener('click', () => { + const tpl = templates.find(t => t.id === Number(btn.dataset.id)); + if (tpl) _openTplModal(el, tpl); + }); + }); + + // Neue Vorlage + el.querySelector('#adm-tpl-new')?.addEventListener('click', () => _openTplModal(el, null)); + // 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(); + const btn = e.target.querySelector('[type="submit"]'); + const from_account = el.querySelector('#adm-outreach-from').value; + 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; } + 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 }); + const res = await API.post('/outreach/send', { to, subject, body, from_account }); 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(', ')}`); + if (res.failed?.length) UI.toast.error(`${res.failed.length} Fehler: ${res.failed.map(f => f.error).join(', ')}`); await _renderOutreach(el); }); }); } + function _openTplModal(el, tpl) { + const isNew = !tpl; + const id = `adm-tpl-modal-${Date.now()}`; + UI.modal.open({ + title: isNew ? 'Neue Vorlage' : 'Vorlage bearbeiten', + body: ` +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
`, + footer: ` + + `, + }); + + document.getElementById(id)?.addEventListener('submit', async e => { + e.preventDefault(); + const payload = { + label: document.getElementById(`${id}-label`).value.trim(), + subject: document.getElementById(`${id}-subject`).value.trim(), + body: document.getElementById(`${id}-body`).value.trim(), + from_account: document.getElementById(`${id}-from`).value, + }; + if (!payload.label || !payload.subject || !payload.body) { + UI.toast.warning('Alle Felder ausfüllen.'); return; + } + if (isNew) { + const key = document.getElementById(`${id}-key`).value.trim(); + if (!key) { UI.toast.warning('Interner Name fehlt.'); return; } + await API.post('/outreach/templates', { ...payload, key }); + } else { + await API.put(`/outreach/templates/${tpl.id}`, payload); + } + UI.modal.close(); + await _renderOutreach(el); + }); + } + async function _renderAudit(el) { el.innerHTML = `
diff --git a/backend/static/sw.js b/backend/static/sw.js index fd9719f..4cd74e1 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v573'; +const CACHE_VERSION = 'by-v574'; 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 diff --git a/docker-compose.yml b/docker-compose.yml index a3d6772..d1f4a45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_CONTACT=mailto:admin@banyaro.app + - SMTP_SUPPORT_USER=support@banyaro.de + - SMTP_SUPPORT_PASS=Marbled-Drool8-Whacky healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] interval: 30s