feat: wire mailbox search to server-side filtering

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-08-28 21:36:55 -04:00
parent 33c44d62db
commit 45cb0bd817
3 changed files with 87 additions and 14 deletions
+4 -2
View File
@@ -16,12 +16,14 @@ interface Props {
selectionMode?: boolean selectionMode?: boolean
selectedCollection?: CollectionObject | null selectedCollection?: CollectionObject | null
loading?: boolean loading?: boolean
searchActive?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
selectionList: () => [], selectionList: () => [],
selectionMode: false, selectionMode: false,
searchActive: false,
}) })
// Emits // Emits
@@ -372,10 +374,10 @@ onBeforeUnmount(() => {
<!-- Empty state --> <!-- Empty state -->
<div v-else-if="messages.length === 0" class="pa-8 text-center"> <div v-else-if="messages.length === 0" class="pa-8 text-center">
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon> <v-icon size="64" color="grey-lighten-1">{{ searchActive ? 'mdi-email-search-outline' : 'mdi-email-outline' }}</v-icon>
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div> <div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
This folder is empty {{ searchActive ? 'No messages match your search' : 'This folder is empty' }}
</div> </div>
</div> </div>
+13 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, unref } from 'vue' import { computed, onMounted, ref, unref, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC' import { useModuleStore } from '@KTXC'
@@ -41,6 +41,8 @@ const {
loading, loading,
selectedMessage, selectedMessage,
currentMessages, currentMessages,
searchResults,
searching,
} = storeToRefs(mailOperationsStore) } = storeToRefs(mailOperationsStore)
const { const {
@@ -74,21 +76,19 @@ const {
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailOperationsStore const { mailSync, entitiesStore } = mailOperationsStore
// Message search (filters the current message list by subject/sender) // Message search — debounced server-side search of the selected folder
const searchQuery = ref('') const searchQuery = ref('')
const filteredMessages = computed(() => { watch(searchQuery, query => mailOperationsStore.searchMessages(query))
const query = (searchQuery.value ?? '').trim().toLowerCase()
if (!query) return currentMessages.value
return currentMessages.value.filter((message: EntityObject) => { // Reset the search box when the folder changes so it doesn't show a stale
const subject = (message.properties.subject || '').toLowerCase() // query for a folder whose results are no longer being filtered.
const fromLabel = (message.properties.from?.label || '').toLowerCase() watch(selectedFolder, () => {
const fromAddress = (message.properties.from?.address || '').toLowerCase() searchQuery.value = ''
return subject.includes(query) || fromLabel.includes(query) || fromAddress.includes(query)
})
}) })
const filteredMessages = computed(() => searchResults.value ?? currentMessages.value)
const lastSyncLabel = computed(() => { const lastSyncLabel = computed(() => {
const lastSync = unref(unref(mailSync.lastSync)) const lastSync = unref(unref(mailSync.lastSync))
@@ -252,6 +252,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
variant="outlined" variant="outlined"
placeholder="Search messages..." placeholder="Search messages..."
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
:loading="searching"
hide-details hide-details
single-line single-line
clearable clearable
@@ -310,6 +311,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
:selection-list="selectionList" :selection-list="selectionList"
:selection-mode="selectionMode" :selection-mode="selectionMode"
:loading="loading" :loading="loading"
:search-active="searchResults !== null"
@open="handleMessageOpen" @open="handleMessageOpen"
@reply="handleMessageComposeReply" @reply="handleMessageComposeReply"
@forward="handleMessageComposeForward" @forward="handleMessageComposeForward"
+70 -1
View File
@@ -4,7 +4,8 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync' import { useMailSync } from '@MailManager/composables/useMailSync'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier, ListFilter } from '@MailManager/types/common'
import { ListFilterComparisonOperator, ListFilterConjunctionOperator } from '@MailManager/types/common'
import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity' import type { EntityTransmitRequest, EntityBlobSelector } from '@MailManager/types/entity'
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message' import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models' import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
@@ -50,6 +51,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
const selectedFolder = shallowRef<CollectionObject | null>(null) const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const searchResults = shallowRef<EntityObject[] | null>(null)
const searching = ref(false)
const composerSaving = ref(false) const composerSaving = ref(false)
const composerSending = ref(false) const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null) const composerLastSaved = ref<Date | null>(null)
@@ -293,6 +296,7 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
} }
async function selectFolder(folder: CollectionObject | null) { async function selectFolder(folder: CollectionObject | null) {
clearSearch()
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
@@ -307,6 +311,67 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
_updateSyncSources() _updateSyncSources()
} }
const SEARCH_DEBOUNCE_MS = 300
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
// Monotonic token so out-of-order/stale search responses are discarded.
let searchToken = 0
/**
* Debounced server-side search of the selected folder. Clearing the query
* (or the folder) reverts the message list back to `currentMessages`.
*/
function searchMessages(query: string) {
const trimmed = (query ?? '').trim()
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
if (!trimmed || !selectedFolder.value) {
searchToken++
searchResults.value = null
searching.value = false
return
}
const folder = selectedFolder.value
// 'from'/'to'/'subject'/'body' are the attributes both IMAP and JMAP
// backends whitelist for entity search (see their entityListFilter
// capability); OR them together for a single-box search.
const filter: ListFilter = {
from: [trimmed, ListFilterComparisonOperator.LIKE],
to: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
subject: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
body: [trimmed, ListFilterComparisonOperator.LIKE, ListFilterConjunctionOperator.OR],
}
searching.value = true
const token = ++searchToken
searchDebounceTimer = setTimeout(async () => {
try {
const results = await entitiesStore.list([folder.identifier], filter)
if (token !== searchToken) return
searchResults.value = Object.values(results)
} catch (error) {
if (token === searchToken) searchResults.value = []
console.error('[Mail][Operations] Failed to search messages:', error)
} finally {
if (token === searchToken) searching.value = false
}
}, SEARCH_DEBOUNCE_MS)
}
function clearSearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
searchToken++
searchResults.value = null
searching.value = false
}
function selectMessage(entity: EntityObject | null) { function selectMessage(entity: EntityObject | null) {
selectedMessage.value = entity selectedMessage.value = entity
} }
@@ -587,6 +652,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
// State // State
loading, loading,
selectedMessage, selectedMessage,
searchResults,
searching,
composerSaving, composerSaving,
composerSending, composerSending,
composerLastSaved, composerLastSaved,
@@ -602,6 +669,8 @@ export const useMailOperationsStore = defineStore('mailOperationsStore', () => {
retrieveService, retrieveService,
selectFolder, selectFolder,
selectMessage, selectMessage,
searchMessages,
clearSearch,
createFolder, createFolder,
saveComposerDraft, saveComposerDraft,
sendComposerMessage, sendComposerMessage,