safe move + sqlite WAL + log indexes + backup with logs

- fix: imap folder names with spaces (RFC3501 quoting in move/create/select)
- fix: move only deletes source after COPY + Message-ID verification in target
- fix: backup endpoint hung on sqlite write locks — enable WAL + busy_timeout
- perf: indexes on filter_logs(created_at, level, account_id+created_at) for
  fast log queries on millions of rows
- feat: optional "logs mit sichern" checkbox in backup export, restore on import
- UI: backup download uses fetch+blob with error display instead of location.href

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 19:54:32 +02:00
parent 66b32ded36
commit 7e7ec67e58
9 changed files with 331 additions and 27 deletions
+42 -4
View File
@@ -9,7 +9,12 @@
<article>
<header><h3>Backup erstellen</h3></header>
<p>Gesamte Konfiguration als JSON-Datei herunterladen.</p>
<button onclick="exportBackup()">Backup herunterladen</button>
<label style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.5rem;">
<input type="checkbox" id="backup-include-logs" style="margin-bottom:0;">
Logs mit sichern <small style="opacity:0.7;">(Datei wird deutlich größer)</small>
</label>
<button id="backup-download-btn" onclick="exportBackup()">Backup herunterladen</button>
<div id="backup-export-status" style="margin-top:0.5rem;"></div>
</article>
<article>
@@ -47,8 +52,40 @@
{% block scripts %}
<script>
function exportBackup() {
window.location.href = '/api/yaml/backup';
async function exportBackup() {
const includeLogs = document.getElementById('backup-include-logs').checked;
const btn = document.getElementById('backup-download-btn');
const statusDiv = document.getElementById('backup-export-status');
btn.setAttribute('aria-busy', 'true');
btn.disabled = true;
statusDiv.innerHTML = `<small>Backup wird erstellt${includeLogs ? ' (Logs werden geladen, kann dauern...)' : ''}</small>`;
try {
const url = `/api/yaml/backup${includeLogs ? '?include_logs=true' : ''}`;
const resp = await fetch(url);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(`HTTP ${resp.status}: ${text || resp.statusText}`);
}
const blob = await resp.blob();
const disposition = resp.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename=([^;]+)/);
const filename = match ? match[1].trim().replace(/^"|"$/g, '') : 'mailfilter-backup.json';
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(downloadUrl);
const sizeMb = (blob.size / 1024 / 1024).toFixed(2);
statusDiv.innerHTML = `<small>Backup heruntergeladen (${sizeMb} MB).</small>`;
} catch (e) {
statusDiv.innerHTML = `<small style="color:var(--pico-color-red-500, #b00);">Fehler: ${e.message}</small>`;
} finally {
btn.removeAttribute('aria-busy');
btn.disabled = false;
}
}
document.getElementById('backup-import-form').addEventListener('submit', async (e) => {
@@ -67,7 +104,8 @@ document.getElementById('backup-import-form').addEventListener('submit', async (
Konten erstellt: ${result.accounts_created}<br>
Konten aktualisiert: ${result.accounts_updated}<br>
Regeln erstellt: ${result.rules_created}<br>
Verarbeitungsstatus: ${result.processed_restored} Einträge
Verarbeitungsstatus: ${result.processed_restored} Einträge<br>
Logs wiederhergestellt: ${result.logs_restored ?? 0} Einträge
</article>`;
}
});