import { ref, computed, shallowRef } 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 { EntityTransmitRequest } from '@MailManager/types/entity' import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message' import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models' import { CollectionPropertiesObject } from '@MailManager/models/collection' interface ComposerMessageInput { to: string[] cc: string[] bcc: string[] subject: string body: { html: string text: string } } 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 loading = ref(false) const serviceFolderLoadingState = ref>({}) const serviceFolderLoadedState = ref>({}) const serviceFolderErrorState = ref>({}) const selectedFolder = shallowRef(null) const selectedMessage = shallowRef(null) const composerSaving = ref(false) const composerSending = ref(false) const composerLastSaved = ref(null) const composerDraftIdentifier = 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] await Promise.all(services.map(service => loadFoldersForService(service))) } catch (error) { console.error('[Mail][Operations] Failed to initialize:', error) } finally { loading.value = false } } async function loadFoldersForService(service: ServiceObject) { if (service.identifier === null) { return } _setServiceFolderLoading(service.provider, service.identifier, true) _setServiceFolderError(service.provider, service.identifier, null) try { // retrieve folders for service await collectionsStore.collectionsForService(service.provider, service.identifier, true) _setServiceFolderLoaded(service.provider, service.identifier, true) _updateSyncSources() } catch (error) { const message = error instanceof Error ? error.message : 'Failed to load folders' _setServiceFolderError(service.provider, service.identifier, message) console.error(`[Mail][Operations] 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 { if (item instanceof ServiceObject) { return `${item.provider}:${String(item.identifier)}` as ServiceIdentifier } return `${item.provider}:${String(item.service)}` as ServiceIdentifier } function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean { if (!left || !right) { return false } return left.provider === right.provider && String(left.service) === String(right.service) && String(left.identifier) === String(right.identifier) } 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 => { if (service.identifier === null) { return } 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 _findDraftFolder(folder: CollectionObject): CollectionObject { return collectionsStore.collectionsForService(folder.provider, folder.service).find( candidate => candidate.provider === folder.provider && String(candidate.service) === String(folder.service) && (candidate.properties.role === 'drafts' || String(candidate.identifier).toLowerCase() === 'drafts' || candidate.properties.label.toLowerCase() === 'drafts'), ) ?? folder } function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined { const normalized = addresses .map(address => address.trim()) .filter(address => address.length > 0) if (normalized.length === 0) { return undefined } return normalized.map(address => ({ address })) } function _toDraftBody(body: ComposerMessageInput['body']): MessagePartInterface | null { const parts: MessagePartInterface[] = [] const text = body.text.trim() const html = body.html.trim() if (text.length > 0) { parts.push({ type: 'text/plain', content: text, }) } if (html.length > 0) { parts.push({ type: 'text/html', content: html, }) } if (parts.length === 0) { return null } if (parts.length === 1) { return parts[0] } return { type: 'multipart/alternative', subParts: parts, } } function _toDraftProperties(message: ComposerMessageInput): MessageInterface { return { '@type': 'mail:message', to: _toMessageAddresses(message.to), cc: _toMessageAddresses(message.cc), bcc: _toMessageAddresses(message.bcc), subject: message.subject.trim() || null, body: _toDraftBody(message.body), flags: { draft: true, }, } } function resetComposerState() { composerSaving.value = false composerSending.value = false composerLastSaved.value = null composerDraftIdentifier.value = null } // ── Actions ─────────────────────────────────────────────────────────────── async function retrieveService(identifier: ServiceIdentifier, force: boolean = false): Promise { let service = servicesStore.serviceByIdentifier(identifier) if (service && !force) { return service } try { service = await servicesStore.serviceByIdentifier(identifier, true) } catch (error) { console.error(`[Mail][Operations] Failed to retrieve service ${identifier}:`, error) throw error } if (!service) { const message = `Service ${identifier} not found` console.error(`[Mail][Operations] ${message}`) throw new Error(message) } return service } async function selectFolder(folder: CollectionObject | null) { selectedFolder.value = folder selectedMessage.value = null if (folder) { try { await entitiesStore.list([folder.identifier]) } catch (error) { console.error('[Mail][Operations] Failed to load messages:', error) } } _updateSyncSources() } function selectMessage(entity: EntityObject | null) { selectedMessage.value = entity } async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) { composerSaving.value = true try { const targetFolder = _findDraftFolder(folder) const properties = _toDraftProperties(message) const draft = composerDraftIdentifier.value ? await entitiesStore.update(composerDraftIdentifier.value, properties) : await entitiesStore.create(targetFolder.identifier, properties) composerDraftIdentifier.value = draft.identifier composerLastSaved.value = new Date() return draft } catch (error) { console.error('[Mail][Operations] Failed to save draft:', error) throw error } finally { composerSaving.value = false } } function findFoldersByRole(role: string): CollectionObject[] { const normalizedRole = role.toLowerCase() return servicesStore.servicesEnabled.flatMap(service => { if (service.identifier === null) { return [] } return collectionsStore.collectionsForService(service.provider, service.identifier).filter( folder => folder.provider === service.provider && String(folder.service) === String(service.identifier) && (folder.properties.role === normalizedRole || String(folder.identifier).toLowerCase() === normalizedRole), ) }) } async function sendComposerMessage(message: ComposerMessageInput) { composerSending.value = true const transmitRequest: EntityTransmitRequest = { message: { to: message.to.map(address => address.trim()).filter(address => address.length > 0), cc: message.cc.map(address => address.trim()).filter(address => address.length > 0), bcc: message.bcc.map(address => address.trim()).filter(address => address.length > 0), subject: message.subject, body: { html: message.body.html, text: message.body.text, }, }, } if (transmitRequest.message.cc?.length === 0) { delete transmitRequest.message.cc } if (transmitRequest.message.bcc?.length === 0) { delete transmitRequest.message.bcc } try { const response = await entitiesStore.transmit(transmitRequest) if (composerDraftIdentifier.value) { try { await entitiesStore.delete([composerDraftIdentifier.value]) } catch (error) { console.error('[Mail][Operations] Failed to delete draft after send:', error) } } notify('Message sent', 'success') resetComposerState() return response } catch (error) { const messageText = error instanceof Error ? error.message : 'Failed to send message' console.error('[Mail][Operations] Failed to send message:', error) notify(messageText, 'error') throw error } finally { composerSending.value = false } } async function createFolder(service: ServiceObject, label: string, parentFolder: CollectionObject | null = null): Promise { if (service.identifier === null) { throw new Error('Cannot create folder for a service without an identifier') } const properties = new CollectionPropertiesObject() properties.label = label.trim() properties.rank = 0 properties.subscribed = true const newFolder = await collectionsStore.create( service.provider, service.identifier, properties, parentFolder?.identifier, ) notify( `Folder "${newFolder.properties.label || properties.label}" created`, 'success', ) return newFolder } async function renameFolder(folder: CollectionObject, label: string): Promise { const properties = new CollectionPropertiesObject() properties.label = label.trim() properties.rank = folder.properties.rank ?? 0 properties.subscribed = folder.properties.subscribed ?? true const updatedFolder = await collectionsStore.update(folder.identifier, properties) if (_sameCollection(selectedFolder.value, folder)) { selectedFolder.value = updatedFolder } notify( `Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || properties.label}"`, 'success', ) return updatedFolder } async function moveFolder(source: CollectionObject, target: CollectionObject): Promise { const movedFolder = await collectionsStore.move(target.identifier, source.identifier) if (_sameCollection(selectedFolder.value, source)) { selectedFolder.value = movedFolder } notify( `Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`, 'success', ) return movedFolder } async function deleteFolder(folder: CollectionObject): Promise { const deletedFolder = await collectionsStore.delete(folder.identifier) if (_sameCollection(selectedFolder.value, folder)) { await selectFolder(null) } notify( `Folder "${folder.properties.label || String(folder.identifier)}" deleted`, 'success', ) return deletedFolder } async function deleteMessages(entityIdentifiers: EntityIdentifier[]) { if (entityIdentifiers.length === 0) { return } loading.value = true try { const { successes, failures } = await entitiesStore.delete(entityIdentifiers) if (failures.length === 0) { notify( successes.length === 1 ? 'Message deleted' : `${successes.length} messages deleted`, 'success', ) } if (failures.length > 0) { notify( successes.length === 0 ? `Delete failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}` : `Deleted ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`, successes.length === 0 ? 'error' : 'warning', ) } } catch (error) { const messageText = error instanceof Error ? error.message : 'Failed to delete messages' console.error('[Mail][Operations] Failed to delete messages:', error) notify(messageText, 'error') throw error } finally { loading.value = false } } async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial, options: { notify?: boolean } = {}) { if (entityIdentifiers.length === 0) { return } const shouldNotify = options.notify ?? true loading.value = true try { const patch = entitiesStore.fresh().properties patch.flags = flags const { successes, failures } = await entitiesStore.patch(patch, entityIdentifiers) if (shouldNotify && successes.length > 0) { notify( successes.length === 1 ? 'Message updated' : `${successes.length} messages updated`, 'success', ) } if (shouldNotify && failures.length > 0) { notify( successes.length === 0 ? `Update failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}` : `Updated ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`, successes.length === 0 ? 'error' : 'warning', ) } } catch (error) { const messageText = error instanceof Error ? error.message : 'Failed to update messages' console.error('[Mail][Operations] Failed to update messages:', error) notify(messageText, 'error') throw error } finally { loading.value = false } } async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) { const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce( (accumulator, identifier) => { const entity = entitiesStore.entity(identifier) if (!entity) { return accumulator } // Only allow moving messages within the same service and disallow moving into the same folder const canMove = entity.provider === target.provider && String(entity.service) === String(target.service) && String(entity.collection) !== String(target.identifier) if (!canMove) { return accumulator } accumulator.movableIdentifiers.push(identifier) if (!accumulator.sourceCollections.some( collection => String(collection) === String(entity.collection), )) { accumulator.sourceCollections.push(entity.collection) } return accumulator }, { movableIdentifiers: [] as EntityIdentifier[], sourceCollections: [] as CollectionIdentifier[], }, ) if (movableIdentifiers.length === 0) { return } loading.value = true try { const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers) if (failures.length === 0) { notify( successes.length === 1 ? 'Message moved' : `${successes.length} messages moved`, 'success', ) } if (failures.length > 0) { notify( successes.length === 0 ? `Move failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}` : `Moved ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`, successes.length === 0 ? 'error' : 'warning', ) } // update source collections to reflect moved messages sourceCollections.push(target.identifier) await collectionsStore.fetch(sourceCollections) } catch (error) { const messageText = error instanceof Error ? error.message : 'Failed to move messages' console.error('[Mail][Operations] Failed to move messages:', error) notify(messageText, 'error') throw error } finally { loading.value = false } } 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 loading, selectedMessage, composerSaving, composerSending, composerLastSaved, composerDraftIdentifier, serviceFolderLoadingState, serviceFolderLoadedState, serviceFolderErrorState, // Computed currentMessages, // Actions retrieveService, selectFolder, selectMessage, createFolder, saveComposerDraft, sendComposerMessage, resetComposerState, flagMessages, deleteMessages, deleteFolder, moveMessages, moveFolder, renameFolder, notify, isServiceFolderLoading, hasServiceFoldersLoaded, getServiceFolderError, findFoldersByRole, loadFoldersForService, initialize, } })