594 lines
19 KiB
TypeScript
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,
|
|
}
|
|
})
|