From 51ed9e423585d765e3d05649dd45691093bcfc97 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 1 Sep 2026 12:32:48 +0200 Subject: [PATCH] detect attachments without Content-Disposition: attachment header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cps datensysteme rule (from cps-datensysteme.de + subject "Rechnung" + has_attachment true + date after) was failing on real invoices from CPS ORMS. Their PDFs arrive without a Content-Disposition header — the filename lives only on the Content-Type parameter — so _has_attachment returned False and the rule never matched. Extended the check to also count a part as an attachment when it has a filename() and its content-type is neither text/* nor multipart/*. That catches PDFs, Office and image attachments regardless of whether the sender set Content-Disposition, without treating body parts as attachments. Verified against four synthetic cases including the exact CPS ORMS shape. Same helper is now applied to non-multipart mails too, in case an entire mail is a single attached file (rare but possible). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/services/imap_client.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/app/services/imap_client.py b/app/services/imap_client.py index 1cb3e80..9fd463e 100644 --- a/app/services/imap_client.py +++ b/app/services/imap_client.py @@ -79,13 +79,32 @@ def _parse_date(msg: Message) -> datetime | None: def _has_attachment(msg: Message) -> bool: - if not msg.is_multipart(): - return False - for part in msg.walk(): - disposition = str(part.get("Content-Disposition") or "") + """Erkennt Anhänge robuster als nur per Content-Disposition: attachment. + Manche Absender (z.B. CPS ORMS) hängen PDFs an, ohne diesen Header zu setzen — + der Dateiname steckt dann nur im filename-Parameter. Zusätzliches Kriterium: + ein Part, der einen Dateinamen hat UND kein reiner Text- oder Multipart-Container + ist, wird als Anhang gewertet. Damit werden PDF/Office/Bilder-Anhänge sicher + erkannt, ohne dass Inline-CSS oder HTML-Body fälschlich als Anhang zählt.""" + def _is_attachment_part(part: Message) -> bool: + disposition = str(part.get("Content-Disposition") or "").lower() if "attachment" in disposition: return True - return False + filename = part.get_filename() + if not filename: + return False + ctype = (part.get_content_type() or "").lower() + # text/* und multipart/* sind normalerweise Body-Teile, keine Anhänge + if ctype.startswith("text/") or ctype.startswith("multipart/"): + return False + return True + + if msg.is_multipart(): + for part in msg.walk(): + if _is_attachment_part(part): + return True + return False + # Single-part Mail kann auch ein Anhang sein (selten, aber möglich) + return _is_attachment_part(msg) def _extract_body(msg: Message) -> str: