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:
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user