From 96111b40dfc02ba5c09b8a66e9666d8cdbef6016 Mon Sep 17 00:00:00 2001
From: Jason Ross
Date: Tue, 30 Jun 2026 17:20:22 -0500
Subject: [PATCH] feat: long-press multi-select, aggressive prefetch, and
download indicators
Add a long-press contextual action bar to the mailbox: Archive, Delete,
Spam, Move, Select All, and (single-selection only) Reply, Reply All, and
Forward. Reply All/Spam/Delete are confirmed first; deleting from Trash or
Spam warns that it is permanent.
- Delete moves to Trash and Spam moves to the Spam folder; only deletes
already in Trash/Spam do a permanent IMAP expunge. Archive/Spam/Move
resolve the destination per account so the unified inbox works.
- Reply/Reply All/Forward force a fresh server fetch of the original
(recipients + body via BODY.PEEK), build a quoted draft, and open compose;
a spinner shows during the fetch and they error via snackbar if offline.
Add a top-level "Message downloading" setting (Always fetch all / Fetch all
on Wi-Fi / Always on-demand; default Always) that aggressively pre-caches
full bodies and all attachment bytes during sync (outside the sync lock,
without marking mail read), into a persistent per-part attachment cache so
messages and attachments open instantly and offline.
Show an "available offline" mark on cached list rows and a per-attachment
"downloaded" check in the reader.
Extract a Syncer interface (MailSyncer implements it) so the mailbox can be
driven end-to-end in tests.
Tests: 66 unit + 27 instrumented, all passing on the API 37 emulator.
Co-Authored-By: Claude Opus 4.8
---
.../kotlin/org/libremail/ui/Fakes.kt | 66 +++-
.../libremail/ui/mailbox/MailboxScreenTest.kt | 178 ++++++++++
.../libremail/ui/reader/ReaderScreenTest.kt | 64 ++++
.../ui/settings/SettingsScreenTest.kt | 52 +++
app/src/main/AndroidManifest.xml | 1 +
.../kotlin/org/libremail/data/ReplyBuilder.kt | 88 +++++
.../org/libremail/data/local/Mappers.kt | 1 +
.../libremail/data/local/dao/AttachmentDao.kt | 4 +
.../org/libremail/data/local/dao/FolderDao.kt | 4 +
.../libremail/data/local/dao/MessageDao.kt | 11 +
.../data/repository/MailRepositoryImpl.kt | 149 +++++++-
.../data/settings/SettingsRepository.kt | 18 +
.../org/libremail/data/sync/MailSyncer.kt | 89 +++--
.../kotlin/org/libremail/data/sync/Syncer.kt | 12 +
.../org/libremail/di/RepositoryModule.kt | 6 +
.../org/libremail/domain/model/Message.kt | 2 +
.../org/libremail/domain/model/ReplyMode.kt | 5 +
.../domain/repository/MailRepository.kt | 31 ++
.../kotlin/org/libremail/mail/ImapClient.kt | 96 +++++
.../kotlin/org/libremail/ui/LibreMailApp.kt | 1 +
.../org/libremail/ui/mailbox/MailboxScreen.kt | 327 ++++++++++++++++--
.../libremail/ui/mailbox/MailboxViewModel.kt | 143 +++++++-
.../org/libremail/ui/reader/ReaderScreen.kt | 17 +-
.../libremail/ui/reader/ReaderViewModel.kt | 5 +-
.../libremail/ui/settings/SettingsScreen.kt | 47 +++
.../ui/settings/SettingsViewModel.kt | 2 +
app/src/main/res/values/strings.xml | 32 ++
.../org/libremail/data/ReplyBuilderTest.kt | 108 ++++++
.../data/repository/MailRepositoryImplTest.kt | 275 ++++++++++++++-
.../org/libremail/data/sync/MailSyncerTest.kt | 115 ++++++
.../org/libremail/mail/ImapClientTest.kt | 42 +++
.../ui/mailbox/MailboxViewModelTest.kt | 224 +++++++++++-
.../ui/reader/ReaderViewModelTest.kt | 84 +++++
33 files changed, 2214 insertions(+), 85 deletions(-)
create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt
create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt
create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
create mode 100644 app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt
create mode 100644 app/src/main/kotlin/org/libremail/data/sync/Syncer.kt
create mode 100644 app/src/main/kotlin/org/libremail/domain/model/ReplyMode.kt
create mode 100644 app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt
create mode 100644 app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelTest.kt
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt
index 7f8c824..e9cd6a1 100644
--- a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt
+++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt
@@ -13,6 +13,8 @@ import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
+import org.libremail.domain.model.ReplyMode
+import org.libremail.data.sync.Syncer
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
@@ -59,32 +61,79 @@ class FakeAccountRepository(
*/
class FakeMailRepository(
var sendResult: Result = Result.success(Unit),
+ private val messages: List = emptyList(),
+ private val folders: List = emptyList(),
+ private val attachments: List = emptyList(),
+ private val downloadedParts: Set = emptySet(),
) : MailRepository {
val sentMessages = mutableListOf()
val savedDrafts = mutableListOf()
val deletedDraftIds = mutableListOf()
+ val archivedIds = mutableListOf>()
+ val spammedIds = mutableListOf>()
+ val trashedIds = mutableListOf>()
+ val expungedIds = mutableListOf>()
+ val movedToFolder = mutableListOf, String>>()
+ val replyDrafts = mutableListOf>()
- override fun observeMessages(): Flow> = flowOf(emptyList())
+ override fun observeMessages(): Flow> = flowOf(messages)
- override fun observeFolders(accountId: String): Flow> = flowOf(emptyList())
+ override fun observeFolders(accountId: String): Flow> =
+ flowOf(folders.filter { it.accountId == accountId })
override suspend fun refreshFolders(accountId: String): Result = Result.success(Unit)
- override suspend fun getMessage(id: String): Message? = null
+ override suspend fun getMessage(id: String): Message? = messages.firstOrNull { it.id == id }
override suspend fun openMessage(id: String): Result =
- Result.failure(UnsupportedOperationException("not used in UI tests"))
+ messages.firstOrNull { it.id == id }?.let { Result.success(it) }
+ ?: Result.failure(NoSuchElementException("no message $id"))
- override fun observeAttachments(messageId: String): Flow> = flowOf(emptyList())
+ override fun observeAttachments(messageId: String): Flow> =
+ flowOf(attachments.filter { it.messageId == messageId })
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result =
Result.failure(UnsupportedOperationException("not used in UI tests"))
+ override suspend fun prefetchMessage(messageId: String): Result = Result.success(Unit)
+
+ override suspend fun downloadedAttachmentParts(messageId: String): Set = downloadedParts
+
override suspend fun setStarred(id: String, starred: Boolean): Result = Result.success(Unit)
override suspend fun deleteMessage(id: String): Result = Result.success(Unit)
+ override suspend fun archive(ids: List): Result {
+ archivedIds += ids
+ return Result.success(Unit)
+ }
+
+ override suspend fun reportSpam(ids: List): Result {
+ spammedIds += ids
+ return Result.success(Unit)
+ }
+
+ override suspend fun trash(ids: List): Result {
+ trashedIds += ids
+ return Result.success(Unit)
+ }
+
+ override suspend fun expunge(ids: List): Result {
+ expungedIds += ids
+ return Result.success(Unit)
+ }
+
+ override suspend fun moveToFolder(ids: List, destFolderFullName: String): Result {
+ movedToFolder += ids to destFolderFullName
+ return Result.success(Unit)
+ }
+
+ override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result {
+ replyDrafts += messageId to mode
+ return Result.success("draft-$messageId")
+ }
+
override suspend fun sendMessage(outgoing: OutgoingMessage): Result {
sentMessages += outgoing
return sendResult
@@ -112,3 +161,10 @@ class FakeMailRepository(
override suspend fun clearSearchResults() {}
}
+
+/** No-op [Syncer] for UI tests: the screen renders cached data, so sync calls do nothing. */
+class FakeMailSyncer : Syncer {
+ override suspend fun syncAll(): Result = Result.success(0)
+ override suspend fun syncAccount(accountId: String): Result = Result.success(0)
+ override suspend fun syncFolder(accountId: String, folder: String): Result = Result.success(0)
+}
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt
new file mode 100644
index 0000000..17d9312
--- /dev/null
+++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt
@@ -0,0 +1,178 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.mailbox
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.hasAnyAncestor
+import androidx.compose.ui.test.hasText
+import androidx.compose.ui.test.isDialog
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.libremail.R
+import org.libremail.domain.model.Account
+import org.libremail.domain.model.AuthType
+import org.libremail.domain.model.Folder
+import org.libremail.domain.model.FolderRole
+import org.libremail.domain.model.MailSecurity
+import org.libremail.domain.model.Message
+import org.libremail.domain.model.ServerConfig
+import org.libremail.ui.FakeAccountRepository
+import org.libremail.ui.FakeMailRepository
+import org.libremail.ui.FakeMailSyncer
+import org.libremail.ui.theme.LibreMailTheme
+
+/**
+ * End-to-end UI test for the mailbox's long-press contextual action bar. Drives the real
+ * [MailboxScreen] + [MailboxViewModel] backed by in-memory fakes.
+ */
+@RunWith(AndroidJUnit4::class)
+class MailboxScreenTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private fun string(resId: Int) = composeTestRule.activity.getString(resId)
+ private fun count(n: Int) = composeTestRule.activity.getString(R.string.cab_selected_count, n)
+
+ private val account = Account(
+ id = "imap:a",
+ email = "a@example.org",
+ displayName = "A",
+ authType = AuthType.PASSWORD_IMAP,
+ imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
+ smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS),
+ )
+
+ private fun message(uid: String, subject: String, bodyFetched: Boolean = false) = Message(
+ id = "imap:a:INBOX:$uid",
+ accountId = "imap:a",
+ sender = "Sender $uid",
+ senderEmail = "s$uid@example.org",
+ subject = subject,
+ snippet = "",
+ body = "",
+ isHtml = false,
+ timestampMillis = 1_000L,
+ isRead = true,
+ isStarred = false,
+ folder = "INBOX",
+ inInbox = true,
+ bodyFetched = bodyFetched,
+ )
+
+ private fun setContent(repo: FakeMailRepository) {
+ val viewModel = MailboxViewModel(repo, FakeAccountRepository(accounts = listOf(account)), FakeMailSyncer())
+ composeTestRule.setContent {
+ LibreMailTheme(darkTheme = false, dynamicColor = false) {
+ MailboxScreen(
+ onOpenMessage = {},
+ onCompose = {},
+ onOpenDrafts = {},
+ onOpenOutbox = {},
+ onAddAccount = {},
+ onOpenCompose = {},
+ onSelectTab = {},
+ viewModel = viewModel,
+ )
+ }
+ }
+ }
+
+ private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
+ }
+
+ @Test
+ fun longPress_entersSelection_andCountTracksTaps() {
+ setContent(FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second"))))
+ waitForText("First")
+
+ composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
+ composeTestRule.onNodeWithText(count(1)).assertIsDisplayed()
+
+ composeTestRule.onNodeWithText("Second").performClick()
+ composeTestRule.onNodeWithText(count(2)).assertIsDisplayed()
+ }
+
+ @Test
+ fun overflow_showsReplyActions_forSingleSelection() {
+ setContent(FakeMailRepository(messages = listOf(message("1", "First"))))
+ waitForText("First")
+
+ composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
+ composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
+
+ composeTestRule.onNodeWithText(string(R.string.action_reply)).assertIsDisplayed()
+ composeTestRule.onNodeWithText(string(R.string.action_reply_all)).assertIsDisplayed()
+ composeTestRule.onNodeWithText(string(R.string.action_forward)).assertIsDisplayed()
+ }
+
+ @Test
+ fun overflow_hidesReplyActions_forMultiSelection() {
+ setContent(FakeMailRepository(messages = listOf(message("1", "First"), message("2", "Second"))))
+ waitForText("First")
+
+ composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
+ composeTestRule.onNodeWithText("Second").performClick()
+ composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
+
+ composeTestRule.onNodeWithText(string(R.string.action_archive)).assertIsDisplayed()
+ composeTestRule.onNodeWithText(string(R.string.action_reply)).assertDoesNotExist()
+ composeTestRule.onNodeWithText(string(R.string.action_forward)).assertDoesNotExist()
+ }
+
+ @Test
+ fun delete_confirmsMoveToTrash_thenTrashesViaRepository() {
+ val repo = FakeMailRepository(messages = listOf(message("1", "First")))
+ setContent(repo)
+ waitForText("First")
+
+ composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
+ composeTestRule.onNodeWithContentDescription(string(R.string.action_delete)).performClick()
+ composeTestRule.onNodeWithText(string(R.string.confirm_trash_title)).assertIsDisplayed()
+ composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
+
+ composeTestRule.waitUntil(5_000) { repo.trashedIds.isNotEmpty() }
+ assertEquals(listOf("imap:a:INBOX:1"), repo.trashedIds.first())
+ }
+
+ @Test
+ fun move_picker_movesSelectionToTheChosenFolder() {
+ val repo = FakeMailRepository(
+ messages = listOf(message("1", "First")),
+ folders = listOf(
+ Folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX, selectable = true),
+ Folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL, selectable = true),
+ ),
+ )
+ setContent(repo)
+ waitForText("First")
+
+ composeTestRule.onNodeWithText("First").performTouchInput { longClick() }
+ composeTestRule.onNodeWithContentDescription(string(R.string.action_more)).performClick()
+ composeTestRule.onNodeWithText(string(R.string.action_move)).performClick()
+ // The off-screen navigation drawer also lists "Receipts", so scope the tap to the move dialog.
+ composeTestRule.onNode(hasText("Receipts") and hasAnyAncestor(isDialog())).performClick()
+
+ composeTestRule.waitUntil(5_000) { repo.movedToFolder.isNotEmpty() }
+ assertEquals("Receipts", repo.movedToFolder.first().second)
+ }
+
+ @Test
+ fun offlineIndicator_showsForCachedMessages() {
+ setContent(FakeMailRepository(messages = listOf(message("1", "Cached", bodyFetched = true))))
+ waitForText("Cached")
+
+ composeTestRule.onNodeWithContentDescription(string(R.string.message_available_offline)).assertIsDisplayed()
+ }
+}
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt
new file mode 100644
index 0000000..1ccae3e
--- /dev/null
+++ b/app/src/androidTest/kotlin/org/libremail/ui/reader/ReaderScreenTest.kt
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.reader
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.lifecycle.SavedStateHandle
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.libremail.R
+import org.libremail.data.settings.SettingsRepository
+import org.libremail.domain.model.Attachment
+import org.libremail.domain.model.Message
+import org.libremail.ui.FakeMailRepository
+import org.libremail.ui.navigation.Routes
+import org.libremail.ui.theme.LibreMailTheme
+
+/** End-to-end test that the reader marks an already-cached attachment as downloaded. */
+@RunWith(AndroidJUnit4::class)
+class ReaderScreenTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private fun string(resId: Int) = composeTestRule.activity.getString(resId)
+
+ private val messageId = "imap:a:INBOX:1"
+ private val message = Message(
+ id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org",
+ subject = "Subject", snippet = "", body = "Hello body", isHtml = false, timestampMillis = 1_000L,
+ isRead = true, isStarred = false,
+ )
+
+ @Test
+ fun reader_showsDownloadedIndicator_forCachedAttachment() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
+ val repo = FakeMailRepository(
+ messages = listOf(message),
+ attachments = listOf(Attachment(messageId, 0, "report.pdf", "application/pdf", 1234L)),
+ downloadedParts = setOf(0),
+ )
+ val viewModel = ReaderViewModel(
+ SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)),
+ repo,
+ SettingsRepository(context),
+ )
+
+ composeTestRule.setContent {
+ LibreMailTheme(darkTheme = false, dynamicColor = false) {
+ ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
+ }
+ }
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("report.pdf").fetchSemanticsNodes().isNotEmpty()
+ }
+ composeTestRule.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed()
+ }
+}
diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
new file mode 100644
index 0000000..2ef0e1e
--- /dev/null
+++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.settings
+
+import androidx.activity.ComponentActivity
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.libremail.R
+import org.libremail.data.settings.FetchPolicy
+import org.libremail.data.settings.SettingsRepository
+import org.libremail.ui.FakeAccountRepository
+import org.libremail.ui.theme.LibreMailTheme
+
+/**
+ * End-to-end test for the top-level message-downloading setting: tapping a policy must round-trip
+ * through the real [SettingsRepository] (DataStore), proving the UI → ViewModel → persistence wiring.
+ */
+@RunWith(AndroidJUnit4::class)
+class SettingsScreenTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ private fun string(resId: Int) = composeTestRule.activity.getString(resId)
+
+ @Test
+ fun selectingFetchPolicy_persistsThroughTheRepository() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
+ val settingsRepository = SettingsRepository(context)
+ runBlocking { settingsRepository.setFetchPolicy(FetchPolicy.ALWAYS) } // known starting state
+ val viewModel = SettingsViewModel(FakeAccountRepository(), settingsRepository)
+
+ composeTestRule.setContent {
+ LibreMailTheme(darkTheme = false, dynamicColor = false) {
+ SettingsScreen(onAddAccount = {}, onSelectTab = {}, viewModel = viewModel)
+ }
+ }
+
+ composeTestRule.onNodeWithText(string(R.string.fetch_on_demand)).performScrollTo().performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ runBlocking { settingsRepository.fetchPolicy() } == FetchPolicy.ON_DEMAND
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 68e1c18..8ee562c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt
new file mode 100644
index 0000000..0eda275
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import org.libremail.domain.model.ReplyMode
+import org.libremail.mail.ReplyContext
+
+/** The pre-filled compose fields for a reply/forward (everything else the user fills in). */
+data class ReplyContent(
+ val to: String,
+ val cc: String,
+ val subject: String,
+ val body: String,
+)
+
+/**
+ * Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a
+ * reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly.
+ */
+object ReplyBuilder {
+
+ fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) {
+ ReplyMode.REPLY -> ReplyContent(
+ to = context.fromEmail,
+ cc = "",
+ subject = prefixedSubject(context.subject, "Re:"),
+ body = quotedReply(context),
+ )
+
+ ReplyMode.REPLY_ALL -> ReplyContent(
+ to = context.fromEmail,
+ cc = replyAllCc(context, selfEmail).joinToString(", "),
+ subject = prefixedSubject(context.subject, "Re:"),
+ body = quotedReply(context),
+ )
+
+ ReplyMode.FORWARD -> ReplyContent(
+ to = "",
+ cc = "",
+ subject = prefixedSubject(context.subject, "Fwd:"),
+ body = forwardedBody(context),
+ )
+ }
+
+ /** Everyone on the original To/Cc except ourselves and the original sender (who becomes the To). */
+ private fun replyAllCc(context: ReplyContext, selfEmail: String): List =
+ (context.toRecipients + context.ccRecipients)
+ .filter { it.isNotBlank() }
+ .distinctBy { it.lowercase(Locale.ROOT) }
+ .filterNot { it.equals(selfEmail, ignoreCase = true) || it.equals(context.fromEmail, ignoreCase = true) }
+
+ /** Adds [prefix] unless the subject already starts with it (case-insensitive), avoiding "Re: Re:". */
+ private fun prefixedSubject(subject: String, prefix: String): String {
+ val trimmed = subject.trim()
+ return if (trimmed.startsWith(prefix, ignoreCase = true)) trimmed else "$prefix $trimmed"
+ }
+
+ private fun quotedReply(context: ReplyContext): String {
+ val original = bodyText(context).lineSequence().joinToString("\n") { "> $it" }
+ return "\n\nOn ${formatDate(context.sentDateMillis)}, ${context.fromEmail} wrote:\n$original"
+ }
+
+ private fun forwardedBody(context: ReplyContext): String = buildString {
+ append("\n\n---------- Forwarded message ----------\n")
+ append("From: ${context.fromEmail}\n")
+ append("Date: ${formatDate(context.sentDateMillis)}\n")
+ append("Subject: ${context.subject}\n")
+ append("To: ${context.toRecipients.joinToString(", ")}\n\n")
+ append(bodyText(context))
+ }
+
+ /** The original body as plain text (HTML stripped), suitable for quoting in a plain-text compose. */
+ private fun bodyText(context: ReplyContext): String =
+ if (context.isHtml) htmlToText(context.body) else context.body
+
+ private fun htmlToText(html: String): String =
+ html
+ .replace(Regex("(?i)
"), "\n")
+ .replace(Regex("(?i)
"), "\n\n")
+ .replace(Regex("<[^>]*>"), "")
+ .replace(Regex("[ \\t]+"), " ")
+ .trim()
+
+ private fun formatDate(millis: Long): String =
+ SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis))
+}
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 54e7ded..52a51bb 100644
--- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt
@@ -89,6 +89,7 @@ internal fun MessageEntity.toDomain(): Message = Message(
isStarred = isStarred,
folder = folder,
inInbox = inInbox,
+ bodyFetched = bodyFetched,
)
internal fun FetchedMessage.toEntity(
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 b016c6e..db6d04a 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
@@ -14,6 +14,10 @@ interface AttachmentDao {
@Query("SELECT * FROM attachments WHERE messageId = :messageId 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). */
+ @Query("SELECT * FROM attachments WHERE messageId = :messageId ORDER BY partIndex")
+ suspend fun getForMessage(messageId: String): List
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(attachments: List)
diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt
index 6b4ee0a..67e8fe4 100644
--- a/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/dao/FolderDao.kt
@@ -14,6 +14,10 @@ interface FolderDao {
@Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC")
fun observeForAccount(accountId: String): Flow>
+ /** One-shot read of an account's folders, e.g. to resolve a role folder for archive/spam/trash. */
+ @Query("SELECT * FROM folders WHERE accountId = :accountId ORDER BY sortOrder ASC")
+ suspend fun getForAccountOnce(accountId: String): List
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(folders: List)
diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt
index 48c4d5b..db2495b 100644
--- a/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt
+++ b/app/src/main/kotlin/org/libremail/data/local/dao/MessageDao.kt
@@ -20,6 +20,13 @@ interface MessageDao {
@Query("SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder AND inInbox = 1")
suspend fun getSyncedIds(accountId: String, folder: String): List
+ /** Ids of synced rows in [folder] whose body hasn't been cached yet (for aggressive prefetch). */
+ @Query(
+ "SELECT id FROM messages WHERE accountId = :accountId AND folder = :folder " +
+ "AND inInbox = 1 AND bodyFetched = 0",
+ )
+ suspend fun getUnfetchedIds(accountId: String, folder: String): List
+
/** Inserts only new messages, leaving existing rows (and their cached bodies/flags) intact. */
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertNew(messages: List)
@@ -57,6 +64,10 @@ interface MessageDao {
@Query("DELETE FROM messages WHERE id = :id")
suspend fun deleteById(id: String)
+ /** Optimistically removes moved/deleted rows; the next sync reconciles if a server op failed. */
+ @Query("DELETE FROM messages WHERE id IN (:ids)")
+ suspend fun deleteByIds(ids: List)
+
@Query("DELETE FROM messages WHERE accountId = :accountId")
suspend fun deleteByAccount(accountId: String)
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 de3680e..b299d89 100644
--- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt
+++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt
@@ -11,12 +11,15 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
+import org.libremail.data.ReplyBuilder
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
+import org.libremail.data.local.entity.FolderEntity
+import org.libremail.data.local.entity.MessageEntity
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
@@ -25,12 +28,14 @@ import org.libremail.data.sync.SendScheduler
import org.libremail.domain.model.Attachment
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.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.OutgoingMessage
+import org.libremail.domain.model.ReplyMode
import org.libremail.domain.repository.MailRepository
-import org.libremail.mail.DownloadedAttachment
import org.libremail.mail.ImapClient
@Singleton
@@ -86,9 +91,38 @@ class MailRepositoryImpl @Inject constructor(
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 }
+ val target = attachmentFile(messageId, partIndex, meta?.filename ?: "attachment")
+ // Reuse a previously downloaded (or pre-fetched) file so it opens instantly and offline.
+ if (target.exists() && target.length() > 0L) return@runCatching target
val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found")
val params = connectionFactory.imapParamsFor(account)
- saveToCache(imapClient.fetchAttachment(params, entity.folder, uidOf(messageId), partIndex))
+ val downloaded = imapClient.fetchAttachment(params, entity.folder, uidOf(messageId), partIndex)
+ target.parentFile?.mkdirs()
+ target.outputStream().use { it.write(downloaded.bytes) }
+ target
+ }
+
+ override suspend fun downloadedAttachmentParts(messageId: String): Set =
+ attachmentDao.getForMessage(messageId)
+ .filter { val file = attachmentFile(messageId, it.partIndex, it.filename); file.exists() && file.length() > 0L }
+ .map { it.partIndex }
+ .toSet()
+
+ override suspend fun prefetchMessage(messageId: String): Result = runCatching {
+ val entity = messageDao.getById(messageId) ?: return@runCatching
+ val account = accountDao.getById(entity.accountId)?.toDomain() ?: return@runCatching
+ val params = connectionFactory.imapParamsFor(account)
+ // Cache the body (peek, so prefetching never marks the message read) and its attachment metadata.
+ if (!entity.bodyFetched) {
+ val content = imapClient.fetchBodyPeek(params, entity.folder, uidOf(messageId))
+ messageDao.updateBody(messageId, content.body, content.isHtml, snippetOf(content.body))
+ attachmentDao.replaceForMessage(messageId, content.attachments.map { it.toEntity(messageId) })
+ }
+ // Auto-download every attachment's bytes into the persistent per-part cache (skips ones present).
+ attachmentDao.getForMessage(messageId).forEach { attachment ->
+ downloadAttachment(messageId, attachment.partIndex)
+ }
}
override suspend fun setStarred(id: String, starred: Boolean): Result = runCatching {
@@ -111,6 +145,101 @@ class MailRepositoryImpl @Inject constructor(
}
}
+ override suspend fun archive(ids: List): Result =
+ moveByRole(ids, FolderRole.ARCHIVE, fallbackExpunge = false)
+
+ override suspend fun reportSpam(ids: List): Result =
+ moveByRole(ids, FolderRole.SPAM, fallbackExpunge = false)
+
+ override suspend fun trash(ids: List): Result =
+ moveByRole(ids, FolderRole.TRASH, fallbackExpunge = true)
+
+ override suspend fun expunge(ids: List): Result = runCatching {
+ val entities = ids.mapNotNull { messageDao.getById(it) }
+ messageDao.deleteByIds(ids) // optimistic
+ forEachAccountFolder(entities) { params, folder, group ->
+ group.forEach { imapClient.deleteMessage(params, folder, uidOf(it.id)) }
+ }
+ }
+
+ override suspend fun moveToFolder(ids: List, destFolderFullName: String): Result = runCatching {
+ val entities = ids.mapNotNull { messageDao.getById(it) }
+ messageDao.deleteByIds(ids) // optimistic
+ forEachAccountFolder(entities) { params, folder, group ->
+ if (folder != destFolderFullName) {
+ imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, destFolderFullName)
+ }
+ }
+ }
+
+ override suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result = runCatching {
+ val entity = messageDao.getById(messageId) ?: error("Message not found")
+ val account = accountDao.getById(entity.accountId)?.toDomain() ?: error("Account not found")
+ val params = connectionFactory.imapParamsFor(account)
+ val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId))
+ val content = ReplyBuilder.build(context, mode, account.email)
+ val draftId = UUID.randomUUID().toString()
+ saveDraft(
+ Draft(
+ id = draftId,
+ accountId = entity.accountId,
+ to = content.to,
+ cc = content.cc,
+ subject = content.subject,
+ body = content.body,
+ updatedAt = System.currentTimeMillis(),
+ attachments = emptyList(),
+ ),
+ )
+ draftId
+ }
+
+ /**
+ * Moves messages to each account's folder for [role], resolving the destination per account so the
+ * unified inbox works. Removes local rows first (optimistic; reconciled on the next sync). When the
+ * role folder is missing: trash falls back to a permanent expunge ([fallbackExpunge]); others fail.
+ */
+ private suspend fun moveByRole(ids: List, role: FolderRole, fallbackExpunge: Boolean): Result =
+ runCatching {
+ val entities = ids.mapNotNull { messageDao.getById(it) }
+ messageDao.deleteByIds(ids) // optimistic
+ val destByAccount = entities.map { it.accountId }.distinct()
+ .associateWith { resolveRoleFolder(it, role) }
+ forEachAccountFolder(entities) { params, folder, group ->
+ when (val dest = destByAccount[group.first().accountId]) {
+ null ->
+ if (fallbackExpunge) {
+ group.forEach { imapClient.deleteMessage(params, folder, uidOf(it.id)) }
+ } else {
+ error("No ${role.name.lowercase()} folder for this account")
+ }
+ else -> imapClient.moveMessages(params, folder, group.map { uidOf(it.id) }, dest)
+ }
+ }
+ }
+
+ /** Groups [entities] by account then source folder and runs [block] once per (account, folder) group. */
+ private suspend fun forEachAccountFolder(
+ entities: List,
+ block: suspend (params: ImapConnectionParams, folder: String, group: List) -> Unit,
+ ) {
+ entities.groupBy { it.accountId }.forEach { (accountId, accountMessages) ->
+ val account = accountDao.getById(accountId)?.toDomain() ?: return@forEach
+ val params = connectionFactory.imapParamsFor(account)
+ accountMessages.groupBy { it.folder }.forEach { (folder, group) -> block(params, folder, group) }
+ }
+ }
+
+ /** Resolves the full name of an account's folder for [role], refreshing the cache once if needed. */
+ private suspend fun resolveRoleFolder(accountId: String, role: FolderRole): String? {
+ fun pick(folders: List) =
+ folders.firstOrNull { it.role == role.name && it.selectable }?.fullName
+ pick(folderDao.getForAccountOnce(accountId))?.let { return it }
+ // The folder cache can be cold (the user may not have opened the drawer yet); refresh and retry.
+ runCatching { refreshFolders(accountId) }
+ return pick(folderDao.getForAccountOnce(accountId))
+ }
+
/** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */
override suspend fun sendMessage(outgoing: OutgoingMessage): Result = runCatching {
requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" }
@@ -194,13 +323,15 @@ class MailRepositoryImpl @Inject constructor(
override suspend fun clearSearchResults() = messageDao.deleteSearchRows()
- /** Writes downloaded bytes to a private cache file that the FileProvider can share. */
- private fun saveToCache(attachment: DownloadedAttachment): File {
- val dir = File(context.cacheDir, "attachments").apply { mkdirs() }
- val safeName = attachment.filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
- return File(dir, safeName).also { file ->
- file.outputStream().use { it.write(attachment.bytes) }
- }
+ /**
+ * Deterministic cache path for one attachment part, under the FileProvider-shared `attachments/`
+ * dir. Keying by message id + part index lets prefetch and on-demand download share the same file
+ * and avoids filename collisions between messages.
+ */
+ private fun attachmentFile(messageId: String, partIndex: Int, filename: String): File {
+ val safeId = messageId.replace(Regex("[^A-Za-z0-9._-]"), "_")
+ val safeName = filename.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" }
+ return File(context.cacheDir, "attachments/$safeId/$partIndex/$safeName")
}
}
diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
index a14585d..e2c4d80 100644
--- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
+++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
@@ -6,6 +6,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
@@ -16,6 +17,14 @@ import kotlinx.coroutines.flow.map
private val Context.settingsDataStore: DataStore by preferencesDataStore(name = "libremail_settings")
+/**
+ * How aggressively the app downloads message content during sync.
+ * - [ALWAYS]: fetch full bodies and all attachments on any connection (default).
+ * - [WIFI_ONLY]: fetch full content only on an unmetered network; otherwise behave like [ON_DEMAND].
+ * - [ON_DEMAND]: sync headers only; fetch a message's body/attachments lazily when it's opened.
+ */
+enum class FetchPolicy { ALWAYS, WIFI_ONLY, ON_DEMAND }
+
/** User preferences. Only [dynamicColor] and [newMailNotifications] are wired to behaviour so far. */
data class AppSettings(
val dynamicColor: Boolean = true,
@@ -24,6 +33,7 @@ data class AppSettings(
val allowStartTls: Boolean = false,
val loadRemoteImages: Boolean = false,
val encryptCache: Boolean = false,
+ val fetchPolicy: FetchPolicy = FetchPolicy.ALWAYS,
)
@Singleton
@@ -38,6 +48,8 @@ class SettingsRepository @Inject constructor(
allowStartTls = prefs[ALLOW_STARTTLS] ?: false,
loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false,
encryptCache = prefs[ENCRYPT_CACHE] ?: false,
+ fetchPolicy = prefs[FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
+ ?: FetchPolicy.ALWAYS,
)
}
@@ -45,12 +57,17 @@ class SettingsRepository @Inject constructor(
suspend fun isNewMailNotificationsEnabled(): Boolean = settings.first().newMailNotifications
+ suspend fun fetchPolicy(): FetchPolicy = settings.first().fetchPolicy
+
suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value)
suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value)
suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value)
suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value)
suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value)
suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, value)
+ suspend fun setFetchPolicy(value: FetchPolicy) {
+ context.settingsDataStore.edit { it[FETCH_POLICY] = value.name }
+ }
private suspend fun put(key: Preferences.Key, value: Boolean) {
context.settingsDataStore.edit { it[key] = value }
@@ -63,5 +80,6 @@ class SettingsRepository @Inject constructor(
val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls")
val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images")
val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
+ val FETCH_POLICY = stringPreferencesKey("fetch_policy")
}
}
diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt
index 86ae9fc..7712015 100644
--- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt
+++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt
@@ -1,9 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -11,21 +17,25 @@ import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
+import org.libremail.data.settings.FetchPolicy
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Account
+import org.libremail.domain.repository.MailRepository
import org.libremail.mail.ImapClient
import org.libremail.notifications.MailNotifier
/** Fetches account folders' headers into Room and notifies about newly-arrived inbox mail. */
@Singleton
class MailSyncer @Inject constructor(
+ @ApplicationContext private val context: Context,
private val accountDao: AccountDao,
private val messageDao: MessageDao,
private val imapClient: ImapClient,
private val connectionFactory: MailConnectionFactory,
private val settingsRepository: SettingsRepository,
private val notifier: MailNotifier,
-) {
+ private val mailRepository: MailRepository,
+) : Syncer {
// Serializes all syncing: syncAll/syncAccount/syncFolder are invoked concurrently by the periodic
// worker, pull-to-refresh, one-shot syncs, folder opens, and one IDLE watcher per account. Without
// this, two runs can both compute the same message as "new" (double-notify) or let a stale
@@ -33,38 +43,46 @@ class MailSyncer @Inject constructor(
private val syncMutex = Mutex()
/** Syncs every account's inbox. Succeeds if at least one account synced (or there are none). */
- suspend fun syncAll(): Result = syncMutex.withLock {
- val accounts = accountDao.getAll()
- if (accounts.isEmpty()) return@withLock Result.success(0)
+ override suspend fun syncAll(): Result {
+ val accounts = accountDao.getAll().map { it.toDomain() }
+ if (accounts.isEmpty()) return Result.success(0)
- var total = 0
- var firstError: Throwable? = null
- var anySuccess = false
- for (entity in accounts) {
- syncFolderInternal(entity.toDomain(), INBOX, notify = true).fold(
- onSuccess = { fetched ->
- total += fetched
- anySuccess = true
- },
- onFailure = { error -> if (firstError == null) firstError = error },
- )
+ val result = syncMutex.withLock {
+ var total = 0
+ var firstError: Throwable? = null
+ var anySuccess = false
+ for (account in accounts) {
+ syncFolderHeaders(account, INBOX, notify = true).fold(
+ onSuccess = { fetched ->
+ total += fetched
+ anySuccess = true
+ },
+ onFailure = { error -> if (firstError == null) firstError = error },
+ )
+ }
+ if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError)
}
- if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError)
+ if (result.isSuccess) accounts.forEach { prefetchIfEnabled(it, INBOX) }
+ return result
}
/** Syncs one account's inbox — used by the per-account IDLE watcher so a push doesn't re-sync all. */
- suspend fun syncAccount(accountId: String): Result = syncMutex.withLock {
- val entity = accountDao.getById(accountId) ?: return@withLock Result.success(0)
- syncFolderInternal(entity.toDomain(), INBOX, notify = true)
+ override suspend fun syncAccount(accountId: String): Result {
+ val account = accountDao.getById(accountId)?.toDomain() ?: return Result.success(0)
+ val result = syncMutex.withLock { syncFolderHeaders(account, INBOX, notify = true) }
+ if (result.isSuccess) prefetchIfEnabled(account, INBOX)
+ return result
}
/** Syncs one (account, folder) on demand — used when a folder is opened or refreshed (no notify). */
- suspend fun syncFolder(accountId: String, folder: String): Result = syncMutex.withLock {
- val entity = accountDao.getById(accountId) ?: return@withLock Result.success(0)
- syncFolderInternal(entity.toDomain(), folder, notify = false)
+ override suspend fun syncFolder(accountId: String, folder: String): Result {
+ val account = accountDao.getById(accountId)?.toDomain() ?: return Result.success(0)
+ val result = syncMutex.withLock { syncFolderHeaders(account, folder, notify = false) }
+ if (result.isSuccess) prefetchIfEnabled(account, folder)
+ return result
}
- private suspend fun syncFolderInternal(account: Account, folder: String, notify: Boolean): Result =
+ private suspend fun syncFolderHeaders(account: Account, folder: String, notify: Boolean): Result =
runCatching {
val params = connectionFactory.imapParamsFor(account)
val fetched = imapClient.fetchRecent(params, folder, FETCH_LIMIT) // cancellable network I/O
@@ -108,6 +126,31 @@ class MailSyncer @Inject constructor(
fetched.size
}
+ /**
+ * Aggressively pre-caches each not-yet-fetched message's full content (body + attachments) per the
+ * user's [FetchPolicy]. Runs outside [syncMutex] so these downloads don't block pull-to-refresh or
+ * other syncs, and is cancellable between messages so an IDLE renewal stops it promptly.
+ */
+ private suspend fun prefetchIfEnabled(account: Account, folder: String) {
+ val shouldPrefetch = when (settingsRepository.fetchPolicy()) {
+ FetchPolicy.ALWAYS -> true
+ FetchPolicy.WIFI_ONLY -> isUnmetered()
+ FetchPolicy.ON_DEMAND -> false
+ }
+ if (!shouldPrefetch) return
+ for (id in messageDao.getUnfetchedIds(account.id, folder)) {
+ currentCoroutineContext().ensureActive()
+ mailRepository.prefetchMessage(id) // best-effort; swallows its own per-message failures
+ }
+ }
+
+ /** True when the active network is unmetered (e.g. Wi-Fi), used by [FetchPolicy.WIFI_ONLY]. */
+ private fun isUnmetered(): Boolean {
+ val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
+ val capabilities = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+ }
+
private companion object {
const val INBOX = "INBOX"
const val FETCH_LIMIT = 50
diff --git a/app/src/main/kotlin/org/libremail/data/sync/Syncer.kt b/app/src/main/kotlin/org/libremail/data/sync/Syncer.kt
new file mode 100644
index 0000000..ae478d5
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/data/sync/Syncer.kt
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.sync
+
+/**
+ * Abstraction over mailbox syncing so UI callers (and their tests) depend on this rather than the
+ * concrete [MailSyncer], whose many collaborators make it impractical to fake.
+ */
+interface Syncer {
+ suspend fun syncAll(): Result
+ suspend fun syncAccount(accountId: String): Result
+ suspend fun syncFolder(accountId: String, folder: String): Result
+}
diff --git a/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt b/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt
index 96622e6..54fc0eb 100644
--- a/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt
+++ b/app/src/main/kotlin/org/libremail/di/RepositoryModule.kt
@@ -8,6 +8,8 @@ import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.libremail.data.repository.AccountRepositoryImpl
import org.libremail.data.repository.MailRepositoryImpl
+import org.libremail.data.sync.MailSyncer
+import org.libremail.data.sync.Syncer
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
@@ -22,4 +24,8 @@ abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindSyncer(impl: MailSyncer): Syncer
}
diff --git a/app/src/main/kotlin/org/libremail/domain/model/Message.kt b/app/src/main/kotlin/org/libremail/domain/model/Message.kt
index e104b1c..d992710 100644
--- a/app/src/main/kotlin/org/libremail/domain/model/Message.kt
+++ b/app/src/main/kotlin/org/libremail/domain/model/Message.kt
@@ -17,4 +17,6 @@ data class Message(
val folder: String = "INBOX",
/** True for messages synced from a folder; false for transient server-search hits. */
val inInbox: Boolean = true,
+ /** True once the body has been cached locally, i.e. the message can be opened offline. */
+ val bodyFetched: Boolean = false,
)
diff --git a/app/src/main/kotlin/org/libremail/domain/model/ReplyMode.kt b/app/src/main/kotlin/org/libremail/domain/model/ReplyMode.kt
new file mode 100644
index 0000000..eb1318d
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/domain/model/ReplyMode.kt
@@ -0,0 +1,5 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.domain.model
+
+/** Which kind of compose a mailbox action produces from an existing message. */
+enum class ReplyMode { REPLY, REPLY_ALL, FORWARD }
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 5c1b4e6..38edcce 100644
--- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt
+++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt
@@ -9,6 +9,7 @@ import org.libremail.domain.model.Folder
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutboxMessage
import org.libremail.domain.model.OutgoingMessage
+import org.libremail.domain.model.ReplyMode
/**
* Abstraction over the local message cache (and, in later increments, network sync).
@@ -34,10 +35,40 @@ interface MailRepository {
/** Downloads an attachment's bytes to a local cache file and returns it. */
suspend fun downloadAttachment(messageId: String, partIndex: Int): Result
+ /** Part indexes of a message whose attachment bytes are already cached on disk (openable offline). */
+ suspend fun downloadedAttachmentParts(messageId: String): Set
+
+ /**
+ * Pre-caches a message's full content (body + every attachment's bytes) without marking it read.
+ * Used by aggressive sync so opening the message — and its attachments — is instant and works offline.
+ */
+ suspend fun prefetchMessage(messageId: String): Result
+
suspend fun setStarred(id: String, starred: Boolean): Result
suspend fun deleteMessage(id: String): Result
+ /** Moves messages to each account's Archive folder. Fails if an account has no archive folder. */
+ suspend fun archive(ids: List): Result
+
+ /** Moves messages to each account's Spam/Junk folder. Fails if an account has no spam folder. */
+ suspend fun reportSpam(ids: List): Result
+
+ /** Moves messages to each account's Trash folder, falling back to a permanent delete if there is none. */
+ suspend fun trash(ids: List): Result
+
+ /** Permanently deletes messages from their current folder (IMAP delete + expunge). */
+ suspend fun expunge(ids: List): Result
+
+ /** Moves messages to a specific destination folder (used by the explicit "Move" picker). */
+ suspend fun moveToFolder(ids: List, destFolderFullName: String): Result
+
+ /**
+ * Fetches the original message and builds a pre-filled reply/forward draft, returning its id so the
+ * caller can open the compose screen on that draft.
+ */
+ suspend fun buildReplyDraft(messageId: String, mode: ReplyMode): Result
+
suspend fun sendMessage(outgoing: OutgoingMessage): Result
/** Drafts: composed-but-unsent messages saved for later. */
diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
index 87bb773..c0d29f1 100644
--- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
+++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt
@@ -32,6 +32,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.eclipse.angus.mail.imap.IMAPFolder
+import org.eclipse.angus.mail.imap.IMAPMessage
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
@@ -78,6 +79,17 @@ class DownloadedAttachment(
val bytes: ByteArray,
)
+/** The original message's fields needed to compose a reply, reply-all, or forward. */
+data class ReplyContext(
+ val fromEmail: String,
+ val toRecipients: List,
+ val ccRecipients: List,
+ val subject: String,
+ val sentDateMillis: Long,
+ val body: String,
+ val isHtml: Boolean,
+)
+
/** Thin IMAP client over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */
@Singleton
class ImapClient @Inject constructor() {
@@ -181,6 +193,27 @@ class ImapClient @Inject constructor() {
}
}
+ /**
+ * Fetches a message body + attachment metadata by UID **without** marking it \Seen (opens READ_ONLY
+ * and uses BODY.PEEK). Used to pre-cache content during sync so opening the message is instant.
+ */
+ suspend fun fetchBodyPeek(params: ImapConnectionParams, folder: String, uid: String): MessageContent =
+ withContext(Dispatchers.IO) {
+ withStore(params) { store ->
+ val mailbox = store.getFolder(folder)
+ mailbox.open(Folder.READ_ONLY)
+ try {
+ val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong())
+ ?: error("Message $uid not found")
+ (message as? IMAPMessage)?.setPeek(true)
+ (extractBody(message) ?: MessageContent("", isHtml = false))
+ .copy(attachments = collectAttachments(message))
+ } finally {
+ runCatching { mailbox.close(false) }
+ }
+ }
+ }
+
/** Downloads the bytes of one attachment part (identified by its [partIndex]) from [folder]. */
suspend fun fetchAttachment(
params: ImapConnectionParams,
@@ -237,6 +270,65 @@ class ImapClient @Inject constructor() {
}
}
+ /**
+ * Moves messages from [sourceFolder] to [destFolder] by UID. Implemented as copy + \Deleted +
+ * expunge so it works on every IMAP server (no reliance on the RFC 6851 MOVE extension).
+ */
+ suspend fun moveMessages(
+ params: ImapConnectionParams,
+ sourceFolder: String,
+ uids: List,
+ destFolder: String,
+ ) = withContext(Dispatchers.IO) {
+ if (uids.isEmpty()) return@withContext
+ withStore(params) { store ->
+ val mailbox = store.getFolder(sourceFolder)
+ mailbox.open(Folder.READ_WRITE)
+ try {
+ val uidFolder = mailbox as UIDFolder
+ val messages = uids.mapNotNull { uidFolder.getMessageByUID(it.toLong()) }.toTypedArray()
+ if (messages.isEmpty()) return@withStore
+ mailbox.copyMessages(messages, store.getFolder(destFolder))
+ mailbox.setFlags(messages, Flags(Flags.Flag.DELETED), true)
+ mailbox.expunge()
+ } finally {
+ runCatching { mailbox.close(false) }
+ }
+ }
+ }
+
+ /**
+ * Fetches the fields needed to compose a reply/forward (recipients + body) without marking the
+ * original \Seen (opens READ_ONLY), so building a reply doesn't change the message's read state.
+ */
+ suspend fun fetchForReply(params: ImapConnectionParams, folder: String, uid: String): ReplyContext =
+ withContext(Dispatchers.IO) {
+ withStore(params) { store ->
+ val mailbox = store.getFolder(folder)
+ mailbox.open(Folder.READ_ONLY)
+ try {
+ val message = (mailbox as UIDFolder).getMessageByUID(uid.toLong())
+ ?: error("Message $uid not found")
+ // BODY.PEEK: reading the body to quote it must not set the \Seen flag.
+ (message as? IMAPMessage)?.setPeek(true)
+ val from = message.from?.firstOrNull() as? InternetAddress
+ val content = extractBody(message) ?: MessageContent("", isHtml = false)
+ ReplyContext(
+ fromEmail = from?.address.orEmpty(),
+ toRecipients = message.recipientEmails(Message.RecipientType.TO),
+ ccRecipients = message.recipientEmails(Message.RecipientType.CC),
+ subject = message.subject ?: "",
+ sentDateMillis = (message.sentDate ?: message.receivedDate)?.time
+ ?: System.currentTimeMillis(),
+ body = content.body,
+ isHtml = content.isHtml,
+ )
+ } finally {
+ runCatching { mailbox.close(false) }
+ }
+ }
+ }
+
/**
* Holds a long-lived IMAP connection and uses IMAP IDLE to wait for server activity. The
* server pushes new-mail notifications while [IMAPFolder.idle] blocks; Jakarta dispatches them
@@ -352,6 +444,10 @@ class ImapClient @Inject constructor() {
private fun baseType(part: Part): String =
runCatching { ContentType(part.contentType).baseType }.getOrDefault("application/octet-stream")
+ /** The email addresses of the given recipient type, skipping any that aren't internet addresses. */
+ private fun Message.recipientEmails(type: Message.RecipientType): List =
+ (getRecipients(type) ?: emptyArray()).mapNotNull { (it as? InternetAddress)?.address }
+
private fun Message.toFetchedMessage(uidFolder: UIDFolder): FetchedMessage {
val from = from?.firstOrNull() as? InternetAddress
return FetchedMessage(
diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
index 550bbac..2b5c3c9 100644
--- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
+++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt
@@ -50,6 +50,7 @@ fun LibreMailApp() {
onOpenDrafts = { navController.navigate(Routes.DRAFTS) },
onOpenOutbox = { navController.navigate(Routes.OUTBOX) },
onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) },
+ onOpenCompose = { draftId -> navController.navigate(Routes.composeDraft(draftId)) },
onSelectTab = navController::navigateTab,
)
}
diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt
index de40b65..b3e8860 100644
--- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt
@@ -3,9 +3,12 @@ package org.libremail.ui.mailbox
import android.text.format.DateUtils
import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -22,14 +25,22 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -44,19 +55,23 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -67,7 +82,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.libremail.R
import org.libremail.domain.model.Account
+import org.libremail.domain.model.Folder
+import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.Message
+import org.libremail.domain.model.ReplyMode
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -77,6 +95,7 @@ fun MailboxScreen(
onOpenDrafts: () -> Unit,
onOpenOutbox: () -> Unit,
onAddAccount: () -> Unit,
+ onOpenCompose: (String) -> Unit,
onSelectTab: (org.libremail.ui.TopDest) -> Unit,
viewModel: MailboxViewModel = hiltViewModel(),
) {
@@ -93,12 +112,22 @@ fun MailboxScreen(
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
+ val selectedIds by viewModel.selectedIds.collectAsStateWithLifecycle()
+ val pendingConfirm by viewModel.pendingConfirm.collectAsStateWithLifecycle()
+ val currentFolderRole by viewModel.currentFolderRole.collectAsStateWithLifecycle()
+ val canMove by viewModel.canMove.collectAsStateWithLifecycle()
+ val moveTargetFolders by viewModel.moveTargetFolders.collectAsStateWithLifecycle()
+ val actionInProgress by viewModel.actionInProgress.collectAsStateWithLifecycle()
+ val selectionMode = selectedIds.isNotEmpty()
+ var showMovePicker by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
BackHandler(enabled = searchActive) { viewModel.closeSearch() }
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
+ // Registered last so it takes priority: Back exits selection before closing search/drawer.
+ BackHandler(enabled = selectionMode) { viewModel.clearSelection() }
LaunchedEffect(drawerState.isOpen) { if (drawerState.isOpen) viewModel.onDrawerOpened() }
LaunchedEffect(error) {
@@ -108,9 +137,17 @@ fun MailboxScreen(
}
}
+ LaunchedEffect(Unit) {
+ viewModel.events.collect { event ->
+ when (event) {
+ is MailboxEvent.OpenCompose -> onOpenCompose(event.draftId)
+ }
+ }
+ }
+
ModalNavigationDrawer(
drawerState = drawerState,
- gesturesEnabled = drawerState.isOpen || (hasAccounts && !searchActive),
+ gesturesEnabled = drawerState.isOpen || (hasAccounts && !searchActive && !selectionMode),
drawerContent = {
ModalDrawerSheet {
FolderDrawer(
@@ -134,37 +171,54 @@ fun MailboxScreen(
) {
Scaffold(
topBar = {
- TopAppBar(
- title = {
- if (searchActive) {
- SearchField(query = searchQuery, onQueryChange = viewModel::onSearchQuery)
- } else {
- val current = folders.firstOrNull { it.fullName == selectedFolder }
- Text(
- if (current != null) folderDisplayLabel(current)
- else stringResource(R.string.title_mailbox),
- )
- }
- },
- navigationIcon = {
- if (searchActive) {
- IconButton(onClick = viewModel::closeSearch) {
- Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_close))
+ if (selectionMode) {
+ SelectionTopBar(
+ count = selectedIds.size,
+ folderRole = currentFolderRole,
+ canMove = canMove,
+ onClose = viewModel::clearSelection,
+ onArchive = viewModel::archiveSelected,
+ onDelete = viewModel::requestDelete,
+ onSpam = viewModel::requestSpam,
+ onMove = { showMovePicker = true },
+ onSelectAll = viewModel::selectAll,
+ onReply = { viewModel.reply(ReplyMode.REPLY) },
+ onReplyAll = viewModel::requestReplyAll,
+ onForward = { viewModel.reply(ReplyMode.FORWARD) },
+ )
+ } else {
+ TopAppBar(
+ title = {
+ if (searchActive) {
+ SearchField(query = searchQuery, onQueryChange = viewModel::onSearchQuery)
+ } else {
+ val current = folders.firstOrNull { it.fullName == selectedFolder }
+ Text(
+ if (current != null) folderDisplayLabel(current)
+ else stringResource(R.string.title_mailbox),
+ )
}
- } else if (hasAccounts) {
- IconButton(onClick = { scope.launch { drawerState.open() } }) {
- Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.drawer_open))
+ },
+ navigationIcon = {
+ if (searchActive) {
+ IconButton(onClick = viewModel::closeSearch) {
+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.search_close))
+ }
+ } else if (hasAccounts) {
+ IconButton(onClick = { scope.launch { drawerState.open() } }) {
+ Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.drawer_open))
+ }
}
- }
- },
- actions = {
- if (hasAccounts && !searchActive) {
- IconButton(onClick = viewModel::openSearch) {
- Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search))
+ },
+ actions = {
+ if (hasAccounts && !searchActive) {
+ IconButton(onClick = viewModel::openSearch) {
+ Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.search))
+ }
}
- }
- },
- )
+ },
+ )
+ }
},
bottomBar = {
org.libremail.ui.LibreMailBottomBar(
@@ -222,7 +276,12 @@ fun MailboxScreen(
MessageRow(
message = message,
accountLabel = if (showAccount) accountsById[message.accountId]?.email else null,
- onClick = { onOpenMessage(message.id) },
+ selected = message.id in selectedIds,
+ onClick = {
+ if (selectionMode) viewModel.toggleSelection(message.id)
+ else onOpenMessage(message.id)
+ },
+ onLongClick = { viewModel.startSelection(message.id) },
)
HorizontalDivider()
}
@@ -233,6 +292,40 @@ fun MailboxScreen(
}
}
}
+
+ pendingConfirm?.let { pending ->
+ ConfirmActionDialog(
+ pending = pending,
+ onConfirm = viewModel::confirmPending,
+ onDismiss = viewModel::dismissConfirm,
+ )
+ }
+ if (showMovePicker) {
+ MoveFolderDialog(
+ folders = moveTargetFolders.filter { it.selectable && it.fullName != selectedFolder },
+ onSelect = { folder ->
+ showMovePicker = false
+ viewModel.moveSelected(folder.fullName)
+ },
+ onDismiss = { showMovePicker = false },
+ )
+ }
+ // Blocking spinner while a reply/forward fetches the latest message from the server.
+ if (actionInProgress) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = {},
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+ }
}
}
@@ -322,16 +415,24 @@ private fun AccountFilterRow(accounts: List, selectedId: String?, onSel
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
-private fun MessageRow(message: Message, accountLabel: String?, onClick: () -> Unit) {
+private fun MessageRow(
+ message: Message,
+ accountLabel: String?,
+ selected: Boolean,
+ onClick: () -> Unit,
+ onLongClick: () -> Unit,
+) {
Row(
modifier = Modifier
.fillMaxWidth()
- .clickable(onClick = onClick)
+ .background(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
+ .combinedClickable(onClick = onClick, onLongClick = onLongClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Avatar(message.sender)
+ if (selected) SelectedAvatar() else Avatar(message.sender)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -344,6 +445,15 @@ private fun MessageRow(message: Message, accountLabel: String?, onClick: () -> U
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
+ if (message.bodyFetched) {
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = stringResource(R.string.message_available_offline),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(14.dp),
+ )
+ Spacer(Modifier.width(4.dp))
+ }
Text(
text = formatTimestamp(message.timestampMillis),
style = MaterialTheme.typography.labelSmall,
@@ -396,6 +506,157 @@ private fun Avatar(name: String) {
}
}
+@Composable
+private fun SelectedAvatar() {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Filled.Check, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary)
+ }
+}
+
+/**
+ * The contextual action bar shown while messages are selected. Archive/Delete are the common actions;
+ * the overflow holds the rest. Reply/Reply All/Forward appear only for a single selected message, and
+ * Archive/Spam are hidden while already viewing that role's folder.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun SelectionTopBar(
+ count: Int,
+ folderRole: FolderRole?,
+ canMove: Boolean,
+ onClose: () -> Unit,
+ onArchive: () -> Unit,
+ onDelete: () -> Unit,
+ onSpam: () -> Unit,
+ onMove: () -> Unit,
+ onSelectAll: () -> Unit,
+ onReply: () -> Unit,
+ onReplyAll: () -> Unit,
+ onForward: () -> Unit,
+) {
+ TopAppBar(
+ title = { Text(stringResource(R.string.cab_selected_count, count)) },
+ navigationIcon = {
+ IconButton(onClick = onClose) {
+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.cab_close))
+ }
+ },
+ actions = {
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
+ }
+ var expanded by remember { mutableStateOf(false) }
+ IconButton(onClick = { expanded = true }) {
+ Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.action_more))
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ if (folderRole != FolderRole.ARCHIVE) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_archive)) },
+ onClick = { expanded = false; onArchive() },
+ )
+ }
+ if (folderRole != FolderRole.SPAM) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_spam)) },
+ onClick = { expanded = false; onSpam() },
+ )
+ }
+ if (canMove) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_move)) },
+ onClick = { expanded = false; onMove() },
+ )
+ }
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_select_all)) },
+ onClick = { expanded = false; onSelectAll() },
+ )
+ if (count == 1) {
+ HorizontalDivider()
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_reply)) },
+ onClick = { expanded = false; onReply() },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_reply_all)) },
+ onClick = { expanded = false; onReplyAll() },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.action_forward)) },
+ onClick = { expanded = false; onForward() },
+ )
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun ConfirmActionDialog(pending: PendingAction, onConfirm: () -> Unit, onDismiss: () -> Unit) {
+ val title: String
+ val text: String
+ val confirmLabel: String
+ when (pending) {
+ is PendingAction.Spam -> {
+ title = stringResource(R.string.confirm_spam_title)
+ text = stringResource(R.string.confirm_spam_text, pending.count)
+ confirmLabel = stringResource(R.string.action_move)
+ }
+ is PendingAction.Delete -> if (pending.permanent) {
+ title = stringResource(R.string.confirm_delete_title)
+ text = stringResource(R.string.confirm_delete_text, pending.count)
+ confirmLabel = stringResource(R.string.action_delete)
+ } else {
+ title = stringResource(R.string.confirm_trash_title)
+ text = stringResource(R.string.confirm_trash_text, pending.count)
+ confirmLabel = stringResource(R.string.action_move)
+ }
+ is PendingAction.ReplyAll -> {
+ title = stringResource(R.string.confirm_reply_all_title)
+ text = stringResource(R.string.confirm_reply_all_text)
+ confirmLabel = stringResource(R.string.action_reply_all)
+ }
+ }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = { Text(text) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
+ )
+}
+
+@Composable
+private fun MoveFolderDialog(folders: List, onSelect: (Folder) -> Unit, onDismiss: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(stringResource(R.string.move_picker_title)) },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ folders.forEach { folder ->
+ Text(
+ text = folderDisplayLabel(folder),
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelect(folder) }
+ .padding(vertical = 12.dp),
+ )
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
+ )
+}
+
@Composable
private fun NoAccountState(onAddAccount: () -> Unit) {
Column(
diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt
index a84b53a..07da80a 100644
--- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt
@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -18,13 +19,15 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
-import org.libremail.data.sync.MailSyncer
+import org.libremail.data.sync.Syncer
import org.libremail.domain.model.Account
import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.Message
+import org.libremail.domain.model.ReplyMode
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
@@ -35,7 +38,7 @@ const val INBOX = "INBOX"
class MailboxViewModel @Inject constructor(
private val mailRepository: MailRepository,
accountRepository: AccountRepository,
- private val mailSyncer: MailSyncer,
+ private val mailSyncer: Syncer,
) : ViewModel() {
val accounts: StateFlow> = accountRepository.observeAccounts()
@@ -107,6 +110,126 @@ class MailboxViewModel @Inject constructor(
private val _error = MutableStateFlow(null)
val error: StateFlow = _error.asStateFlow()
+ // --- Multi-select contextual action bar ---
+
+ private val _selectedIds = MutableStateFlow>(emptySet())
+ val selectedIds: StateFlow> = _selectedIds.asStateFlow()
+
+ private val _pendingConfirm = MutableStateFlow(null)
+ val pendingConfirm: StateFlow = _pendingConfirm.asStateFlow()
+
+ /** True while a reply/forward draft is being fetched and built (a brief network round-trip). */
+ private val _actionInProgress = MutableStateFlow(false)
+ val actionInProgress: StateFlow = _actionInProgress.asStateFlow()
+
+ private val _events = Channel(Channel.BUFFERED)
+ val events = _events.receiveAsFlow()
+
+ /** The role of the folder currently shown — decides whether "Delete" trashes or permanently expunges. */
+ val currentFolderRole: StateFlow =
+ combine(folders, _selectedFolder) { fs, sel -> fs.firstOrNull { it.fullName == sel }?.role }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ /** The single account every selected message belongs to, or null if the selection spans accounts. */
+ private val selectionAccountId: StateFlow =
+ combine(_selectedIds, messages) { ids, msgs ->
+ msgs.filter { it.id in ids }.map { it.accountId }.distinct().singleOrNull()
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
+
+ /** Whether "Move" is offered: only when the whole selection sits in a single account's folder tree. */
+ val canMove: StateFlow = selectionAccountId
+ .map { it != null }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
+
+ /** Destination folders for the Move picker: the selection account's selectable folders. */
+ val moveTargetFolders: StateFlow> = selectionAccountId
+ .flatMapLatest { acct -> if (acct == null) flowOf(emptyList()) else mailRepository.observeFolders(acct) }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ fun startSelection(id: String) {
+ _selectedIds.value = setOf(id)
+ }
+
+ fun toggleSelection(id: String) {
+ _selectedIds.value = _selectedIds.value.let { if (id in it) it - id else it + id }
+ }
+
+ fun clearSelection() {
+ _selectedIds.value = emptySet()
+ }
+
+ fun selectAll() {
+ _selectedIds.value = messages.value.map { it.id }.toSet()
+ }
+
+ fun archiveSelected() = runOnSelection { mailRepository.archive(it) }
+
+ fun moveSelected(destFolderFullName: String) =
+ runOnSelection { mailRepository.moveToFolder(it, destFolderFullName) }
+
+ /** Reply and Forward open compose directly; Reply All is confirmed first (see [requestReplyAll]). */
+ fun reply(mode: ReplyMode) {
+ val id = _selectedIds.value.singleOrNull() ?: return
+ buildReply(mode, id)
+ }
+
+ // Confirmation-gated actions: the screen shows a dialog bound to [pendingConfirm], then [confirmPending].
+
+ fun requestSpam() {
+ if (_selectedIds.value.isEmpty()) return
+ _pendingConfirm.value = PendingAction.Spam(_selectedIds.value.size)
+ }
+
+ fun requestDelete() {
+ if (_selectedIds.value.isEmpty()) return
+ val role = currentFolderRole.value
+ val permanent = role == FolderRole.TRASH || role == FolderRole.SPAM
+ _pendingConfirm.value = PendingAction.Delete(_selectedIds.value.size, permanent)
+ }
+
+ fun requestReplyAll() {
+ val id = _selectedIds.value.singleOrNull() ?: return
+ _pendingConfirm.value = PendingAction.ReplyAll(id)
+ }
+
+ fun confirmPending() {
+ when (val pending = _pendingConfirm.value) {
+ is PendingAction.Spam -> runOnSelection { mailRepository.reportSpam(it) }
+ is PendingAction.Delete ->
+ if (pending.permanent) runOnSelection { mailRepository.expunge(it) }
+ else runOnSelection { mailRepository.trash(it) }
+ is PendingAction.ReplyAll -> buildReply(ReplyMode.REPLY_ALL, pending.messageId)
+ null -> Unit
+ }
+ _pendingConfirm.value = null
+ }
+
+ fun dismissConfirm() {
+ _pendingConfirm.value = null
+ }
+
+ /** Snapshots the selection, exits selection mode, then runs [block]; failures surface via [error]. */
+ private fun runOnSelection(block: suspend (List) -> Result) {
+ val ids = _selectedIds.value.toList()
+ if (ids.isEmpty()) return
+ clearSelection()
+ viewModelScope.launch {
+ block(ids).onFailure { _error.value = it.message ?: "Action failed" }
+ }
+ }
+
+ private fun buildReply(mode: ReplyMode, messageId: String) {
+ clearSelection()
+ viewModelScope.launch {
+ _actionInProgress.value = true
+ mailRepository.buildReplyDraft(messageId, mode).fold(
+ onSuccess = { draftId -> _events.send(MailboxEvent.OpenCompose(draftId)) },
+ onFailure = { _error.value = it.message ?: "Could not open compose" },
+ )
+ _actionInProgress.value = false
+ }
+ }
+
init {
// Fall back to the unified inbox if the filtered account is removed.
viewModelScope.launch {
@@ -133,11 +256,13 @@ class MailboxViewModel @Inject constructor(
}
fun selectAccount(accountId: String?) {
+ clearSelection()
_selectedAccountId.value = accountId
}
/** Browses a specific account's folder; syncs it from the server in the background. */
fun selectFolder(accountId: String, folderFullName: String) {
+ clearSelection()
_selectedAccountId.value = accountId
_drawerAccountId.value = accountId
_selectedFolder.value = folderFullName
@@ -146,6 +271,7 @@ class MailboxViewModel @Inject constructor(
/** Returns to the unified inbox across all accounts. */
fun selectUnifiedInbox() {
+ clearSelection()
_selectedAccountId.value = null
_drawerAccountId.value = null
_selectedFolder.value = INBOX
@@ -164,6 +290,7 @@ class MailboxViewModel @Inject constructor(
}
fun openSearch() {
+ clearSelection()
_searchActive.value = true
}
@@ -202,6 +329,18 @@ class MailboxViewModel @Inject constructor(
}
}
+/** A destructive or notable CAB action awaiting user confirmation. */
+sealed interface PendingAction {
+ data class Spam(val count: Int) : PendingAction
+ data class Delete(val count: Int, val permanent: Boolean) : PendingAction
+ data class ReplyAll(val messageId: String) : PendingAction
+}
+
+/** One-shot effects the mailbox screen acts on. */
+sealed interface MailboxEvent {
+ data class OpenCompose(val draftId: String) : MailboxEvent
+}
+
/** Guarantees an inbox entry so the drawer is usable before the first folder-list refresh completes. */
private fun withInbox(accountId: String, folders: List): List =
if (folders.any { it.fullName.equals(INBOX, ignoreCase = true) }) {
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 89fbf94..1967617 100644
--- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt
@@ -23,6 +23,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.CircularProgressIndicator
@@ -133,6 +134,7 @@ fun ReaderScreen(
message = message,
attachments = state.attachments,
downloading = state.downloading,
+ downloaded = state.downloaded,
onDownloadAttachment = viewModel::downloadAttachment,
loadRemoteImages = state.loadRemoteImages,
onLoadRemoteImages = viewModel::loadRemoteImages,
@@ -151,6 +153,7 @@ private fun MessageBody(
message: Message,
attachments: List,
downloading: Set,
+ downloaded: Set,
onDownloadAttachment: (Attachment) -> Unit,
loadRemoteImages: Boolean,
onLoadRemoteImages: () -> Unit,
@@ -160,7 +163,7 @@ private fun MessageBody(
Header(message)
HorizontalDivider()
if (attachments.isNotEmpty()) {
- Attachments(attachments, downloading, onDownloadAttachment)
+ Attachments(attachments, downloading, downloaded, onDownloadAttachment)
HorizontalDivider()
}
if (message.isHtml && !loadRemoteImages) {
@@ -195,6 +198,7 @@ private fun MessageBody(
private fun Attachments(
attachments: List,
downloading: Set,
+ downloaded: Set,
onDownload: (Attachment) -> Unit,
) {
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
@@ -208,6 +212,7 @@ private fun Attachments(
AttachmentRow(
attachment = attachment,
downloading = attachment.partIndex in downloading,
+ downloaded = attachment.partIndex in downloaded,
onClick = { onDownload(attachment) },
)
Spacer(Modifier.height(8.dp))
@@ -216,7 +221,7 @@ private fun Attachments(
}
@Composable
-private fun AttachmentRow(attachment: Attachment, downloading: Boolean, onClick: () -> Unit) {
+private fun AttachmentRow(attachment: Attachment, downloading: Boolean, downloaded: Boolean, onClick: () -> Unit) {
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant,
@@ -259,6 +264,14 @@ private fun AttachmentRow(attachment: Attachment, downloading: Boolean, onClick:
)
}
}
+ if (downloaded) {
+ Spacer(Modifier.width(8.dp))
+ Icon(
+ Icons.Filled.Check,
+ contentDescription = stringResource(R.string.attachment_downloaded),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
}
}
}
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 c80efa3..6f3f09e 100644
--- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderViewModel.kt
@@ -26,6 +26,8 @@ data class ReaderUiState(
val message: Message? = null,
val attachments: List = emptyList(),
val downloading: Set = emptySet(),
+ /** Part indexes whose bytes are already cached on disk (openable offline). */
+ val downloaded: Set = emptySet(),
val loadRemoteImages: Boolean = false,
val deleted: Boolean = false,
val error: String? = null,
@@ -67,7 +69,7 @@ class ReaderViewModel @Inject constructor(
}
viewModelScope.launch {
repository.observeAttachments(messageId).collect { attachments ->
- _state.update { it.copy(attachments = attachments) }
+ _state.update { it.copy(attachments = attachments, downloaded = repository.downloadedAttachmentParts(messageId)) }
}
}
}
@@ -78,6 +80,7 @@ class ReaderViewModel @Inject constructor(
viewModelScope.launch {
repository.downloadAttachment(attachment.messageId, attachment.partIndex).fold(
onSuccess = { file ->
+ _state.update { it.copy(downloaded = it.downloaded + attachment.partIndex) }
_events.send(ReaderEvent.OpenFile(file, attachment.mimeType, attachment.filename))
},
onFailure = { _events.send(ReaderEvent.DownloadFailed(attachment.filename)) },
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
index f857a58..77b2d88 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
@@ -18,6 +18,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -33,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
+import org.libremail.data.settings.FetchPolicy
import org.libremail.ui.LibreMailBottomBar
import org.libremail.ui.TopDest
@@ -91,6 +93,27 @@ fun SettingsScreen(
)
HorizontalDivider()
+ SectionHeader(stringResource(R.string.settings_downloading))
+ RadioRow(
+ title = stringResource(R.string.fetch_always),
+ subtitle = stringResource(R.string.fetch_always_summary),
+ selected = settings.fetchPolicy == FetchPolicy.ALWAYS,
+ onClick = { viewModel.setFetchPolicy(FetchPolicy.ALWAYS) },
+ )
+ RadioRow(
+ title = stringResource(R.string.fetch_wifi),
+ subtitle = stringResource(R.string.fetch_wifi_summary),
+ selected = settings.fetchPolicy == FetchPolicy.WIFI_ONLY,
+ onClick = { viewModel.setFetchPolicy(FetchPolicy.WIFI_ONLY) },
+ )
+ RadioRow(
+ title = stringResource(R.string.fetch_on_demand),
+ subtitle = stringResource(R.string.fetch_on_demand_summary),
+ selected = settings.fetchPolicy == FetchPolicy.ON_DEMAND,
+ onClick = { viewModel.setFetchPolicy(FetchPolicy.ON_DEMAND) },
+ )
+ HorizontalDivider()
+
AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced)
AnimatedVisibility(visible = advancedExpanded) {
Column {
@@ -174,6 +197,30 @@ private fun SwitchRow(
}
}
+@Composable
+private fun RadioRow(title: String, subtitle: String?, selected: Boolean, onClick: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(selected = selected, onClick = onClick)
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.bodyLarge)
+ if (subtitle != null) {
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun ClickRow(title: String, onClick: () -> Unit) {
Row(
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
index b629774..341f22c 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.data.settings.AppSettings
+import org.libremail.data.settings.FetchPolicy
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.repository.AccountRepository
@@ -42,6 +43,7 @@ class SettingsViewModel @Inject constructor(
fun setAllowStartTls(value: Boolean) = update { settingsRepository.setAllowStartTls(value) }
fun setLoadRemoteImages(value: Boolean) = update { settingsRepository.setLoadRemoteImages(value) }
fun setEncryptCache(value: Boolean) = update { settingsRepository.setEncryptCache(value) }
+ fun setFetchPolicy(value: FetchPolicy) = update { settingsRepository.setFetchPolicy(value) }
private inline fun update(crossinline action: suspend () -> Unit) {
viewModelScope.launch { action() }
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3d76a29..66dca97 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -33,6 +33,29 @@
Welcome to LibreMail
Add an account to start reading your mail.
+
+ %1$d selected
+ Clear selection
+ Archive
+ Delete
+ Spam
+ Move
+ Reply
+ Reply all
+ Forward
+ Select all
+ More options
+ Move to
+ Cancel
+ Move to Spam?
+ Move %1$d message(s) to the Spam folder?
+ Move to Trash?
+ Move %1$d message(s) to Trash?
+ Delete permanently?
+ This permanently deletes %1$d message(s) and can\'t be undone.
+ Reply to all?
+ Reply to everyone included on this message?
+
Show folders
Switch account
@@ -74,6 +97,8 @@
Reply
Attachments
+ Downloaded
+ Available offline
Couldn\'t download %1$s
No app can open this file
@@ -105,6 +130,13 @@
Accounts
+ Message downloading
+ Always fetch all
+ Download full messages and attachments as they sync, on any connection.
+ Fetch all on Wi-Fi
+ Download full messages and attachments only on Wi-Fi or other unmetered networks; otherwise fetch them when you open a message.
+ Always on-demand
+ Sync only message headers; download a message\'s content when you open it.
Notifications
New-mail notifications
Show a notification when new mail arrives
diff --git a/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt
new file mode 100644
index 0000000..1b551db
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt
@@ -0,0 +1,108 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data
+
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import org.junit.Test
+import org.libremail.domain.model.ReplyMode
+import org.libremail.mail.ReplyContext
+
+class ReplyBuilderTest {
+
+ private fun context(
+ from: String = "alice@example.org",
+ to: List = listOf("me@example.org"),
+ cc: List = emptyList(),
+ subject: String = "Lunch",
+ body: String = "Original body",
+ isHtml: Boolean = false,
+ ) = ReplyContext(
+ fromEmail = from,
+ toRecipients = to,
+ ccRecipients = cc,
+ subject = subject,
+ sentDateMillis = 0L,
+ body = body,
+ isHtml = isHtml,
+ )
+
+ @Test
+ fun `reply targets the sender and quotes the original`() {
+ val result = ReplyBuilder.build(context(), ReplyMode.REPLY, selfEmail = "me@example.org")
+
+ assertEquals("alice@example.org", result.to)
+ assertEquals("", result.cc)
+ assertEquals("Re: Lunch", result.subject)
+ assertTrue(result.body.contains("Original body"), "body=${result.body}")
+ }
+
+ @Test
+ fun `reply does not stack a second Re prefix`() {
+ val result = ReplyBuilder.build(context(subject = "Re: Lunch"), ReplyMode.REPLY, "me@example.org")
+
+ assertEquals("Re: Lunch", result.subject)
+ }
+
+ @Test
+ fun `reply all ccs everyone except self and the original sender`() {
+ val result = ReplyBuilder.build(
+ context(
+ from = "alice@example.org",
+ to = listOf("me@example.org", "bob@example.org"),
+ cc = listOf("carol@example.org", "alice@example.org"),
+ ),
+ ReplyMode.REPLY_ALL,
+ selfEmail = "me@example.org",
+ )
+
+ assertEquals("alice@example.org", result.to)
+ assertEquals(setOf("bob@example.org", "carol@example.org"), result.cc.split(", ").toSet())
+ }
+
+ @Test
+ fun `reply all excludes self case-insensitively`() {
+ val result = ReplyBuilder.build(
+ context(to = listOf("ME@Example.org")),
+ ReplyMode.REPLY_ALL,
+ selfEmail = "me@example.org",
+ )
+
+ assertEquals("", result.cc)
+ }
+
+ @Test
+ fun `forward clears recipients, prefixes Fwd, and includes the original`() {
+ val result = ReplyBuilder.build(context(), ReplyMode.FORWARD, "me@example.org")
+
+ assertEquals("", result.to)
+ assertEquals("Fwd: Lunch", result.subject)
+ assertTrue(result.body.contains("Forwarded message"), "body=${result.body}")
+ assertTrue(result.body.contains("Original body"), "body=${result.body}")
+ }
+
+ @Test
+ fun `forward lists the original recipients in the quoted header`() {
+ val result = ReplyBuilder.build(
+ context(to = listOf("ada@example.org", "team@example.org")),
+ ReplyMode.FORWARD,
+ selfEmail = "me@example.org",
+ )
+
+ assertTrue(result.body.contains("ada@example.org"), "body=${result.body}")
+ assertTrue(result.body.contains("team@example.org"), "body=${result.body}")
+ }
+
+ @Test
+ fun `html bodies are stripped to text when quoting`() {
+ val result = ReplyBuilder.build(
+ context(body = "Hello there
", isHtml = true),
+ ReplyMode.REPLY,
+ "me@example.org",
+ )
+
+ assertFalse(result.body.contains("<"), "body=${result.body}")
+ assertTrue(result.body.contains("Hello"), "body=${result.body}")
+ assertTrue(result.body.contains("there"), "body=${result.body}")
+ }
+}
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 8a89917..e792ead 100644
--- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt
+++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.repository
+import android.content.Context
import app.cash.turbine.test
import io.mockk.Runs
import io.mockk.coEvery
@@ -8,6 +9,9 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
+import io.mockk.slot
+import java.io.File
+import java.nio.file.Files
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.flowOf
@@ -15,9 +19,12 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AttachmentDao
+import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.entity.AccountEntity
+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.ServerConfigEmbedded
@@ -25,8 +32,12 @@ import org.libremail.data.sync.MailConnectionFactory
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
+import org.libremail.domain.model.ReplyMode
+import org.libremail.mail.AttachmentPart
+import org.libremail.mail.DownloadedAttachment
import org.libremail.mail.ImapClient
import org.libremail.mail.MessageContent
+import org.libremail.mail.ReplyContext
class MailRepositoryImplTest {
@@ -34,15 +45,18 @@ class MailRepositoryImplTest {
private val accountDao = mockk()
private val folderDao = mockk()
private val attachmentDao = mockk(relaxed = true)
- private val imapClient = mockk()
+ private val draftDao = mockk()
+ // Relaxed: move/delete return jakarta Message[] (from Folder.expunge), which we only verify, not stub.
+ private val imapClient = mockk(relaxed = true)
private val connectionFactory = mockk()
+ private val context = mockk(relaxed = true)
private val repository = MailRepositoryImpl(
- context = mockk(),
+ context = context,
messageDao = messageDao,
accountDao = accountDao,
attachmentDao = attachmentDao,
outboxDao = mockk(),
- draftDao = mockk(),
+ draftDao = draftDao,
folderDao = folderDao,
imapClient = imapClient,
connectionFactory = connectionFactory,
@@ -102,9 +116,252 @@ class MailRepositoryImplTest {
coVerify { imapClient.fetchBodyMarkingSeen(any(), "Archive", "5") }
}
- private fun messageEntity(id: String, folder: String) = MessageEntity(
+ @Test
+ fun `archive moves messages to the account's archive folder and drops the local rows`() = runTest {
+ val id = "acct:INBOX:5"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { folderDao.getForAccountOnce("acct") } returns listOf(folderEntity("Archive", "ARCHIVE"))
+
+ val result = repository.archive(listOf(id))
+
+ assertTrue(result.isSuccess)
+ coVerify { messageDao.deleteByIds(listOf(id)) } // optimistic local removal
+ coVerify { imapClient.moveMessages(any(), "INBOX", listOf("5"), "Archive") }
+ }
+
+ @Test
+ fun `trash falls back to a permanent delete when the account has no trash folder`() = runTest {
+ val id = "acct:INBOX:7"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { messageDao.deleteByIds(any()) } just Runs
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ // No Trash folder, and a forced refresh doesn't add one.
+ coEvery { folderDao.getForAccountOnce("acct") } returns listOf(folderEntity("INBOX", "INBOX"))
+ coEvery { folderDao.replaceForAccount(any(), any()) } just Runs
+ coEvery { imapClient.listFolders(any()) } returns emptyList()
+
+ val result = repository.trash(listOf(id))
+
+ assertTrue(result.isSuccess)
+ coVerify { imapClient.deleteMessage(any(), "INBOX", "7") }
+ }
+
+ @Test
+ fun `archive fails when the account has no archive folder`() = runTest {
+ val id = "acct:INBOX:9"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { messageDao.deleteByIds(any()) } just Runs
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { folderDao.getForAccountOnce("acct") } returns listOf(folderEntity("INBOX", "INBOX"))
+ coEvery { folderDao.replaceForAccount(any(), any()) } just Runs
+ coEvery { imapClient.listFolders(any()) } returns emptyList()
+
+ assertTrue(repository.archive(listOf(id)).isFailure)
+ }
+
+ @Test
+ fun `expunge permanently deletes each message from its own folder`() = runTest {
+ val id = "acct:Trash:3"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "Trash")
+ coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+
+ repository.expunge(listOf(id))
+
+ coVerify { imapClient.deleteMessage(any(), "Trash", "3") }
+ }
+
+ @Test
+ fun `moveToFolder moves messages to the chosen destination`() = runTest {
+ val id = "acct:INBOX:11"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+
+ repository.moveToFolder(listOf(id), "Receipts")
+
+ coVerify { imapClient.moveMessages(any(), "INBOX", listOf("11"), "Receipts") }
+ }
+
+ @Test
+ fun `buildReplyDraft fetches the original and saves a prefilled draft`() = runTest {
+ val id = "acct:INBOX:2"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { imapClient.fetchForReply(any(), "INBOX", "2") } returns ReplyContext(
+ fromEmail = "boss@example.org",
+ toRecipients = listOf("ada@example.org"),
+ ccRecipients = emptyList(),
+ subject = "Plan",
+ sentDateMillis = 0L,
+ body = "Original",
+ isHtml = false,
+ )
+ val draft = slot()
+ coEvery { draftDao.upsert(capture(draft)) } just Runs
+
+ val result = repository.buildReplyDraft(id, ReplyMode.REPLY)
+
+ assertTrue(result.isSuccess)
+ assertEquals("boss@example.org", draft.captured.toAddresses)
+ assertEquals("Re: Plan", draft.captured.subject)
+ }
+
+ @Test
+ fun `downloadAttachment reuses an already cached file without re-downloading`() = runTest {
+ val cache = Files.createTempDirectory("attach").toFile()
+ every { context.cacheDir } returns cache
+ val id = "acct:INBOX:4"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "report.pdf"))
+ val cached = File(cache, "attachments/acct_INBOX_4/0/report.pdf").apply {
+ parentFile?.mkdirs()
+ writeText("cached")
+ }
+
+ val result = repository.downloadAttachment(id, 0)
+
+ assertEquals(cached, result.getOrNull())
+ coVerify(exactly = 0) { imapClient.fetchAttachment(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `downloadAttachment downloads and caches when no file exists yet`() = runTest {
+ val cache = Files.createTempDirectory("attach").toFile()
+ every { context.cacheDir } returns cache
+ val id = "acct:INBOX:6"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "a.txt"))
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { imapClient.fetchAttachment(any(), "INBOX", "6", 0) } returns
+ DownloadedAttachment("a.txt", "text/plain", "hello".toByteArray())
+
+ val result = repository.downloadAttachment(id, 0)
+
+ assertTrue(result.isSuccess)
+ assertEquals("hello", result.getOrThrow().readText())
+ }
+
+ @Test
+ fun `downloadedAttachmentParts reports only the parts whose bytes exist on disk`() = runTest {
+ val cache = Files.createTempDirectory("attach").toFile()
+ every { context.cacheDir } returns cache
+ val id = "acct:INBOX:8"
+ coEvery { attachmentDao.getForMessage(id) } returns listOf(
+ attachmentEntity(id, 0, "have.bin"),
+ attachmentEntity(id, 1, "missing.bin"),
+ )
+ File(cache, "attachments/acct_INBOX_8/0/have.bin").apply {
+ parentFile?.mkdirs()
+ writeText("x")
+ }
+
+ assertEquals(setOf(0), repository.downloadedAttachmentParts(id))
+ }
+
+ @Test
+ fun `prefetchMessage caches the body and downloads attachments`() = runTest {
+ val cache = Files.createTempDirectory("attach").toFile()
+ every { context.cacheDir } returns cache
+ val id = "acct:INBOX:10"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") // bodyFetched = false
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { imapClient.fetchBodyPeek(any(), "INBOX", "10") } returns
+ MessageContent("Body", isHtml = false, attachments = listOf(AttachmentPart(0, "f.bin", "application/octet-stream", 3)))
+ coEvery { messageDao.updateBody(id, "Body", false, any()) } just Runs
+ coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "f.bin"))
+ coEvery { imapClient.fetchAttachment(any(), "INBOX", "10", 0) } returns
+ DownloadedAttachment("f.bin", "application/octet-stream", "abc".toByteArray())
+
+ val result = repository.prefetchMessage(id)
+
+ assertTrue(result.isSuccess)
+ coVerify { messageDao.updateBody(id, "Body", false, any()) }
+ coVerify { imapClient.fetchAttachment(any(), "INBOX", "10", 0) }
+ }
+
+ @Test
+ fun `archive resolves a separate destination folder for each account`() = runTest {
+ val id1 = "acct:INBOX:1"
+ val id2 = "acct2:INBOX:1"
+ coEvery { messageDao.getById(id1) } returns messageEntity(id1, "INBOX", accountId = "acct")
+ coEvery { messageDao.getById(id2) } returns messageEntity(id2, "INBOX", accountId = "acct2")
+ coEvery { messageDao.deleteByIds(any()) } just Runs
+ coEvery { accountDao.getById("acct") } returns accountEntity(id = "acct")
+ coEvery { accountDao.getById("acct2") } returns accountEntity(id = "acct2", email = "bob@example.org")
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { folderDao.getForAccountOnce("acct") } returns listOf(folderEntity("Archive", "ARCHIVE"))
+ coEvery { folderDao.getForAccountOnce("acct2") } returns listOf(folderEntity("All Mail", "ARCHIVE"))
+
+ val result = repository.archive(listOf(id1, id2))
+
+ assertTrue(result.isSuccess)
+ coVerify { imapClient.moveMessages(any(), "INBOX", listOf("1"), "Archive") }
+ coVerify { imapClient.moveMessages(any(), "INBOX", listOf("1"), "All Mail") }
+ }
+
+ @Test
+ fun `prefetchMessage skips the body fetch when it is already cached`() = runTest {
+ val cache = Files.createTempDirectory("attach").toFile()
+ every { context.cacheDir } returns cache
+ val id = "acct:INBOX:12"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX", bodyFetched = true)
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "f.bin"))
+ coEvery { imapClient.fetchAttachment(any(), "INBOX", "12", 0) } returns
+ DownloadedAttachment("f.bin", "application/octet-stream", "x".toByteArray())
+
+ repository.prefetchMessage(id)
+
+ coVerify(exactly = 0) { imapClient.fetchBodyPeek(any(), any(), any()) }
+ coVerify { imapClient.fetchAttachment(any(), "INBOX", "12", 0) }
+ }
+
+ @Test
+ fun `archive refreshes folders once and retries when the archive folder is initially missing`() = runTest {
+ val id = "acct:INBOX:14"
+ coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
+ coEvery { messageDao.deleteByIds(any()) } just Runs
+ coEvery { accountDao.getById("acct") } returns accountEntity()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
+ coEvery { folderDao.getForAccountOnce("acct") } returnsMany listOf(
+ listOf(folderEntity("INBOX", "INBOX")), // cold cache: no archive yet
+ listOf(folderEntity("INBOX", "INBOX"), folderEntity("Archive", "ARCHIVE")), // after a refresh
+ )
+ coEvery { folderDao.replaceForAccount(any(), any()) } just Runs
+ coEvery { imapClient.listFolders(any()) } returns emptyList()
+
+ val result = repository.archive(listOf(id))
+
+ assertTrue(result.isSuccess)
+ coVerify { imapClient.moveMessages(any(), "INBOX", listOf("14"), "Archive") }
+ }
+
+ private fun folderEntity(fullName: String, role: String) =
+ FolderEntity("acct", fullName, fullName.substringAfterLast('/'), role, selectable = true, sortOrder = 0)
+
+ private fun attachmentEntity(messageId: String, partIndex: Int, filename: String) =
+ AttachmentEntity(messageId, partIndex, filename, "application/octet-stream", 10L)
+
+ private fun messageEntity(
+ id: String,
+ folder: String,
+ accountId: String = "acct",
+ bodyFetched: Boolean = false,
+ ) = MessageEntity(
id = id,
- accountId = "acct",
+ accountId = accountId,
sender = "Ada",
senderEmail = "ada@example.org",
subject = "Hi",
@@ -114,12 +371,12 @@ class MailRepositoryImplTest {
isRead = false,
isStarred = false,
folder = folder,
- bodyFetched = false,
+ bodyFetched = bodyFetched,
)
- private fun accountEntity() = AccountEntity(
- id = "acct",
- email = "ada@example.org",
+ private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity(
+ id = id,
+ email = email,
displayName = "Ada",
authType = "PASSWORD_IMAP",
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
diff --git a/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt
new file mode 100644
index 0000000..0b82440
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/data/sync/MailSyncerTest.kt
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.data.sync
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.libremail.data.local.dao.AccountDao
+import org.libremail.data.local.dao.MessageDao
+import org.libremail.data.local.entity.AccountEntity
+import org.libremail.data.local.entity.ServerConfigEmbedded
+import org.libremail.data.settings.FetchPolicy
+import org.libremail.data.settings.SettingsRepository
+import org.libremail.domain.model.ImapConnectionParams
+import org.libremail.domain.repository.MailRepository
+import org.libremail.mail.ImapClient
+import org.libremail.notifications.MailNotifier
+
+class MailSyncerTest {
+
+ private val account = AccountEntity(
+ id = "acct",
+ email = "a@example.org",
+ displayName = "A",
+ authType = "PASSWORD_IMAP",
+ imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
+ smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
+ )
+
+ /** A syncer whose header sync is a no-op (no server messages) so tests focus on the prefetch step. */
+ private fun syncer(
+ policy: FetchPolicy,
+ mailRepository: MailRepository,
+ context: Context = mockk(relaxed = true),
+ ): MailSyncer {
+ val accountDao = mockk()
+ coEvery { accountDao.getById("acct") } returns account
+ val messageDao = mockk(relaxed = true)
+ coEvery { messageDao.getSyncedIds(any(), any()) } returns emptyList()
+ coEvery { messageDao.getUnfetchedIds("acct", "INBOX") } returns listOf("acct:INBOX:1")
+ val imapClient = mockk()
+ coEvery { imapClient.fetchRecent(any(), any(), any()) } returns emptyList()
+ val connectionFactory = mockk()
+ coEvery { connectionFactory.imapParamsFor(any()) } returns mockk()
+ val settingsRepository = mockk()
+ coEvery { settingsRepository.fetchPolicy() } returns policy
+ return MailSyncer(
+ context = context,
+ accountDao = accountDao,
+ messageDao = messageDao,
+ imapClient = imapClient,
+ connectionFactory = connectionFactory,
+ settingsRepository = settingsRepository,
+ notifier = mockk(relaxed = true),
+ mailRepository = mailRepository,
+ )
+ }
+
+ @Test
+ fun `ALWAYS policy prefetches unfetched messages after the header sync`() = runTest {
+ val repo = mockk()
+ coEvery { repo.prefetchMessage(any()) } returns Result.success(Unit)
+
+ syncer(FetchPolicy.ALWAYS, repo).syncFolder("acct", "INBOX")
+
+ coVerify { repo.prefetchMessage("acct:INBOX:1") }
+ }
+
+ @Test
+ fun `ON_DEMAND policy never prefetches`() = runTest {
+ val repo = mockk(relaxed = true)
+
+ syncer(FetchPolicy.ON_DEMAND, repo).syncFolder("acct", "INBOX")
+
+ coVerify(exactly = 0) { repo.prefetchMessage(any()) }
+ }
+
+ @Test
+ fun `WIFI_ONLY prefetches on an unmetered network`() = runTest {
+ val repo = mockk()
+ coEvery { repo.prefetchMessage(any()) } returns Result.success(Unit)
+
+ syncer(FetchPolicy.WIFI_ONLY, repo, context = networkContext(unmetered = true)).syncFolder("acct", "INBOX")
+
+ coVerify { repo.prefetchMessage("acct:INBOX:1") }
+ }
+
+ @Test
+ fun `WIFI_ONLY does not prefetch on a metered network`() = runTest {
+ val repo = mockk(relaxed = true)
+
+ syncer(FetchPolicy.WIFI_ONLY, repo, context = networkContext(unmetered = false)).syncFolder("acct", "INBOX")
+
+ coVerify(exactly = 0) { repo.prefetchMessage(any()) }
+ }
+
+ /** A context whose active network reports the given metered state via ConnectivityManager. */
+ private fun networkContext(unmetered: Boolean): Context {
+ val capabilities = mockk()
+ every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } returns unmetered
+ val network = mockk()
+ val manager = mockk()
+ every { manager.activeNetwork } returns network
+ every { manager.getNetworkCapabilities(network) } returns capabilities
+ return mockk(relaxed = true).also {
+ every { it.getSystemService(ConnectivityManager::class.java) } returns manager
+ }
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt
index 6b4c3cd..f4043ec 100644
--- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt
+++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt
@@ -12,6 +12,7 @@ import jakarta.mail.internet.MimeMessage
import java.util.Properties
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.After
@@ -104,6 +105,47 @@ class ImapClientTest {
assertTrue(names.any { it.equals("Archive", ignoreCase = true) }, "folders=$names")
}
+ @Test
+ fun `moveMessages relocates a message to another folder`() = runTest {
+ GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Move me", "Relocate this")
+ greenMail.waitForIncomingEmail(1)
+ appendMessage("Archive", "carol@example.org", "Seed", "Creates the Archive folder")
+ val uid = client.fetchRecent(params(), "INBOX", limit = 50).first { it.subject == "Move me" }.uid
+
+ client.moveMessages(params(), "INBOX", listOf(uid), "Archive")
+
+ val inbox = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject }
+ val archive = client.fetchRecent(params(), "Archive", limit = 50).map { it.subject }
+ assertFalse(inbox.contains("Move me"), "inbox=$inbox")
+ assertTrue(archive.contains("Move me"), "archive=$archive")
+ }
+
+ @Test
+ fun `fetchForReply returns recipients and body without marking the message read`() = runTest {
+ GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Question", "What time?")
+ greenMail.waitForIncomingEmail(1)
+ val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
+
+ val context = client.fetchForReply(params(), "INBOX", uid)
+
+ assertEquals("bob@example.org", context.fromEmail)
+ assertTrue(context.toRecipients.contains("alice@example.org"), "to=${context.toRecipients}")
+ assertTrue(context.body.contains("What time?"), "body=${context.body}")
+ assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should stay unread")
+ }
+
+ @Test
+ fun `fetchBodyPeek returns body and attachments without marking the message read`() = runTest {
+ GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Peek", "Peek body text")
+ greenMail.waitForIncomingEmail(1)
+ val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
+
+ val content = client.fetchBodyPeek(params(), "INBOX", uid)
+
+ assertTrue(content.body.contains("Peek body text"), "body=${content.body}")
+ assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should stay unread")
+ }
+
@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")
diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt
index 6abf8cd..42e06a5 100644
--- a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt
+++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.mailbox
+import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
@@ -10,6 +11,7 @@ import kotlin.test.assertTrue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -26,6 +28,7 @@ import org.libremail.domain.model.Folder
import org.libremail.domain.model.FolderRole
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.Message
+import org.libremail.domain.model.ReplyMode
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
@@ -147,13 +150,232 @@ class MailboxViewModelTest {
assertTrue(vm.folders.value.none { it.fullName == "Archive" }, "alice's folders should no longer show")
}
+ @Test
+ fun `toggle adds then removes a message from the selection`() = runTest(testDispatcher) {
+ val vm = createViewModel(accounts = listOf(alice), messages = emptyList())
+
+ vm.startSelection("a")
+ assertEquals(setOf("a"), vm.selectedIds.value)
+ vm.toggleSelection("b")
+ assertEquals(setOf("a", "b"), vm.selectedIds.value)
+ vm.toggleSelection("a")
+ assertEquals(setOf("b"), vm.selectedIds.value)
+ vm.clearSelection()
+ assertTrue(vm.selectedIds.value.isEmpty())
+ }
+
+ @Test
+ fun `selectAll selects every visible message`() = runTest(testDispatcher) {
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:a:INBOX:2", "imap:a", "INBOX")),
+ )
+ backgroundScope.launch { vm.messages.collect {} }
+
+ vm.selectAll()
+
+ assertEquals(setOf("imap:a:INBOX:1", "imap:a:INBOX:2"), vm.selectedIds.value)
+ }
+
+ @Test
+ fun `delete from a normal folder confirms then moves to trash`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.trash(any()) } returns Result.success(Unit)
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
+ repo = repo,
+ )
+ backgroundScope.launch { vm.messages.collect {} }
+
+ vm.startSelection("imap:a:INBOX:1")
+ vm.requestDelete()
+ val pending = vm.pendingConfirm.value
+ assertTrue(pending is PendingAction.Delete && !pending.permanent)
+
+ vm.confirmPending()
+
+ coVerify { repo.trash(listOf("imap:a:INBOX:1")) }
+ assertTrue(vm.selectedIds.value.isEmpty())
+ assertNull(vm.pendingConfirm.value)
+ }
+
+ @Test
+ fun `delete from the spam folder warns and expunges permanently`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.expunge(any()) } returns Result.success(Unit)
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:Spam:1", "imap:a", "Spam")),
+ folders = mapOf(
+ "imap:a" to listOf(
+ folder("imap:a", "INBOX", FolderRole.INBOX),
+ folder("imap:a", "Spam", FolderRole.SPAM),
+ ),
+ ),
+ repo = repo,
+ )
+ backgroundScope.launch { vm.messages.collect {} }
+ backgroundScope.launch { vm.currentFolderRole.collect {} }
+ vm.selectFolder("imap:a", "Spam")
+
+ vm.startSelection("imap:a:Spam:1")
+ vm.requestDelete()
+ val pending = vm.pendingConfirm.value
+ assertTrue(pending is PendingAction.Delete && pending.permanent)
+
+ vm.confirmPending()
+
+ coVerify { repo.expunge(listOf("imap:a:Spam:1")) }
+ }
+
+ @Test
+ fun `spam acts only after the confirmation is accepted`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.reportSpam(any()) } returns Result.success(Unit)
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
+ repo = repo,
+ )
+
+ vm.startSelection("imap:a:INBOX:1")
+ vm.requestSpam()
+ assertTrue(vm.pendingConfirm.value is PendingAction.Spam)
+ coVerify(exactly = 0) { repo.reportSpam(any()) }
+
+ vm.confirmPending()
+ coVerify(exactly = 1) { repo.reportSpam(listOf("imap:a:INBOX:1")) }
+ }
+
+ @Test
+ fun `forward builds a draft and emits OpenCompose`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.FORWARD) } returns Result.success("draft1")
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
+ repo = repo,
+ )
+
+ vm.startSelection("imap:a:INBOX:1")
+ vm.reply(ReplyMode.FORWARD)
+
+ assertEquals(MailboxEvent.OpenCompose("draft1"), vm.events.first())
+ coVerify { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.FORWARD) }
+ }
+
+ @Test
+ fun `archive delegates the selection to the repository and exits selection`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.archive(any()) } returns Result.success(Unit)
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
+ repo = repo,
+ )
+
+ vm.startSelection("imap:a:INBOX:1")
+ vm.archiveSelected()
+
+ coVerify { repo.archive(listOf("imap:a:INBOX:1")) }
+ assertTrue(vm.selectedIds.value.isEmpty())
+ }
+
+ @Test
+ fun `reply opens a prefilled draft for the single selection`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY) } returns Result.success("d2")
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
+ repo = repo,
+ )
+
+ vm.startSelection("imap:a:INBOX:1")
+ vm.reply(ReplyMode.REPLY)
+
+ assertEquals(MailboxEvent.OpenCompose("d2"), vm.events.first())
+ }
+
+ @Test
+ fun `dismissing a pending confirmation cancels the action`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
+ repo = repo,
+ )
+ vm.startSelection("imap:a:INBOX:1")
+
+ vm.requestSpam()
+ vm.dismissConfirm()
+ assertNull(vm.pendingConfirm.value)
+
+ vm.confirmPending() // nothing pending — no-op
+ coVerify(exactly = 0) { repo.reportSpam(any()) }
+ }
+
+ @Test
+ fun `move is enabled for a single-account selection and disabled across accounts`() = runTest(testDispatcher) {
+ val vm = createViewModel(
+ accounts = listOf(alice, bob),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:b:INBOX:1", "imap:b", "INBOX")),
+ )
+ backgroundScope.launch { vm.messages.collect {} }
+ backgroundScope.launch { vm.canMove.collect {} }
+
+ vm.startSelection("imap:a:INBOX:1")
+ assertEquals(true, vm.canMove.value)
+
+ vm.toggleSelection("imap:b:INBOX:1")
+ assertEquals(false, vm.canMove.value)
+ }
+
+ @Test
+ fun `move sends the selection to the chosen folder`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.moveToFolder(any(), any()) } returns Result.success(Unit)
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
+ repo = repo,
+ )
+
+ vm.startSelection("imap:a:INBOX:1")
+ vm.moveSelected("Receipts")
+
+ coVerify { repo.moveToFolder(listOf("imap:a:INBOX:1"), "Receipts") }
+ assertTrue(vm.selectedIds.value.isEmpty())
+ }
+
+ @Test
+ fun `reply all confirms first, then opens a prefilled draft`() = runTest(testDispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY_ALL) } returns Result.success("d1")
+ val vm = createViewModel(
+ accounts = listOf(alice),
+ messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX")),
+ repo = repo,
+ )
+ vm.startSelection("imap:a:INBOX:1")
+
+ vm.requestReplyAll()
+ assertTrue(vm.pendingConfirm.value is PendingAction.ReplyAll)
+ coVerify(exactly = 0) { repo.buildReplyDraft(any(), any()) }
+
+ vm.confirmPending()
+ assertEquals(MailboxEvent.OpenCompose("d1"), vm.events.first())
+ coVerify { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY_ALL) }
+ }
+
private fun createViewModel(
accounts: List,
messages: List,
folders: Map> = emptyMap(),
syncer: MailSyncer = mockk(relaxed = true),
+ repo: MailRepository = mockk(relaxed = true),
): MailboxViewModel {
- val repo = mockk(relaxed = true)
every { repo.observeMessages() } returns MutableStateFlow(messages)
every { repo.observeDrafts() } returns flowOf(emptyList())
every { repo.observeOutbox() } returns flowOf(emptyList())
diff --git a/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelTest.kt
new file mode 100644
index 0000000..873531e
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelTest.kt
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package org.libremail.ui.reader
+
+import androidx.lifecycle.SavedStateHandle
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.libremail.data.settings.AppSettings
+import org.libremail.data.settings.SettingsRepository
+import org.libremail.domain.model.Attachment
+import org.libremail.domain.model.Message
+import org.libremail.domain.repository.MailRepository
+import org.libremail.ui.navigation.Routes
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReaderViewModelTest {
+
+ private val dispatcher = StandardTestDispatcher()
+ private val messageId = "acct:INBOX:1"
+
+ private val message = Message(
+ id = messageId, accountId = "acct", sender = "A", senderEmail = "a@example.org",
+ subject = "s", snippet = "", body = "Body", isHtml = false, timestampMillis = 1,
+ isRead = true, isStarred = false,
+ )
+
+ @Before
+ fun setUp() = Dispatchers.setMain(dispatcher)
+
+ @After
+ fun tearDown() = Dispatchers.resetMain()
+
+ private fun attachment(partIndex: Int) =
+ Attachment(messageId, partIndex, "file$partIndex.bin", "application/octet-stream", 10L)
+
+ private fun viewModel(repo: MailRepository): ReaderViewModel {
+ val settings = mockk()
+ every { settings.settings } returns flowOf(AppSettings())
+ return ReaderViewModel(SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)), repo, settings)
+ }
+
+ @Test
+ fun `downloaded set reflects the repository's cached parts on load`() = runTest(dispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.openMessage(messageId) } returns Result.success(message)
+ every { repo.observeAttachments(messageId) } returns flowOf(listOf(attachment(0), attachment(1)))
+ coEvery { repo.downloadedAttachmentParts(messageId) } returns setOf(0)
+
+ val vm = viewModel(repo)
+ advanceUntilIdle()
+
+ assertEquals(setOf(0), vm.state.value.downloaded)
+ }
+
+ @Test
+ fun `downloading an attachment adds its part to the downloaded set`() = runTest(dispatcher) {
+ val repo = mockk(relaxed = true)
+ coEvery { repo.openMessage(messageId) } returns Result.success(message)
+ every { repo.observeAttachments(messageId) } returns flowOf(listOf(attachment(0)))
+ coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet()
+ coEvery { repo.downloadAttachment(messageId, 0) } returns Result.success(File("cached.bin"))
+
+ val vm = viewModel(repo)
+ advanceUntilIdle()
+ vm.downloadAttachment(attachment(0))
+ advanceUntilIdle()
+
+ assertTrue(0 in vm.state.value.downloaded)
+ }
+}