Files
imap-mail-filter-service/app/templates/filters.html
T
duffyduckandClaude Opus 5.5 9bd2507cb1 fast, readable log: stop routine noise, keyset paging, token search
The log had 6.45M rows, of which 434 were actual actions: every mail x
every non-matching rule wrote "Keine Übereinstimmung" (5.3M rows, ~1 GB),
plus "keine neuen Mails" per rule every 30s and a "Poll gestartet" row.
Free-text searches took 50-70s.

- Routine entries are only written with LOG_VERBOSE=true; the poll
  summary is logged only when there were new mails, actions or errors.
  "Why didn't my rule match" is now answered by the rule tester.
- The scheduler commits every 25 evaluated mails and after each rule,
  since the dropped log calls used to be the implicit commit points.
- Migration rebuilds filter_logs without the routine rows (live:
  6.45M -> ~38k) via copy instead of DELETE.
- /api/logs: keyset pagination (before_id) instead of OFFSET, count
  capped at 1000, since/until range, multi-level filter, per-field
  filters; ordner: also matches the move target stored in details.
- New log page: one search field with shortcuts (von:, betreff:,
  regel:, ordner:, text:), live search with debounce and abort, active
  filters as removable chips, click on rule/sender/folder in a row to
  filter, time range (default 7 days), level quick filter, expandable
  row details, "Ältere laden", filters kept in the URL.
- Disable Pico's colour transition on segmented buttons (they briefly
  flashed in the primary colour on load).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:21:32 +02:00

