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:
parent
b17b061496
commit
e79290edb7
6 changed files with 331 additions and 98 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -2024,34 +2024,72 @@ window.Page_admin = (() => {
|
|||
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 = `
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
<h3 style="margin:0 0 var(--space-3);font-size:var(--text-base)">E-Mail senden</h3>
|
||||
<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">
|
||||
<!-- Absender -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">Absender</label>
|
||||
<select id="adm-outreach-from" class="form-control">
|
||||
<option value="partner">partner@banyaro.app (Influencer/Partner)</option>
|
||||
<option value="support">support@banyaro.app (Support/Moderation)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" style="font-size:var(--text-xs)">
|
||||
Empfänger <span style="color:var(--c-text-muted)">(Komma-getrennt)</span>
|
||||
</label>
|
||||
<input class="form-control" id="adm-outreach-to" type="text"
|
||||
placeholder="name@example.com, andere@example.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Betreff -->
|
||||
|
|
@ -2063,7 +2101,7 @@ window.Page_admin = (() => {
|
|||
<!-- Text -->
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -2072,7 +2110,7 @@ window.Page_admin = (() => {
|
|||
${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.
|
||||
{name} wird nicht automatisch ersetzt — bitte manuell anpassen.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -2086,17 +2124,21 @@ window.Page_admin = (() => {
|
|||
: `<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)">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)">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>
|
||||
</thead>
|
||||
<tbody>
|
||||
${log.map(l => `
|
||||
<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);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('')}
|
||||
</tbody>
|
||||
</table>`}
|
||||
|
|
@ -2105,36 +2147,127 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
// 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: `
|
||||
<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) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue