Files
imap-mail-filter-service/app/templates/logs.html
T
duffyduckandClaude Opus 5.5 d5cb0a83f3 new action: attach the message as PDF and file it
For senders whose mail body is the receipt (e.g. GoCardless payouts)
there is no attachment to file. The new action "Als PDF anhängen +
nach <Ordner>" renders the mail like a print from a mail client
(header block From/To/Date/Subject, then the content) and replaces it
in the target folder with: original content + PDF + the original mail
byte-for-byte as Original.eml, so the unaltered original is retained
(GoBD).

- Rendering with WeasyPrint. Images are fetched by us and inlined as
  data: URIs; WeasyPrint itself may only read data:. Fetching is
  restricted to http(s), public addresses (private, loopback,
  link-local and PDF_BLOCKED_NETWORKS, default 172.0.0.0/12, are
  refused; redirects are checked hop by hop), with time and size
  limits. cid: images come from the MIME parts.
- The replacement is APPENDed with the original flags and INTERNALDATE,
  verified in the target via its new Message-ID, and only then is the
  original deleted. Already converted mails are only moved.
- "PDF-Vorschau" in the rule dialog renders the newest matching mail
  read-only and opens it in the browser.
- List/log show 📄 targets; warnings for PDF + move/delete and for
  PDF + "Hat Anhang = Ja"; log folder search covers PDF targets.
- Docker image gets Pango and fonts (~316 MB total).

Tested with the real GoCardless mail (3 remote images, rendered both
locally and inside the built image) and a simulated IMAP server for
success, rejected APPEND, missing copy and already converted mails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:51:26 +02:00

542 lines
26 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 %}Log — IMAP Mail Filter{% endblock %}
{% block content %}
<div class="page-head">
<h1>Log</h1>
<select id="log-account" class="head-select" onchange="reload()" title="Konto">
<option value="">Alle Konten</option>
{% for acc in accounts %}
<option value="{{ acc.id }}">{{ acc.name }}</option>
{% endfor %}
</select>
<select id="log-range" class="head-select" onchange="reload()" title="Zeitraum">
<option value="1h">Letzte Stunde</option>
<option value="today">Heute</option>
<option value="7d" selected>Letzte 7 Tage</option>
<option value="30d">Letzte 30 Tage</option>
<option value="all">Gesamter Zeitraum</option>
</select>
<span id="log-stats" class="muted small-text"></span>
<label class="auto-refresh" title="Erste Seite alle 10 Sekunden neu laden">
<input type="checkbox" role="switch" id="auto-refresh" onchange="toggleAutoRefresh()"> Live
</label>
<details class="dropdown head-menu" ontoggle="if (this.open) loadDbStatus()">
<summary>⋯</summary>
<ul dir="rtl">
<li class="db-status" id="db-status">Datenbank wird geprüft…</li>
<li><a href="#" onclick="runMaintenance(); return false;" title="Alte Einträge nach Aufbewahrungsregel löschen und die Datenbankdatei verkleinern">Datenbank aufräumen &amp; verkleinern</a></li>
<li><a href="#" onclick="retryFailed(); return false;" title="Mails mit Abruf-Fehler oder fehlgeschlagener Aktion beim nächsten Poll erneut verarbeiten">Fehlgeschlagene neu einlesen</a></li>
<li><a href="#" onclick="clearLogs(); return false;">Log leeren</a></li>
</ul>
</details>
</div>
<div class="toolbar">
<div class="search-wrap">
<input type="search" id="log-search" autocomplete="off" spellcheck="false"
placeholder="Suchen … z.B. hetzner oder von:hetzner betreff:rechnung regel:cps"
oninput="onSearchInput()" onkeydown="if(event.key==='Enter'){ reload(); }">
<button type="button" class="help-btn" onclick="toggleHelp()" title="Suchsyntax">?</button>
</div>
<div class="seg" id="level-filter">
<button data-l="" class="active" onclick="setLevel('')">Alle</button>
<button data-l="success" onclick="setLevel('success')">✅ Aktionen</button>
<button data-l="error,warning" onclick="setLevel('error,warning')">❌ Fehler</button>
<button data-l="info" onclick="setLevel('info')">ℹ Info</button>
</div>
</div>
<div id="search-help" class="search-help" hidden>
<strong>Suchsyntax</strong> — Begriffe werden UND-verknüpft, Groß/Klein egal. Mehrere Wörter in Anführungszeichen: <code>betreff:"ihre rechnung"</code>
<div class="help-grid">
<code>hetzner</code><span>Freitext in allen Feldern</span>
<code>von:hetzner.com</code><span>Absender</span>
<code>betreff:rechnung</code><span>Betreff</span>
<code>regel:cps</code><span>Name der Regel</span>
<code>ordner:Rechnungen</code><span>Ordner / Ziel</span>
<code>text:verschoben</code><span>Log-Nachricht</span>
</div>
Tipp: Klick auf Regel, Absender oder Ordner in einer Zeile fügt ihn als Filter hinzu.
</div>
<div id="active-filters" class="active-filters"></div>
<div id="log-container"><p aria-busy="true">Log wird geladen…</p></div>
<div id="log-more"></div>
{% 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.6rem; flex-wrap:wrap; margin-bottom:0.75rem; }
.page-head h1 { margin:0 0.4rem 0 0; }
.head-select { width:auto; margin:0; padding-top:0.3rem; padding-bottom:0.3rem; font-size:0.9rem; }
.page-head #log-stats { flex:1; }
.auto-refresh { margin:0; font-size:0.85rem; white-space:nowrap; }
.head-menu { margin:0; }
.head-menu summary { padding:0.2rem 0.7rem; height:auto; font-size:1.1rem; line-height:1.2; }
.head-menu summary::after { display:none; }
.head-menu ul { min-width:290px; font-size:0.9rem; }
.head-menu li.db-status { direction:ltr; text-align:left; font-size:0.78rem; color:var(--pico-muted-color); line-height:1.5;
border-bottom:1px solid var(--app-border, #e3e6ea); padding-bottom:0.5rem; white-space:normal; }
.toolbar { display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap; margin-bottom:0.4rem; }
.search-wrap { position:relative; flex:1 1 360px; }
.search-wrap input { margin:0; padding-top:0.35rem; padding-bottom:0.35rem; padding-right:2.4rem; font-size:0.9rem; }
.search-wrap .help-btn { position:absolute; right:0.35rem; top:50%; transform:translateY(-50%); margin:0; padding:0 0.5rem;
font-size:0.8rem; line-height:1.6; border-radius:999px;
--pico-background-color:#eef0f3; --pico-border-color:transparent; --pico-color:#555; }
.seg { display:inline-flex; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius); overflow:hidden; background:#fff; }
.toolbar .seg button, .search-wrap .help-btn { transition:none; }
.toolbar .seg button { --pico-background-color:transparent; --pico-border-color:transparent; --pico-color:#374151;
margin:0; border-radius:0; padding:0.3rem 0.7rem; font-size:0.8rem; box-shadow:none; }
.toolbar .seg button + button { border-left:1px solid var(--app-border, #e3e6ea); }
.toolbar .seg button:hover { --pico-background-color:#f1f3f5; }
.toolbar .seg button.active { --pico-background-color:var(--pico-primary); --pico-color:var(--pico-primary-inverse); }
.search-help { background:#fff; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius);
padding:0.6rem 0.8rem; font-size:0.83rem; margin-bottom:0.5rem; }
.help-grid { display:grid; grid-template-columns:max-content 1fr; gap:0.15rem 1rem; margin:0.4rem 0; }
.help-grid code { font-size:0.8rem; padding:0 0.3rem; }
.active-filters { display:flex; flex-wrap:wrap; gap:0.3rem; margin-bottom:0.5rem; min-height:0; }
.fchip { display:inline-flex; align-items:center; gap:0.25rem; font-size:0.78rem; padding:0.05rem 0.2rem 0.05rem 0.5rem;
border-radius:999px; background:#e8eefc; color:#274b9f; border:1px solid #cdd9f5; }
.fchip button { --pico-background-color:transparent; --pico-border-color:transparent; --pico-color:#274b9f;
margin:0; padding:0 0.3rem; font-size:0.8rem; line-height:1.3; box-shadow:none; }
.fchip.clear-all { background:transparent; border-color:transparent; }
.logs { background:#fff; border:1px solid var(--app-border, #e3e6ea); border-radius:var(--pico-border-radius); overflow:hidden; font-size:0.84rem; }
.logs .l { display:grid; grid-template-columns:98px 22px minmax(110px, 170px) minmax(0, 1fr) minmax(90px, 190px);
gap:0.6rem; align-items:baseline; padding:0.3rem 0.75rem; border-bottom:1px solid #eef0f3; cursor:pointer; }
.logs .l:hover { background:#f8f9fb; }
.logs .l.open { background:#f4f7fd; }
.logs .l.head { background:#f6f7f9; cursor:default; font-size:0.72rem; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; color:var(--pico-muted-color); }
.logs .l.lv-error { box-shadow:inset 3px 0 0 #dc3545; }
.logs .l.lv-warning { box-shadow:inset 3px 0 0 #ffc107; }
.logs .l.lv-success { box-shadow:inset 3px 0 0 #28a745; }
.logs .l.retried { box-shadow:inset 3px 0 0 #c9ced6; }
.logs .l.retried > span:not(.l-main), .logs .l.retried .l-mail { opacity:0.55; }
.retried-tag { font-size:0.72rem; font-weight:600; padding:0 0.35rem; border-radius:3px; background:#eef0f3; color:#555; white-space:nowrap; }
.l-time { color:var(--pico-muted-color); font-variant-numeric:tabular-nums; white-space:nowrap; font-size:0.78rem; }
.l-day { display:block; font-size:0.68rem; opacity:0.8; }
.l-rule, .l-folder { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.l-main { min-width:0; }
.l-msg { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.l-mail { font-size:0.78rem; color:var(--pico-muted-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.l-mail b { color:var(--pico-color); font-weight:500; }
.flink { color:inherit; text-decoration:none; border-bottom:1px dotted #aab; cursor:pointer; }
.flink:hover { color:var(--pico-primary); border-bottom-color:var(--pico-primary); }
.l-folder { color:#1f6f43; }
.l-detail { grid-column:1 / -1; font-size:0.78rem; background:#fff; border:1px solid #e6e9ed; border-radius:4px;
padding:0.4rem 0.6rem; margin:0.2rem 0 0.2rem; cursor:text; white-space:pre-wrap; word-break:break-word; }
.l-detail dl { display:grid; grid-template-columns:max-content 1fr; gap:0.1rem 0.8rem; margin:0; }
.l-detail dt { color:var(--pico-muted-color); }
.l-detail dd { margin:0; }
#log-more { text-align:center; margin-top:0.6rem; }
#log-more button { padding:0.3rem 1rem; font-size:0.85rem; }
@media (max-width: 800px) {
.logs .l { grid-template-columns:80px 22px minmax(0, 1fr); }
.logs .l .l-rule, .logs .l .l-folder, .logs .l.head { display:none; }
}
</style>
<script>
const PAGE_SIZE = 100;
const LEVEL_ICON = {success: '✅', info: 'ℹ️', warning: '⚠️', error: '❌'};
// Suchkürzel → API-Parameter
const KEYS = {
von: 'sender', from: 'sender', absender: 'sender',
betreff: 'subject', subject: 'subject',
regel: 'rule', rule: 'rule', filter: 'rule',
ordner: 'folder', folder: 'folder', ziel: 'folder',
text: 'message', nachricht: 'message', msg: 'message',
};
const KEY_LABEL = {sender: 'Von', subject: 'Betreff', rule: 'Regel', folder: 'Ordner', message: 'Text', q: 'Text'};
let levelFilter = '';
let logs = [];
let nextBeforeId = null;
let hasMore = false;
let inflight = null;
let searchTimer = null;
let refreshTimer = null;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function parseUtc(iso) {
// Server liefert naive UTC-Zeitstempel — ohne 'Z' würde der Browser sie als Ortszeit lesen
return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
}
// ---------- Suchsyntax ----------
function tokenize(input) {
// Liefert [{key, value, raw}] — key null = Freitext
const re = /(\w+):"([^"]*)"|(\w+):(\S+)|"([^"]*)"|(\S+)/g;
const out = [];
let m;
while ((m = re.exec(input)) !== null) {
let key = m[1] || m[3] || null;
let value = m[2] ?? m[4] ?? m[5] ?? m[6] ?? '';
if (key && !KEYS[key.toLowerCase()]) { value = m[0]; key = null; } // unbekanntes Kürzel = Freitext
if (value === '') continue;
out.push({key: key ? KEYS[key.toLowerCase()] : null, value, raw: m[0]});
}
return out;
}
function serializeTokens(tokens) {
return tokens.map(t => {
if (!t.key) return /\s/.test(t.value) ? `"${t.value}"` : t.value;
const short = {sender: 'von', subject: 'betreff', rule: 'regel', folder: 'ordner', message: 'text'}[t.key];
return `${short}:${/\s/.test(t.value) ? `"${t.value}"` : t.value}`;
}).join(' ');
}
function addFilter(key, value) {
const input = document.getElementById('log-search');
const tokens = tokenize(input.value).filter(t => !(t.key === key));
tokens.push({key, value});
input.value = serializeTokens(tokens);
reload();
}
function removeToken(index) {
const input = document.getElementById('log-search');
const tokens = tokenize(input.value);
tokens.splice(index, 1);
input.value = serializeTokens(tokens);
reload();
}
function clearFilters() {
document.getElementById('log-search').value = '';
setLevel('');
}
function renderActiveFilters(tokens) {
const box = document.getElementById('active-filters');
const chips = tokens.map((t, i) =>
`<span class="fchip">${esc(KEY_LABEL[t.key || 'q'])}: <b>${esc(t.value)}</b><button title="Filter entfernen" onclick="removeToken(${i})">✕</button></span>`);
if (levelFilter) {
const label = document.querySelector(`#level-filter button[data-l="${levelFilter}"]`)?.textContent || levelFilter;
chips.push(`<span class="fchip">${esc(label)}<button title="Filter entfernen" onclick="setLevel('')">✕</button></span>`);
}
if (chips.length > 1) chips.push(`<span class="fchip clear-all"><button onclick="clearFilters()">alle entfernen</button></span>`);
box.innerHTML = chips.join('');
}
// ---------- Laden ----------
function sinceFor(range) {
const now = new Date();
switch (range) {
case '1h': return new Date(now - 3600e3);
case 'today': { const d = new Date(now); d.setHours(0, 0, 0, 0); return d; }
case '7d': return new Date(now - 7 * 86400e3);
case '30d': return new Date(now - 30 * 86400e3);
default: return null;
}
}
function buildParams(beforeId) {
const p = new URLSearchParams({limit: PAGE_SIZE});
const acc = document.getElementById('log-account').value;
if (acc) p.set('account_id', acc);
if (levelFilter) p.set('level', levelFilter);
const since = sinceFor(document.getElementById('log-range').value);
if (since) p.set('since', since.toISOString());
const tokens = tokenize(document.getElementById('log-search').value);
const free = [];
for (const t of tokens) {
if (t.key) p.set(t.key, t.value);
else free.push(t.value);
}
if (free.length) p.set('q', free.join(' '));
if (beforeId) p.set('before_id', beforeId);
renderActiveFilters(tokens);
syncUrl();
return p;
}
async function fetchLogs(beforeId = null) {
if (inflight) inflight.abort();
inflight = new AbortController();
const params = buildParams(beforeId);
const resp = await fetch(`/api/logs/?${params}`, {signal: inflight.signal});
const text = await resp.text();
let data;
try { data = JSON.parse(text); } catch { throw new Error(text || `HTTP ${resp.status}`); }
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
return data;
}
async function reload() {
const container = document.getElementById('log-container');
if (!logs.length) container.innerHTML = '<p aria-busy="true">Log wird geladen…</p>';
else container.style.opacity = '0.55';
try {
const data = await fetchLogs();
logs = data.logs;
hasMore = data.has_more;
nextBeforeId = data.next_before_id;
renderStats(data);
renderLogs();
} catch (e) {
if (e.name === 'AbortError') return;
container.innerHTML = `<p style="color:#b42318">Fehler beim Laden: ${esc(e.message)}</p>`;
} finally {
container.style.opacity = '';
}
}
async function loadMore() {
const btn = document.querySelector('#log-more button');
if (btn) btn.setAttribute('aria-busy', 'true');
try {
const data = await fetchLogs(nextBeforeId);
logs = logs.concat(data.logs);
hasMore = data.has_more;
nextBeforeId = data.next_before_id;
renderLogs();
} catch (e) {
if (e.name !== 'AbortError') alert('Fehler: ' + e.message);
}
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(reload, 350);
}
function setLevel(l) {
levelFilter = l;
document.querySelectorAll('#level-filter button').forEach(b => b.classList.toggle('active', b.dataset.l === l));
reload();
}
function toggleHelp() {
const h = document.getElementById('search-help');
h.hidden = !h.hidden;
}
// ---------- Darstellung ----------
function renderStats(data) {
const el = document.getElementById('log-stats');
if (data.total == null) return;
el.textContent = data.total_capped ? 'mehr als 1.000 Einträge' : `${data.total.toLocaleString('de-DE')} Einträge`;
}
function emailOf(from) {
const m = String(from || '').match(/<([^>]+)>/);
return m ? m[1] : String(from || '').trim();
}
function targetOf(log) {
// Bei Aktionen steht der Zielordner in details, sonst Quellordner
if (log.action_type === 'move' && log.details) return {text: '📁 ' + log.details.replace(/^INBOX[./]/, ''), filter: log.details};
if (log.action_type === 'pdf' && log.details) return {text: '📄 ' + log.details.replace(/^INBOX[./]/, ''), filter: log.details};
if (log.action_type === 'forward' && log.details) return {text: '↪ ' + log.details, filter: null};
if (log.action_type === 'delete') return {text: '🗑 Papierkorb', filter: null};
if (log.folder) return {text: log.folder, filter: log.folder};
return {text: '', filter: null};
}
function shortMessage(log) {
const m = log.message || '';
if (log.action_type && m.startsWith('Aktion ausgeführt')) {
return {move: 'Verschoben', pdf: 'Als PDF angehängt & abgelegt', forward: 'Weitergeleitet', delete: 'Gelöscht', mark_read: 'Als gelesen markiert'}[log.action_type] || m;
}
return m;
}
function jsArg(s) {
return esc(JSON.stringify(String(s)));
}
function renderLogs() {
const container = document.getElementById('log-container');
const more = document.getElementById('log-more');
if (!logs.length) {
const hasFilter = document.getElementById('log-search').value.trim() || levelFilter;
container.innerHTML = `<article><p class="muted">Keine Einträge${hasFilter ? ' für diese Suche' : ''} im gewählten Zeitraum.</p>
${document.getElementById('log-range').value !== 'all' ? '<button class="outline small" onclick="document.getElementById(\'log-range\').value=\'all\'; reload();">Gesamten Zeitraum durchsuchen</button>' : ''}</article>`;
more.innerHTML = '';
return;
}
const today = new Date().toDateString();
let html = `<div class="logs"><div class="l head"><span>Zeit</span><span></span><span>Regel</span><span>Ereignis / Mail</span><span>Ordner / Ziel</span></div>`;
for (const log of logs) {
const d = log.created_at ? parseUtc(log.created_at) : null;
const time = d ? d.toLocaleTimeString('de-DE') : '';
const day = d && d.toDateString() !== today ? d.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit', year: '2-digit'}) : '';
const target = targetOf(log);
const mail = (log.mail_subject || log.mail_from)
? `<div class="l-mail"><b>${esc(log.mail_subject || '(kein Betreff)')}</b>${log.mail_from ? ` · <a class="flink" onclick="event.stopPropagation(); addFilter('sender', ${jsArg(emailOf(log.mail_from))})" title="Nach diesem Absender filtern">${esc(log.mail_from)}</a>` : ''}</div>`
: '';
html += `
<div class="l lv-${esc(log.level)}${log.retried_at ? ' retried' : ''}" onclick="toggleDetail(this, ${log.id})">
<span class="l-time">${day ? `<span class="l-day">${day}</span>` : ''}${time}</span>
<span title="${esc(log.level)}">${LEVEL_ICON[log.level] || ''}</span>
<span class="l-rule">${log.rule_name ? `<a class="flink" onclick="event.stopPropagation(); addFilter('rule', ${jsArg(log.rule_name)})" title="Nach dieser Regel filtern">${esc(log.rule_name)}</a>` : '<span class="muted">—</span>'}</span>
<span class="l-main"><div class="l-msg">${log.retried_at ? `<span class="retried-tag" title="Mit „Fehlgeschlagene neu einlesen“ erneut angestoßen. Scheitert es wieder, erscheint ein neuer Fehler-Eintrag.">↻ erneut versucht ${esc(fmtShort(log.retried_at))}</span> ` : ''}${esc(shortMessage(log))}</div>${mail}</span>
<span class="l-folder">${target.filter ? `<a class="flink" onclick="event.stopPropagation(); addFilter('folder', ${jsArg(target.filter)})" title="Nach diesem Ordner filtern">${esc(target.text)}</a>` : esc(target.text)}</span>
</div>`;
}
html += '</div>';
container.innerHTML = html;
more.innerHTML = hasMore
? `<button class="outline secondary" onclick="loadMore()">Ältere laden</button> <span class="muted small-text">${logs.length} angezeigt</span>`
: `<span class="muted small-text">${logs.length} angezeigt · Ende des Zeitraums</span>`;
}
function toggleDetail(row, id) {
const open = row.querySelector('.l-detail');
if (open) { open.remove(); row.classList.remove('open'); return; }
const log = logs.find(l => l.id === id);
if (!log) return;
const rows = [
['Zeit', log.created_at ? parseUtc(log.created_at).toLocaleString('de-DE') : ''],
['Konto', log.account_name],
['Nachricht', log.message],
['Regel', log.rule_name],
['Von', log.mail_from],
['Betreff', log.mail_subject],
['Ordner', log.folder],
['Mail-UID', log.mail_uid],
['Details', log.details],
['Erneut versucht', log.retried_at ? parseUtc(log.retried_at).toLocaleString('de-DE') : ''],
].filter(([, v]) => v);
const div = document.createElement('div');
div.className = 'l-detail';
div.onclick = e => e.stopPropagation();
div.innerHTML = `<dl>${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>`;
row.appendChild(div);
row.classList.add('open');
}
// ---------- Aktionen ----------
async function readJson(resp) {
const text = await resp.text();
try { return JSON.parse(text); } catch { return {detail: text}; }
}
async function clearLogs() {
document.querySelector('.head-menu').open = false;
const acc = document.getElementById('log-account');
const scope = acc.value ? `für „${acc.options[acc.selectedIndex].text}“` : 'für alle Konten';
if (!confirm(`Log ${scope} wirklich leeren?`)) return;
const resp = await fetch(`/api/logs/${acc.value ? `?account_id=${acc.value}` : ''}`, {method: 'DELETE'});
const d = await readJson(resp);
if (!resp.ok) { alert('Fehler beim Leeren: ' + (d.detail || resp.status)); return; }
logs = [];
reload();
}
async function retryFailed() {
document.querySelector('.head-menu').open = false;
if (!confirm('Alle offenen Fehler (Abrufen oder Aktion, z.B. Verschieben) beim nächsten Poll erneut versuchen?\n\nDie Einträge werden als „erneut versucht“ markiert. Scheitert eine Mail wieder, erscheint ein neuer Fehler-Eintrag.')) return;
const acc = document.getElementById('log-account').value;
const resp = await fetch(`/api/filters/retry-failed${acc ? `?account_id=${acc}` : ''}`, {method: 'POST'});
const d = await readJson(resp);
if (!resp.ok) { alert('Fehler: ' + (d.detail || resp.status)); return; }
alert(d.log_entries
? `${d.unique_uids} Mail(s) aus ${d.log_entries} offenen Fehler-Einträgen neu vorgemerkt. Sie werden beim nächsten Poll erneut verarbeitet.`
: 'Keine offenen Fehler — alle wurden bereits erneut versucht.');
reload();
}
function fmtShort(iso) {
const d = parseUtc(iso);
return d.toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit'}) + ' ' + d.toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'});
}
function fmtBytes(n) {
if (n == null) return '?';
if (n >= 1e9) return (n / 1e9).toLocaleString('de-DE', {maximumFractionDigits: 2}) + ' GB';
return Math.round(n / 1e6).toLocaleString('de-DE') + ' MB';
}
async function loadDbStatus() {
const el = document.getElementById('db-status');
try {
const resp = await fetch('/api/logs/maintenance');
const s = await readJson(resp);
if (!resp.ok) throw new Error(s.detail || resp.status);
const last = s.last_run && s.last_run.started_at
? `Letzte Wartung: ${parseUtc(s.last_run.started_at).toLocaleString('de-DE', {dateStyle: 'short', timeStyle: 'short'})}`
: 'Wartung läuft täglich automatisch';
el.innerHTML = `Datenbank: <b>${fmtBytes(s.file_bytes)}</b> (davon ${fmtBytes(s.free_bytes)} ungenutzt)<br>
Log: ${s.log_rows.toLocaleString('de-DE')} Einträge · Aufbewahrung ${s.retention_days} Tage, max. ${s.max_entries.toLocaleString('de-DE')}<br>
${esc(last)}`;
} catch (e) {
el.textContent = 'Status nicht verfügbar: ' + e.message;
}
}
async function runMaintenance() {
document.querySelector('.head-menu').open = false;
if (!confirm('Datenbank jetzt aufräumen und verkleinern?\n\nAlte Log-Einträge werden nach der Aufbewahrungsregel gelöscht und die Datei wird neu geschrieben. Bei großen Datenbanken kann das einige Minuten dauern; die Mail-Verarbeitung wartet solange.')) return;
const stats = document.getElementById('log-stats');
const prev = stats.textContent;
stats.innerHTML = '<span aria-busy="true">Datenbank wird aufgeräumt…</span>';
try {
const resp = await fetch('/api/logs/maintenance?vacuum=true', {method: 'POST'});
const d = await readJson(resp);
if (!resp.ok) throw new Error(d.detail || resp.status);
const r = d.result;
if (r.skipped) { alert(r.skipped); return; }
alert(`Fertig in ${r.duration_s} s.\n\n` +
`Größe: ${fmtBytes(r.size_before)} → ${fmtBytes(r.size_after)}\n` +
`Gelöschte Log-Einträge: ${(r.logs_deleted_age + r.logs_deleted_cap).toLocaleString('de-DE')}\n` +
`Verwaiste Marker entfernt: ${r.processed_orphans_deleted.toLocaleString('de-DE')}\n` +
`Verkleinern: ${r.vacuum}`);
reload();
} catch (e) {
alert('Wartung fehlgeschlagen: ' + e.message);
} finally {
if (stats.querySelector('[aria-busy]')) stats.textContent = prev;
}
}
function toggleAutoRefresh() {
clearInterval(refreshTimer);
refreshTimer = null;
if (document.getElementById('auto-refresh').checked) {
// Nur neu laden, wenn niemand gerade eine Zeile aufgeklappt hat
refreshTimer = setInterval(() => { if (!document.querySelector('.l.open')) reload(); }, 10000);
reload();
}
}
// Suche, Level, Zeitraum und Konto in der URL halten: Reload behält die
// Filter, und eine Suche lässt sich als Lesezeichen speichern.
function syncUrl() {
const u = new URLSearchParams();
const q = document.getElementById('log-search').value.trim();
if (q) u.set('q', q);
if (levelFilter) u.set('level', levelFilter);
const range = document.getElementById('log-range').value;
if (range !== '7d') u.set('range', range);
const acc = document.getElementById('log-account').value;
if (acc) u.set('account', acc);
history.replaceState(null, '', u.toString() ? `?${u}` : location.pathname);
}
(function init() {
const u = new URLSearchParams(location.search);
if (u.get('q')) document.getElementById('log-search').value = u.get('q');
if (u.get('range')) document.getElementById('log-range').value = u.get('range');
if (u.get('account')) document.getElementById('log-account').value = u.get('account');
levelFilter = u.get('level') || '';
document.querySelectorAll('#level-filter button').forEach(b => b.classList.toggle('active', b.dataset.l === levelFilter));
reload();
})();
</script>
{% endblock %}