fix hung poll, header whitespace and fetch parsing

- IMAP connections now use a 120s socket timeout. Without it the poll
  hung for 5 days in logout() on a silently dropped connection; every
  later poll was skipped ("max instances reached"), so nothing got
  sorted and newly created rules were never evaluated.
- Header decoding joined decode_header() parts with " " although the
  whitespace is already part of the unencoded chunks, producing
  "IT-Mönch  <noreply@…>" (two spaces) — the it-moench rule could never
  match. Join without separator and collapse whitespace/folded lines.
- Rule text matching (contains/exact) normalizes whitespace on both
  sides, so stray or doubled spaces in rule values no longer break them.
- Pseudo charsets (unknown-8bit, …) try UTF-8 before latin-1, fixing
  mojibake like "nächster" in raw 8-bit headers.
- Fetch via BODY.PEEK[] instead of RFC822, which set \Seen on every
  evaluated mail. Payload is located by shape in the FETCH response,
  fixing "'int' object has no attribute 'decode'".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 09:30:52 +02:00
co-authored by Claude Opus 5.5
parent 9888a5749e
commit ce7caf5dfe
2 changed files with 68 additions and 19 deletions
+8 -2
View File
@@ -25,12 +25,18 @@ def _get_field_value(mail: MailMessage, field: ConditionField) -> str:
return ""
def _norm(text: str) -> str:
# Whitespace zusammenfassen und trimmen: Regelwerte mit versehentlichen
# Leerzeichen am Rand oder doppelten Leerzeichen sollen trotzdem greifen.
return " ".join((text or "").split()).lower()
def _match_text(value: str, pattern: str, match_type: MatchType) -> bool:
match match_type:
case MatchType.CONTAINS:
return pattern.lower() in value.lower()
return _norm(pattern) in _norm(value)
case MatchType.EXACT:
return value.lower() == pattern.lower()
return _norm(value) == _norm(pattern)
case MatchType.REGEX:
try:
return bool(re.search(pattern, value, re.IGNORECASE))
+60 -17
View File
@@ -20,6 +20,20 @@ logger = logging.getLogger(__name__)
# Millionen Mails.
imaplib._MAXLINE = 100_000_000
# Maximale Wartezeit pro IMAP-Socket-Operation (connect, Lesen, Schreiben).
IMAP_TIMEOUT_SECONDS = 120
def _fetch_payload(msg_data) -> bytes | None:
"""Sucht in einer FETCH-Antwort den Teil mit den Mail-Bytes. Die Antwort kann
zusätzliche Elemente enthalten (z.B. unaufgeforderte FLAGS-Updates als bytes),
daher nicht blind msg_data[0][1] nehmen — das führte zu
"'int' object has no attribute 'decode'"."""
for part in msg_data or []:
if isinstance(part, tuple) and len(part) >= 2 and isinstance(part[1], (bytes, bytearray)):
return part[1]
return None
@dataclass
class MailMessage:
@@ -41,20 +55,33 @@ def _quote_mailbox(name: str) -> str:
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."""
"""Dekodiert Bytes robust: bekannten Codec normal. Bei unbekannten oder MIME-Pseudo-
Encodings (unknown-8bit, x-unknown, 8bit, binary, ...) erst UTF-8 versuchen — rohe
8-Bit-Header sind heute fast immer UTF-8 — und nur wenn das scheitert latin-1,
das jedes Byte 1:1 abbildet und nie einen Fehler wirft."""
if not isinstance(data, (bytes, bytearray)):
return str(data)
def _utf8_or_latin1() -> str:
try:
return data.decode("utf-8")
except UnicodeDecodeError:
return data.decode("latin-1", errors="replace")
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")
return _utf8_or_latin1()
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")
return _utf8_or_latin1()
def _normalize_ws(text: str) -> str:
"""Fasst Leerzeichen, Tabs und Zeilenumbrüche (gefaltete Header) zu je einem
Leerzeichen zusammen, damit Regelwerte wie 'IT-Mönch <noreply@…>' auch gegen
'IT-Mönch <noreply@…>' matchen."""
return " ".join(text.split())
def _decode_header_value(value: str | None) -> str:
@@ -67,7 +94,11 @@ def _decode_header_value(value: str | None) -> str:
decoded.append(_safe_decode(part, charset))
else:
decoded.append(part)
return " ".join(decoded)
# decode_header behält die Leerzeichen zwischen kodierten und unkodierten Teilen
# bereits im unkodierten Teil — ein zusätzliches " ".join erzeugte doppelte
# Leerzeichen ("IT-Mönch <noreply@…>"). Daher ohne Trenner zusammenfügen und
# Whitespace/Zeilenumbrüche normalisieren.
return _normalize_ws("".join(decoded))
def _parse_date(msg: Message) -> datetime | None:
@@ -146,10 +177,14 @@ class IMAPClient:
self._conn: imaplib.IMAP4 | None = None
def connect(self) -> None:
# Ohne Timeout blockiert ein Socket ewig, wenn der Server (oder eine
# Firewall/NAT dazwischen) die Verbindung stillschweigend fallen lässt —
# der Poll hing so tagelang im logout() und alle weiteren Polls wurden
# übersprungen.
if self.use_ssl:
self._conn = imaplib.IMAP4_SSL(self.host, self.port)
self._conn = imaplib.IMAP4_SSL(self.host, self.port, timeout=IMAP_TIMEOUT_SECONDS)
else:
self._conn = imaplib.IMAP4(self.host, self.port)
self._conn = imaplib.IMAP4(self.host, self.port, timeout=IMAP_TIMEOUT_SECONDS)
self._conn.login(self.username, self.password)
logger.info("IMAP verbunden: %s@%s", self.username, self.host)
@@ -158,7 +193,10 @@ class IMAPClient:
try:
self._conn.logout()
except Exception:
pass
try:
self._conn.shutdown()
except Exception:
pass
self._conn = None
def __enter__(self):
@@ -235,10 +273,14 @@ class IMAPClient:
return [uid.decode() if isinstance(uid, bytes) else str(uid) for uid in data[0].split()]
def fetch_mail(self, uid: str) -> MailMessage | None:
status, msg_data = self.conn.uid("FETCH", uid, "(RFC822)")
if status != "OK" or not msg_data[0]:
# BODY.PEEK[] statt RFC822: RFC822 setzt serverseitig das \Seen-Flag, der
# Dienst hat so jede geprüfte Mail als gelesen markiert.
status, msg_data = self.conn.uid("FETCH", uid, "(BODY.PEEK[])")
if status != "OK":
return None
raw_email = _fetch_payload(msg_data)
if raw_email is None:
return None
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
return MailMessage(
uid=uid,
@@ -396,10 +438,11 @@ class IMAPClient:
smtp_password: str,
) -> bool:
try:
status, msg_data = self.conn.uid("FETCH", uid, "(RFC822)")
if status != "OK" or not msg_data[0]:
status, msg_data = self.conn.uid("FETCH", uid, "(BODY.PEEK[])")
raw = _fetch_payload(msg_data) if status == "OK" else None
if raw is None:
return False
original = email.message_from_bytes(msg_data[0][1])
original = email.message_from_bytes(raw)
subject = _decode_header_value(original.get("Subject"))
body = _extract_body(original)