fix(reader): render inline cid: images in HTML emails #140

Merged
JMR-dev merged 2 commits from fix-reader-inline-images into main 2026-07-02 16:36:44 +00:00
JMR-dev commented 2026-07-02 15:44:35 +00:00 (Migrated from github.com)

Problem

Inline images in rich HTML emails — embedded via Content-ID + <img src="cid:..."> (e.g. USPS Informed Delivery digests) — were listed under Attachments with a download chip and never rendered in the message body. Two independent bugs combined; both are fixed together.

Fixes

1. Misclassification (ImapClient). isAttachment() treated any part with a filename as a downloadable attachment, sweeping inline images (which carry a filename and a Content-ID under Content-Disposition: inline) into the attachment list. Classification now treats a part as a downloadable attachment only when its disposition is attachment, or it has a filename but no Content-ID. Inline images are collected with their Content-ID and excluded from the displayed list (AttachmentDao.observeForMessage filters contentId IS NULL; getForMessage still returns everything so their bytes remain fetchable). The Content-ID is read via MimePart.getContentID() (from the already-fetched IMAP BODYSTRUCTURE) rather than a raw getHeader("Content-ID"), which Angus leaves unpopulated on IMAP parts without a separate per-part MIME-header fetch.

2. No rendering path (HtmlBody). The WebView's WebViewClient now overrides shouldInterceptRequest to resolve cid:<id> requests to the matching part's bytes with the correct MIME type — backing the CSP's existing img-src ... cid: allowance (inline images render even while remote images are blocked, since they are embedded, not remote). The resolver is factored into pure, unit-testable functions (cidKey / resolveInlineImage).

Content-ID plumbing. Threaded end-to-end: AttachmentPart → Attachment → AttachmentEntity, plus MailRepository.inlineImages(messageId) (downloads/caches inline bytes, reusing the attachment cache) and ReaderViewModel, which surfaces the cid → bytes map to HtmlBody.

Schema migration

Adds attachments.contentId (nullable): v16 → v17, MIGRATION_16_17, registered in DatabaseModule, 17.json exported. Existing rows read null (treated as ordinary attachments).

Tests

  • MIME-part classification: inline+Content-ID excluded; real attachment, attachment-disposition, and filename-without-cid all kept; Content-ID normalization.
  • GreenMail multipart/related round-trip through fetchBodyPeek (inline image split from a real PDF attachment).
  • cid → bytes resolver (cid parsing, remote/unknown → null).
  • Repository inlineImages() (resolves cid parts to cached bytes, excludes real attachments).
  • DAO display filter (inline hidden from observeForMessage, kept in getForMessage).
  • MIGRATION_16_17 (plus the auto-discovered chain-replay suite).

Reader UI

ReaderScreen needed only pass-through wiring of inlineImages to HtmlBody; the #134 attachment accordion is untouched (it renders whatever list it receives, now correctly excluding inline images).

Companion to #77 (compose-side inline images); this is the reader-side follow-up.

Closes #133

🤖 Generated with Claude Code

## Problem Inline images in rich HTML emails — embedded via `Content-ID` + `<img src="cid:...">` (e.g. USPS Informed Delivery digests) — were listed under **Attachments** with a download chip and never rendered in the message body. Two independent bugs combined; both are fixed together. ## Fixes **1. Misclassification (`ImapClient`).** `isAttachment()` treated any part with a filename as a downloadable attachment, sweeping inline images (which carry a filename **and** a `Content-ID` under `Content-Disposition: inline`) into the attachment list. Classification now treats a part as a downloadable attachment only when its disposition is `attachment`, **or** it has a filename but **no** `Content-ID`. Inline images are collected with their `Content-ID` and excluded from the displayed list (`AttachmentDao.observeForMessage` filters `contentId IS NULL`; `getForMessage` still returns everything so their bytes remain fetchable). The Content-ID is read via `MimePart.getContentID()` (from the already-fetched IMAP BODYSTRUCTURE) rather than a raw `getHeader("Content-ID")`, which Angus leaves unpopulated on IMAP parts without a separate per-part MIME-header fetch. **2. No rendering path (`HtmlBody`).** The WebView's `WebViewClient` now overrides `shouldInterceptRequest` to resolve `cid:<id>` requests to the matching part's bytes with the correct MIME type — backing the CSP's existing `img-src ... cid:` allowance (inline images render even while remote images are blocked, since they are embedded, not remote). The resolver is factored into pure, unit-testable functions (`cidKey` / `resolveInlineImage`). **Content-ID plumbing.** Threaded end-to-end: `AttachmentPart` → `Attachment` → `AttachmentEntity`, plus `MailRepository.inlineImages(messageId)` (downloads/caches inline bytes, reusing the attachment cache) and `ReaderViewModel`, which surfaces the `cid → bytes` map to `HtmlBody`. ## Schema migration Adds `attachments.contentId` (nullable): **v16 → v17**, `MIGRATION_16_17`, registered in `DatabaseModule`, `17.json` exported. Existing rows read `null` (treated as ordinary attachments). ## Tests - MIME-part classification: inline+Content-ID excluded; real attachment, attachment-disposition, and filename-without-cid all kept; Content-ID normalization. - GreenMail `multipart/related` round-trip through `fetchBodyPeek` (inline image split from a real PDF attachment). - `cid → bytes` resolver (cid parsing, remote/unknown → null). - Repository `inlineImages()` (resolves cid parts to cached bytes, excludes real attachments). - DAO display filter (inline hidden from `observeForMessage`, kept in `getForMessage`). - `MIGRATION_16_17` (plus the auto-discovered chain-replay suite). ## Reader UI `ReaderScreen` needed only pass-through wiring of `inlineImages` to `HtmlBody`; the #134 attachment accordion is untouched (it renders whatever list it receives, now correctly excluding inline images). Companion to #77 (compose-side inline images); this is the reader-side follow-up. Closes #133 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.