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

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