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:
Inline images are misclassified as attachments.ImapClient.isAttachment() treats any
MIME part with a non-blank filename as a downloadable attachment:
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.
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:
In isAttachment(), only classify a part as a downloadable attachment when its Content-Disposition is attachment, or it has a filename but noContent-ID header
(part.getHeader("Content-ID")). A part with Content-Disposition: inline and a Content-ID should be excluded from collectAttachments() entirely.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Problem
Inline images in rich HTML emails (embedded via
Content-ID+<img src="cid:...">, notlinked/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:
Inline images are misclassified as attachments.
ImapClient.isAttachment()treats anyMIME part with a non-blank filename as a downloadable attachment:
Inline images almost always carry a filename (via
Content-Disposition: inline; filename=...or aContent-Type: ...; name=...parameter) alongside aContent-IDheader— but
Content-Dispositionisinline, notattachment. The!part.fileName.isNullOrBlank()clause catches them anyway, so
collectAttachmentParts(which callsisAttachmentwhilewalking the whole MIME tree, including everything nested under a
multipart/related) sweepsevery inline image into the attachments list.
Even a correctly-identified inline image has no rendering path.
HtmlBody's WebViewWebViewClientonly overridesshouldOverrideUrlLoading— there is noshouldInterceptRequestoverride to resolvecid:scheme requests to actual image bytes.The page's CSP already allowlists it (
img-src http: https: data: cid:), implying inlinerendering was intended, but nothing serves the bytes, so any
<img src="cid:...">left inthe HTML would fail to load regardless of (1). Compounding this, no
Content-IDfield existsanywhere in the pipeline — not on
AttachmentPart, theAttachmentdomain model, orAttachmentEntity— so there's no data available end-to-end to map acid:reference backto 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) — classifiesby filename presence, not
Content-Disposition.app/src/main/kotlin/org/libremail/mail/ImapClient.kt:415-422(collectAttachmentParts) —applies
isAttachmentto every non-multipart part in the tree, including inline parts nestedunder
multipart/related.app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt:57-105—WebViewClienthas noshouldInterceptRequest; thecid:allowance in the CSP at line 130 is currently a no-op.Content-IDfield onAttachmentPart(ImapClient.kt:64),Attachment(
domain/model/Attachment.kt), orAttachmentEntity(
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 aContent-IDreferenced 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:
isAttachment(), only classify a part as a downloadable attachment when itsContent-Dispositionisattachment, or it has a filename but noContent-IDheader(
part.getHeader("Content-ID")). A part withContent-Disposition: inlineand aContent-IDshould be excluded fromcollectAttachments()entirely.Content-IDand bytes throughMessageContent/persistence, andgive
HtmlBody'sWebViewClientashouldInterceptRequestoverride that resolvescid:<id>requests to the matching part's bytes, so the existingimg-src ... cid:CSPallowance is actually backed by something.
Relevant files
app/src/main/kotlin/org/libremail/mail/ImapClient.ktapp/src/main/kotlin/org/libremail/ui/reader/HtmlBody.ktapp/src/main/kotlin/org/libremail/domain/model/Attachment.ktapp/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.ktapp/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 againstthe read path instead of compose.