retry-failed: only pick open errors and mark them as retried

"Fehlgeschlagene neu einlesen" reported the same 29 mails on every
click: it selected all error log entries ever written and never noted
that one had already been retried.

Error log entries now get retried_at when they are picked up; the
endpoint only considers entries without it. If the mail fails again,
the scheduler writes a new, open error entry, so a second click right
after the first reports 0. The log shows retried errors greyed out
with "↻ erneut versucht <Zeit>", and the result dialog says how many
open error entries and mails were requeued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 12:12:03 +02:00
co-authored by Claude Opus 5.5
parent fbb2fe95c8
commit 360feea42e
5 changed files with 77 additions and 16 deletions
@@ -0,0 +1,29 @@
"""add retried_at to filter_logs
Revision ID: b4e8d2f61a07
Revises: 9a3f5c1d7e42
Create Date: 2026-09-29 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "b4e8d2f61a07"
down_revision: Union[str, Sequence[str], None] = "9a3f5c1d7e42"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Markiert Fehler-Einträge, die bereits erneut angestoßen wurden, damit
'Fehlgeschlagene neu einlesen' nicht immer wieder dieselben aufgreift.
Nullable-Spalte → SQLite kann sie ohne Tabellen-Neuaufbau anhängen."""
op.add_column("filter_logs", sa.Column("retried_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("filter_logs", schema=None) as batch_op:
batch_op.drop_column("retried_at")
+3
View File
@@ -141,6 +141,9 @@ class FilterLog(Base):
folder: Mapped[str | None] = mapped_column(String(255), nullable=True)
details: Mapped[str | None] = mapped_column(String(2000), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
# Fehler-Einträge: Zeitpunkt, zu dem "Fehlgeschlagene neu einlesen" die Mail
# erneut angestoßen hat. Scheitert sie wieder, entsteht ein neuer Eintrag.
retried_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
__table_args__ = (
Index("ix_filter_logs_account_created", "account_id", "created_at"),
+28 -12
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, or_, text
from sqlalchemy.orm import Session
@@ -92,29 +94,33 @@ def reset_all_processed(db: Session = Depends(get_db)):
@router.post("/retry-failed")
def retry_failed_fetches(account_id: int | None = None, db: Session = Depends(get_db)):
"""Löscht ProcessedMail-Einträge für UIDs, bei denen das Abrufen oder eine
Aktion (z.B. Verschieben) fehlgeschlagen ist — damit der Scheduler sie beim
nächsten Poll neu versucht (z.B. nach einem Fix). Schon verschobene Mails
liegen nicht mehr im Quellordner und werden daher nicht doppelt verarbeitet."""
"""Stößt Mails, bei denen das Abrufen oder eine Aktion (z.B. Verschieben)
fehlgeschlagen ist, für den nächsten Poll erneut an: ihre 'verarbeitet'-
Marker werden gelöscht. Schon verschobene Mails liegen nicht mehr im
Quellordner und werden daher nicht doppelt verarbeitet.
Nur offene Fehler zählen: die aufgegriffenen Log-Einträge werden mit
retried_at markiert. Scheitert die Mail erneut, entsteht ein neuer,
wieder offener Fehler-Eintrag."""
try:
log_q = db.query(FilterLog.mail_uid, FilterLog.account_id).filter(
log_q = db.query(FilterLog.id, FilterLog.mail_uid, FilterLog.account_id).filter(
FilterLog.level == LogLevel.ERROR,
or_(
FilterLog.message.like("Fehler beim Abrufen%"),
FilterLog.message.like("Aktion fehlgeschlagen%"),
),
FilterLog.mail_uid.isnot(None),
FilterLog.retried_at.is_(None),
)
if account_id is not None:
log_q = log_q.filter(FilterLog.account_id == account_id)
pairs = log_q.distinct().all()
if not pairs:
return {"reset": 0, "unique_uids": 0}
rows = log_q.all()
if not rows:
return {"reset": 0, "unique_uids": 0, "log_entries": 0}
# Bulk-Delete pro Account: alle UIDs in einem Query
by_account: dict[int | None, set[str]] = {}
for uid, acc_id in pairs:
for _, uid, acc_id in rows:
by_account.setdefault(acc_id, set()).add(uid)
total = 0
@@ -123,12 +129,22 @@ def retry_failed_fetches(account_id: int | None = None, db: Session = Depends(ge
if acc_id is not None:
q = q.filter(ProcessedMail.account_id == acc_id)
total += q.delete(synchronize_session=False)
# Aufgegriffene Fehler als erneut versucht markieren (in Blöcken, SQLite
# begrenzt die Zahl der Parameter pro Anweisung)
now = datetime.utcnow()
ids = [row[0] for row in rows]
for i in range(0, len(ids), 500):
db.query(FilterLog).filter(FilterLog.id.in_(ids[i:i + 500])).update(
{FilterLog.retried_at: now}, synchronize_session=False
)
db.commit()
unique_uids = sum(len(v) for v in by_account.values())
logger.info(
"Retry-Failed: %d ProcessedMail-Einträge für %d UIDs entfernt", total, unique_uids
"Retry-Failed: %d Fehler-Einträge, %d Mails, %d Marker entfernt",
len(ids), unique_uids, total,
)
return {"reset": total, "unique_uids": unique_uids}
return {"reset": total, "unique_uids": unique_uids, "log_entries": len(ids)}
except Exception as e:
logger.exception("retry-failed fehlgeschlagen")
raise HTTPException(500, f"Fehler bei retry-failed: {e}")
+1
View File
@@ -129,6 +129,7 @@ def get_logs(
"folder": log.folder,
"details": log.details,
"created_at": log.created_at.isoformat() if log.created_at else None,
"retried_at": log.retried_at.isoformat() if log.retried_at else None,
}
for log in rows
],
+16 -4
View File
@@ -116,6 +116,9 @@
.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; }
@@ -375,11 +378,11 @@ function renderLogs() {
? `<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)}" onclick="toggleDetail(this, ${log.id})">
<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">${esc(shortMessage(log))}</div>${mail}</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>`;
}
@@ -405,6 +408,7 @@ function toggleDetail(row, id) {
['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';
@@ -435,12 +439,20 @@ async function clearLogs() {
async function retryFailed() {
document.querySelector('.head-menu').open = false;
if (!confirm('Alle Mails, bei denen Abrufen oder eine Aktion (z.B. Verschieben) fehlgeschlagen ist, für den nächsten Poll neu vormerken?')) return;
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.unique_uids || 0} Mail(s) neu vorgemerkt. Werden beim nächsten Poll erneut geprüft.`);
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) {