599 lines
20 KiB
TypeScript
599 lines
20 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 { 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<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 selectedMessageIds = ref<EntityIdentifier[]>([])
|
|
const selectionModeActive = ref(false)
|
|
|
|
// ── Compose State ─────────────────────────────────────────────────────────
|
|
const composeMode = ref(false)
|
|
const composeReplyTo = shallowRef<EntityObject | null>(null)
|
|
|
|
// ── Move State ────────────────────────────────────────────────────────────
|
|
const moveDialogVisible = ref(false)
|
|
const moveMessageCandidates = shallowRef<EntityObject[]>([])
|
|
|
|
// ── 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<EntityIdentifier, EntityObject>()
|
|
|
|
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<string, ServiceObject>()
|
|
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,
|
|
}
|
|
})
|