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