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:
2026-09-01 11:32:54 +02:00
co-authored by Claude Opus 4.7
parent e4669cfccd
commit 54183fdc3a
3 changed files with 74 additions and 6 deletions
+20 -5
View File
@@ -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 ""