Inline images in HTML emails render as attachments instead of embedded in the body #133

Closed
opened 2026-07-02 14:40:48 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-02 14:40:48 +00:00 (Migrated from github.com)

Problem

Inline images in rich HTML emails (embedded via Content-ID + <img src="cid:...">, not
linked/remote images) never render in the message body. Instead every inline image is listed
under "Attachments" with a download affordance, as if the user had attached ordinary files.

Two independent bugs combine to cause this:

  1. Inline images are misclassified as attachments. ImapClient.isAttachment() treats any
    MIME part with a non-blank filename as a downloadable attachment:

    private fun isAttachment(part: Part): Boolean =
        Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) || !part.fileName.isNullOrBlank()
    

    Inline images almost always carry a filename (via Content-Disposition: inline; filename=... or a Content-Type: ...; name=... parameter) alongside a Content-ID header
    — but Content-Disposition is inline, not attachment. The !part.fileName.isNullOrBlank()
    clause catches them anyway, so collectAttachmentParts (which calls isAttachment while
    walking the whole MIME tree, including everything nested under a multipart/related) sweeps
    every inline image into the attachments list.

  2. Even a correctly-identified inline image has no rendering path. HtmlBody's WebView
    WebViewClient only overrides shouldOverrideUrlLoading — there is no
    shouldInterceptRequest override to resolve cid: scheme requests to actual image bytes.
    The page's CSP already allowlists it (img-src http: https: data: cid:), implying inline
    rendering was intended, but nothing serves the bytes, so any <img src="cid:..."> left in
    the HTML would fail to load regardless of (1). Compounding this, no Content-ID field exists
    anywhere in the pipeline — not on AttachmentPart, the Attachment domain model, or
    AttachmentEntity — so there's no data available end-to-end to map a cid: reference back
    to the part that satisfies it even if the WebView could intercept the request.

Location

  • app/src/main/kotlin/org/libremail/mail/ImapClient.kt:425-426 (isAttachment) — classifies
    by filename presence, not Content-Disposition.
  • app/src/main/kotlin/org/libremail/mail/ImapClient.kt:415-422 (collectAttachmentParts) —
    applies isAttachment to every non-multipart part in the tree, including inline parts nested
    under multipart/related.
  • app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt:57-105 — WebViewClient has no
    shouldInterceptRequest; the cid: allowance in the CSP at line 130 is currently a no-op.
  • No Content-ID field on AttachmentPart (ImapClient.kt:64), Attachment
    (domain/model/Attachment.kt), or AttachmentEntity
    (data/local/entity/AttachmentEntity.kt).

Failure scenario

Reported against a real HTML "Daily Digest" email (USPS Informed Delivery) containing several
inline images (mail-piece scan thumbnails plus a campaign banner), each sent as
Content-Disposition: inline; filename=... with a Content-ID referenced from the HTML via
<img src="cid:...">. In LibreMail's reader, all four inline images (mailer-*.jpg,
content-*.jpg, and two mail-piece thumbnails) show up under "Attachments" with a JPG chip,
filename, size, and download button, and none of them appear inline in the body where the
sender placed them — this is a general-purpose HTML digest/newsletter pattern, not specific to
this one sender.

Suggested fix

Both parts are needed together — fixing only the classification still leaves inline images
invisible (just no longer mislabeled as attachments), and fixing only the WebView side still
leaves them duplicated into the attachments list:

  1. In isAttachment(), only classify a part as a downloadable attachment when its
    Content-Disposition is attachment, or it has a filename but no Content-ID header
    (part.getHeader("Content-ID")). A part with Content-Disposition: inline and a
    Content-ID should be excluded from collectAttachments() entirely.
  2. Thread each inline part's Content-ID and bytes through MessageContent/persistence, and
    give HtmlBody's WebViewClient a shouldInterceptRequest override that resolves
    cid:<id> requests to the matching part's bytes, so the existing img-src ... cid: CSP
    allowance is actually backed by something.

Relevant files

  • app/src/main/kotlin/org/libremail/mail/ImapClient.kt
  • app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt
  • app/src/main/kotlin/org/libremail/domain/model/Attachment.kt
  • app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt
  • app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt,
    app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt (attachments list wiring)

Notes

Companion to #77 (compose-side inline images), whose scope explicitly calls out "Reader-side
cid: rendering is explicitly OUT of scope (follow-up)" — this is that follow-up, filed against
the read path instead of compose.

