diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/17.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/17.json new file mode 100644 index 0000000..c1c9dbe --- /dev/null +++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/17.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt index 5879ba1..31bbde3 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/LibreMailDatabaseTest.kt @@ -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() diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt index 1d669db..8a4ec5b 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/MigrationTest.kt @@ -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) diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt index d9a4261..2454909 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt @@ -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 = emptyList() + override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result = Result.failure(UnsupportedOperationException("not used in UI tests")) diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index 567ad08..2494c04 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -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() { diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index f2c0171..0854815 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -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( diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index feadf8a..566d0a4 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -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 + * (``) 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") + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt index db6d04a..4356db0 100644 --- a/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt +++ b/app/src/main/kotlin/org/libremail/data/local/dao/AttachmentDao.kt @@ -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> - /** 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 diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt index ecbc181..149a123 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/AttachmentEntity.kt @@ -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 (``) — 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, ) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index bcfa1d8..d785cff 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -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 = 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 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 = runCatching { val entity = messageDao.getById(messageId) ?: error("Message not found") val meta = attachmentDao.getForMessage(messageId).firstOrNull { it.partIndex == partIndex } diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index d247ad1..9bbc8ba 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -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 diff --git a/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt b/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt index bcb3932..df1e96c 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Attachment.kt @@ -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, ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/InlineImage.kt b/app/src/main/kotlin/org/libremail/domain/model/InlineImage.kt new file mode 100644 index 0000000..f86c650 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/InlineImage.kt @@ -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:` + * (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) diff --git a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt index 8b79e00..e9c92a5 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -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> + /** + * The message's inline images (HTML `` 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 + /** Downloads an attachment's bytes to a local cache file and returns it. */ suspend fun downloadAttachment(messageId: String, partIndex: Int): Result diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 001409d..5baed9c 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -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 = 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 { val parts = mutableListOf() 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:` (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() } diff --git a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt index 112d9d2..e61ab7c 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/HtmlBody.kt @@ -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 (``, 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, + 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?>(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>?>(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 = 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): 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 diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt index 4b3895b..0226fcd 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -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, + inlineImages: Map, downloading: Set, downloaded: Set, onDownloadAttachment: (Attachment) -> Unit, @@ -194,6 +197,7 @@ private fun MessageBody( message.isHtml -> HtmlBody( html = message.body, loadRemoteImages = loadRemoteImages, + inlineImages = inlineImages, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt index 8c90726..f31ff7a 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt @@ -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 = emptyList(), + /** Inline `cid:` images for the HTML body, keyed by normalized Content-ID (see [HtmlBody]). */ + val inlineImages: Map = emptyMap(), val downloading: Set = emptySet(), /** Part indexes whose bytes are already cached on disk (openable offline). */ val downloaded: Set = 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( diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index ad92865..7aaeb95 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -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() diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index ca06638..a4c11ed 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -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("

Hello

", "text/html; charset=utf-8") + } + val inlineImage = MimeBodyPart().apply { + dataHandler = DataHandler(ByteArrayDataSource(byteArrayOf(1, 2, 3, 4), "image/png")) + contentID = "" + 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 { diff --git a/app/src/test/kotlin/org/libremail/mail/MimePartClassificationTest.kt b/app/src/test/kotlin/org/libremail/mail/MimePartClassificationTest.kt new file mode 100644 index 0000000..651b5e9 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/MimePartClassificationTest.kt @@ -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 = "") + + 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 = ""))) + assertEquals("bare@id", inlineContentId(part("image/jpeg", contentId = "bare@id"))) + assertNull(inlineContentId(part("application/pdf", disposition = "attachment", filename = "x.pdf"))) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/reader/InlineImageResolverTest.kt b/app/src/test/kotlin/org/libremail/ui/reader/InlineImageResolverTest.kt new file mode 100644 index 0000000..c753a4b --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reader/InlineImageResolverTest.kt @@ -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 + * `` 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:")) + 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:", 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())) + } +}