Merge pull request #52 from JMR-dev/fix/message-list-cursorwindow-overflow

fix(mailbox): project message list to avoid CursorWindow overflow
This commit was merged in pull request #52.
This commit is contained in:
Jason Ross
2026-07-01 13:54:36 -05:00
committed by GitHub
7 changed files with 105 additions and 10 deletions
@@ -53,7 +53,7 @@ class DatabaseEncryptionTest {
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
assertTrue("file must not read as plaintext once encrypted", DatabaseEncryption.isEncrypted(dbFile))
openEncrypted().apply {
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
close()
}
@@ -61,7 +61,7 @@ class DatabaseEncryptionTest {
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
assertFalse("file must be plaintext again after decrypt", DatabaseEncryption.isEncrypted(dbFile))
openPlaintext().apply {
assertEquals(listOf("acct:1"), messageDao().observeAll().first().map { it.id })
assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id })
close()
}
}
@@ -102,10 +102,30 @@ class LibreMailDatabaseTest {
assertEquals(listOf("acct:1"), messageDao.getSyncedIds("acct", "INBOX"))
messageDao.deleteSearchRows()
val remaining = messageDao.observeAll().first().map { it.id }
val remaining = messageDao.observeSummaries().first().map { it.id }
assertEquals(listOf("acct:1"), remaining)
}
@Test
fun observeSummariesReadsRowsWhoseBodiesExceedTheCursorWindow() = runBlocking {
val messageDao = db.messageDao()
// Each body is larger than SQLite's shared (~2 MB) CursorWindow. The old list query did
// SELECT * and dragged these bodies through the window, overflowing it with
// "Couldn't read row … from CursorWindow" (issue #51). observeSummaries omits body, so the
// rows stay tiny and read fine.
val hugeBody = "x".repeat(3 * 1024 * 1024)
messageDao.insertNew(
listOf(
message("acct:1", body = hugeBody),
message("acct:2", body = hugeBody),
),
)
val ids = messageDao.observeSummaries().first().map { it.id }.toSet()
assertEquals(setOf("acct:1", "acct:2"), ids)
}
@Test
fun foldersAreStoredOrderedAndReplaceablePerAccount() = runBlocking {
val folderDao = db.folderDao()
@@ -146,7 +166,7 @@ class LibreMailDatabaseTest {
messageDao.deleteSyncedNotIn("acct", "INBOX", listOf("acct:INBOX:1"))
assertEquals(
setOf("acct:INBOX:1", "acct:Archive:1"),
messageDao.observeAll().first().map { it.id }.toSet(),
messageDao.observeSummaries().first().map { it.id }.toSet(),
)
}
}
@@ -9,6 +9,7 @@ import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageSummary
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.domain.model.Account
@@ -103,6 +104,28 @@ internal fun MessageEntity.toDomain(): Message = Message(
bodyFetched = bodyFetched,
)
/**
* Maps a mailbox-list projection to the domain model. [Message.body]/[Message.isHtml] are left
* empty because the list never renders them — the reader loads the body on demand (see
* [MessageSummary]).
*/
internal fun MessageSummary.toDomain(): Message = Message(
id = id,
accountId = accountId,
sender = sender,
senderEmail = senderEmail,
subject = subject,
snippet = snippet,
body = "",
isHtml = false,
timestampMillis = timestampMillis,
isRead = isRead,
isStarred = isStarred,
folder = folder,
inInbox = inInbox,
bodyFetched = bodyFetched,
)
internal fun FetchedMessage.toEntity(accountId: String, folder: String, inInbox: Boolean = true): MessageEntity =
MessageEntity(
id = "$accountId:$folder:$uid",
@@ -7,11 +7,21 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageSummary
@Dao
interface MessageDao {
@Query("SELECT * FROM messages ORDER BY timestampMillis DESC")
fun observeAll(): Flow<List<MessageEntity>>
/**
* Mailbox-list projection ordered newest-first. Deliberately omits the large `body`/`isHtml`
* columns: the list observes every cached message at once, and pulling full bodies through
* SQLite's shared ~2 MB CursorWindow overflows it once enough large bodies are cached
* (issue #51). Bodies are loaded lazily per-message via [getById] when a message is opened.
*/
@Query(
"SELECT id, accountId, sender, senderEmail, subject, snippet, timestampMillis, " +
"isRead, isStarred, folder, inInbox, bodyFetched FROM messages ORDER BY timestampMillis DESC",
)
fun observeSummaries(): Flow<List<MessageSummary>>
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
suspend fun getById(id: String): MessageEntity?
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local.entity
/**
* Lightweight projection of [MessageEntity] for the mailbox list: every column the list renders
* or searches, but *not* the potentially large `body`/`isHtml`.
*
* The list observes every cached message at once, so selecting full HTML bodies would drag them
* all through SQLite's shared (~2 MB) CursorWindow and overflow it once enough large bodies are
* cached — crashing with "Couldn't read row N from CursorWindow" (issue #51). Bodies are read
* lazily, one message at a time, via `MessageDao.getById` when a message is opened.
*/
data class MessageSummary(
val id: String,
val accountId: String,
val sender: String,
val senderEmail: String,
val subject: String,
val snippet: String,
val timestampMillis: Long,
val isRead: Boolean,
val isStarred: Boolean,
val folder: String,
val inInbox: Boolean,
val bodyFetched: Boolean,
)
@@ -57,7 +57,7 @@ class MailRepositoryImpl @Inject constructor(
private val signatureRepository: SignatureRepository,
) : MailRepository {
override fun observeMessages(): Flow<List<Message>> = messageDao.observeAll().map { rows ->
override fun observeMessages(): Flow<List<Message>> = messageDao.observeSummaries().map { rows ->
rows.map { it.toDomain() }
}
@@ -23,6 +23,7 @@ import org.libremail.data.local.entity.AttachmentEntity
import org.libremail.data.local.entity.DraftEntity
import org.libremail.data.local.entity.FolderEntity
import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.MessageSummary
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
@@ -73,7 +74,7 @@ class MailRepositoryImplTest {
@Test
fun `observeMessages is empty when the cache is empty`() = runTest {
every { messageDao.observeAll() } returns flowOf(emptyList())
every { messageDao.observeSummaries() } returns flowOf(emptyList())
repository.observeMessages().test {
assertTrue(awaitItem().isEmpty())
awaitComplete()
@@ -82,8 +83,7 @@ class MailRepositoryImplTest {
@Test
fun `observeMessages maps cached entities`() = runTest {
val entity = messageEntity("1", "INBOX")
every { messageDao.observeAll() } returns flowOf(listOf(entity))
every { messageDao.observeSummaries() } returns flowOf(listOf(messageSummary("1", "INBOX")))
repository.observeMessages().test {
val items = awaitItem()
assertEquals(1, items.size)
@@ -421,6 +421,22 @@ class MailRepositoryImplTest {
bodyFetched = bodyFetched,
)
private fun messageSummary(id: String, folder: String, accountId: String = "acct", bodyFetched: Boolean = false) =
MessageSummary(
id = id,
accountId = accountId,
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
snippet = "snippet",
timestampMillis = 1_000L,
isRead = false,
isStarred = false,
folder = folder,
inInbox = true,
bodyFetched = bodyFetched,
)
private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity(
id = id,
email = email,