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:
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user