]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] Check MIME structure for alternatives in has_only_html_part
authorVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 19 Feb 2026 10:31:07 +0000 (10:31 +0000)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 19 Feb 2026 10:31:07 +0000 (10:31 +0000)
has_only_html_part() relied solely on alt_text_part to find alternatives,
but parts like text/calendar (detected as ics with no_text=true) are not
in text_parts and thus invisible to the search. Now we also walk the MIME
tree: if the HTML part sits inside a multipart/alternative with other
siblings, it has a structural alternative and MIME_HTML_ONLY should not
fire.

src/libmime/mime_expressions.c
test/functional/cases/001_merged/100_general.robot
test/functional/messages/alt-html-calendar.eml [new file with mode: 0644]

index d62c28735bcb9b41563254cf38ebd8f038cbf17a..061210d1ef35a5d7a102b450bbea4a000267adc6 100644 (file)
@@ -1474,16 +1474,41 @@ rspamd_has_only_html_part(struct rspamd_task *task, GArray *args,
 
        /*
         * Return TRUE if there's any HTML part (not attachment) that has no
-        * text/plain alternative in its multipart/alternative parent.
-        * This properly handles nested structures like:
-        * - multipart/mixed → multipart/related → text/html (no text/plain)
-        * - multipart/alternative → text/plain + multipart/related → text/html
+        * alternative in its multipart/alternative parent.
+        *
+        * We check two things:
+        * 1. alt_text_part: a text/plain sibling found in text_parts
+        * 2. MIME structure: any sibling in a multipart/alternative parent
+        *    (covers non-text alternatives like text/calendar that may not
+        *    be in text_parts due to no_text detection flag)
         */
        PTR_ARRAY_FOREACH(MESSAGE_FIELD(task, text_parts), i, p)
        {
                if (!IS_TEXT_PART_ATTACHMENT(p) && IS_TEXT_PART_HTML(p)) {
-                       if (p->alt_text_part == NULL) {
-                               /* HTML part with no text alternative */
+                       if (p->alt_text_part != NULL) {
+                               /* Has a text/plain alternative */
+                               continue;
+                       }
+
+                       /* Check MIME structure: walk up to find multipart/alternative */
+                       struct rspamd_mime_part *parent = p->mime_part->parent_part;
+                       gboolean has_structural_alt = FALSE;
+                       rspamd_ftok_t alt_tok = {.begin = "alternative", .len = 11};
+
+                       while (parent) {
+                               if (IS_PART_MULTIPART(parent) && parent->ct &&
+                                       rspamd_ftok_casecmp(&parent->ct->subtype, &alt_tok) == 0) {
+                                       /* Found a multipart/alternative ancestor */
+                                       if (parent->specific.mp && parent->specific.mp->children &&
+                                               parent->specific.mp->children->len > 1) {
+                                               has_structural_alt = TRUE;
+                                       }
+                                       break;
+                               }
+                               parent = parent->parent_part;
+                       }
+
+                       if (!has_structural_alt) {
                                return TRUE;
                        }
                }
index 4552f416740f1bce3d33331c5e7236ecb8a26229..a6e6191ea9089cb517d50d669cb5a56c30033e91 100644 (file)
@@ -10,6 +10,7 @@ ${MIXED_RELATED_HTML}  ${RSPAMD_TESTDIR}/messages/mixed-related-html-only.eml
 ${ALT_NESTED_RFC822}   ${RSPAMD_TESTDIR}/messages/alternative-nested-rfc822.eml
 ${ALT_EMPTY_RELATED}   ${RSPAMD_TESTDIR}/messages/alternative-empty-related.eml
 ${MIXED_HTML_ZIP}      ${RSPAMD_TESTDIR}/messages/mixed-html-zip.eml
+${ALT_HTML_CALENDAR}   ${RSPAMD_TESTDIR}/messages/alt-html-calendar.eml
 ${SETTINGS_NOSYMBOLS}  {symbols_enabled = []}
 
 *** Test Cases ***
@@ -94,3 +95,8 @@ HTML ONLY - multipart/mixed with html and non-text attachment
   Scan File  ${MIXED_HTML_ZIP}
   ...  Settings={symbols_enabled = [MIME_HTML_ONLY]}
   Expect Symbol  MIME_HTML_ONLY
+
+HTML ONLY - multipart/alternative with html and text/calendar
+  Scan File  ${ALT_HTML_CALENDAR}
+  ...  Settings={symbols_enabled = [MIME_HTML_ONLY]}
+  Do Not Expect Symbol  MIME_HTML_ONLY
diff --git a/test/functional/messages/alt-html-calendar.eml b/test/functional/messages/alt-html-calendar.eml
new file mode 100644 (file)
index 0000000..1143855
--- /dev/null
@@ -0,0 +1,61 @@
+Return-Path: test@test.com
+From: TEST <test@test.com>
+To: Me <me@me.me>
+Subject: Calendar invite with HTML alternative
+MIME-Version: 1.0
+Date: Mon, 01 Jan 2024 00:00:00 +0000
+Message-ID: <alt-calendar@test.com>
+Content-Type: multipart/mixed; boundary="outer-boundary"
+
+--outer-boundary
+Content-Type: multipart/alternative; boundary="inner-boundary"
+
+--inner-boundary
+Content-Type: text/html; charset=utf-8
+
+<html>
+<body>
+<p>You are invited to a meeting.</p>
+<p>When: Monday, January 1, 2024</p>
+<p>Where: Conference Room A</p>
+</body>
+</html>
+
+--inner-boundary
+Content-Type: text/calendar; charset=utf-8; method=REQUEST
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20240101T100000Z
+DTEND:20240101T110000Z
+SUMMARY:Team Meeting
+LOCATION:Conference Room A
+ORGANIZER:mailto:test@test.com
+ATTENDEE:mailto:me@me.me
+END:VEVENT
+END:VCALENDAR
+
+--inner-boundary--
+
+--outer-boundary
+Content-Type: application/ics; name="invite.ics"
+Content-Disposition: attachment; filename="invite.ics"
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20240101T100000Z
+DTEND:20240101T110000Z
+SUMMARY:Team Meeting
+LOCATION:Conference Room A
+ORGANIZER:mailto:test@test.com
+ATTENDEE:mailto:me@me.me
+END:VEVENT
+END:VCALENDAR
+
+--outer-boundary--