diff options
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 2 | ||||
| -rw-r--r-- | src/mimeparser.cpp | 9 | ||||
| -rw-r--r-- | tests/fixtures/body_with_content_id.eml | 20 | ||||
| -rw-r--r-- | tests/test_mimeparser.cpp | 31 |
4 files changed, 60 insertions, 2 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 0091d27..d4465ce 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -88,7 +88,7 @@ taking that too literally. | 38 | `test_mainwindow` fails when a real sync holds the lock | testing | XS | **done** | | 39 | Thread list cannot be sorted by clicking a column header | workflow | S | open | | 40 | No live filter over the current view | workflow | M | open | -| 41 | A message whose HTML body carries a `Content-Id` renders blank | correctness | S | open | +| 41 | A message whose HTML body carries a `Content-Id` renders blank | correctness | S | **done** (fixture-verified; the reported message was not relocated) | | 42 | "Syncing..." says nothing about what is being synced | feedback | S | open | | 43 | No "Mark all read" for the current view | workflow | S | open | | 44 | No way to manage the filters applied at sync time | workflow | ? | open, unspecified | diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp index f5ef38a..4ad617e 100644 --- a/src/mimeparser.cpp +++ b/src/mimeparser.cpp @@ -130,13 +130,20 @@ void collectParts(GMimeObject *object, ParsedMessage &out) return; } + // A content id makes a part referenceable; it does not make it + // undisplayable. The two are independent, so register it and then fall + // through to the body branches: setting a Content-Id on the text/html body + // is legal and common in bulk-sender output, and returning here left such a + // message with both body slots empty and a blank pane. + // + // Register before assigning, so a part that is both the body and a cid: + // target stays reachable under its id for any sibling referencing it. if (contentId) { // Strip the angle brackets so the key matches a cid: URL body. QString id = QString::fromUtf8(contentId); if (id.startsWith(QLatin1Char('<')) && id.endsWith(QLatin1Char('>'))) id = id.mid(1, id.size() - 2); out.inlineParts.insert(id, InlinePart{ mimeType, decodePart(part) }); - return; } if (mimeType == QLatin1String("text/plain") && out.plainBody.isEmpty()) { diff --git a/tests/fixtures/body_with_content_id.eml b/tests/fixtures/body_with_content_id.eml new file mode 100644 index 0000000..dd0f6be --- /dev/null +++ b/tests/fixtures/body_with_content_id.eml @@ -0,0 +1,20 @@ +From: Bulk Sender <news@example.org> +Subject: Body part carrying a content id +Date: Sat, 01 Aug 2026 10:00:00 +0000 +Message-ID: <body-cid-1@example.org> +MIME-Version: 1.0 +Content-Type: multipart/related; boundary="REL" + +--REL +Content-Type: text/html; charset=utf-8 +Content-ID: <body@example.org> + +<html><body><p>Body text.</p><img src="cid:logo@example.org"></body></html> +--REL +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-ID: <logo@example.org> + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9 +awAAAABJRU5ErkJggg== +--REL-- diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp index f1bbc8a..d16735f 100644 --- a/tests/test_mimeparser.cpp +++ b/tests/test_mimeparser.cpp @@ -31,6 +31,7 @@ private slots: void prefersHtmlWhenAvailable(); void fallsBackToPlainWhenHtmlDisabled(); void collectsInlineCidParts(); + void aBodyCarryingAContentIdStillRenders(); void decodesQuotedPrintableAttachment(); void decodesEncodedHeaders(); void malformedMessageDoesNotCrash(); @@ -109,6 +110,36 @@ void TestMimeParser::collectsInlineCidParts() QVERIFY(part.data.startsWith(QByteArray("\x89PNG", 4))); } +void TestMimeParser::aBodyCarryingAContentIdStillRenders() +{ + // Reported by the user: a bulk sender's message opened blank, with the app + // saying it had no HTML part. + // + // A Content-Id makes a part referenceable, not non-displayable, and setting + // one on the text/html body is legal and common. collectParts filed any + // part with an id into inlineParts and returned before the body branches, + // so such a message parsed with both body slots empty. + MimeParser parser; + const ParsedMessage msg = + parser.parse(fixture(QStringLiteral("body_with_content_id.eml"))); + + QVERIFY(msg.ok); + + // The body fills its slot despite the id. + QVERIFY2(msg.hasHtml(), "the html body was swallowed by its own content id"); + QVERIFY(msg.htmlBody.contains(QStringLiteral("Body text."))); + + // And it stays reachable under that id, so a sibling referencing it still + // resolves. Register first, then assign: the two are independent. + QVERIFY(msg.inlineParts.contains(QStringLiteral("body@example.org"))); + QCOMPARE(msg.inlineParts.value(QStringLiteral("body@example.org")).mimeType, + QStringLiteral("text/html")); + + // The genuinely inline image is untouched by the change. + QVERIFY(msg.inlineParts.contains(QStringLiteral("logo@example.org"))); + QCOMPARE(msg.inlineParts.size(), 2); +} + void TestMimeParser::decodesQuotedPrintableAttachment() { MimeParser parser; |
