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 { CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models' export const useMailStore = defineStore('mailStore', () => { const collectionsStore = useCollectionsStore() const entitiesStore = useEntitiesStore() const servicesStore = useServicesStore() 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 selectedMessageIds = ref([]) const selectionModeActive = ref(false) // ── Compose State ───────────────────────────────────────────────────────── const composeMode = ref(false) const composeReplyTo = shallowRef(null) // ── Move State ──────────────────────────────────────────────────────────── const moveDialogVisible = ref(false) const moveMessageCandidates = shallowRef([]) // ── 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), ) }) const selectedMessageIdSet = computed(() => new Set(selectedMessageIds.value)) const selectedMessageMap = computed(() => { const messageMap = new Map() currentMessages.value.forEach(message => { const identifier = _entityIdentifier(message) if (selectedMessageIdSet.value.has(identifier)) { messageMap.set(identifier, message) } }) return messageMap }) const selectedMessages = computed(() => Array.from(selectedMessageMap.value.values())) const selectionCount = computed(() => selectedMessageIds.value.length) const hasSelection = computed(() => selectionCount.value > 0) const allCurrentMessagesSelected = computed(() => { return currentMessages.value.length > 0 && currentMessages.value.every(message => isMessageSelected(message)) }) // ── Initialization ──────────────────────────────────────────────────────── async function initialize() { loading.value = true try { await servicesStore.list() const services = [...servicesStore.services] 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) } } // ── Sync Helpers ────────────────────────────────────────────────────────── function _serviceKey(provider: string, service: string | number) { return `${provider}:${String(service)}` } function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) { serviceFolderLoadingState.value = { ...serviceFolderLoadingState.value, [_serviceKey(provider, service)]: loadingState, } } function _setServiceFolderLoaded(provider: string, service: string | number, loaded: boolean) { serviceFolderLoadedState.value = { ...serviceFolderLoadedState.value, [_serviceKey(provider, service)]: loaded, } } function _setServiceFolderError(provider: string, service: string | number, error: string | null) { serviceFolderErrorState.value = { ...serviceFolderErrorState.value, [_serviceKey(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.services.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[_serviceKey(provider, service)] === true } function hasServiceFoldersLoaded(provider: string, service: string | number) { return serviceFolderLoadedState.value[_serviceKey(provider, service)] === true } function getServiceFolderError(provider: string, service: string | number) { return serviceFolderErrorState.value[_serviceKey(provider, service)] ?? null } function _entityIdentifier(entity: EntityObject): EntityIdentifier { return `${entity.provider}:${String(entity.service)}:${String(entity.collection)}:${String(entity.identifier)}` as EntityIdentifier } function _collectionIdentifier(collection: CollectionObject): CollectionIdentifier { return `${collection.provider}:${String(collection.service)}:${String(collection.identifier)}` as CollectionIdentifier } function _serviceFor(provider: string, serviceIdentifier: string | number) { return servicesStore.services.find(service => service.provider === provider && String(service.identifier) === String(serviceIdentifier), ) ?? null } function _reloadFolderMessages(folder: CollectionObject) { return entitiesStore.list({ [folder.provider]: { [String(folder.service)]: { [String(folder.identifier)]: true, }, }, }) } function _setSelectedMessageIds(nextIds: EntityIdentifier[]) { selectedMessageIds.value = Array.from(new Set(nextIds)) } function _removeSelection(sourceIdentifiers: EntityIdentifier[]) { if (sourceIdentifiers.length === 0 || selectedMessageIds.value.length === 0) { return } const removedIds = new Set(sourceIdentifiers) selectedMessageIds.value = selectedMessageIds.value.filter(identifier => !removedIds.has(identifier)) } function _reconcileSelection() { if (!selectedFolder.value) { clearSelection() selectedMessage.value = null return } const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message))) const nextSelectedIds = selectedMessageIds.value.filter(identifier => currentMessageIdentifiers.has(identifier)) if (nextSelectedIds.length !== selectedMessageIds.value.length) { selectedMessageIds.value = 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() selectionModeActive.value = false composeMode.value = false try { await _reloadFolderMessages(folder) } catch (error) { console.error('[Mail] Failed to load messages:', error) } _updateSyncSources() } function selectMessage(message: EntityObject, closeSidebar = false) { selectedMessage.value = message composeMode.value = false if (closeSidebar) { sidebarVisible.value = false } } function openCompose(replyTo?: EntityObject) { composeMode.value = true composeReplyTo.value = replyTo ?? null selectedMessage.value = null } function isMessageSelected(message: EntityObject) { return selectedMessageIdSet.value.has(_entityIdentifier(message)) } function toggleMessageSelection(message: EntityObject) { const identifier = _entityIdentifier(message) selectionModeActive.value = true if (selectedMessageIdSet.value.has(identifier)) { selectedMessageIds.value = selectedMessageIds.value.filter(selectedId => selectedId !== identifier) if (selectedMessageIds.value.length === 0) { selectionModeActive.value = false } return } _setSelectedMessageIds([...selectedMessageIds.value, identifier]) } function selectAllCurrentMessages() { selectionModeActive.value = true _setSelectedMessageIds(currentMessages.value.map(message => _entityIdentifier(message))) } function activateSelectionMode(message?: EntityObject) { selectionModeActive.value = true if (message) { const identifier = _entityIdentifier(message) if (!selectedMessageIdSet.value.has(identifier)) { _setSelectedMessageIds([...selectedMessageIds.value, identifier]) } } } function deactivateSelectionMode() { selectionModeActive.value = false clearSelection() } function clearSelection() { selectedMessageIds.value = [] } function openMoveDialog(messages: EntityObject | EntityObject[]) { const nextCandidates = Array.isArray(messages) ? messages : [messages] moveMessageCandidates.value = Array.from( new Map(nextCandidates.map(candidate => [_entityIdentifier(candidate), candidate])).values(), ) if (moveMessageCandidates.value.length === 0) { return } moveDialogVisible.value = true } function openMoveDialogForSelection() { if (selectedMessages.value.length === 0) { return } openMoveDialog(selectedMessages.value) } function closeMoveDialog() { moveDialogVisible.value = false moveMessageCandidates.value = [] } 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) } } async function deleteMessage(message: EntityObject) { // TODO: implement delete via entity / collection store console.log('[Mail] Delete message:', message.identifier) } async function moveMessages(targetFolder: CollectionObject) { const candidates = moveMessageCandidates.value if (candidates.length === 0) { return } const movableCandidates = candidates.filter(message => !( targetFolder.provider === message.provider && String(targetFolder.service) === String(message.service) && String(targetFolder.identifier) === String(message.collection) )) if (movableCandidates.length === 0) { closeMoveDialog() return } loading.value = true try { const sourceIdentifiers = movableCandidates.map(message => _entityIdentifier(message)) const response = await entitiesStore.move(_collectionIdentifier(targetFolder), sourceIdentifiers) const successfulMoves: EntityIdentifier[] = [] const failedMoves: string[] = [] Object.entries(response).forEach(([sourceIdentifier, result]) => { if (result.success) { successfulMoves.push(sourceIdentifier as EntityIdentifier) return } failedMoves.push(result.error) }) if (successfulMoves.length === 0) { throw new Error(failedMoves[0] ?? 'Failed to move messages') } _removeSelection(successfulMoves) if (selectedMessage.value && successfulMoves.includes(_entityIdentifier(selectedMessage.value))) { selectedMessage.value = null } if (selectedMessageIds.value.length === 0) { selectionModeActive.value = false } closeMoveDialog() const servicesToRefresh = new Map() movableCandidates.forEach(message => { const service = _serviceFor(message.provider, message.service) if (service && service.identifier !== null) { servicesToRefresh.set(`${service.provider}:${String(service.identifier)}`, service) } }) const targetService = _serviceFor(targetFolder.provider, targetFolder.service) if (targetService && targetService.identifier !== null) { servicesToRefresh.set(`${targetService.provider}:${String(targetService.identifier)}`, targetService) } await Promise.allSettled([ ...Array.from(servicesToRefresh.values()).map(service => loadFoldersForService(service)), ...(selectedFolder.value ? [_reloadFolderMessages(selectedFolder.value)] : []), ]) const notification = _formatMoveNotification(successfulMoves.length, failedMoves.length, targetFolder) 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 } } 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, selectedMessageIds, selectionModeActive, composeMode, composeReplyTo, moveDialogVisible, moveMessageCandidates, serviceFolderLoadingState, serviceFolderLoadedState, serviceFolderErrorState, // Computed currentMessages, selectedMessageMap, selectedMessages, selectionCount, hasSelection, allCurrentMessagesSelected, // Actions selectFolder, selectMessage, isMessageSelected, activateSelectionMode, deactivateSelectionMode, toggleMessageSelection, selectAllCurrentMessages, clearSelection, openCompose, openMoveDialog, openMoveDialogForSelection, closeMoveDialog, closeCompose, afterSent, deleteMessage, moveMessages, toggleSidebar, openSettings, notify, isServiceFolderLoading, hasServiceFoldersLoaded, getServiceFolderError, loadFoldersForService, initialize, } })