detect attachments without Content-Disposition: attachment header

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 12:32:48 +02:00
co-authored by Claude Opus 4.7
parent a38b8ce8c1
commit 51ed9e4235
+24 -5
View File
@@ -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: