Files
mail/src/stores/mailStore.ts
2026-05-05 23:42:27 -04:00

594 lines
19 KiB
TypeScript

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<Record<string, boolean>>({})
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
const serviceFolderErrorState = ref<Record<string, string | null>>({})
// ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([])
// ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false)
const composeReplyTo = shallowRef<EntityObject | null>(null)
// ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false)
const moveDialogService = ref<ServiceIdentifier | null>(null)
const moveDialogCandidates = ref<EntityIdentifier[] | null>(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,
}
})