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

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

1. Misclassification: ImapClient classified any part with a filename as
   an attachment, sweeping inline images (which carry a filename AND a
   Content-ID under Content-Disposition: inline) into the list. A part is
   now a downloadable attachment only when its disposition is attachment,
   or it has a filename but no Content-ID; an inline image is collected
   separately and excluded from the displayed list (AttachmentDao filters
   contentId IS NULL). The Content-ID is read via MimePart.getContentID()
   so it resolves from IMAP BODYSTRUCTURE rather than a per-part header
   fetch that Angus leaves unpopulated.

2. No rendering path: HtmlBody's WebViewClient now overrides
   shouldInterceptRequest to resolve cid:<id> to the matching part's
   bytes (backing the CSP's existing cid: allowance). Content-ID is
   threaded end-to-end through AttachmentPart, Attachment,
   AttachmentEntity, and MailRepository.inlineImages(); ReaderViewModel
   surfaces the cid->bytes map to the WebView.

Schema: adds attachments.contentId (v16 -> v17, MIGRATION_16_17).

Tests: MIME-part classification (inline+cid excluded, real/disposition/
filename-only kept), a GreenMail multipart/related round-trip, the
cid->bytes resolver, repository inlineImages(), the DAO display filter,
and the v16->v17 migration.

Closes #133

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 10:44:05 -05:00
co-authored by Claude Fable 5
parent 7e7e92bb02
commit 3971e89d1e
22 changed files with 951 additions and 15 deletions
@@ -0,0 +1,460 @@
{
"formatVersion": 1,
"database": {
"version": 17,
"identityHash": "6fbe947ef0c6133ba5e621251a00fa1b",
"entities": [
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sender",
"columnName": "sender",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderEmail",
"columnName": "senderEmail",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "snippet",
"columnName": "snippet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isHtml",
"columnName": "isHtml",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestampMillis",
"columnName": "timestampMillis",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isRead",
"columnName": "isRead",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'INBOX'"
},
{
"fieldPath": "inInbox",
"columnName": "inInbox",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyFetched",
"columnName": "bodyFetched",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_messages_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
},
{
"name": "index_messages_timestampMillis",
"unique": false,
"columnNames": [
"timestampMillis"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"tableName": "attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "messageId",
"columnName": "messageId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "partIndex",
"columnName": "partIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "filename",
"columnName": "filename",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sizeBytes",
"columnName": "sizeBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"messageId",
"partIndex"
]
},
"indices": [
{
"name": "index_attachments_messageId",
"unique": false,
"columnNames": [
"messageId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
}
],
"foreignKeys": [
{
"table": "messages",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"messageId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "outbox",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "drafts",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT"
},
{
"fieldPath": "toAddresses",
"columnName": "toAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ccAddresses",
"columnName": "ccAddresses",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updatedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "bodyHtml",
"columnName": "bodyHtml",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "folders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullName",
"columnName": "fullName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "selectable",
"columnName": "selectable",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sortOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "hierarchyDelimiter",
"columnName": "hierarchyDelimiter",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6fbe947ef0c6133ba5e621251a00fa1b')"
]
}
}
@@ -96,6 +96,25 @@ class LibreMailDatabaseTest {
)
}
@Test
fun observeForMessageHidesInlineImagesWhileGetForMessageKeepsThem() = runBlocking {
val messageDao = db.messageDao()
val attachmentDao = db.attachmentDao()
messageDao.insertNew(listOf(message("acct:1")))
attachmentDao.insert(
listOf(
AttachmentEntity("acct:1", 0, "logo.png", "image/png", 4, contentId = "logo1"),
AttachmentEntity("acct:1", 1, "invoice.pdf", "application/pdf", 10, contentId = null),
),
)
// The displayed list excludes inline cid: images (issue #133) ...
val displayed = attachmentDao.observeForMessage("acct:1").first()
assertEquals(listOf("invoice.pdf"), displayed.map { it.filename })
// ... while the full read keeps them so their bytes can back a cid: request.
assertEquals(2, attachmentDao.getForMessage("acct:1").size)
}
@Test
fun searchRowsAreNotInboxAndAreCleared() = runBlocking {
val messageDao = db.messageDao()
@@ -169,6 +169,37 @@ class MigrationTest {
db.close()
}
/** v16 -> v17 (issue #133): `attachments.contentId` appears defaulting to NULL; cached rows survive. */
@Test
fun migrate16To17_addsNullContentIdToAttachments() {
helper.createDatabase(TEST_DB, 16).apply {
// v16 dropped the account tables, so a message (no FK to accounts) plus its attachment is
// all that's needed to exercise the attachments table rebuild.
execSQL(
"INSERT INTO messages (id, accountId, sender, senderEmail, subject, snippet, body, isHtml, " +
"timestampMillis, isRead, isStarred, folder, inInbox, bodyFetched, uid) VALUES " +
"('acct:INBOX:1', 'acct', 'Ada', 'ada@example.org', 'Hi', '', '', 0, 1000, 0, 0, " +
"'INBOX', 1, 1, 1)",
)
execSQL(
"INSERT INTO attachments (messageId, partIndex, filename, mimeType, sizeBytes) " +
"VALUES ('acct:INBOX:1', 0, 'report.pdf', 'application/pdf', 2048)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 17, true, MIGRATION_16_17)
db.query("SELECT filename, contentId FROM attachments WHERE messageId = 'acct:INBOX:1'").use { c ->
assertTrue("the pre-upgrade attachment row must survive", c.moveToFirst())
assertEquals("report.pdf", c.getString(0))
assertTrue("existing attachments read a null contentId (treated as ordinary downloads)", c.isNull(1))
assertFalse("only the one pre-upgrade attachment row must survive", c.moveToNext())
}
assertEquals("the cached message must be untouched by 16->17", 1, db.count("messages"))
db.close()
}
/** The newest schema JSON exported to app/schemas (shipped to the test APK as assets). */
private fun latestExportedSchemaVersion(): Int {
val schemaFolder = checkNotNull(LibreMailDatabase::class.java.canonicalName)
@@ -11,6 +11,7 @@ import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Folder
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
@@ -120,6 +121,8 @@ class FakeMailRepository(
},
)
override suspend fun inlineImages(messageId: String): List<InlineImage> = emptyList()
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
Result.failure(UnsupportedOperationException("not used in UI tests"))
@@ -35,7 +35,7 @@ import org.libremail.data.local.entity.OutboxEntity
FolderEntity::class,
BackfillProgressEntity::class,
],
version = 16,
version = 17,
exportSchema = true,
)
abstract class LibreMailDatabase : RoomDatabase() {
@@ -184,6 +184,7 @@ internal fun AttachmentEntity.toDomain(): Attachment = Attachment(
filename = filename,
mimeType = mimeType,
sizeBytes = sizeBytes,
contentId = contentId,
)
internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = AttachmentEntity(
@@ -192,6 +193,7 @@ internal fun AttachmentPart.toEntity(messageId: String): AttachmentEntity = Atta
filename = filename,
mimeType = mimeType,
sizeBytes = sizeBytes,
contentId = contentId,
)
internal fun DraftEntity.toDomain(): Draft = Draft(
@@ -330,3 +330,17 @@ val MIGRATION_15_16 = object : Migration(15, 16) {
db.execSQL("DROP TABLE IF EXISTS `accounts`")
}
}
/**
* v16 -> v17: inline-image support in the reader (issue #133; preserves existing data). Adds a
* nullable `contentId` column to `attachments` recording the `Content-ID` of an inline image
* (`<img src="cid:...">`) so the reader's WebView can resolve `cid:` requests to the cached bytes,
* and so such parts can be filtered out of the user-facing attachment list. Nullable with no SQL
* default (the MIGRATION_14_15 `hierarchyDelimiter` pattern) so existing attachment rows read back
* null — i.e. treated as ordinary attachments — until the next fetch reclassifies them.
*/
val MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `attachments` ADD COLUMN `contentId` TEXT")
}
}
@@ -11,10 +11,18 @@ import org.libremail.data.local.entity.AttachmentEntity
@Dao
interface AttachmentDao {
@Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
/**
* The message's user-facing attachments for the reader's attachment list. Inline images
* (`contentId IS NOT NULL`) are excluded — they render in the body via `cid:`, not as downloads
* (issue #133).
*/
@Query("SELECT * FROM attachments WHERE messageId = :messageId AND contentId IS NULL ORDER BY partIndex")
fun observeForMessage(messageId: String): Flow<List<AttachmentEntity>>
/** One-shot read of a message's cached attachment metadata (e.g. to pre-download their bytes). */
/**
* One-shot read of ALL of a message's cached parts — attachments AND inline images — e.g. to
* pre-download their bytes or resolve a `cid:` reference. The reader filters by [AttachmentEntity.contentId].
*/
@Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
suspend fun getForMessage(messageId: String): List<AttachmentEntity>
@@ -25,4 +25,10 @@ data class AttachmentEntity(
val filename: String,
val mimeType: String,
val sizeBytes: Long,
/**
* The normalized `Content-ID` when this part is an inline image (`<img src="cid:...">`) — null for
* an ordinary attachment. Inline rows are cached so the reader's WebView can resolve `cid:`
* requests offline, but are filtered out of the displayed attachment list (issue #133).
*/
val contentId: String? = null,
)
@@ -35,6 +35,7 @@ import org.libremail.domain.model.Draft
import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingAttachment
@@ -125,6 +126,15 @@ class MailRepositoryImpl @Inject constructor(
rows.map { it.toDomain() }
}
override suspend fun inlineImages(messageId: String): List<InlineImage> = attachmentDao.getForMessage(messageId)
.filter { it.contentId != null }
.mapNotNull { row ->
// Reuse the on-disk attachment cache (download once, then instant + offline). A failed
// fetch just omits that image, leaving a broken <img> rather than failing the open.
val file = downloadAttachment(messageId, row.partIndex).getOrNull() ?: return@mapNotNull null
InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes())
}
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> = runCatching {
val entity = messageDao.getById(messageId) ?: error("Message not found")
val meta = attachmentDao.getForMessage(messageId).firstOrNull { it.partIndex == partIndex }
@@ -21,6 +21,7 @@ import org.libremail.data.local.MIGRATION_12_13
import org.libremail.data.local.MIGRATION_13_14
import org.libremail.data.local.MIGRATION_14_15
import org.libremail.data.local.MIGRATION_15_16
import org.libremail.data.local.MIGRATION_16_17
import org.libremail.data.local.MIGRATION_1_2
import org.libremail.data.local.MIGRATION_2_3
import org.libremail.data.local.MIGRATION_3_4
@@ -69,6 +70,7 @@ object DatabaseModule {
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16,
MIGRATION_16_17,
)
// No destructive fallback: the migration chain is complete, and silently dropping the
// mail/message tables would lose cached data. A missing migration should fail loudly in
@@ -7,4 +7,10 @@ data class Attachment(
val filename: String,
val mimeType: String,
val sizeBytes: Long,
/**
* The normalized `Content-ID` when this part is an inline image referenced from the HTML body via
* `cid:` — null for an ordinary attachment. Inline parts are cached (so the reader can resolve
* `cid:` requests) but filtered out of the user-facing attachment list.
*/
val contentId: String? = null,
)
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/**
* An inline image embedded in an HTML message body and referenced from it via `cid:<contentId>`
* (e.g. the mail-piece thumbnails in a USPS Informed Delivery digest). Unlike an [Attachment] it is
* rendered in place by the reader's WebView — which resolves the `cid:` request to these [bytes] —
* rather than being offered as a download. Not a `data class`: [bytes] identity/equality is
* irrelevant and array structural equality would be misleading (matching [DownloadedAttachment]).
*/
class InlineImage(val contentId: String, val mimeType: String, val bytes: ByteArray)
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.Draft
import org.libremail.domain.model.Folder
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
@@ -57,6 +58,13 @@ interface MailRepository {
/** Cached attachment metadata for a message, populated when the message is opened. */
fun observeAttachments(messageId: String): Flow<List<Attachment>>
/**
* The message's inline images (HTML `<img src="cid:...">` parts), each with the bytes the reader's
* WebView serves for its `cid:` request. Downloads and caches any not yet on disk; returns empty
* for a plain-text message or one with no inline parts.
*/
suspend fun inlineImages(messageId: String): List<InlineImage>
/** Downloads an attachment's bytes to a local cache file and returns it. */
suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File>
@@ -15,6 +15,7 @@ import jakarta.mail.event.MessageCountAdapter
import jakarta.mail.event.MessageCountEvent
import jakarta.mail.internet.ContentType
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimePart
import jakarta.mail.internet.MimeUtility
import jakarta.mail.search.BodyTerm
import jakarta.mail.search.FromStringTerm
@@ -67,8 +68,19 @@ data class FetchedMessage(
/** A message body extracted from the server, with metadata for any attachment parts. */
data class MessageContent(val body: String, val isHtml: Boolean, val attachments: List<AttachmentPart> = emptyList())
/** Metadata for one attachment part. [partIndex] is its position in attachment-tree order. */
data class AttachmentPart(val partIndex: Int, val filename: String, val mimeType: String, val sizeBytes: Long)
/**
* Metadata for one downloadable part. [partIndex] is its position in attachment-tree order.
* [contentId] is the normalized `Content-ID` (angle brackets stripped) for an inline image referenced
* from the HTML body via `cid:` — null for an ordinary attachment. Inline parts are persisted so the
* reader can resolve `cid:` requests, but excluded from the user-facing attachment list.
*/
data class AttachmentPart(
val partIndex: Int,
val filename: String,
val mimeType: String,
val sizeBytes: Long,
val contentId: String? = null,
)
/** A downloaded attachment's bytes plus the metadata needed to open it. */
class DownloadedAttachment(val filename: String, val mimeType: String, val bytes: ByteArray)
@@ -476,7 +488,12 @@ class ImapClient @Inject constructor() {
return plain
}
/** Walks the MIME tree and returns attachment metadata in a stable, depth-first order. */
/**
* Walks the MIME tree and returns downloadable-part metadata in a stable, depth-first order.
* Inline images (a `Content-ID` referenced from the HTML via `cid:`) are included so their
* bytes can be fetched by [partIndex] and resolved by the reader, but each carries its
* [AttachmentPart.contentId] so the display layer can filter them out of the attachment list.
*/
private fun collectAttachments(message: Part): List<AttachmentPart> {
val parts = mutableListOf<Part>()
collectAttachmentParts(message, parts)
@@ -486,6 +503,7 @@ class ImapClient @Inject constructor() {
filename = attachmentName(part) ?: "attachment",
mimeType = baseType(part),
sizeBytes = part.size.toLong().coerceAtLeast(0L),
contentId = if (isInlineImagePart(part)) inlineContentId(part) else null,
)
}
}
@@ -496,13 +514,10 @@ class ImapClient @Inject constructor() {
val multipart = part.content as? Multipart ?: return
for (i in 0 until multipart.count) collectAttachmentParts(multipart.getBodyPart(i), into)
}
isAttachment(part) -> into.add(part)
isAttachmentPart(part) || isInlineImagePart(part) -> into.add(part)
}
}
private fun isAttachment(part: Part): Boolean =
Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) || !part.fileName.isNullOrBlank()
private fun attachmentName(part: Part): String? = part.fileName?.let {
runCatching { MimeUtility.decodeText(it) }.getOrDefault(it)
}
@@ -566,3 +581,30 @@ class ImapClient @Inject constructor() {
const val TAG = "LibreMailIdle"
}
}
/**
* True when [part] is a user-facing downloadable attachment: its `Content-Disposition` is
* `attachment`, OR it has a filename but no `Content-ID` header. A part with a filename AND a
* `Content-ID` (an inline image carried by `Content-Disposition: inline`) is deliberately NOT an
* attachment — it belongs in the message body, not the attachment list (issue #133).
*/
internal fun isAttachmentPart(part: Part): Boolean = Part.ATTACHMENT.equals(part.disposition, ignoreCase = true) ||
(!part.fileName.isNullOrBlank() && inlineContentId(part) == null)
/**
* True when [part] is an inline image embedded in the HTML body and referenced from it via
* `cid:<Content-ID>` (e.g. a USPS Informed Delivery digest's mail-piece thumbnails): it has a
* `Content-ID`, is an image, and is not already an [isAttachmentPart]. Such parts are excluded from
* the attachment list and instead served to the reader's WebView by their Content-ID.
*/
internal fun isInlineImagePart(part: Part): Boolean =
inlineContentId(part) != null && part.isMimeType("image/*") && !isAttachmentPart(part)
/**
* The normalized `Content-ID` of [part] (surrounding angle brackets stripped), or null if it has
* none. Reads it via [MimePart.getContentID] rather than `getHeader("Content-ID")`: over IMAP the
* Content-ID comes from the already-fetched BODYSTRUCTURE, whereas a raw header lookup would force
* (and often miss on) a separate per-part MIME-header fetch.
*/
internal fun inlineContentId(part: Part): String? = runCatching { (part as? MimePart)?.contentID }.getOrNull()
?.trim()?.trim('<', '>')?.trim()?.takeUnless { it.isBlank() }
@@ -4,6 +4,7 @@ package org.libremail.ui.reader
import android.annotation.SuppressLint
import android.content.Intent
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
@@ -19,12 +20,18 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import org.libremail.domain.model.InlineImage
import java.io.ByteArrayInputStream
/**
* Renders an HTML email body in a hardened WebView: JavaScript and file/content access are
* disabled, links open in the system browser, and remote content is blocked until the user
* opts in (tracking-pixel protection).
*
* Inline images the email carries itself (`<img src="cid:...">`, resolved via [inlineImages]) are
* always served — they are embedded content, not a remote fetch, so they render even while remote
* images are blocked.
*
* The email is wrapped with an explicit background/text/link color drawn from the active Material
* theme so it is always readable — in dark mode the previous transparent WebView showed the
* near-black app surface through emails whose own CSS left the text at the browser default of
@@ -33,7 +40,12 @@ import androidx.webkit.WebViewFeature
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun HtmlBody(html: String, loadRemoteImages: Boolean, modifier: Modifier = Modifier) {
fun HtmlBody(
html: String,
loadRemoteImages: Boolean,
inlineImages: Map<String, InlineImage>,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
val surface = colorScheme.surface
@@ -50,10 +62,15 @@ fun HtmlBody(html: String, loadRemoteImages: Boolean, modifier: Modifier = Modif
dark = isDark,
)
}
// A mutable holder the WebViewClient reads on the (background) interception thread, kept current
// by the update block so inline images that arrive after the first composition are resolvable.
val imageHolder = remember { InlineImageHolder() }
imageHolder.images = inlineImages
// Tracks the content actually loaded so recompositions (star/attachment state changes) don't
// reload the page and throw away the user's scroll position. Keyed on the fully wrapped
// document so a theme (light/dark) change still re-renders with the new colors.
val lastLoaded = remember { mutableStateOf<Pair<String, Boolean>?>(null) }
// document so a theme (light/dark) change still re-renders with the new colors, and on the set
// of available cid: keys so the page reloads once when inline images finish resolving.
val lastLoaded = remember { mutableStateOf<Triple<String, Boolean, Set<String>>?>(null) }
AndroidView(
modifier = modifier,
factory = { ctx ->
@@ -72,6 +89,17 @@ fun HtmlBody(html: String, loadRemoteImages: Boolean, modifier: Modifier = Modif
applyAlgorithmicDarkening(isDark)
isVerticalScrollBarEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
// Serve inline images the email embedded itself (cid:) from the message's own
// parts; everything else falls through to normal (remote-blockable) loading.
val image = request?.url?.toString()?.let { resolveInlineImage(it, imageHolder.images) }
?: return null
return WebResourceResponse(image.mimeType, null, ByteArrayInputStream(image.bytes))
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url ?: return false
// Only open ordinary web/mail links, and only on an actual user tap — never
@@ -96,7 +124,7 @@ fun HtmlBody(html: String, loadRemoteImages: Boolean, modifier: Modifier = Modif
webView.setBackgroundColor(surfaceArgb)
webView.applyAlgorithmicDarkening(isDark)
webView.settings.blockNetworkLoads = !loadRemoteImages
val key = document to loadRemoteImages
val key = Triple(document, loadRemoteImages, inlineImages.keys.toSet())
if (lastLoaded.value != key) {
lastLoaded.value = key
webView.loadDataWithBaseURL(null, document, "text/html", "UTF-8", null)
@@ -105,6 +133,27 @@ fun HtmlBody(html: String, loadRemoteImages: Boolean, modifier: Modifier = Modif
)
}
/** Mutable, thread-visible reference to the current inline images (read from the interception thread). */
private class InlineImageHolder {
@Volatile
var images: Map<String, InlineImage> = emptyMap()
}
/**
* Extracts and normalizes the `Content-ID` from a `cid:` URL (surrounding angle brackets stripped),
* or null when [url] is not a `cid:` reference. Kept separate from Android types so it is unit-testable.
*/
internal fun cidKey(url: String): String? {
if (!url.startsWith("cid:", ignoreCase = true)) return null
return url.substring(CID_PREFIX_LENGTH).trim().trim('<', '>').trim().takeUnless { it.isBlank() }
}
/** Resolves a `cid:` [url] to its [InlineImage] among [images] (keyed by normalized Content-ID), or null. */
internal fun resolveInlineImage(url: String, images: Map<String, InlineImage>): InlineImage? =
cidKey(url)?.let { images[it] }
private const val CID_PREFIX_LENGTH = 4
/**
* Lets the WebView algorithmically darken email content that does not declare its own dark support,
* but only in dark mode and only where the installed WebView supports the feature. This is a
@@ -64,6 +64,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import java.io.File
@@ -147,6 +148,7 @@ fun ReaderScreen(
message != null -> MessageBody(
message = message,
attachments = state.attachments,
inlineImages = state.inlineImages,
downloading = state.downloading,
downloaded = state.downloaded,
onDownloadAttachment = viewModel::downloadAttachment,
@@ -166,6 +168,7 @@ fun ReaderScreen(
private fun MessageBody(
message: Message,
attachments: List<Attachment>,
inlineImages: Map<String, InlineImage>,
downloading: Set<Int>,
downloaded: Set<Int>,
onDownloadAttachment: (Attachment) -> Unit,
@@ -194,6 +197,7 @@ private fun MessageBody(
message.isHtml -> HtmlBody(
html = message.body,
loadRemoteImages = loadRemoteImages,
inlineImages = inlineImages,
modifier = Modifier.fillMaxSize(),
)
@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Attachment
import org.libremail.domain.model.InlineImage
import org.libremail.domain.model.Message
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
@@ -25,6 +26,8 @@ data class ReaderUiState(
val loading: Boolean = true,
val message: Message? = null,
val attachments: List<Attachment> = emptyList(),
/** Inline `cid:` images for the HTML body, keyed by normalized Content-ID (see [HtmlBody]). */
val inlineImages: Map<String, InlineImage> = emptyMap(),
val downloading: Set<Int> = emptySet(),
/** Part indexes whose bytes are already cached on disk (openable offline). */
val downloaded: Set<Int> = emptySet(),
@@ -63,7 +66,15 @@ class ReaderViewModel @Inject constructor(
}
viewModelScope.launch {
repository.openMessage(messageId).fold(
onSuccess = { message -> _state.update { it.copy(loading = false, message = message) } },
onSuccess = { message ->
_state.update { it.copy(loading = false, message = message) }
// Resolve inline cid: images so the WebView can embed them. Runs after openMessage
// has cached the parts; skipped for plain-text mail and messages with none.
if (message.isHtml) {
val images = repository.inlineImages(messageId).associateBy { it.contentId }
if (images.isNotEmpty()) _state.update { it.copy(inlineImages = images) }
}
},
onFailure = { e ->
_state.update {
it.copy(
@@ -417,6 +417,32 @@ class MailRepositoryImplTest {
assertEquals(setOf(0), repository.downloadedAttachmentParts(id))
}
@Test
fun `inlineImages resolves cid parts to their cached bytes and excludes real attachments`() = runTest {
val cache = Files.createTempDirectory("attach").toFile()
every { context.cacheDir } returns cache
val id = "acct:INBOX:30"
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
coEvery { attachmentDao.getForMessage(id) } returns listOf(
AttachmentEntity(id, 0, "logo.png", "image/png", 4, contentId = "logo1"),
AttachmentEntity(id, 1, "invoice.pdf", "application/pdf", 10, contentId = null),
)
// The inline part's bytes are already cached, so no network fetch is needed.
File(cache, "attachments/acct_INBOX_30/0/logo.png").apply {
parentFile?.mkdirs()
writeBytes(byteArrayOf(9, 8, 7))
}
val images = repository.inlineImages(id)
assertEquals(1, images.size)
assertEquals("logo1", images.first().contentId)
assertEquals("image/png", images.first().mimeType)
assertTrue(images.first().bytes.contentEquals(byteArrayOf(9, 8, 7)))
// The ordinary attachment (contentId == null) must never be pulled in as an inline image.
coVerify(exactly = 0) { imapClient.fetchAttachment(any(), any(), any(), any()) }
}
@Test
fun `prefetchMessage caches the body and downloads attachments`() = runTest {
val cache = Files.createTempDirectory("attach").toFile()
@@ -4,11 +4,16 @@ package org.libremail.mail
import com.icegreen.greenmail.util.GreenMail
import com.icegreen.greenmail.util.GreenMailUtil
import com.icegreen.greenmail.util.ServerSetupTest
import jakarta.activation.DataHandler
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Part
import jakarta.mail.Session
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeBodyPart
import jakarta.mail.internet.MimeMessage
import jakarta.mail.internet.MimeMultipart
import jakarta.mail.util.ByteArrayDataSource
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
@@ -146,6 +151,27 @@ class ImapClientTest {
assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should stay unread")
}
@Test
fun `fetchBodyPeek splits inline cid images from real attachments`() = runTest {
// A digest-style message: multipart/related(html + inline image) alongside a real attachment.
appendInlineImageDigest()
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
val content = client.fetchBodyPeek(params(), "INBOX", uid)
assertTrue(content.isHtml, "the html body must be chosen")
assertTrue(content.body.contains("cid:logo1"), "body=${content.body}")
// Both parts are collected (so the inline image's bytes are fetchable by index), but only the
// inline one carries a Content-ID — the reader filters on that to keep it out of the list.
assertEquals(2, content.attachments.size, "attachments=${content.attachments}")
val inline = content.attachments.single { it.contentId != null }
assertEquals("logo1", inline.contentId)
assertEquals("logo.png", inline.filename)
assertTrue(inline.mimeType.equals("image/png", ignoreCase = true), "mime=${inline.mimeType}")
val attachment = content.attachments.single { it.contentId == null }
assertEquals("invoice.pdf", attachment.filename)
}
@Test
fun `fetchRecent reads a non-inbox folder isolated from the inbox`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Inbox subject", "In the inbox")
@@ -161,6 +187,68 @@ class ImapClientTest {
assertEquals(setOf("Inbox subject"), inbox.map { it.subject }.toSet())
}
/**
* Appends a rich digest to the INBOX: a `multipart/mixed` of a `multipart/related` (HTML body
* referencing an inline image via `cid:logo1`) plus a genuine PDF attachment — the shape that
* regressed inline images into the attachment list (issue #133).
*/
private fun appendInlineImageDigest() {
val props = Properties().apply {
put("mail.store.protocol", "imap")
put("mail.imap.host", "127.0.0.1")
put("mail.imap.port", greenMail.imap.port.toString())
}
val session = Session.getInstance(props)
val htmlPart = MimeBodyPart().apply {
setContent("<html><body><img src=\"cid:logo1\"><p>Hello</p></body></html>", "text/html; charset=utf-8")
}
val inlineImage = MimeBodyPart().apply {
dataHandler = DataHandler(ByteArrayDataSource(byteArrayOf(1, 2, 3, 4), "image/png"))
contentID = "<logo1>"
disposition = Part.INLINE
fileName = "logo.png"
}
val related = MimeBodyPart().apply {
setContent(
MimeMultipart("related").apply {
addBodyPart(htmlPart)
addBodyPart(inlineImage)
},
)
}
val attachment = MimeBodyPart().apply {
dataHandler = DataHandler(ByteArrayDataSource(byteArrayOf(5, 6, 7), "application/pdf"))
disposition = Part.ATTACHMENT
fileName = "invoice.pdf"
}
val message = MimeMessage(session).apply {
setFrom(InternetAddress("bob@example.org"))
setRecipient(Message.RecipientType.TO, InternetAddress("alice@example.org"))
subject = "Daily Digest"
setContent(
MimeMultipart("mixed").apply {
addBodyPart(related)
addBodyPart(attachment)
},
)
// Flush each part's Content-Type/Content-ID/Content-Disposition into headers so the
// appended raw MIME round-trips them (without this the Content-ID is dropped).
saveChanges()
}
val store = session.getStore("imap")
store.connect("127.0.0.1", greenMail.imap.port, "alice@example.org", "secret")
try {
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_WRITE)
inbox.appendMessages(arrayOf(message))
inbox.close(false)
} finally {
store.close()
}
}
/** Creates [folderName] if needed and appends a message to it, via Jakarta Mail directly. */
private fun appendMessage(folderName: String, from: String, subject: String, body: String) {
val props = Properties().apply {
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import jakarta.mail.Part
import jakarta.mail.internet.MimeBodyPart
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Unit tests for the MIME-part classification behind issue #133. An inline image (a `Content-ID` that
* the HTML body references via `cid:`) carries a filename AND a `Content-ID` under
* `Content-Disposition: inline`; it must NOT be swept into the downloadable-attachment list, while
* genuine attachments and filename-only parts still must.
*/
class MimePartClassificationTest {
private fun part(
contentType: String,
disposition: String? = null,
filename: String? = null,
contentId: String? = null,
): MimeBodyPart = MimeBodyPart().apply {
setHeader("Content-Type", contentType)
if (disposition != null || filename != null) {
val header = buildString {
append(disposition ?: Part.INLINE)
if (filename != null) append("; filename=\"").append(filename).append("\"")
}
setHeader("Content-Disposition", header)
}
if (contentId != null) setHeader("Content-ID", contentId)
}
@Test
fun `an inline image with a Content-ID is not a downloadable attachment`() {
val inline = part("image/jpeg", disposition = "inline", filename = "mailer-1.jpg", contentId = "<cid-1@usps>")
assertFalse(isAttachmentPart(inline), "inline+Content-ID must be excluded from attachments")
assertTrue(isInlineImagePart(inline), "inline+Content-ID image must be collected for cid: rendering")
}
@Test
fun `a real attachment with a filename is kept as an attachment`() {
val attachment = part("application/pdf", disposition = "attachment", filename = "invoice.pdf")
assertTrue(isAttachmentPart(attachment))
assertFalse(isInlineImagePart(attachment))
}
@Test
fun `a part with attachment disposition and no filename is kept as an attachment`() {
val attachment = part("application/octet-stream", disposition = "attachment")
assertTrue(isAttachmentPart(attachment))
assertFalse(isInlineImagePart(attachment))
}
@Test
fun `an image with a filename but no Content-ID is kept as an attachment`() {
// No cid means nothing references it from the body, so it is a genuine download, not inline.
val image = part("image/png", disposition = "inline", filename = "photo.png")
assertTrue(isAttachmentPart(image), "filename without a Content-ID stays a downloadable attachment")
assertFalse(isInlineImagePart(image))
}
@Test
fun `an inline image without an explicit disposition is still classified inline by its Content-ID`() {
// Some mailers omit Content-Disposition entirely and rely on the Content-ID + cid: reference.
val inline = part("image/gif", contentId = "logo@example.com")
assertFalse(isAttachmentPart(inline))
assertTrue(isInlineImagePart(inline))
}
@Test
fun `Content-ID is normalized by stripping surrounding angle brackets`() {
assertEquals("cid-1@usps", inlineContentId(part("image/jpeg", contentId = "<cid-1@usps>")))
assertEquals("bare@id", inlineContentId(part("image/jpeg", contentId = "bare@id")))
assertNull(inlineContentId(part("application/pdf", disposition = "attachment", filename = "x.pdf")))
}
}
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reader
import org.junit.Test
import org.libremail.domain.model.InlineImage
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertSame
/**
* Unit tests for the reader WebView's `cid:` resolution (issue #133): the pure logic that turns an
* `<img src="cid:...">` request URL into the matching inline image's bytes, factored out of
* [HtmlBody] so it needs no WebView. Only `cid:` URLs are served; anything else falls through to the
* WebView's normal (remote-blockable) loading.
*/
class InlineImageResolverTest {
private fun image(contentId: String) = InlineImage(contentId, "image/png", byteArrayOf(1, 2, 3))
@Test
fun `cidKey extracts and normalizes the Content-ID from a cid URL`() {
assertEquals("logo1", cidKey("cid:logo1"))
assertEquals("logo1", cidKey("cid:<logo1>"))
assertEquals("a@b.example", cidKey("CID:a@b.example")) // scheme is case-insensitive
}
@Test
fun `cidKey rejects non-cid and empty references`() {
assertNull(cidKey("https://example.com/tracker.png"))
assertNull(cidKey("data:image/png;base64,AAAA"))
assertNull(cidKey("cid:"))
}
@Test
fun `resolveInlineImage returns the matching image for a cid reference`() {
val logo = image("logo1")
val images = mapOf("logo1" to logo, "banner" to image("banner"))
assertSame(logo, resolveInlineImage("cid:logo1", images))
assertSame(logo, resolveInlineImage("cid:<logo1>", images))
}
@Test
fun `resolveInlineImage returns null for remote urls and unknown cids`() {
val images = mapOf("logo1" to image("logo1"))
assertNull(resolveInlineImage("https://example.com/pixel.gif", images))
assertNull(resolveInlineImage("cid:does-not-exist", images))
assertNull(resolveInlineImage("cid:logo1", emptyMap()))
}
}