diff --git a/app/services/filter_engine.py b/app/services/filter_engine.py index c8873fb..a895c8b 100644 --- a/app/services/filter_engine.py +++ b/app/services/filter_engine.py @@ -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)) diff --git a/app/services/imap_client.py b/app/services/imap_client.py index dcd3fc6..a50dd75 100644 --- a/app/services/imap_client.py +++ b/app/services/imap_client.py @@ -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 ' auch gegen + 'IT-Mönch ' 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 "). 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)