Files
imap-mail-filter-service/app/templates/filters.html
T
duffyduckandClaude Opus 5.5 86ae896ae9 compact filter list: one row per rule, smaller global type scale
Pico scales the root font up to 125% on wide screens, which made the
whole admin UI oversized. Pin it to 93.75% and tighten spacing tokens.

The filter list is now a dense table-like list: switch, priority, name,
condition chips, target folder, match stats and icon buttons on a single
row (~35 rules fit on one screen instead of ~4). Warnings collapse into
a ⚠ icon with tooltip; attachment and date conditions render as short
chips; the full condition list is in the row tooltip; double-click opens
the rule. Account picker, summary and main actions share one header row.

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

964 lines
48 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 { --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) 118px 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 fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit', year:'2-digit'});
}
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 ${fmtDate(f.last_match_at)}"><strong>${f.match_count}</strong> · ${fmtDate(f.last_match_at).slice(0, 6)}</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 %}