## Problem Inline images in rich HTML emails (embedded via `Content-ID` + `<img src="cid:...">`, not linked/remote images) never render in the message body. Instead every inline image is listed under "Attachments" with a download affordance, as if the user had attached ordinary files. Two independent bugs combine to cause this: 1. **Inline images are misclassified as attachments.** `ImapClient.isAttachment()` treats any MIME part with a non-blank filename as a downloadable attachment: ```kotlin private fun isAttachment(part: Part): Boolean = Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) || !part.fileName.isNullOrBlank() ``` Inline images almost always carry a filename (via `Content-Disposition: inline; filename=...` or a `Content-Type: ...; name=...` parameter) alongside a `Content-ID` header — but `Content-Disposition` is `inline`, not `attachment`. The `!part.fileName.isNullOrBlank()` clause catches them anyway, so `collectAttachmentParts` (which calls `isAttachment` while walking the whole MIME tree, including everything nested under a `multipart/related`) sweeps every inline image into the attachments list. 2. **Even a correctly-identified inline image has no rendering path.** `HtmlBody`'s WebView `WebViewClient` only overrides `shouldOverrideUrlLoading` — there is no `shouldInterceptRequest` override to resolve `cid:` scheme requests to actual image bytes. The page's CSP already allowlists it (`img-src http: https: data: cid:`), implying inline rendering was intended, but nothing serves the bytes, so any `<img src="cid:...">` left in the HTML would fail to load regardless of (1). Compounding this, no `Content-ID` field exists anywhere in the pipeline — not on `AttachmentPart`, the `Attachment` domain model, or `AttachmentEntity` — so there's no data available end-to-end to map a `cid:` reference back to the part that satisfies it even if the WebView could intercept the request. ## Location - `app/src/main/kotlin/org/libremail/mail/ImapClient.kt:425-426` (`isAttachment`) — classifies by filename presence, not `Content-Disposition`. - `app/src/main/kotlin/org/libremail/mail/ImapClient.kt:415-422` (`collectAttachmentParts`) — applies `isAttachment` to every non-multipart part in the tree, including inline parts nested under `multipart/related`. - `app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt:57-105` — `WebViewClient` has no `shouldInterceptRequest`; the `cid:` allowance in the CSP at line 130 is currently a no-op. - No `Content-ID` field on `AttachmentPart` (`ImapClient.kt:64`), `Attachment` (`domain/model/Attachment.kt`), or `AttachmentEntity` (`data/local/entity/AttachmentEntity.kt`). ## Failure scenario Reported against a real HTML "Daily Digest" email (USPS Informed Delivery) containing several inline images (mail-piece scan thumbnails plus a campaign banner), each sent as `Content-Disposition: inline; filename=...` with a `Content-ID` referenced from the HTML via `<img src="cid:...">`. In LibreMail's reader, all four inline images (`mailer-*.jpg`, `content-*.jpg`, and two mail-piece thumbnails) show up under "Attachments" with a JPG chip, filename, size, and download button, and none of them appear inline in the body where the sender placed them — this is a general-purpose HTML digest/newsletter pattern, not specific to this one sender. ## Suggested fix Both parts are needed together — fixing only the classification still leaves inline images invisible (just no longer mislabeled as attachments), and fixing only the WebView side still leaves them duplicated into the attachments list: 1. In `isAttachment()`, only classify a part as a downloadable attachment when its `Content-Disposition` is `attachment`, or it has a filename but **no** `Content-ID` header (`part.getHeader("Content-ID")`). A part with `Content-Disposition: inline` and a `Content-ID` should be excluded from `collectAttachments()` entirely. 2. Thread each inline part's `Content-ID` and bytes through `MessageContent`/persistence, and give `HtmlBody`'s `WebViewClient` a `shouldInterceptRequest` override that resolves `cid:<id>` requests to the matching part's bytes, so the existing `img-src ... cid:` CSP allowance is actually backed by something. ## Relevant files - `app/src/main/kotlin/org/libremail/mail/ImapClient.kt` - `app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt` - `app/src/main/kotlin/org/libremail/domain/model/Attachment.kt` - `app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt` - `app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt`, `app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt` (attachments list wiring) ## Notes Companion to #77 (compose-side inline images), whose scope explicitly calls out "Reader-side `cid:` rendering is explicitly OUT of scope (follow-up)" — this is that follow-up, filed against the read path instead of compose.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#133