Add server-side IMAP search

Search previously only filtered the cached inbox. Now a query also runs an IMAP SEARCH
on the server and folds the matches into the cache, so messages beyond the synced
window surface in the results.

- ImapClient.search(query) ORs SUBJECT/FROM/BODY terms and fetches matching headers
  (extracted a shared toFetchedMessage mapper, reused by fetchRecentInbox).
- MailRepository.searchServer inserts/updates matches into the message cache (no
  pruning); MailboxViewModel triggers it from a debounced, deduplicated search query.
- assemble/test/lint green, including a new ImapClient test asserting SEARCH returns
  only the matching message against GreenMail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 17:00:28 -05:00
co-authored by Claude Opus 4.8
parent f65226a4d2
commit 57b628f90f
6 changed files with 99 additions and 15 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ experience with power-user features tucked under an **Advanced Settings** group.
> foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a
> system viewer, and attach files when composing; **multiple accounts** — a unified inbox
> with per-account filtering; and
> **search** across cached mail. Outlook Graph send is upcoming.
> **search** across cached mail and the server (IMAP SEARCH). Outlook Graph send is upcoming.
## Features (target MVP)
@@ -145,6 +145,28 @@ class MailRepositoryImpl @Inject constructor(
override suspend fun retryOutbox() = sendScheduler.sendNow()
override suspend fun searchServer(query: String) {
accountDao.getAll().forEach { entity ->
val account = entity.toDomain()
runCatching {
val results = imapClient.search(connectionFactory.imapParamsFor(account), query, SEARCH_LIMIT)
val entities = results.map { it.toEntity(account.id) }
messageDao.insertNew(entities)
entities.forEach {
messageDao.updateHeader(
id = it.id,
sender = it.sender,
senderEmail = it.senderEmail,
subject = it.subject,
timestampMillis = it.timestampMillis,
isRead = it.isRead,
isStarred = it.isStarred,
)
}
}
}
}
/** 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() }
@@ -160,6 +182,8 @@ class MailRepositoryImpl @Inject constructor(
}
}
private const val SEARCH_LIMIT = 50
/** Message id is "<accountId>:<uid>"; the uid is the trailing segment. */
private fun uidOf(id: String): String = id.substringAfterLast(':')
@@ -43,4 +43,7 @@ interface MailRepository {
fun observeOutbox(): Flow<List<OutboxMessage>>
suspend fun cancelOutboxMessage(id: String)
suspend fun retryOutbox()
/** Fetches server-side search matches into the cache so the message list can surface them. */
suspend fun searchServer(query: String)
}
@@ -5,6 +5,7 @@ import android.util.Log
import jakarta.mail.FetchProfile
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Multipart
import jakarta.mail.Part
import jakarta.mail.Session
@@ -15,6 +16,10 @@ import jakarta.mail.event.MessageCountEvent
import jakarta.mail.internet.ContentType
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeUtility
import jakarta.mail.search.BodyTerm
import jakarta.mail.search.FromStringTerm
import jakarta.mail.search.OrTerm
import jakarta.mail.search.SubjectTerm
import java.util.Properties
import javax.inject.Inject
import javax.inject.Singleton
@@ -94,19 +99,34 @@ class ImapClient @Inject constructor() {
},
)
val uidFolder = inbox as UIDFolder
messages.reversed().map { message ->
val from = message.from?.firstOrNull() as? InternetAddress
FetchedMessage(
uid = uidFolder.getUID(message).toString(),
sender = from?.personal ?: from?.address ?: "(unknown sender)",
senderEmail = from?.address.orEmpty(),
subject = message.subject ?: "(no subject)",
timestampMillis = (message.sentDate ?: message.receivedDate)?.time
?: System.currentTimeMillis(),
isRead = message.isSet(Flags.Flag.SEEN),
isFlagged = message.isSet(Flags.Flag.FLAGGED),
)
}
messages.reversed().map { it.toFetchedMessage(uidFolder) }
} finally {
runCatching { inbox.close(false) }
}
}
}
/** Runs an IMAP SEARCH over the whole INBOX (subject/from/body) and returns matching headers. */
suspend fun search(params: ImapConnectionParams, query: String, limit: Int): List<FetchedMessage> =
withContext(Dispatchers.IO) {
withStore(params) { store ->
val inbox = store.getFolder("INBOX")
inbox.open(Folder.READ_ONLY)
try {
val term = OrTerm(arrayOf(SubjectTerm(query), FromStringTerm(query), BodyTerm(query)))
val matches = inbox.search(term).toList()
if (matches.isEmpty()) return@withStore emptyList()
val recent = if (matches.size > limit) matches.takeLast(limit) else matches
inbox.fetch(
recent.toTypedArray(),
FetchProfile().apply {
add(FetchProfile.Item.ENVELOPE)
add(FetchProfile.Item.FLAGS)
add(UIDFolder.FetchProfileItem.UID)
},
)
val uidFolder = inbox as UIDFolder
recent.reversed().map { it.toFetchedMessage(uidFolder) }
} finally {
runCatching { inbox.close(false) }
}
@@ -292,6 +312,19 @@ class ImapClient @Inject constructor() {
private fun baseType(part: Part): String =
runCatching { ContentType(part.contentType).baseType }.getOrDefault("application/octet-stream")
private fun Message.toFetchedMessage(uidFolder: UIDFolder): FetchedMessage {
val from = from?.firstOrNull() as? InternetAddress
return FetchedMessage(
uid = uidFolder.getUID(this).toString(),
sender = from?.personal ?: from?.address ?: "(unknown sender)",
senderEmail = from?.address.orEmpty(),
subject = subject ?: "(no subject)",
timestampMillis = (sentDate ?: receivedDate)?.time ?: System.currentTimeMillis(),
isRead = isSet(Flags.Flag.SEEN),
isFlagged = isSet(Flags.Flag.FLAGGED),
)
}
private inline fun <T> withStore(params: ImapConnectionParams, block: (Store) -> T): T {
val protocol = if (params.security == MailSecurity.SSL_TLS) "imaps" else "imap"
val store = Session.getInstance(buildProps(protocol, params)).getStore(protocol)
@@ -10,6 +10,9 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -21,7 +24,7 @@ import org.libremail.domain.repository.MailRepository
@HiltViewModel
class MailboxViewModel @Inject constructor(
mailRepository: MailRepository,
private val mailRepository: MailRepository,
accountRepository: AccountRepository,
private val mailSyncer: MailSyncer,
) : ViewModel() {
@@ -74,6 +77,15 @@ class MailboxViewModel @Inject constructor(
if (selected != null && list.none { it.id == selected }) _selectedAccountId.value = null
}
}
// Server-side search: fetch matches into the cache; the list filter then surfaces them.
viewModelScope.launch {
_searchQuery
.debounce(400L)
.map { it.trim() }
.filter { it.length >= 2 }
.distinctUntilChanged()
.collect { query -> mailRepository.searchServer(query) }
}
}
fun selectAccount(accountId: String?) {
@@ -75,4 +75,16 @@ class ImapClientTest {
assertTrue(content.body.contains("quick brown fox"), "body=${content.body}")
assertTrue(client.fetchRecentInbox(params(), limit = 50).first().isRead, "should be marked read")
}
@Test
fun `search returns only messages matching the query`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Vacation plans", "Beach trip")
GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Invoice 42", "Payment due")
greenMail.waitForIncomingEmail(2)
val results = client.search(params(), query = "Vacation", limit = 50)
assertEquals(1, results.size)
assertEquals("Vacation plans", results.first().subject)
}
}