import { ref, computed, shallowRef, watch } from 'vue' import { defineStore } from 'pinia' import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useServicesStore } from '@MailManager/stores/servicesStore' import { useMailSync } from '@MailManager/composables/useMailSync' import { useSnackbar } from '@KTXC' import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models' export const useMailStore = defineStore('mailStore', () => { const servicesStore = useServicesStore() const collectionsStore = useCollectionsStore() const entitiesStore = useEntitiesStore() const { showSnackbar } = useSnackbar() // Background mail sync const mailSyncController = useMailSync({ interval: 30000, autoStart: false, fetchDetails: true, }) const mailSync = { isRunning: mailSyncController.isRunning, lastSync: mailSyncController.lastSync, error: mailSyncController.error, sync: mailSyncController.sync, start: mailSyncController.start, stop: mailSyncController.stop, restart: mailSyncController.restart, } // ── General State ─────────────────-─────────────────────────────────────── const sidebarVisible = ref(true) const settingsDialogVisible = ref(false) const loading = ref(false) const serviceFolderLoadingState = ref>({}) const serviceFolderLoadedState = ref>({}) const serviceFolderErrorState = ref>({}) // ── Selection State ─────────────────────────────────────────────────────── const selectedFolder = shallowRef(null) const selectedMessage = shallowRef(null) const selectionMode = ref(false) const selectionList = ref([]) // ── Compose State ───────────────────────────────────────────────────────── const composeMode = ref(false) const composeReplyTo = shallowRef(null) // ── Move State ──────────────────────────────────────────────────────────── const moveDialogVisible = ref(false) const moveDialogService = ref(null) const moveDialogCandidates = ref(null) // ── Computed ────────────────────────────────────────────────────────────── const currentMessages = computed(() => { if (!selectedFolder.value) return [] const folder = selectedFolder.value return entitiesStore.entities.filter(e => e.provider === folder.provider && String(e.service) === String(folder.service) && String(e.collection) === String(folder.identifier), ) }) // ── Initialization ──────────────────────────────────────────────────────── async function initialize() { loading.value = true try { await servicesStore.list() const services = [...servicesStore.servicesEnabled] services.forEach(service => { void loadFoldersForService(service,{ selectInbox: true }) }) } catch (error) { console.error('[Mail] Failed to initialize:', error) } finally { loading.value = false } } async function loadFoldersForService( service: ServiceObject, options: { selectInbox?: boolean } = {}, ) { if (service.identifier === null) { return } _setServiceFolderLoading(service.provider, service.identifier, true) _setServiceFolderError(service.provider, service.identifier, null) try { // retrieve folders for service const collections = await collectionsStore.list({ [service.provider]: { [String(service.identifier)]: true, }, }) _setServiceFolderLoaded(service.provider, service.identifier, true) if (options.selectInbox && !selectedFolder.value) { const inbox = Object.values(collections).find( folder => folder.provider === service.provider && String(folder.service) === String(service.identifier) && (folder.properties.role === 'inbox' || String(folder.identifier).toLowerCase() === 'inbox'), ) if (inbox) { await selectFolder(inbox) } } _updateSyncSources() return collections } catch (error) { const message = error instanceof Error ? error.message : 'Failed to load folders' _setServiceFolderError(service.provider, service.identifier, message) console.error( `[Mail] Failed to load folders for ${service.provider}:${String(service.identifier)}:`, error, ) _updateSyncSources() return {} } finally { _setServiceFolderLoading(service.provider, service.identifier, false) } } // ── Helpers ────────────────────────────────────────────────────────── function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier { return `${item.provider}:${String(item.service)}` as ServiceIdentifier } function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier { return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier } function _entityIdentifier(item: EntityObject): EntityIdentifier { return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier } function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) { serviceFolderLoadingState.value = { ...serviceFolderLoadingState.value, [_serviceIdentifier({ provider, service })]: loadingState, } } function _setServiceFolderLoaded(provider: string, service: string | number, loaded: boolean) { serviceFolderLoadedState.value = { ...serviceFolderLoadedState.value, [_serviceIdentifier({ provider, service })]: loaded, } } function _setServiceFolderError(provider: string, service: string | number, error: string | null) { serviceFolderErrorState.value = { ...serviceFolderErrorState.value, [_serviceIdentifier({ provider, service })]: error, } } function _updateSyncSources() { mailSyncController.clearSources() // Track the currently selected folder if (selectedFolder.value) { mailSyncController.addSource({ provider: selectedFolder.value.provider, service: selectedFolder.value.service, collections: [selectedFolder.value.identifier], }) } // Always track inboxes for each account (for new-mail notifications) servicesStore.servicesEnabled.forEach(service => { const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter( c => String(c.service) === String(service.identifier) && (c.properties.role === 'inbox' || String(c.identifier).toLowerCase() === 'inbox'), ) if (inboxes.length > 0) { mailSyncController.addSource({ provider: service.provider, service: service.identifier as string | number, collections: inboxes.map(inbox => inbox.identifier), }) } }) if (mailSyncController.sources.value.length > 0 && !mailSyncController.isRunning.value) { mailSyncController.start() } } function isServiceFolderLoading(provider: string, service: string | number) { return serviceFolderLoadingState.value[_serviceIdentifier({ provider, service })] === true } function hasServiceFoldersLoaded(provider: string, service: string | number) { return serviceFolderLoadedState.value[_serviceIdentifier({ provider, service })] === true } function getServiceFolderError(provider: string, service: string | number) { return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null } function _reloadFolderMessages(folder: CollectionObject) { return entitiesStore.list({ [folder.provider]: { [String(folder.service)]: { [String(folder.identifier)]: true, }, }, }) } function _setSelectionList(nextIds: EntityIdentifier[]) { selectionList.value = Array.from(new Set(nextIds)) if (selectionList.value.length === 0) { selectionMode.value = false } } function _reconcileSelection() { if (!selectedFolder.value) { clearSelection() selectedMessage.value = null return } const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message))) const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier)) if (nextSelectedIds.length !== selectionList.value.length) { _setSelectionList(nextSelectedIds) } if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) { selectedMessage.value = null } } function _formatMoveNotification(successCount: number, failureCount: number, targetFolder: CollectionObject) { const folderLabel = targetFolder.properties.label || String(targetFolder.identifier) if (failureCount === 0) { return { message: successCount === 1 ? `Message moved to "${folderLabel}"` : `${successCount} messages moved to "${folderLabel}"`, color: 'success' as const, } } return { message: successCount === 0 ? `Move failed for ${failureCount === 1 ? '1 message' : `${failureCount} messages`}` : `Moved ${successCount} ${successCount === 1 ? 'message' : 'messages'} to "${folderLabel}". ${failureCount} failed.`, color: successCount === 0 ? 'error' as const : 'warning' as const, } } watch(currentMessages, () => { _reconcileSelection() }) // ── Actions ─────────────────────────────────────────────────────────────── async function selectFolder(folder: CollectionObject) { selectedFolder.value = folder selectedMessage.value = null clearSelection() selectionMode.value = false composeMode.value = false try { await _reloadFolderMessages(folder) } catch (error) { console.error('[Mail] Failed to load messages:', error) } _updateSyncSources() } function clearSelectedFolder() { selectedFolder.value = null selectedMessage.value = null clearSelection() selectionMode.value = false composeMode.value = false composeReplyTo.value = null _updateSyncSources() } function selectMessage(entity: EntityObject, closeSidebar = false) { selectedMessage.value = entity composeMode.value = false if (closeSidebar) { sidebarVisible.value = false } } function openCompose(replyTo?: EntityObject) { composeMode.value = true composeReplyTo.value = replyTo ?? null selectedMessage.value = null } function closeCompose() { composeMode.value = false composeReplyTo.value = null } async function afterSent() { composeMode.value = false composeReplyTo.value = null // Reload the current folder so the sent message appears in Sent if (selectedFolder.value) { await selectFolder(selectedFolder.value) } } function isMessageSelected(message: EntityObject) { return selectionList.value.includes(_entityIdentifier(message)) } function toggleMessageSelection(message: EntityObject) { const identifier = _entityIdentifier(message) selectionMode.value = true if (selectionList.value.includes(identifier)) { _setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier)) return } _setSelectionList([...selectionList.value, identifier]) } function selectAllCurrentMessages() { selectionMode.value = true _setSelectionList(currentMessages.value.map(message => _entityIdentifier(message))) } function activateSelectionMode(message?: EntityObject) { selectionMode.value = true if (message) { const identifier = _entityIdentifier(message) if (!selectionList.value.includes(identifier)) { _setSelectionList([...selectionList.value, identifier]) } } } function deactivateSelectionMode() { selectionMode.value = false clearSelection() } function clearSelection() { _setSelectionList([]) } function openMoveDialog(entities?: EntityObject | EntityObject[]) { moveDialogCandidates.value = [] if (entities) { if (Array.isArray(entities)) { moveDialogCandidates.value = entities.map(entity => _entityIdentifier(entity)) moveDialogService.value = _serviceIdentifier(entities[0]) } else { moveDialogCandidates.value = [_entityIdentifier(entities)] moveDialogService.value = _serviceIdentifier(entities) } } else { moveDialogCandidates.value = selectionList.value moveDialogService.value = _serviceIdentifier(selectedFolder.value) } moveDialogVisible.value = true } function closeMoveDialog() { moveDialogVisible.value = false moveDialogService.value = null moveDialogCandidates.value = null } async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) { const movableIdentifiers = entityIdentifiers.filter(identifier => { const entity = entitiesStore.entityByIdentifier(identifier) if (!entity) { return false } // Only allow moving messages within the same service and disallow moving into the same folder return entity.provider === target.provider && String(entity.service) === String(target.service) && String(entity.collection) !== String(target.identifier) }) if (movableIdentifiers.length === 0) { closeMoveDialog() return } loading.value = true try { const response = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers) const operationSucceeded: EntityIdentifier[] = [] const operationFailures: EntityIdentifier[] = [] Object.entries(response).forEach(([sourceIdentifier, result]) => { if (result.success) { operationSucceeded.push(sourceIdentifier as EntityIdentifier) return } operationFailures.push(sourceIdentifier as EntityIdentifier) }) if (operationSucceeded.length === 0) { throw new Error(operationFailures[0] ?? 'Failed to move messages') } if (selectedMessage.value && operationSucceeded.includes(_entityIdentifier(selectedMessage.value))) { selectedMessage.value = null } clearSelection() closeMoveDialog() const notification = _formatMoveNotification(operationSucceeded.length, operationFailures.length, target) notify(notification.message, notification.color) } catch (error) { const messageText = error instanceof Error ? error.message : 'Failed to move messages' console.error('[Mail] Failed to move messages:', error) notify(messageText, 'error') throw error } finally { loading.value = false } } async function deleteMessages(entityIdentifiers: EntityIdentifier[]) { if (entityIdentifiers.length === 0) { return } loading.value = true try { const response = await entitiesStore.delete(entityIdentifiers) const operationSucceeded: EntityIdentifier[] = [] const operationFailures: EntityIdentifier[] = [] Object.entries(response).forEach(([sourceIdentifier, result]) => { if (result.success) { operationSucceeded.push(sourceIdentifier as EntityIdentifier) return } operationFailures.push(sourceIdentifier as EntityIdentifier) }) if (operationSucceeded.length === 0) { throw new Error(operationFailures[0] ?? 'Failed to delete messages') } if (selectedMessage.value && operationSucceeded.includes(_entityIdentifier(selectedMessage.value))) { selectedMessage.value = null } clearSelection() const successCount = operationSucceeded.length const failureCount = operationFailures.length if (failureCount === 0) { notify( successCount === 1 ? 'Message deleted' : `${successCount} messages deleted`, 'success', ) } else { notify( successCount === 0 ? `Delete failed for ${failureCount === 1 ? '1 message' : `${failureCount} messages`}` : `Deleted ${successCount} ${successCount === 1 ? 'message' : 'messages'}. ${failureCount} failed.`, successCount === 0 ? 'error' : 'warning', ) } } catch (error) { const messageText = error instanceof Error ? error.message : 'Failed to delete messages' console.error('[Mail] Failed to delete messages:', error) notify(messageText, 'error') throw error } finally { loading.value = false } } function toggleSidebar() { sidebarVisible.value = !sidebarVisible.value } function openSettings() { settingsDialogVisible.value = true } function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') { showSnackbar({ message, color }) } // ── Exports ─────────────────────────────────────────────────────────────── return { // Sub-stores (forwarded for template convenience) collectionsStore, entitiesStore, servicesStore, mailSync, // State sidebarVisible, settingsDialogVisible, loading, selectedFolder, selectedMessage, selectionList, selectionMode, composeMode, composeReplyTo, moveDialogVisible, moveDialogService, moveDialogCandidates, serviceFolderLoadingState, serviceFolderLoadedState, serviceFolderErrorState, // Computed currentMessages, // Actions selectFolder, clearSelectedFolder, selectMessage, isMessageSelected, activateSelectionMode, deactivateSelectionMode, toggleMessageSelection, selectAllCurrentMessages, clearSelection, openCompose, openMoveDialog, closeMoveDialog, closeCompose, afterSent, deleteMessages, moveMessages, toggleSidebar, openSettings, notify, isServiceFolderLoading, hasServiceFoldersLoaded, getServiceFolderError, loadFoldersForService, initialize, } })