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:
rene 2026-04-30 19:41:58 +02:00
parent b17b061496
commit e79290edb7
6 changed files with 331 additions and 98 deletions

View file

@ -1524,6 +1524,39 @@ def _migrate(conn_factory):
except Exception as e: except Exception as e:
logger.warning(f"Migration outreach_log: {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 # 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()] existing_te = [row[1] for row in conn.execute("PRAGMA table_info(training_exercises)").fetchall()]
if 'js_exercise_id' not in existing_te: if 'js_exercise_id' not in existing_te:

View file

@ -1,4 +1,4 @@
"""BAN YARO — Outreach E-Mail (Admin)""" """BAN YARO — Mailing (Admin)"""
import os import os
import smtplib import smtplib
@ -7,81 +7,134 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.utils import formataddr from email.utils import formataddr
from datetime import datetime from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional
from auth import require_admin from auth import require_admin
from database import db from database import db
router = APIRouter() 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_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 = { _ACCOUNTS = {
"influencer_de": { "partner": {
"label": "Influencer-Ansprache (DE)", "user": os.getenv("SMTP_USER", ""),
"subject": "Ban Yaro — 100 Gründer-Plätze, einer davon für deine Community", "pass": os.getenv("SMTP_PASS", ""),
"body": """Hallo {name}, "from": "partner@banyaro.app",
"name": "Ban Yaro Partner",
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. },
"support": {
Ich kontaktiere gerade einige Influencer aus der deutschen Hunde-Community mit einem konkreten Angebot: "user": os.getenv("SMTP_SUPPORT_USER", "support@banyaro.de"),
"pass": os.getenv("SMTP_SUPPORT_PASS", ""),
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. "from": "support@banyaro.app",
"name": "Ban Yaro Support",
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""",
}, },
} }
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): class SendRequest(BaseModel):
to: List[str] to: List[str]
subject: str subject: str
body: 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: # Templates CRUD
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") @router.get("/templates")
def list_templates(user=Depends(require_admin)): def list_templates(user=Depends(require_admin)):
return [{"id": k, "label": v["label"], "subject": v["subject"], "body": v["body"]} with db() as conn:
for k, v in TEMPLATES.items()] 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") @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: if not data.to:
raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.") raise HTTPException(400, "Mindestens eine Empfänger-Adresse angeben.")
if not data.subject.strip() or not data.body.strip(): 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: if not addr:
continue continue
try: try:
_send_smtp(addr, data.subject, data.body) _send_smtp(addr, data.subject, data.body, data.from_account)
sent.append(addr) sent.append(addr)
# Log in DB
with db() as conn: with db() as conn:
conn.execute( conn.execute(
"""INSERT INTO outreach_log """INSERT INTO outreach_log
(sent_by, recipient, subject, body, sent_at) (sent_by, recipient, subject, body, from_account, sent_at)
VALUES (?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?)""",
(user["id"], addr, data.subject, data.body, (user["id"], addr, data.subject, data.body, data.from_account,
datetime.utcnow().isoformat()) datetime.utcnow().isoformat())
) )
except Exception as e: except Exception as e:
@ -110,14 +162,27 @@ def send_outreach(data: SendRequest, user=Depends(require_admin)):
return {"sent": sent, "failed": failed} 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") @router.get("/log")
def outreach_log(user=Depends(require_admin)): def outreach_log_endpoint(user=Depends(require_admin)):
with db() as conn: with db() as conn:
rows = conn.execute( rows = conn.execute(
"""SELECT ol.id, ol.recipient, ol.subject, ol.sent_at, """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 FROM outreach_log ol
JOIN users u ON u.id = ol.sent_by 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() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 APP_VERSION = '1.1.4'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';

View file

@ -2024,34 +2024,72 @@ window.Page_admin = (() => {
API.get('/outreach/log').catch(() => []), API.get('/outreach/log').catch(() => []),
]); ]);
const accountBadge = a => a === 'support'
? `<span style="font-size:10px;background:var(--c-warning-bg,#FEF3C7);color:var(--c-warning,#D97706);padding:1px 6px;border-radius:999px">support@</span>`
: `<span style="font-size:10px;background:var(--c-primary-bg,#EFF6FF);color:var(--c-primary,#2563EB);padding:1px 6px;border-radius:999px">partner@</span>`;
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;flex-direction:column;gap:var(--space-5)"> <div style="display:flex;flex-direction:column;gap:var(--space-5)">
<!-- Vorlagen-Manager -->
<div class="by-card" style="padding:var(--space-4)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
<h3 style="margin:0;font-size:var(--text-base)">Vorlagen</h3>
<button class="btn btn-sm btn-secondary" id="adm-tpl-new">
${UI.icon('plus')} Neue Vorlage
</button>
</div>
${templates.length === 0
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Vorlagen.</p>`
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
${templates.map(t => `
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-2) var(--space-3);
background:var(--c-bg-elevated);border-radius:var(--radius-md);border:1px solid var(--c-border)">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2)">
<span style="font-size:var(--text-sm);font-weight:600">${_esc(t.label)}</span>
${accountBadge(t.from_account)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
${_esc(t.subject)}
</div>
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-xs btn-secondary adm-tpl-load" data-id="${t.id}" title="In Compose laden">
${UI.icon('arrow-bend-up-left')}
</button>
<button class="btn btn-xs btn-secondary adm-tpl-edit" data-id="${t.id}" title="Bearbeiten">
${UI.icon('pencil-simple')}
</button>
<button class="btn btn-xs btn-danger adm-tpl-del" data-id="${t.id}" title="Löschen">
${UI.icon('trash')}
</button>
</div>
</div>`).join('')}
</div>`}
</div>
<!-- Compose --> <!-- Compose -->
<div class="by-card" style="padding:var(--space-4)"> <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> <h3 style="margin:0 0 var(--space-3);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)"> <form id="adm-outreach-form" style="display:flex;flex-direction:column;gap:var(--space-3)">
<!-- Vorlage --> <!-- Absender -->
<div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<label class="form-label" style="font-size:var(--text-xs)">Vorlage laden</label> <div>
<select id="adm-outreach-tpl" class="form-control"> <label class="form-label" style="font-size:var(--text-xs)">Absender</label>
<option value=""> Vorlage wählen </option> <select id="adm-outreach-from" class="form-control">
${templates.map(t => `<option value="${t.id}">${_esc(t.label)}</option>`).join('')} <option value="partner">partner@banyaro.app (Influencer/Partner)</option>
</select> <option value="support">support@banyaro.app (Support/Moderation)</option>
</div> </select>
</div>
<!-- Empfänger --> <div>
<div> <label class="form-label" style="font-size:var(--text-xs)">
<label class="form-label" style="font-size:var(--text-xs)"> Empfänger <span style="color:var(--c-text-muted)">(Komma-getrennt)</span>
Empfänger <span style="color:var(--c-text-muted)">(mehrere mit Komma trennen)</span> </label>
</label> <input class="form-control" id="adm-outreach-to" type="text"
<input class="form-control" id="adm-outreach-to" type="text" placeholder="name@example.com, andere@example.com">
placeholder="name@example.com, andere@example.com"> </div>
</div> </div>
<!-- Betreff --> <!-- Betreff -->
@ -2063,7 +2101,7 @@ window.Page_admin = (() => {
<!-- Text --> <!-- Text -->
<div> <div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label> <label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="adm-outreach-body" class="form-control" rows="12" <textarea id="adm-outreach-body" class="form-control" rows="14"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea> style="font-family:monospace;font-size:var(--text-sm);resize:vertical"></textarea>
</div> </div>
@ -2072,7 +2110,7 @@ window.Page_admin = (() => {
${UI.icon('paper-plane-tilt')} Senden ${UI.icon('paper-plane-tilt')} Senden
</button> </button>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)"> <span style="font-size:var(--text-xs);color:var(--c-text-muted)">
Hinweis: {name} im Text wird nicht automatisch ersetzt bitte manuell anpassen. {name} wird nicht automatisch ersetzt bitte manuell anpassen.
</span> </span>
</div> </div>
</form> </form>
@ -2086,17 +2124,21 @@ window.Page_admin = (() => {
: `<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)"> : `<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead> <thead>
<tr style="border-bottom:1px solid var(--c-border)"> <tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Von</th>
<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)">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)">Betreff</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Gesendet</th> <th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wer</th>
<th style="text-align:left;padding:var(--space-2);color:var(--c-text-muted)">Wann</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${log.map(l => ` ${log.map(l => `
<tr style="border-bottom:1px solid var(--c-border)"> <tr style="border-bottom:1px solid var(--c-border)">
<td style="padding:var(--space-2)">${accountBadge(l.from_account)}</td>
<td style="padding:var(--space-2)">${_esc(l.recipient)}</td> <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-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> <td style="padding:var(--space-2);color:var(--c-text-muted)">${_esc(l.sent_by_name || '')}</td>
<td style="padding:var(--space-2);color:var(--c-text-muted)">${(l.sent_at||'').slice(0,16).replace('T',' ')}</td>
</tr>`).join('')} </tr>`).join('')}
</tbody> </tbody>
</table>`} </table>`}
@ -2105,36 +2147,127 @@ window.Page_admin = (() => {
</div> </div>
`; `;
// Vorlage laden // Vorlage in Compose laden
el.querySelector('#adm-outreach-tpl')?.addEventListener('change', e => { function _loadTplIntoCompose(id) {
const tpl = templates.find(t => t.id === e.target.value); const tpl = templates.find(t => t.id === id);
if (!tpl) return; 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-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 // Senden
el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => { el.querySelector('#adm-outreach-form')?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const btn = e.target.querySelector('[type="submit"]'); const btn = e.target.querySelector('[type="submit"]');
const to = (el.querySelector('#adm-outreach-to').value || '') const from_account = el.querySelector('#adm-outreach-from').value;
.split(',').map(s => s.trim()).filter(Boolean); const to = (el.querySelector('#adm-outreach-to').value || '')
const subject = el.querySelector('#adm-outreach-subject').value.trim(); .split(',').map(s => s.trim()).filter(Boolean);
const body = el.querySelector('#adm-outreach-body').value.trim(); 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 (!to.length) { UI.toast.warning('Bitte mindestens einen Empfänger eingeben.'); return; }
if (!subject) { UI.toast.warning('Betreff fehlt.'); return; } if (!subject) { UI.toast.warning('Betreff fehlt.'); return; }
if (!body) { UI.toast.warning('Text fehlt.'); return; } if (!body) { UI.toast.warning('Text fehlt.'); return; }
await UI.asyncButton(btn, async () => { 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.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); 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: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Name (intern)</label>
<input class="form-control" id="${id}-key" type="text" placeholder="z.B. willkommen_neu"
value="${_esc(tpl?.key || '')}" ${isNew ? '' : 'readonly'}>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
<select id="${id}-from" class="form-control">
<option value="partner" ${(tpl?.from_account||'partner')==='partner'?'selected':''}>partner@banyaro.app</option>
<option value="support" ${tpl?.from_account==='support'?'selected':''}>support@banyaro.app</option>
</select>
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Bezeichnung (sichtbar)</label>
<input class="form-control" id="${id}-label" type="text" placeholder="z.B. Willkommensnachricht"
value="${_esc(tpl?.label || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Betreff</label>
<input class="form-control" id="${id}-subject" type="text"
value="${_esc(tpl?.subject || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Text</label>
<textarea id="${id}-body" class="form-control" rows="12"
style="font-family:monospace;font-size:var(--text-sm);resize:vertical">${_esc(tpl?.body || '')}</textarea>
</div>
</form>`,
footer: `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
});
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) { async function _renderAudit(el) {
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)"> <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 Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v573'; const CACHE_VERSION = 'by-v574';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache

View file

@ -15,6 +15,8 @@ services:
- VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0 - VAPID_PUBLIC_KEY=BMKbFAmpsqJ-eFef_4XJcYpuxPWqBNAoy9buMNnMSa6ijcPzltboHi_YccPKJrUD0isBez-vJIzAgjnLTWkzcC0
- VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878 - VAPID_PRIVATE_KEY=8PWa9vvwMqtqsJEJGcwmiLhR0_Yl7duVX3wmWiKS878
- VAPID_CONTACT=mailto:admin@banyaro.app - VAPID_CONTACT=mailto:admin@banyaro.app
- SMTP_SUPPORT_USER=support@banyaro.de
- SMTP_SUPPORT_PASS=Marbled-Drool8-Whacky
healthcheck: healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"] test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
interval: 30s interval: 30s