refactor: split stores and use events

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-05-14 22:26:45 -04:00
parent 46632d2454
commit 232f588225
19 changed files with 1808 additions and 1210 deletions

View File

@@ -1,4 +1,4 @@
import { ref, computed, shallowRef, watch } from 'vue'
import { ref, computed, shallowRef } from 'vue'
import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
@@ -6,7 +6,21 @@ 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'
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()
@@ -31,27 +45,17 @@ export const useMailStore = defineStore('mailStore', () => {
}
// ── 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)
const composerSaving = ref(false)
const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null)
const composerDraftIdentifier = ref<EntityIdentifier | null>(null)
// ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => {
@@ -98,11 +102,7 @@ export const useMailStore = defineStore('mailStore', () => {
try {
// retrieve folders for service
const collections = await collectionsStore.list({
[service.provider]: {
[String(service.identifier)]: true,
},
})
const collections = await collectionsStore.collectionsForService(service.provider, service.identifier, true)
_setServiceFolderLoaded(service.provider, service.identifier, true)
@@ -121,7 +121,6 @@ export const useMailStore = defineStore('mailStore', () => {
}
_updateSyncSources()
return collections
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message)
@@ -139,15 +138,20 @@ export const useMailStore = defineStore('mailStore', () => {
// ── 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 _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier {
return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier
}
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
}
function _entityIdentifier(item: EntityObject): EntityIdentifier {
return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier
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) {
@@ -176,15 +180,18 @@ export const useMailStore = defineStore('mailStore', () => {
// Track the currently selected folder
if (selectedFolder.value) {
mailSyncController.addSource({
provider: selectedFolder.value.provider,
service: selectedFolder.value.service,
collections: [selectedFolder.value.identifier],
})
//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) &&
@@ -193,11 +200,11 @@ export const useMailStore = defineStore('mailStore', () => {
)
if (inboxes.length > 0) {
mailSyncController.addSource({
provider: service.provider,
service: service.identifier as string | number,
collections: inboxes.map(inbox => inbox.identifier),
})
//mailSyncController.addSource({
// provider: service.provider,
// service: service.identifier as string | number,
// collections: inboxes.map(inbox => inbox.identifier),
//})
}
})
@@ -218,58 +225,113 @@ export const useMailStore = defineStore('mailStore', () => {
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
}
function _reloadFolderMessages(folder: CollectionObject) {
return entitiesStore.list({
[folder.provider]: {
[String(folder.service)]: {
[String(folder.identifier)]: true,
},
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 _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 resetComposerState() {
composerSaving.value = false
composerSending.value = false
composerLastSaved.value = null
composerDraftIdentifier.value = null
}
watch(currentMessages, () => {
_reconcileSelection()
})
// ── Actions ───────────────────────────────────────────────────────────────
async function retrieveService(identifier: ServiceIdentifier, force: boolean = false): Promise<ServiceObject | null> {
let service = servicesStore.serviceByIdentifier(identifier)
if (service && !force) {
return service
}
try {
service = await servicesStore.serviceByIdentifier(identifier, true)
} catch (error) {
console.error(`[Mail] Failed to retrieve service ${identifier}:`, error)
throw error
}
if (!service) {
const message = `Service ${identifier} not found`
console.error(`[Mail] ${message}`)
throw new Error(message)
}
return service
}
async function selectFolder(folder: CollectionObject) {
selectedFolder.value = folder
selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
try {
await _reloadFolderMessages(folder)
await entitiesStore.list([folder.identifier])
} catch (error) {
console.error('[Mail] Failed to load messages:', error)
}
@@ -280,140 +342,216 @@ export const useMailStore = defineStore('mailStore', () => {
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) {
function selectMessage(entity: EntityObject) {
selectedMessage.value = entity
composeMode.value = false
if (closeSidebar) {
sidebarVisible.value = false
}
}
function openCompose(replyTo?: EntityObject) {
composeMode.value = true
composeReplyTo.value = replyTo ?? null
function clearSelectedMessage() {
selectedMessage.value = null
}
function closeCompose() {
composeMode.value = false
composeReplyTo.value = null
}
async function afterSent() {
composeMode.value = false
composeReplyTo.value = null
async function reloadSelectedFolder() {
// 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))
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] Failed to save draft:', error)
throw error
} finally {
composerSaving.value = false
}
}
function toggleMessageSelection(message: EntityObject) {
const identifier = _entityIdentifier(message)
async function sendComposerMessage(message: ComposerMessageInput) {
composerSending.value = true
selectionMode.value = true
if (selectionList.value.includes(identifier)) {
_setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
return
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,
},
},
}
_setSelectionList([...selectionList.value, identifier])
}
if (transmitRequest.message.cc?.length === 0) {
delete transmitRequest.message.cc
}
function selectAllCurrentMessages() {
selectionMode.value = true
_setSelectionList(currentMessages.value.map(message => _entityIdentifier(message)))
}
if (transmitRequest.message.bcc?.length === 0) {
delete transmitRequest.message.bcc
}
function activateSelectionMode(message?: EntityObject) {
selectionMode.value = true
try {
const response = await entitiesStore.transmit(transmitRequest)
if (message) {
const identifier = _entityIdentifier(message)
if (!selectionList.value.includes(identifier)) {
_setSelectionList([...selectionList.value, identifier])
if (composerDraftIdentifier.value) {
try {
await entitiesStore.delete([composerDraftIdentifier.value])
} catch (error) {
console.error('[Mail] 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] Failed to send message:', error)
notify(messageText, 'error')
throw error
} finally {
composerSending.value = false
}
}
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)
async function createFolder(
service: ServiceObject,
label: string,
parentFolder: CollectionObject | null = null,
): Promise<CollectionObject> {
if (service.identifier === null) {
throw new Error('Cannot create folder for a service without an identifier')
}
moveDialogVisible.value = true
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
}
function closeMoveDialog() {
moveDialogVisible.value = false
moveDialogService.value = null
moveDialogCandidates.value = null
async function renameFolder(folder: CollectionObject, label: string): Promise<CollectionObject> {
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<CollectionObject> {
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<CollectionObject | boolean> {
const deletedFolder = await collectionsStore.delete(folder.identifier)
if (_sameCollection(selectedFolder.value, folder)) {
clearSelectedFolder()
}
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" deleted`,
'success',
)
return deletedFolder
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const movableIdentifiers = entityIdentifiers.filter(identifier => {
const entity = entitiesStore.entityByIdentifier(identifier)
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
(accumulator, identifier) => {
const entity = entitiesStore.entity(identifier)
if (!entity) {
return false
}
if (!entity) {
return accumulator
}
// 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)
})
// 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) {
closeMoveDialog()
return
}
loading.value = true
try {
const [successes, failures] = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
clearSelection()
closeMoveDialog()
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
if (failures.length === 0) {
notify(
@@ -430,6 +568,10 @@ export const useMailStore = defineStore('mailStore', () => {
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] Failed to move messages:', error)
@@ -448,9 +590,7 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true
try {
const [successes, failures] = await entitiesStore.delete(entityIdentifiers)
clearSelection()
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
if (failures.length === 0) {
notify(
@@ -477,14 +617,6 @@ export const useMailStore = defineStore('mailStore', () => {
}
}
function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value
}
function openSettings() {
settingsDialogVisible.value = true
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color })
}
@@ -499,18 +631,13 @@ export const useMailStore = defineStore('mailStore', () => {
mailSync,
// State
sidebarVisible,
settingsDialogVisible,
loading,
selectedFolder,
selectedMessage,
selectionList,
selectionMode,
composeMode,
composeReplyTo,
moveDialogVisible,
moveDialogService,
moveDialogCandidates,
composerSaving,
composerSending,
composerLastSaved,
composerDraftIdentifier,
serviceFolderLoadingState,
serviceFolderLoadedState,
serviceFolderErrorState,
@@ -519,24 +646,21 @@ export const useMailStore = defineStore('mailStore', () => {
currentMessages,
// Actions
retrieveService,
selectFolder,
clearSelectedFolder,
selectMessage,
isMessageSelected,
activateSelectionMode,
deactivateSelectionMode,
toggleMessageSelection,
selectAllCurrentMessages,
clearSelection,
openCompose,
openMoveDialog,
closeMoveDialog,
closeCompose,
afterSent,
clearSelectedMessage,
createFolder,
reloadSelectedFolder,
saveComposerDraft,
sendComposerMessage,
resetComposerState,
deleteMessages,
deleteFolder,
moveMessages,
toggleSidebar,
openSettings,
moveFolder,
renameFolder,
notify,
isServiceFolderLoading,
hasServiceFoldersLoaded,

534
src/stores/mailUiStore.ts Normal file
View File

@@ -0,0 +1,534 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore'
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { EntityObject, ServiceObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection'
export const useMailUiStore = defineStore('mailUiStore', () => {
const collectionsStore = useCollectionsStore()
const mailStore = useMailStore()
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
const composeMode = ref<'new' | 'reply' | 'forward'>('new')
const composeSource = shallowRef<EntityObject | null>(null)
const composeVisible = ref(false)
const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([])
const moveMessagesDialogVisible = ref(false)
const moveMessagesDialogService = shallowRef<ServiceObject | null>(null)
const moveMessagesDialogCandidates = ref<EntityIdentifier[] | null>(null)
const createFolderDialogVisible = ref(false)
const createFolderDialogService = shallowRef<ServiceObject | null>(null)
const createFolderDialogParent = shallowRef<CollectionObject | null>(null)
const createFolderDialogLoading = ref(false)
const createFolderDialogError = ref('')
const renameFolderDialogVisible = ref(false)
const renameFolderDialogService = shallowRef<ServiceObject | null>(null)
const renameFolderDialogFolder = shallowRef<CollectionObject | null>(null)
const renameFolderDialogLoading = ref(false)
const renameFolderDialogError = ref('')
const moveFolderDialogVisible = ref(false)
const moveFolderDialogService = shallowRef<ServiceObject | null>(null)
const moveFolderDialogSource = shallowRef<CollectionObject | null>(null)
const deleteFolderDialogVisible = ref(false)
const deleteFolderDialogService = shallowRef<ServiceObject | null>(null)
const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null)
const deleteFolderDialogLoading = ref(false)
const deleteFolderDialogError = ref('')
const createFolderDialogParentLabel = computed(() => {
return createFolderDialogParent.value?.properties.label || 'Root'
})
const renameFolderDialogParentLabel = computed(() => {
const folder = renameFolderDialogFolder.value
if (!folder || folder.collection === null || folder.collection === undefined) {
return 'Root'
}
const parent = collectionsStore.collectionsInCollection(folder.provider, folder.service, null)
.flatMap(rootFolder => [rootFolder, ...collectionsStore.collectionsForService(folder.provider, folder.service)])
.find(candidate => String(candidate.identifier) === String(folder.collection))
return parent?.properties.label || 'Root'
})
const moveFolderDialogInvalidFolderKeys = computed(() => {
const sourceFolder = moveFolderDialogSource.value
if (!sourceFolder) {
return []
}
const invalidKeys = new Set<string>()
const queue = [sourceFolder]
while (queue.length > 0) {
const currentFolder = queue.shift()
if (!currentFolder) {
continue
}
invalidKeys.add(String(currentFolder.identifier))
collectionsStore
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
.forEach(childFolder => {
queue.push(childFolder)
})
}
return Array.from(invalidKeys)
})
watch(
() => mailStore.selectedFolder,
() => {
closeCompose()
deactivateSelectionMode()
},
)
watch(
() => mailStore.selectedMessage,
selectedMessage => {
if (selectedMessage) {
closeCompose()
}
},
)
watch(
() => mailStore.currentMessages,
() => {
reconcileSelection()
},
)
function validateFolderNameBase(service: ServiceObject, name: string): string[] {
const errors: string[] = []
if (!name || name.trim().length === 0) {
errors.push('Folder name is required')
return errors
}
if (name.length > 255) {
errors.push('Folder name too long (max 255 characters)')
}
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
if (service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
function validateCreateFolderName(name: string): string[] {
const service = createFolderDialogService.value
if (!service || service.identifier === null) {
return ['Folder service is unavailable']
}
const errors = validateFolderNameBase(service, name)
if (errors.length > 0) {
return errors
}
const parentIdentifier = createFolderDialogParent.value?.identifier ?? null
const duplicate = collectionsStore
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
.some(folder => folder.properties.label === name)
if (duplicate) {
errors.push('A folder with this name already exists in this location')
}
return errors
}
function validateRenameFolderName(name: string): string[] {
const service = renameFolderDialogService.value
const folder = renameFolderDialogFolder.value
if (!service || !folder || service.identifier === null) {
return ['Folder service is unavailable']
}
const errors = validateFolderNameBase(service, name)
if (errors.length > 0) {
return errors
}
const parentIdentifier = folder.collection ?? null
const duplicate = collectionsStore
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
.some(candidate =>
String(candidate.identifier) !== String(folder.identifier) &&
candidate.properties.label === name,
)
if (duplicate) {
errors.push('A folder with this name already exists in this location')
}
return errors
}
function setSelectionList(nextIds: EntityIdentifier[]) {
selectionList.value = Array.from(new Set(nextIds))
}
function openCompose(source?: EntityObject, mode: 'reply' | 'forward' = 'reply') {
mailStore.clearSelectedMessage()
composeSource.value = source ?? null
composeMode.value = mode
composeVisible.value = true
}
function closeCompose() {
composeMode.value = 'new'
composeSource.value = null
composeVisible.value = false
}
async function afterSent() {
closeCompose()
await mailStore.reloadSelectedFolder()
}
function clearSelection() {
setSelectionList([])
}
function activateSelectionMode(message?: EntityObject) {
selectionMode.value = true
if (!message) {
return
}
const identifier = message.identifier
if (!selectionList.value.includes(identifier)) {
setSelectionList([...selectionList.value, identifier])
}
}
function deactivateSelectionMode() {
selectionMode.value = false
clearSelection()
}
function toggleMessageSelection(message: EntityObject) {
const identifier = message.identifier
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(mailStore.currentMessages.map(message => message.identifier))
}
function reconcileSelection() {
if (!mailStore.selectedFolder) {
clearSelection()
return
}
const currentMessageIdentifiers = new Set(mailStore.currentMessages.map(message => message.identifier))
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectionList.value.length) {
setSelectionList(nextSelectedIds)
}
if (nextSelectedIds.length === 0) {
selectionMode.value = false
}
}
function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value
}
function closeSidebar() {
sidebarVisible.value = false
}
function openSettings() {
settingsDialogVisible.value = true
}
function closeSettings() {
settingsDialogVisible.value = false
}
async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) {
let moveMessagesServiceIdentifier = null as ServiceIdentifier | null
moveMessagesDialogCandidates.value = []
if (entities) {
if (Array.isArray(entities)) {
moveMessagesDialogCandidates.value = entities.map(entity => entity.identifier)
moveMessagesServiceIdentifier = entities[0]?.service as ServiceIdentifier || null
} else {
moveMessagesDialogCandidates.value = [entities.identifier]
moveMessagesServiceIdentifier = entities.service as ServiceIdentifier || null
}
} else {
moveMessagesDialogCandidates.value = [...selectionList.value]
moveMessagesServiceIdentifier = mailStore.selectedFolder?.service as ServiceIdentifier || null
}
moveMessagesDialogService.value = await mailStore.retrieveService(moveMessagesServiceIdentifier);
moveMessagesDialogVisible.value = true
}
function closeMoveMessagesDialog() {
moveMessagesDialogVisible.value = false
moveMessagesDialogService.value = null
moveMessagesDialogCandidates.value = null
}
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailStore.moveMessages>[0]) {
await mailStore.moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
deactivateSelectionMode()
closeMoveMessagesDialog()
}
async function deleteSelectedMessages() {
await mailStore.deleteMessages([...selectionList.value])
deactivateSelectionMode()
}
function openCreateFolderDialog(service: ServiceObject, parentFolder: CollectionObject | null = null) {
createFolderDialogService.value = service
createFolderDialogParent.value = parentFolder
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
createFolderDialogVisible.value = true
}
function closeCreateFolderDialog() {
createFolderDialogVisible.value = false
createFolderDialogService.value = null
createFolderDialogParent.value = null
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
}
async function confirmCreateFolder(label: string) {
const service = createFolderDialogService.value
if (!service) {
return null
}
createFolderDialogLoading.value = true
createFolderDialogError.value = ''
try {
const folder = await mailStore.createFolder(service, label, createFolderDialogParent.value)
closeCreateFolderDialog()
return folder
} catch (error) {
createFolderDialogError.value = error instanceof Error ? error.message : 'Failed to create folder. Please try again.'
throw error
} finally {
createFolderDialogLoading.value = false
}
}
async function openRenameFolderDialog(target: CollectionObject) {
const service = await mailStore.retrieveService(target.service)
renameFolderDialogService.value = service
renameFolderDialogFolder.value = target
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
renameFolderDialogVisible.value = true
}
function closeRenameFolderDialog() {
renameFolderDialogVisible.value = false
renameFolderDialogService.value = null
renameFolderDialogFolder.value = null
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
}
async function confirmRenameFolder(label: string) {
const folder = renameFolderDialogFolder.value
if (!folder) {
return null
}
renameFolderDialogLoading.value = true
renameFolderDialogError.value = ''
try {
const updatedFolder = await mailStore.renameFolder(folder, label)
closeRenameFolderDialog()
return updatedFolder
} catch (error) {
renameFolderDialogError.value = error instanceof Error ? error.message : 'Failed to rename folder. Please try again.'
throw error
} finally {
renameFolderDialogLoading.value = false
}
}
async function openMoveFolderDialog(source: CollectionObject) {
const service = await mailStore.retrieveService(source.service)
moveFolderDialogService.value = service
moveFolderDialogSource.value = source
moveFolderDialogVisible.value = true
}
function closeMoveFolderDialog() {
moveFolderDialogVisible.value = false
moveFolderDialogService.value = null
moveFolderDialogSource.value = null
}
async function confirmMoveFolder(target: CollectionObject) {
const source = moveFolderDialogSource.value
if (!source) {
return null
}
const movedFolder = await mailStore.moveFolder(source, target)
closeMoveFolderDialog()
return movedFolder
}
async function openDeleteFolderDialog(target: CollectionObject) {
const service = await mailStore.retrieveService(target.service)
deleteFolderDialogService.value = service
deleteFolderDialogFolder.value = target
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
deleteFolderDialogVisible.value = true
}
function closeDeleteFolderDialog() {
deleteFolderDialogVisible.value = false
deleteFolderDialogService.value = null
deleteFolderDialogFolder.value = null
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
}
async function confirmDeleteFolder() {
const folder = deleteFolderDialogFolder.value
if (!folder) {
return null
}
deleteFolderDialogLoading.value = true
deleteFolderDialogError.value = ''
try {
const deleted = await mailStore.deleteFolder(folder)
closeDeleteFolderDialog()
return deleted
} catch (error) {
deleteFolderDialogError.value = error instanceof Error ? error.message : 'Failed to delete folder. Please try again.'
throw error
} finally {
deleteFolderDialogLoading.value = false
}
}
return {
sidebarVisible,
settingsDialogVisible,
composeMode,
composeSource,
composeVisible,
selectionMode,
selectionList,
moveMessagesDialogVisible,
moveMessagesDialogService,
moveMessagesDialogCandidates,
createFolderDialogParentLabel,
createFolderDialogVisible,
createFolderDialogService,
createFolderDialogParent,
createFolderDialogLoading,
createFolderDialogError,
renameFolderDialogVisible,
renameFolderDialogService,
renameFolderDialogFolder,
renameFolderDialogParentLabel,
renameFolderDialogLoading,
renameFolderDialogError,
moveFolderDialogVisible,
moveFolderDialogService,
moveFolderDialogSource,
moveFolderDialogInvalidFolderKeys,
deleteFolderDialogVisible,
deleteFolderDialogService,
deleteFolderDialogFolder,
deleteFolderDialogLoading,
deleteFolderDialogError,
toggleSidebar,
closeSidebar,
openSettings,
closeSettings,
openCompose,
closeCompose,
afterSent,
activateSelectionMode,
deactivateSelectionMode,
toggleMessageSelection,
selectAllCurrentMessages,
clearSelection,
validateCreateFolderName,
validateRenameFolderName,
openMoveMessagesDialog,
closeMoveMessagesDialog,
confirmMoveMessages,
deleteSelectedMessages,
openCreateFolderDialog,
closeCreateFolderDialog,
confirmCreateFolder,
openRenameFolderDialog,
closeRenameFolderDialog,
confirmRenameFolder,
openMoveFolderDialog,
closeMoveFolderDialog,
confirmMoveFolder,
openDeleteFolderDialog,
closeDeleteFolderDialog,
confirmDeleteFolder,
}
})