976 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Filterregeln — IMAP Mail Filter{% endblock %}
{% block content %}
<div class="page-head">
<h1>Filterregeln</h1>
<select id="account-select" class="account-pick" onchange="onAccountChange()" title="Konto">
<option value="">— Konto wählen —</option>
{% for acc in accounts %}
<option value="{{ acc.id }}" {{ 'selected' if selected_account_id == acc.id else '' }}>{{ acc.name }}</option>
{% endfor %}
</select>
<span id="filters-summary" class="muted small-text"></span>
<div class="toolbar-actions">
<button class="secondary outline" onclick="resetAllProcessed()" title="Verwirft den 'verarbeitet'-Marker aller Mails. Beim nächsten Poll werden alle Mails im Ordner neu gegen alle Regeln geprüft. Sicher — schon verschobene Mails liegen nicht mehr in der Quelle.">Alle neu prüfen</button>
<button onclick="openNewFilter()">+ Neue Regel</button>
</div>
</div>
<div id="toolbar" class="toolbar" hidden>
<input type="search" id="filter-search" placeholder="Suchen: Name, Absender, Ordner, „am (exakt)“ …" oninput="renderFilters()">
<div class="seg" id="quick-filter">
<button data-q="all" class="active" onclick="setQuick('all')">Alle</button>
<button data-q="active" onclick="setQuick('active')">Aktiv</button>
<button data-q="inactive" onclick="setQuick('inactive')">Inaktiv</button>
<button data-q="warn" onclick="setQuick('warn')">⚠ Warnungen</button>
<button data-q="never" onclick="setQuick('never')">Nie getroffen</button>
</div>
</div>
<div id="filters-container">
<p class="muted">Bitte ein Konto auswählen.</p>
</div>
<dialog id="filter-dialog">
<article class="rule-dialog">
<header>
<button aria-label="Close" rel="prev" onclick="closeDialog()"></button>
<h3 id="dialog-title">Neue Filterregel</h3>
</header>
<form id="filter-form" onsubmit="return false;">
<input type="hidden" id="filter-id" value="">
<section class="dlg-section">
<div class="grid-3">
<label class="span-2">
Name
<input type="text" id="filter-name" required placeholder="z.B. Rechnungen Hetzner">
</label>
<label>
Priorität <small class="muted">(niedriger = früher)</small>
<input type="number" id="filter-priority" placeholder="auto">
</label>
</div>
<div class="grid-3">
<label class="span-2">
Quellordner
<div role="group">
<input type="text" id="filter-source-folder" value="INBOX" readonly>
<button type="button" class="outline" onclick="openFolderBrowser('filter-source-folder')">Durchsuchen</button>
</div>
</label>
<div class="switches">
<label><input type="checkbox" role="switch" id="filter-enabled" checked> Aktiv</label>
<label title="Wenn diese Regel greift, werden nachfolgende Regeln für diese Mail nicht mehr geprüft."><input type="checkbox" role="switch" id="filter-stop-processing"> Danach stoppen</label>
</div>
</div>
</section>
<section class="dlg-section">
<h4>Bedingungen <small class="muted">— alle müssen zutreffen</small></h4>
<div id="conditions-list"></div>
<button type="button" class="outline small" onclick="addCondition(); refreshWarnings();">+ Bedingung</button>
</section>
<section class="dlg-section">
<h4>Aktionen</h4>
<div id="actions-list"></div>
<button type="button" class="outline small" onclick="addAction(); refreshWarnings();">+ Aktion</button>
</section>
<div id="dialog-warnings"></div>
<section class="dlg-section test-section">
<div class="test-head">
<h4>Regel testen</h4>
<button type="button" class="outline small" id="test-btn" onclick="runTest()">Gegen Postfach testen</button>
</div>
<p class="muted small-text">Prüft die aktuelle (ungespeicherte) Regel nur lesend gegen die neuesten passenden Mails im Quellordner. Es wird nichts verschoben.</p>
<div id="test-result"></div>
</section>
<footer>
<div class="dlg-footer">
<button type="button" class="secondary outline" onclick="closeDialog()">Abbrechen</button>
<button type="button" id="save-btn" onclick="saveFilter()">Speichern</button>
</div>
</footer>
</form>
</article>
</dialog>
<dialog id="folder-browser">
<article style="min-width: 50vw; max-width: 700px;">
<header>
<button aria-label="Close" rel="prev" onclick="document.getElementById('folder-browser').close()"></button>
<h3>Ordner auswählen</h3>
</header>
<div id="folder-tree" style="max-height: 400px; overflow-y: auto;">
<p aria-busy="true">Ordner werden geladen...</p>
</div>
<div id="folder-status" style="display:none; margin-top:0.5rem; padding:0.5rem; border-radius:4px; background: var(--pico-muted-border-color);"></div>
</article>
</dialog>
{% endblock %}
{% block scripts %}
<style>
.muted { color: var(--pico-muted-color); }
.small-text { font-size:0.85rem; }
.page-head { display:flex; align-items:center; gap:0.75rem; flex-wrap:wrap; margin-bottom:0.75rem; }
.page-head h1 { margin:0; }
.page-head .account-pick { width:auto; min-width:200px; margin:0; padding-top:0.3rem; padding-bottom:0.3rem; font-size:0.9rem; }
.page-head #filters-summary { flex:1; }
.toolbar-actions { display:flex; gap:0.4rem; }
.toolbar-actions button { margin:0; padding:0.35rem 0.8rem; font-size:0.85rem; }
.toolbar { display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap; margin-bottom:0.6rem; }
.toolbar input[type=search] { flex:1 1 280px; margin:0; padding-top:0.3rem; padding-bottom:0.3rem; font-size:0.9rem; }
.seg { display:inline-flex; flex-wrap:wrap; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius); overflow:hidden; background:#fff; }
.toolbar .seg button { transition:none; }
.toolbar .seg button { --pico-background-color:transparent; --pico-border-color:transparent; --pico-color:#374151;
margin:0; border-radius:0; padding:0.3rem 0.7rem; font-size:0.8rem; box-shadow:none; }
.toolbar .seg button + button { border-left:1px solid var(--app-border, #e3e6ea); }
.toolbar .seg button:hover { --pico-background-color:#f1f3f5; }
.toolbar .seg button.active { --pico-background-color:var(--pico-primary); --pico-color:var(--pico-primary-inverse); }
/* Kompakte Zeilenliste: eine Zeile pro Regel, feste Spalten */
.rules { background:#fff; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius); overflow:hidden; font-size:0.85rem; }
.rules .r { display:grid; grid-template-columns:34px 38px minmax(140px, 1.1fr) minmax(0, 2.4fr) minmax(120px, 1fr) 140px 84px;
gap:0.6rem; align-items:center; padding:0.3rem 0.75rem; border-bottom:1px solid #eef0f3; min-height:36px; }
.rules .r:last-child { border-bottom:0; }
.rules .r:hover { background:#f8f9fb; }
.rules .r.head { background:#f6f7f9; font-size:0.72rem; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; color:var(--pico-muted-color); min-height:0; padding-top:0.35rem; padding-bottom:0.35rem; }
.rules .r.head:hover { background:#f6f7f9; }
.rules .r.disabled > *:not(.c-toggle):not(.c-btns) { opacity:0.45; }
.rules .r.warn { box-shadow:inset 3px 0 0 #e0a800; }
.rules input[role=switch] { margin:0; width:1.9em; height:1.05em; }
.c-prio { color:var(--pico-muted-color); font-variant-numeric:tabular-nums; text-align:right; }
.c-name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.c-name .warn-ico { cursor:help; margin-left:0.25rem; }
.c-name .tag { font-size:0.68rem; font-weight:600; padding:0 0.35rem; border-radius:3px; margin-left:0.3rem; vertical-align:1px; }
.tag.stop { background:#fde2e1; color:#9b1c1c; }
.tag.src { background:#e8eefc; color:#274b9f; }
.c-cond { display:flex; gap:0.25rem; overflow:hidden; white-space:nowrap; mask-image:linear-gradient(to right, #000 92%, transparent); }
.chip { font-size:0.75rem; padding:0 0.4rem; border-radius:3px; background:#f1f3f5; border:1px solid #e6e9ed; white-space:nowrap; line-height:1.55; }
.chip b { font-weight:600; }
.chip.neg { background:#fff4e5; border-color:#ffd8a8; }
.c-target { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#1f6f43; }
.c-stat { font-size:0.78rem; color:var(--pico-muted-color); white-space:nowrap; text-align:right; font-variant-numeric:tabular-nums; }
.c-stat strong { color:var(--pico-color); }
.c-stat.never { color:#b36b00; }
.c-btns { display:flex; justify-content:flex-end; gap:0.1rem; }
.c-btns button { --pico-background-color:transparent; --pico-border-color:transparent; --pico-color:var(--pico-muted-color);
margin:0; padding:0.15rem 0.35rem; font-size:0.95rem; line-height:1; box-shadow:none; }
.c-btns button:hover { --pico-background-color:#eef0f3; --pico-color:var(--pico-color); }
.c-btns button.del:hover { --pico-color:#b42318; }
dialog article.rule-dialog { width:min(960px, 96vw); max-width:none; }
.dlg-section { margin-bottom:1.25rem; }
.dlg-section h4 { margin-bottom:0.5rem; }
.grid-3 { display:grid; grid-template-columns:repeat(3, 1fr); gap:0 1rem; }
.grid-3 .span-2 { grid-column:span 2; }
.switches { display:flex; flex-direction:column; justify-content:center; gap:0.25rem; padding-top:1.4rem; }
.switches label { margin-bottom:0; }
.row-line { display:grid; gap:0.4rem; align-items:center; margin-bottom:0.4rem; }
.cond-row .row-line { grid-template-columns:150px 170px 1fr auto auto; }
.act-row .row-line { grid-template-columns:240px 1fr auto; }
.row-line select, .row-line input { margin-bottom:0; }
.row-line .icon-btn { margin:0; padding:0.3rem 0.6rem; }
.neg-toggle { margin:0; font-size:0.8rem; white-space:nowrap; }
.dlg-footer { display:flex; justify-content:flex-end; gap:0.5rem; }
.dlg-footer button { margin:0; }
#dialog-warnings .warn-box { border-left:4px solid #e0a800; background:#fff8e6; padding:0.6rem 0.9rem; border-radius:4px; margin-bottom:1rem; font-size:0.9rem; }
#dialog-warnings ul { margin:0.25rem 0 0; padding-left:1.1rem; }
.test-section { border-top:1px solid var(--pico-muted-border-color); padding-top:1rem; }
.test-head { display:flex; justify-content:space-between; align-items:center; }
.test-head h4 { margin:0; }
.test-head button { margin:0; }
.test-summary { font-size:0.9rem; margin:0.5rem 0; }
.test-list { max-height:260px; overflow-y:auto; border:1px solid var(--pico-muted-border-color); border-radius:4px; }
.test-item { padding:0.4rem 0.7rem; font-size:0.82rem; border-bottom:1px solid var(--pico-muted-border-color); }
.test-item:last-child { border-bottom:0; }
.test-item.ok { background:#eefaf2; }
.test-item .reason { color:#9b1c1c; }
.test-item .meta { color:var(--pico-muted-color); }
.folder-row { display:flex; align-items:center; padding:0.35rem 0.5rem; border-radius:4px; cursor:pointer; transition:background 0.15s; }
.folder-row:hover { background:var(--pico-primary-focus); }
.folder-row .folder-icon { flex-shrink:0; width:1.4em; margin-right:0.4rem; }
.folder-row .folder-label { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.folder-row .folder-path { font-size:0.75rem; opacity:0.45; margin-left:0.5rem; white-space:nowrap; }
.folder-row .folder-add-btn { flex-shrink:0; background:none; border:none; cursor:pointer; font-size:1.1rem; padding:0.1rem 0.3rem; border-radius:4px; opacity:0.5; color:var(--pico-color); }
.folder-row .folder-add-btn:hover { opacity:1; background:var(--pico-primary-focus); }
.folder-new-input { display:flex; align-items:center; gap:0.3rem; padding:0.25rem 0; }
.folder-new-input input { margin-bottom:0; padding:0.3rem 0.5rem; font-size:0.9rem; height:auto; }
.folder-new-input button { margin-bottom:0; padding:0.3rem 0.6rem; font-size:0.85rem; white-space:nowrap; }
@media (max-width: 1000px) {
.rules .r { grid-template-columns:34px minmax(0,1fr) 84px; }
.rules .r .c-prio, .rules .r .c-stat, .rules .r.head { display:none; }
.rules .r .c-cond, .rules .r .c-target { grid-column:2 / 3; }
.rules .r .c-btns { grid-row:1; grid-column:3; }
}
@media (max-width: 760px) {
.grid-3 { grid-template-columns:1fr; }
.grid-3 .span-2 { grid-column:auto; }
.switches { padding-top:0; flex-direction:row; gap:1rem; }
.cond-row .row-line { grid-template-columns:1fr 1fr; }
.cond-row .cond-value-container { grid-column:1 / -1; }
.act-row .row-line { grid-template-columns:1fr auto; }
.act-row .act-param-group { grid-column:1 / -1; }
}
</style>
<script>
const FIELDS = [
{value: 'from', label: 'Von', type: 'text'},
{value: 'to', label: 'An', type: 'text'},
{value: 'subject', label: 'Betreff', type: 'text'},
{value: 'body', label: 'Text', type: 'text'},
{value: 'has_attachment', label: 'Hat Anhang', type: 'bool'},
{value: 'date', label: 'Datum', type: 'date'},
];
const TEXT_MATCH_TYPES = [
{value: 'contains', label: 'enthält'},
{value: 'regex', label: 'Regex'},
{value: 'exact', label: 'exakt'},
];
const DATE_MATCH_TYPES = [
{value: 'after', label: 'ab / nach', inputType: 'date', placeholder: ''},
{value: 'before', label: 'vor', inputType: 'date', placeholder: ''},
{value: 'on_date', label: 'am (exakt)', inputType: 'date', placeholder: ''},
{value: 'date_range', label: 'Zeitraum (von–bis)', inputType: 'daterange', placeholder: ''},
{value: 'year', label: 'im Jahr', inputType: 'number', placeholder: 'z.B. 2026'},
{value: 'last_n_days', label: 'letzte N Tage', inputType: 'number', placeholder: 'Anzahl Tage'},
{value: 'last_n_weeks', label: 'letzte N Wochen', inputType: 'number', placeholder: 'Anzahl Wochen'},
{value: 'last_n_months', label: 'letzte N Monate', inputType: 'number', placeholder: 'Anzahl Monate'},
{value: 'older_than_days', label: 'älter als N Tage', inputType: 'number', placeholder: 'Anzahl Tage'},
{value: 'older_than_weeks', label: 'älter als N Wochen', inputType: 'number', placeholder: 'Anzahl Wochen'},
{value: 'older_than_months', label: 'älter als N Monate', inputType: 'number', placeholder: 'Anzahl Monate'},
];
const ALL_MATCH_TYPES = [...TEXT_MATCH_TYPES, ...DATE_MATCH_TYPES];
const ACTION_TYPES = [
{value: 'move', label: 'Verschieben nach', needsParam: true, needsFolder: true, paramLabel: 'Zielordner'},
{value: 'forward', label: 'Weiterleiten an', needsParam: true, needsFolder: false, paramLabel: 'E-Mail-Adresse'},
{value: 'delete', label: 'Löschen (in Papierkorb)', needsParam: false, needsFolder: false},
{value: 'mark_read', label: 'Als gelesen markieren', needsParam: false, needsFolder: false},
];
const ACCOUNT_KEY = 'mailfilter.account';
let currentFilters = [];
let quickFilter = 'all';
let lastTest = null;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function fieldLabel(v) { return FIELDS.find(x => x.value === v)?.label || v; }
function matchLabel(v) { return ALL_MATCH_TYPES.find(x => x.value === v)?.label || v; }
function actionLabel(v) { return ACTION_TYPES.find(x => x.value === v)?.label || v; }
function parseUtc(iso) {
// Der Server liefert UTC-Zeitstempel ohne Zeitzone — ohne 'Z' würde der
// Browser sie als Ortszeit lesen und die Uhrzeit um 1–2 h verschieben.
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
}
function fmtDate(iso) {
if (!iso) return '';
return parseUtc(iso).toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit', year:'2-digit'});
}
function fmtDateTime(iso, withYear = true) {
if (!iso) return '';
const d = parseUtc(iso);
const date = d.toLocaleDateString('de-DE', withYear ? {day:'2-digit', month:'2-digit', year:'2-digit'} : {day:'2-digit', month:'2-digit'});
const time = d.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'});
return `${date} ${time}`;
}
function fmtDateValue(v) {
// '2026-01-01' → '01.01.2026'; Zeitraum 'a,b' → 'a – b'
return String(v || '').split(',').map(p => {
const m = p.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
return m ? `${m[3]}.${m[2]}.${m[1]}` : p.trim();
}).join(' – ');
}
async function readJson(resp) {
const text = await resp.text();
try { return JSON.parse(text); } catch { return {detail: text}; }
}
// ---------- Warnungen ----------
function ruleWarnings(r) {
const w = [];
const conds = r.conditions || [];
const acts = r.actions || [];
if (!conds.length) w.push('Keine Bedingungen — die Regel greift nie.');
if (!acts.length) w.push('Keine Aktion — ein Treffer bewirkt nichts.');
const seen = new Set();
for (const c of conds) {
const key = [c.field, c.match_type, (c.value || '').trim().toLowerCase(), !!c.negate].join('|');
if (seen.has(key)) { w.push('Doppelte Bedingung: ' + fieldLabel(c.field) + ' ' + matchLabel(c.match_type) + ' „' + c.value + '“.'); }
seen.add(key);
const v = (c.value || '').trim();
if ((c.field === 'from' || c.field === 'to') && c.match_type !== 'regex' && (v.includes('<') || (v.includes('@') && /\s/.test(v)))) {
w.push(fieldLabel(c.field) + ': „' + v + '“ enthält einen Anzeigenamen. Besser nur die Adresse eintragen (Anzeigenamen variieren).');
}
if (c.field === 'date' && c.match_type === 'on_date') {
w.push('Datum „am (exakt) ' + fmtDateValue(v) + '“ greift nur an genau diesem Tag. Gemeint war vermutlich „ab / nach“.');
}
if (c.field !== 'date' && c.field !== 'has_attachment' && !v) {
w.push(fieldLabel(c.field) + ': leerer Wert.');
}
if (c.match_type === 'regex') {
try { new RegExp(v); } catch { w.push('Ungültiger Regex: „' + v + '“.'); }
}
}
for (const a of acts) {
if (a.action_type === 'move' && !a.parameter) w.push('Verschieben ohne Zielordner.');
if (a.action_type === 'forward' && !a.parameter) w.push('Weiterleiten ohne Zieladresse.');
if (a.action_type === 'move' && a.parameter && a.parameter === r.source_folder) w.push('Zielordner ist gleich dem Quellordner.');
}
return w;
}
// ---------- Liste ----------
function onAccountChange() {
const v = document.getElementById('account-select').value;
try { v ? localStorage.setItem(ACCOUNT_KEY, v) : localStorage.removeItem(ACCOUNT_KEY); } catch {}
loadFilters();
}
async function loadFilters() {
const accountId = document.getElementById('account-select').value;
const container = document.getElementById('filters-container');
const toolbar = document.getElementById('toolbar');
if (!accountId) {
toolbar.hidden = true;
document.getElementById('filters-summary').textContent = '';
container.innerHTML = '<p class="muted">Bitte ein Konto auswählen.</p>';
return;
}
toolbar.hidden = false;
container.innerHTML = '<p aria-busy="true">Regeln werden geladen…</p>';
const resp = await fetch(`/api/filters/account/${accountId}`);
currentFilters = resp.ok ? await resp.json() : [];
renderFilters();
}
function setQuick(q) {
quickFilter = q;
document.querySelectorAll('#quick-filter button').forEach(b => b.classList.toggle('active', b.dataset.q === q));
renderFilters();
}
function filterHaystack(f) {
const parts = [f.name, f.source_folder, f.stop_processing ? 'stop' : '', f.enabled ? 'aktiv' : 'inaktiv'];
for (const c of f.conditions || []) parts.push(c.field, c.match_type, c.value, fieldLabel(c.field), matchLabel(c.match_type), c.negate ? 'nicht not' : '');
for (const a of f.actions || []) parts.push(a.action_type, a.parameter || '', actionLabel(a.action_type));
return parts.join(' ').toLowerCase();
}
// Kurzform einer Bedingung für die Listenzeile, z.B. "Von: billing@hetzner.com",
// "Betreff = Rechnung", "📎", "ab 01.01.26"
const COND_ORDER = {from: 0, to: 1, subject: 2, body: 3, has_attachment: 4, date: 5};
const DATE_SHORT = {after: 'ab', before: 'vor', on_date: 'am', date_range: '', year: 'Jahr',
last_n_days: 'letzte Tage:', last_n_weeks: 'letzte Wochen:', last_n_months: 'letzte Monate:',
older_than_days: 'älter Tage:', older_than_weeks: 'älter Wochen:', older_than_months: 'älter Monate:'};
function condText(c) {
const neg = c.negate ? 'NICHT ' : '';
if (c.field === 'has_attachment') {
return ((c.value === 'true') !== !!c.negate) ? '📎 Anhang' : 'ohne Anhang';
}
if (c.field === 'date') return `${neg}${DATE_SHORT[c.match_type] ?? matchLabel(c.match_type)} ${fmtDateValue(c.value)}`.trim();
const op = c.match_type === 'exact' ? ' =' : c.match_type === 'regex' ? ' ~' : ':';
return `${neg}${fieldLabel(c.field)}${op} ${c.value}`;
}
function condChip(c) {
// Häufige Bedingungen in der Liste platzsparend darstellen
if (c.field === 'has_attachment') {
const yes = (c.value === 'true') !== !!c.negate;
return `<span class="chip" title="${yes ? 'mit Anhang' : 'ohne Anhang'}">${yes ? '📎' : '🚫📎'}</span>`;
}
if (c.field === 'date') {
const short = condText(c).replace(/(\d{2})\.(\d{2})\.\d{2}(\d{2})/g, '$1.$2.$3');
const warn = c.match_type === 'on_date' ? ' style="background:#fff3cd;border-color:#ffe08a"' : '';
return `<span class="chip${c.negate ? ' neg' : ''}"${warn} title="${esc(condText(c))}">${esc(short)}</span>`;
}
const text = condText(c);
const i = text.indexOf(': ');
const html = i > 0 && c.field !== 'date' ? `${esc(text.slice(0, i + 1))} <b>${esc(text.slice(i + 2))}</b>` : esc(text);
const cls = c.negate ? ' neg' : '';
const warn = c.field === 'date' && c.match_type === 'on_date' ? ' style="background:#fff3cd;border-color:#ffe08a"' : '';
return `<span class="chip${cls}"${warn}>${html}</span>`;
}
function targetText(f) {
return (f.actions || []).map(a => {
if (a.action_type === 'move') return '📁 ' + (a.parameter || '?').replace(/^INBOX[./]/, '');
if (a.action_type === 'forward') return '↪ ' + (a.parameter || '?');
if (a.action_type === 'delete') return '🗑 Papierkorb';
if (a.action_type === 'mark_read') return '✓ gelesen';
return a.action_type;
}).join(', ') || '—';
}
function renderFilters() {
const container = document.getElementById('filters-container');
const filters = currentFilters;
const summary = document.getElementById('filters-summary');
if (!filters.length) {
summary.textContent = '';
container.innerHTML = `<article><p>Noch keine Filterregeln für dieses Konto.</p><button onclick="openNewFilter()">Erste Regel anlegen</button></article>`;
return;
}
const term = document.getElementById('filter-search').value.trim().toLowerCase();
const enriched = filters.map(f => ({f, warnings: ruleWarnings(f)}));
const visible = enriched.filter(({f, warnings}) => {
if (term && !filterHaystack(f).includes(term)) return false;
if (quickFilter === 'active') return f.enabled;
if (quickFilter === 'inactive') return !f.enabled;
if (quickFilter === 'warn') return warnings.length > 0;
if (quickFilter === 'never') return !f.match_count;
return true;
});
const nActive = filters.filter(f => f.enabled).length;
const nWarn = enriched.filter(e => e.warnings.length).length;
summary.innerHTML = `${filters.length} Regeln · ${nActive} aktiv` + (nWarn ? ` · <strong style="color:#8a6100">${nWarn} mit Warnung</strong>` : '') + (visible.length !== filters.length ? ` · ${visible.length} angezeigt` : '');
if (!visible.length) {
container.innerHTML = '<p class="muted"><em>Keine Regeln passen zu Suche/Filter.</em></p>';
return;
}
let html = `<div class="rules">
<div class="r head"><span></span><span style="text-align:right">Prio</span><span>Name</span><span>Bedingungen</span><span>Ziel</span><span style="text-align:right">Treffer</span><span></span></div>`;
for (const {f, warnings} of visible) {
const conds = [...(f.conditions || [])].sort((a, b) => (COND_ORDER[a.field] ?? 9) - (COND_ORDER[b.field] ?? 9));
const condTitle = conds.map(condText).join('\n');
const stat = f.match_count
? `<span class="c-stat" title="${f.match_count} Treffer, zuletzt am ${fmtDateTime(f.last_match_at)} Uhr"><strong>${f.match_count}</strong> · ${fmtDateTime(f.last_match_at, false)}</span>`
: `<span class="c-stat never" title="Noch kein Treffer seit Einführung der Statistik">—</span>`;
html += `
<div class="r${f.enabled ? '' : ' disabled'}${warnings.length ? ' warn' : ''}" ondblclick="editFilter(${f.id})">
<span class="c-toggle"><input type="checkbox" role="switch" ${f.enabled ? 'checked' : ''} title="${f.enabled ? 'Aktiv' : 'Inaktiv'}" onchange="toggleEnabled(${f.id}, this.checked)"></span>
<span class="c-prio">${f.priority}</span>
<span class="c-name" title="${esc(f.name)}">${esc(f.name)}${f.stop_processing ? '<span class="tag stop" title="Nach Treffer keine weiteren Regeln">stop</span>' : ''}${f.source_folder !== 'INBOX' ? `<span class="tag src" title="Quellordner">${esc(f.source_folder)}</span>` : ''}${warnings.length ? `<span class="warn-ico" title="${esc(warnings.join('\n'))}">⚠️</span>` : ''}</span>
<span class="c-cond" title="${esc(condTitle)}">${conds.map(condChip).join('') || '<span class="chip">keine Bedingung</span>'}</span>
<span class="c-target" title="${esc(targetText(f))}">${esc(targetText(f))}</span>
${stat}
<span class="c-btns">
<button onclick="editFilter(${f.id})" title="Bearbeiten">✎</button>
<button onclick="duplicateFilter(${f.id})" title="Als Vorlage kopieren">⧉</button>
<button class="del" onclick="deleteFilter(${f.id})" title="Löschen">🗑</button>
</span>
</div>`;
}
html += '</div><p class="muted small-text" style="margin-top:0.4rem">Doppelklick auf eine Zeile öffnet die Regel. Regeln laufen von niedriger zu hoher Priorität.</p>';
container.innerHTML = html;
}
async function toggleEnabled(id, enabled) {
const resp = await fetch(`/api/filters/${id}/enabled`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({enabled}),
});
if (!resp.ok) {
const d = await readJson(resp);
alert('Fehler: ' + (d.detail || resp.status));
}
const f = currentFilters.find(x => x.id === id);
if (f && resp.ok) f.enabled = enabled;
renderFilters();
}
// ---------- Dialog: Bedingungen ----------
function addCondition(data = null) {
const list = document.getElementById('conditions-list');
const div = document.createElement('div');
div.className = 'cond-row';
const selectedField = FIELDS.find(f => f.value === (data?.field || 'from')) || FIELDS[0];
div.innerHTML = `
<div class="row-line">
<select class="cond-field" onchange="onCondFieldChange(this)">
${FIELDS.map(f => `<option value="${f.value}" data-type="${f.type}" ${selectedField.value === f.value ? 'selected' : ''}>${f.label}</option>`).join('')}
</select>
<div class="cond-match-container"></div>
<div class="cond-value-container"></div>
<label class="neg-toggle" title="Bedingung umkehren"><input type="checkbox" class="cond-negate" ${data?.negate ? 'checked' : ''} onchange="refreshWarnings()"> NICHT</label>
<button type="button" class="outline secondary icon-btn" title="Bedingung entfernen" onclick="this.closest('.cond-row').remove(); refreshWarnings();">✕</button>
</div>`;
list.appendChild(div);
updateConditionInputs(div, selectedField.type, data);
}
function onCondFieldChange(select) {
const row = select.closest('.cond-row');
updateConditionInputs(row, select.options[select.selectedIndex].dataset.type, null);
refreshWarnings();
}
function updateConditionInputs(row, fieldType, data) {
const matchContainer = row.querySelector('.cond-match-container');
const valueContainer = row.querySelector('.cond-value-container');
if (fieldType === 'date') {
const selected = DATE_MATCH_TYPES.find(m => m.value === (data?.match_type || 'after')) || DATE_MATCH_TYPES[0];
matchContainer.innerHTML = `<select class="cond-match" onchange="onDateMatchChange(this)">
${DATE_MATCH_TYPES.map(m => `<option value="${m.value}" ${selected.value === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>`;
renderDateValueInput(valueContainer, selected, data?.value || '');
} else if (fieldType === 'bool') {
matchContainer.innerHTML = `<select class="cond-match"><option value="exact" selected>ist</option></select>`;
valueContainer.innerHTML = `<select class="cond-value" onchange="refreshWarnings()">
<option value="true" ${data?.value !== 'false' ? 'selected' : ''}>Ja</option>
<option value="false" ${data?.value === 'false' ? 'selected' : ''}>Nein</option>
</select>`;
} else {
matchContainer.innerHTML = `<select class="cond-match" onchange="refreshWarnings()">
${TEXT_MATCH_TYPES.map(m => `<option value="${m.value}" ${data?.match_type === m.value ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>`;
valueContainer.innerHTML = `<input type="text" class="cond-value" value="${esc(data?.value || '')}" placeholder="Wert, z.B. rechnung@firma.de" oninput="refreshWarnings()">`;
}
}
function onDateMatchChange(select) {
const row = select.closest('.cond-row');
const info = DATE_MATCH_TYPES.find(m => m.value === select.value);
renderDateValueInput(row.querySelector('.cond-value-container'), info, '');
refreshWarnings();
}
function renderDateValueInput(container, info, value) {
if (info.inputType === 'daterange') {
const [a, b] = value ? value.split(',') : ['', ''];
container.innerHTML = `<div style="display:flex; gap:0.3rem; align-items:center;">
<input type="date" class="cond-value-from" value="${esc(a || '')}" onchange="refreshWarnings()">
<span>–</span>
<input type="date" class="cond-value-to" value="${esc(b || '')}" onchange="refreshWarnings()">
</div>`;
} else if (info.inputType === 'date') {
container.innerHTML = `<input type="date" class="cond-value" value="${esc(value)}" onchange="refreshWarnings()">`;
} else {
container.innerHTML = `<input type="number" class="cond-value" value="${esc(value)}" placeholder="${esc(info.placeholder)}" min="1" oninput="refreshWarnings()">`;
}
}
// ---------- Dialog: Aktionen ----------
let actionSeq = 0;
function addAction(data = null) {
const list = document.getElementById('actions-list');
const div = document.createElement('div');
div.className = 'act-row';
const selected = ACTION_TYPES.find(a => a.value === (data?.action_type || 'move')) || ACTION_TYPES[0];
const inputId = `act_param_${++actionSeq}`;
div.innerHTML = `
<div class="row-line">
<select class="act-type" onchange="toggleActionParam(this)">
${ACTION_TYPES.map(a => `<option value="${a.value}" data-needs-param="${a.needsParam}" data-needs-folder="${a.needsFolder}" data-param-label="${a.paramLabel || ''}" ${selected.value === a.value ? 'selected' : ''}>${a.label}</option>`).join('')}
</select>
<div class="act-param-group" role="group" style="margin:0; ${selected.needsParam ? '' : 'visibility:hidden;'}">
<input type="text" id="${inputId}" name="${inputId}" class="act-param" value="${esc(data?.parameter || '')}" placeholder="${esc(selected.paramLabel || '')}" ${selected.needsFolder ? 'readonly' : ''} oninput="refreshWarnings()">
<button type="button" class="outline act-browse-btn" onclick="openFolderBrowser('${inputId}')" style="${selected.needsFolder ? '' : 'display:none;'}">Durchsuchen</button>
</div>
<button type="button" class="outline secondary icon-btn" title="Aktion entfernen" onclick="this.closest('.act-row').remove(); refreshWarnings();">✕</button>
</div>`;
list.appendChild(div);
}
function toggleActionParam(select) {
const opt = select.options[select.selectedIndex];
const group = select.closest('.act-row').querySelector('.act-param-group');
const input = group.querySelector('.act-param');
const needsParam = opt.dataset.needsParam === 'true';
const needsFolder = opt.dataset.needsFolder === 'true';
group.style.visibility = needsParam ? '' : 'hidden';
if (!needsParam) input.value = '';
input.placeholder = opt.dataset.paramLabel;
input.readOnly = needsFolder;
group.querySelector('.act-browse-btn').style.display = needsFolder ? '' : 'none';
refreshWarnings();
}
// ---------- Dialog: Öffnen / Sammeln / Speichern ----------
function resetDialog(title) {
document.getElementById('dialog-title').textContent = title;
document.getElementById('conditions-list').innerHTML = '';
document.getElementById('actions-list').innerHTML = '';
document.getElementById('test-result').innerHTML = '';
lastTest = null;
}
function nextPriority() {
const max = currentFilters.reduce((m, f) => Math.max(m, f.priority), -Infinity);
return isFinite(max) ? max + 10 : 10;
}
function openNewFilter() {
resetDialog('Neue Filterregel');
document.getElementById('filter-id').value = '';
document.getElementById('filter-name').value = '';
document.getElementById('filter-priority').value = nextPriority();
document.getElementById('filter-source-folder').value = 'INBOX';
document.getElementById('filter-enabled').checked = true;
document.getElementById('filter-stop-processing').checked = false;
addCondition({field: 'from', match_type: 'contains', value: ''});
addCondition({field: 'date', match_type: 'after', value: `${new Date().getFullYear()}-01-01`});
addAction({action_type: 'move'});
refreshWarnings();
document.getElementById('filter-dialog').showModal();
}
function fillDialog(f, title, keepId) {
resetDialog(title);
document.getElementById('filter-id').value = keepId ? f.id : '';
document.getElementById('filter-name').value = keepId ? f.name : `${f.name} (Kopie)`;
document.getElementById('filter-priority').value = keepId ? f.priority : nextPriority();
document.getElementById('filter-source-folder').value = f.source_folder;
document.getElementById('filter-enabled').checked = keepId ? f.enabled : true;
document.getElementById('filter-stop-processing').checked = f.stop_processing;
for (const c of f.conditions) addCondition(c);
for (const a of f.actions) addAction(a);
refreshWarnings();
document.getElementById('filter-dialog').showModal();
}
function editFilter(id) {
const f = currentFilters.find(x => x.id === id);
if (f) fillDialog(f, 'Regel bearbeiten', true);
}
function duplicateFilter(id) {
const f = currentFilters.find(x => x.id === id);
if (f) fillDialog(f, 'Neue Regel (Kopie)', false);
}
function closeDialog() {
document.getElementById('filter-dialog').close();
}
function collectConditions() {
const conditions = [];
for (const row of document.querySelectorAll('#conditions-list .cond-row')) {
const from = row.querySelector('.cond-value-from');
const to = row.querySelector('.cond-value-to');
const valInput = row.querySelector('.cond-value');
const value = (from && to) ? `${from.value},${to.value}` : (valInput ? valInput.value.trim() : '');
conditions.push({
field: row.querySelector('.cond-field').value,
match_type: row.querySelector('.cond-match').value,
value,
negate: row.querySelector('.cond-negate').checked,
});
}
return conditions;
}
function collectActions() {
const actions = [];
for (const row of document.querySelectorAll('#actions-list .act-row')) {
const input = row.querySelector('.act-param');
actions.push({action_type: row.querySelector('.act-type').value, parameter: input.value.trim() || null});
}
return actions;
}
function collectFormData() {
const prioRaw = document.getElementById('filter-priority').value.trim();
return {
account_id: parseInt(document.getElementById('account-select').value),
name: document.getElementById('filter-name').value.trim(),
priority: prioRaw === '' ? null : parseInt(prioRaw),
source_folder: document.getElementById('filter-source-folder').value,
stop_processing: document.getElementById('filter-stop-processing').checked,
enabled: document.getElementById('filter-enabled').checked,
conditions: collectConditions(),
actions: collectActions(),
};
}
function refreshWarnings() {
const data = collectFormData();
const w = ruleWarnings(data);
// Erkenntnisse aus dem letzten Test ergänzen
if (lastTest) {
const wantsAttachment = data.conditions.some(c => c.field === 'has_attachment' && c.value === 'true' && !c.negate);
if (wantsAttachment && lastTest.checked > 0 && lastTest.attachments_seen === 0) {
w.push(`Test: keine der ${lastTest.checked} geprüften Mails hatte einen Anhang. Kommen die Rechnungen evtl. als Link? Dann „Hat Anhang“ entfernen.`);
}
if (lastTest.checked > 0 && lastTest.matches.length === 0) {
w.push('Test: keine der geprüften Mails erfüllt alle Bedingungen.');
}
}
document.getElementById('dialog-warnings').innerHTML = w.length
? `<div class="warn-box"><strong>Hinweise</strong><ul>${w.map(x => `<li>${esc(x)}</li>`).join('')}</ul></div>`
: '';
}
async function saveFilter() {
const data = collectFormData();
if (!data.name) { alert('Bitte einen Namen eingeben.'); return; }
const blocking = ruleWarnings(data).filter(x => x.startsWith('Keine Bedingungen') || x.startsWith('Verschieben ohne'));
if (blocking.length && !confirm(blocking.join('\n') + '\n\nTrotzdem speichern?')) return;
const filterId = document.getElementById('filter-id').value;
const btn = document.getElementById('save-btn');
btn.setAttribute('aria-busy', 'true');
try {
const resp = await fetch(filterId ? `/api/filters/${filterId}` : '/api/filters/', {
method: filterId ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
if (!resp.ok) {
const d = await readJson(resp);
alert('Fehler: ' + (typeof d.detail === 'string' ? d.detail : JSON.stringify(d.detail)));
return;
}
closeDialog();
loadFilters();
} finally {
btn.removeAttribute('aria-busy');
}
}
// ---------- Test ----------
async function runTest() {
const data = collectFormData();
const out = document.getElementById('test-result');
const btn = document.getElementById('test-btn');
if (!data.conditions.length) { out.innerHTML = '<p class="muted">Erst mindestens eine Bedingung anlegen.</p>'; return; }
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
out.innerHTML = '<p class="muted small-text">Postfach wird durchsucht…</p>';
try {
const resp = await fetch('/api/filters/test', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({account_id: data.account_id, source_folder: data.source_folder, conditions: data.conditions, limit: 40}),
});
const r = await readJson(resp);
if (!resp.ok) { out.innerHTML = `<p class="reason">Fehler: ${esc(typeof r.detail === 'string' ? r.detail : JSON.stringify(r.detail))}</p>`; return; }
lastTest = r;
const failed = Object.entries(r.failed_on || {}).sort((a, b) => b[1] - a[1])
.map(([k, n]) => `${esc(k === 'Datum' ? 'Datum' : fieldLabel(k))}: ${n}×`).join(', ');
let html = `<div class="test-summary">
<strong>${r.matches.length}</strong> von ${r.checked} geprüften Mails würden verschoben
<span class="muted">(${r.candidates} Kandidaten gefunden${r.candidates > r.checked ? ', neueste ' + r.checked + ' geprüft' : ''})</span>
${failed ? `<br><span class="muted">Gescheitert an — ${failed}</span>` : ''}
</div>`;
const items = [
...r.matches.map(m => ({...m, ok: true})),
...r.misses.map(m => ({...m, ok: false})),
];
if (items.length) {
html += '<div class="test-list">' + items.map(m => `
<div class="test-item${m.ok ? ' ok' : ''}">
${m.ok ? '✅' : '✖'} <strong>${esc(m.subject || '(kein Betreff)')}</strong>
<div class="meta">${esc(m.from)} · ${m.date ? fmtDate(m.date) : 'ohne Datum'}${m.has_attachment ? ' · 📎' : ''}</div>
${m.ok ? '' : `<div class="reason">${esc(m.reason || '')}</div>`}
</div>`).join('') + '</div>';
} else {
html += '<p class="muted small-text">Keine Mails gefunden, die zur ersten Bedingung passen. Stimmt der Absender/Betreff?</p>';
}
out.innerHTML = html;
refreshWarnings();
} catch (e) {
out.innerHTML = `<p class="reason">Netzwerk-Fehler: ${esc(e.message)}</p>`;
} finally {
btn.removeAttribute('aria-busy');
btn.disabled = false;
}
}
// ---------- Löschen / Zurücksetzen ----------
async function deleteFilter(id) {
const f = currentFilters.find(x => x.id === id);
if (!confirm(`Regel „${f ? f.name : id}“ wirklich löschen?`)) return;
await fetch(`/api/filters/${id}`, {method: 'DELETE'});
loadFilters();
}
async function resetAllProcessed() {
if (!confirm('Alle Mails werden beim nächsten Poll erneut von allen Regeln geprüft. Fortfahren?')) return;
try {
const resp = await fetch('/api/filters/reset-all-processed', {method: 'POST'});
const d = await readJson(resp);
if (!resp.ok) { alert('Fehler: ' + (d.detail || resp.status)); return; }
alert(`${d.reset ?? 0} Einträge zurückgesetzt. Beim nächsten Poll wird alles neu geprüft.`);
} catch (e) {
alert('Netzwerk-Fehler: ' + e.message);
}
}
// --- Ordner-Browser (Baumansicht) ---
let folderTargetInputName = null;
let folderSeparator = '.';
async function openFolderBrowser(targetInputName) {
const accountId = document.getElementById('account-select').value;
if (!accountId) {
alert('Bitte zuerst ein Konto auswählen');
return;
}
folderTargetInputName = targetInputName;
document.getElementById('folder-status').style.display = 'none';
document.getElementById('folder-browser').showModal();
await refreshFolderList(accountId);
}
async function refreshFolderList(accountId) {
const container = document.getElementById('folder-tree');
container.innerHTML = '<p aria-busy="true">Ordner werden geladen...</p>';
try {
const resp = await fetch(`/api/accounts/${accountId}/folders`);
const data = await resp.json();
// Detect separator: if any folder contains ".", use "."; otherwise "/"
folderSeparator = data.folders.some(f => f.includes('.')) ? '.' : '/';
renderFolderTree(data.folders);
} catch(e) {
container.innerHTML = `<p>Fehler beim Laden: ${e.message}</p>`;
}
}
function buildTree(folders) {
const root = {children: {}, folders: []};
for (const path of folders) {
const parts = path.split(folderSeparator);
let node = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!node.children[part]) {
node.children[part] = {
name: part,
fullPath: parts.slice(0, i + 1).join(folderSeparator),
children: {},
depth: i,
};
}
node = node.children[part];
}
}
return root;
}
function renderFolderTree(folders) {
const container = document.getElementById('folder-tree');
if (!folders.length) {
container.innerHTML = '<p>Keine Ordner gefunden.</p>';
return;
}
const tree = buildTree(folders);
container.innerHTML = renderNode(tree, -1);
}
function renderNode(node, depth) {
let html = '';
const sortedKeys = Object.keys(node.children).sort();
for (const key of sortedKeys) {
const child = node.children[key];
const indent = (depth + 1) * 1.2;
const esc = child.fullPath.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const hasChildren = Object.keys(child.children).length > 0;
html += `<div class="folder-row" style="padding-left:${indent + 0.5}rem;" onclick="selectFolder('${esc}')">
<span class="folder-icon">${hasChildren ? '📂' : '📁'}</span>
<span class="folder-label">${child.name}</span>
${depth >= 0 ? `<span class="folder-path">${child.fullPath}</span>` : ''}
<button class="folder-add-btn" title="Unterordner erstellen" onclick="event.stopPropagation(); showNewSubfolder('${esc}', this)">📁+</button>
</div>`;
if (hasChildren) {
html += renderNode(child, depth + 1);
}
}
return html;
}
function selectFolder(folderName) {
const target = document.querySelector(`[name="${folderTargetInputName}"]`)
|| document.getElementById(folderTargetInputName);
if (target) target.value = folderName;
if (document.getElementById("filter-dialog").open) refreshWarnings();
document.getElementById('folder-browser').close();
}
function showNewSubfolder(parentPath, btn) {
// Remove any existing new-folder input
document.querySelectorAll('.folder-new-input').forEach(el => el.remove());
const row = btn.closest('.folder-row');
const inputRow = document.createElement('div');
inputRow.className = 'folder-new-input';
inputRow.style.paddingLeft = row.style.paddingLeft;
inputRow.style.marginLeft = '1.8rem';
inputRow.innerHTML = `
<span style="margin-right:0.3rem;">📁</span>
<input type="text" placeholder="Neuer Ordnername" autofocus>
<button type="button" class="outline small" onclick="doCreateSubfolder('${parentPath.replace(/'/g, "\\'")}', this)">Erstellen</button>
<button type="button" class="outline small contrast" onclick="this.parentElement.remove()">X</button>
`;
row.after(inputRow);
inputRow.querySelector('input').focus();
inputRow.querySelector('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
doCreateSubfolder(parentPath, inputRow.querySelector('.outline.small'));
}
if (e.key === 'Escape') inputRow.remove();
});
}
async function doCreateSubfolder(parentPath, btn) {
const input = btn.parentElement.querySelector('input');
const name = input.value.trim();
if (!name) return;
const fullPath = parentPath + folderSeparator + name;
const accountId = document.getElementById('account-select').value;
const status = document.getElementById('folder-status');
btn.setAttribute('aria-busy', 'true');
try {
const resp = await fetch(`/api/accounts/${accountId}/folders`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({folder_name: fullPath}),
});
const data = await resp.json();
if (resp.ok) {
status.style.display = 'block';
status.textContent = `Ordner "${fullPath}" erstellt`;
setTimeout(() => status.style.display = 'none', 3000);
await refreshFolderList(accountId);
} else {
status.style.display = 'block';
status.textContent = 'Fehler: ' + (data.detail || JSON.stringify(data));
}
} catch(e) {
status.style.display = 'block';
status.textContent = 'Fehler: ' + e.message;
}
btn.removeAttribute('aria-busy');
}
// ---------- Init ----------
(function init() {
const sel = document.getElementById('account-select');
if (!sel.value) {
let saved = null;
try { saved = localStorage.getItem(ACCOUNT_KEY); } catch {}
if (saved && [...sel.options].some(o => o.value === saved)) sel.value = saved;
else if (sel.options.length === 2) sel.value = sel.options[1].value; // nur ein Konto
}
if (sel.value) loadFilters();
})();
</script>
{% endblock %}