The match column now shows "27 · 22.09. 12:00", with the full date and time in the tooltip. Server timestamps are naive UTC; appending 'Z' before parsing keeps the browser from treating them as local time, which also fixes mail dates in the rule test results. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
975 lines
48 KiB
HTML
975 lines
48 KiB
HTML
{% 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) 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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, '"');
|
||
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 %}
|