From 57b628f90f30f8d265e1a5d78770dbf69bb3477a Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 17:00:28 -0500 Subject: [PATCH] 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 --- README.md | 2 +- .../data/repository/MailRepositoryImpl.kt | 24 ++++++++ .../domain/repository/MailRepository.kt | 3 + .../kotlin/org/libremail/mail/ImapClient.kt | 59 +++++++++++++++---- .../libremail/ui/mailbox/MailboxViewModel.kt | 14 ++++- .../org/libremail/mail/ImapClientTest.kt | 12 ++++ 6 files changed, 99 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7eed124..66acc5e 100644 --- a/README.md +++ b/README.md @@ -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) 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 abec432..82cd959 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -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 ":"; the uid is the trailing segment. */ private fun uidOf(id: String): String = id.substringAfterLast(':') 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 1ddc87c..7102aa2 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -43,4 +43,7 @@ interface MailRepository { fun observeOutbox(): Flow> 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) } diff --git a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt index 5515db5..44f7f1e 100644 --- a/app/src/main/kotlin/org/libremail/mail/ImapClient.kt +++ b/app/src/main/kotlin/org/libremail/mail/ImapClient.kt @@ -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 = + 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 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) 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 1982ff9..47fa449 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -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?) { diff --git a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt index c679a6a..01dbe9f 100644 --- a/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/ImapClientTest.kt @@ -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) + } }