robust charset decoding + retry-failed endpoint
- fix: fetch_mail crashed on MIME pseudo-encodings like 'unknown-8bit',
'x-unknown', '8bit'. New _safe_decode helper maps those (and any
LookupError from unknown codecs) to latin-1, which never fails on
8-bit input. Used in _extract_body and _decode_header_value.
- Consequence of the crash: scheduler marked the affected UIDs as
processed to avoid retry loops, so those mails never got sorted
even after the underlying issue was fixable.
- feat: POST /api/filters/retry-failed drops the ProcessedMail markers
for UIDs that appear in "Fehler beim Abrufen" error logs, so the
next poll re-evaluates them. Reachable via a button in the log UI
("Fehlgeschlagene neu einlesen"), optionally scoped per account.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+39
-1
@@ -5,7 +5,15 @@ from sqlalchemy.orm import Session
|
||||
import logging
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.db_models import Account, FilterAction, FilterCondition, FilterRule, ProcessedMail
|
||||
from app.models.db_models import (
|
||||
Account,
|
||||
FilterAction,
|
||||
FilterCondition,
|
||||
FilterLog,
|
||||
FilterRule,
|
||||
LogLevel,
|
||||
ProcessedMail,
|
||||
)
|
||||
from app.schemas.schemas import FilterRuleCreate, FilterRuleResponse, FilterRuleUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,6 +33,36 @@ def _reset_processed_for_rule(db: Session, rule_id: int) -> int:
|
||||
router = APIRouter(prefix="/api/filters", tags=["filters"])
|
||||
|
||||
|
||||
@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, für die es einen ERROR-Log
|
||||
mit 'Fehler beim Abrufen' gibt — damit der Scheduler sie beim nächsten
|
||||
Poll neu versucht (z.B. nach einem Codec-Fix)."""
|
||||
log_q = db.query(FilterLog.mail_uid, FilterLog.account_id).filter(
|
||||
FilterLog.level == LogLevel.ERROR,
|
||||
FilterLog.message.like("Fehler beim Abrufen%"),
|
||||
FilterLog.mail_uid.isnot(None),
|
||||
)
|
||||
if account_id is not None:
|
||||
log_q = log_q.filter(FilterLog.account_id == account_id)
|
||||
|
||||
pairs = {(row[0], row[1]) for row in log_q.all()}
|
||||
if not pairs:
|
||||
return {"reset": 0, "unique_uids": 0}
|
||||
|
||||
total = 0
|
||||
for uid, acc_id in pairs:
|
||||
q = db.query(ProcessedMail).filter(ProcessedMail.mail_uid == uid)
|
||||
if acc_id is not None:
|
||||
q = q.filter(ProcessedMail.account_id == acc_id)
|
||||
total += q.delete(synchronize_session=False)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Retry-Failed: %d ProcessedMail-Einträge für %d UIDs entfernt", total, len(pairs)
|
||||
)
|
||||
return {"reset": total, "unique_uids": len(pairs)}
|
||||
|
||||
|
||||
@router.get("/account/{account_id}", response_model=list[FilterRuleResponse])
|
||||
def list_filters(account_id: int, db: Session = Depends(get_db)):
|
||||
account = db.get(Account, account_id)
|
||||
|
||||
@@ -33,6 +33,23 @@ def _quote_mailbox(name: str) -> str:
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _safe_decode(data: bytes, charset: str | None) -> str:
|
||||
"""Dekodiert Bytes robust: bekannten Codec normal, unbekannte oder MIME-Pseudo-
|
||||
Encodings (unknown-8bit, x-unknown, 8bit, binary, ...) fallen auf latin-1 zurück —
|
||||
latin-1 kann jedes Byte 1:1 abbilden und wirft nie einen Fehler."""
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
return str(data)
|
||||
charset = (charset or "").strip().lower() or "utf-8"
|
||||
# Bekannte Pseudo-Encodings direkt auf latin-1 mappen
|
||||
if charset in {"unknown-8bit", "x-unknown", "unknown", "8bit", "7bit", "binary", "us-ascii-8bit"}:
|
||||
return data.decode("latin-1", errors="replace")
|
||||
try:
|
||||
return data.decode(charset, errors="replace")
|
||||
except (LookupError, TypeError):
|
||||
# Unbekannter Codec (z.B. proprietäre Schreibweisen) → latin-1 als sicherer Fallback
|
||||
return data.decode("latin-1", errors="replace")
|
||||
|
||||
|
||||
def _decode_header_value(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
@@ -40,7 +57,7 @@ def _decode_header_value(value: str | None) -> str:
|
||||
decoded = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
decoded.append(_safe_decode(part, charset))
|
||||
else:
|
||||
decoded.append(part)
|
||||
return " ".join(decoded)
|
||||
@@ -78,13 +95,11 @@ def _extract_body(msg: Message) -> str:
|
||||
if content_type == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
return _safe_decode(payload, part.get_content_charset())
|
||||
return ""
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
return _safe_decode(payload, msg.get_content_charset())
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
</label>
|
||||
<div role="group" style="margin-bottom:0;">
|
||||
<button class="outline small" onclick="loadLogs()">Suchen</button>
|
||||
<button class="outline small" onclick="retryFailed()" title="Löscht den 'verarbeitet'-Marker für Mails, die einen Fetch-Fehler hatten, damit sie beim nächsten Poll neu versucht werden">Fehlgeschlagene neu einlesen</button>
|
||||
<button class="outline small contrast" onclick="clearLogs()">Log leeren</button>
|
||||
</div>
|
||||
<label style="margin-bottom:0;">
|
||||
@@ -236,6 +237,20 @@ async function clearLogs() {
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
async function retryFailed() {
|
||||
if (!confirm('Alle Mails mit "Fehler beim Abrufen" für den nächsten Poll neu vormerken?')) return;
|
||||
const accountId = document.getElementById('log-account').value;
|
||||
let url = '/api/filters/retry-failed';
|
||||
if (accountId) url += `?account_id=${accountId}`;
|
||||
try {
|
||||
const resp = await fetch(url, {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
alert(`${data.unique_uids || 0} UID(s) neu vorgemerkt (${data.reset || 0} ProcessedMail-Einträge gelöscht). Werden beim nächsten Poll neu versucht.`);
|
||||
} catch(e) {
|
||||
alert('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
if (document.getElementById('auto-refresh').checked) {
|
||||
refreshTimer = setInterval(() => loadLogs(currentOffset), 5000);
|
||||
|
||||
Reference in New Issue
Block a user