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,7 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models' import type { ServiceObject } from '@MailManager/models'
@@ -9,28 +7,27 @@ import type { ServiceObject } from '@MailManager/models'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
service: ServiceObject service: ServiceObject
parentFolder?: CollectionObject | null parentFolderLabel?: string
allFolders?: CollectionObject[] validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
parentFolder: null, parentFolderLabel: 'Root',
allFolders: () => [] validateName: () => [],
loading: false,
errorMessage: '',
}) })
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
'created': [folder: CollectionObject] confirm: [folderName: string]
}>() }>()
// Store
const collectionsStore = useCollectionsStore()
// Form state // Form state
const folderName = ref('') const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([]) const validationErrors = ref<string[]>([])
// Computed // Computed
@@ -39,67 +36,13 @@ const dialogValue = computed({
set: (value: boolean) => emit('update:modelValue', value) set: (value: boolean) => emit('update:modelValue', value)
}) })
const parentFolderLabel = computed(() => {
if (!props.parentFolder) return 'Root'
return props.parentFolder.properties.label
})
const isValid = computed(() => { const isValid = computed(() => {
return folderName.value.trim().length > 0 && validationErrors.value.length === 0 return folderName.value.trim().length > 0 && validationErrors.value.length === 0
}) })
// Validation functions
const validateFolderName = (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)')
}
// No special characters that might cause issues
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
// Provider-specific rules
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
// Leading/trailing spaces
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
const checkDuplicateName = (name: string): boolean => {
const parentId = props.parentFolder?.identifier ?? null
return props.allFolders.some(f =>
f.properties.label === name &&
String(f.collection) === String(parentId) &&
String(f.service) === String(props.service.identifier)
)
}
// Watch folder name for validation // Watch folder name for validation
watch(folderName, (newName) => { watch(folderName, (newName) => {
errorMessage.value = '' validationErrors.value = props.validateName(newName)
validationErrors.value = validateFolderName(newName)
// Check for duplicates only if no other validation errors
if (validationErrors.value.length === 0 && newName.trim().length > 0) {
if (checkDuplicateName(newName)) {
validationErrors.value.push('A folder with this name already exists in this location')
}
}
}) })
// Reset form when dialog opens/closes // Reset form when dialog opens/closes
@@ -111,59 +54,25 @@ watch(dialogValue, (isOpen) => {
const resetForm = () => { const resetForm = () => {
folderName.value = '' folderName.value = ''
errorMessage.value = ''
validationErrors.value = [] validationErrors.value = []
loading.value = false
} }
const handleCreate = async () => { const handleCreate = async () => {
// Final validation const errors = props.validateName(folderName.value)
const errors = validateFolderName(folderName.value)
if (errors.length > 0) { if (errors.length > 0) {
validationErrors.value = errors validationErrors.value = errors
return return
} }
if (checkDuplicateName(folderName.value)) { emit('confirm', folderName.value.trim())
validationErrors.value = ['A folder with this name already exists in this location']
return
}
loading.value = true
errorMessage.value = ''
try {
// Create properties object
const properties = new CollectionPropertiesObject()
properties.label = folderName.value.trim()
properties.rank = 0
properties.subscribed = true
// Create the collection
const newFolder = await collectionsStore.create(
props.service.provider,
props.service.identifier as string | number,
props.parentFolder?.identifier ?? null,
properties
)
// Success!
emit('created', newFolder)
dialogValue.value = false
resetForm()
} catch (error: any) {
console.error('[CreateFolderDialog] Failed to create folder:', error)
errorMessage.value = error.message || 'Failed to create folder. Please try again.'
} finally {
loading.value = false
}
} }
const handleCancel = () => { const handleCancel = () => {
dialogValue.value = false dialogValue.value = false
resetForm() resetForm()
} }
</script> </script>
<template> <template>

View File

@@ -8,20 +8,22 @@ interface Props {
modelValue: boolean modelValue: boolean
service: ServiceObject service: ServiceObject
folder: CollectionObject folder: CollectionObject
loading?: boolean
errorMessage?: string
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
loading: false,
errorMessage: '',
})
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
deleted: [folder: CollectionObject] confirm: []
}>() }>()
const collectionsStore = useCollectionsStore() const collectionsStore = useCollectionsStore()
const loading = ref(false)
const errorMessage = ref('')
const dialogValue = computed({ const dialogValue = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value), set: (value: boolean) => emit('update:modelValue', value),
@@ -33,37 +35,17 @@ const hasChildren = computed(() => {
return collectionsStore.hasChildrenInCollection(props.folder.provider, props.folder.service, props.folder.identifier) return collectionsStore.hasChildrenInCollection(props.folder.provider, props.folder.service, props.folder.identifier)
}) })
const resetState = () => {
loading.value = false
errorMessage.value = ''
}
watch(dialogValue, isOpen => { watch(dialogValue, isOpen => {
if (isOpen) { if (isOpen) {
resetState()
} }
}) })
const handleDelete = async () => { const handleDelete = () => {
loading.value = true emit('confirm')
errorMessage.value = ''
try {
await collectionsStore.delete(props.folder.provider, props.folder.service, props.folder.identifier)
emit('deleted', props.folder)
dialogValue.value = false
resetState()
} catch (error: any) {
console.error('[DeleteFolderDialog] Failed to delete folder:', error)
errorMessage.value = error.message || 'Failed to delete folder. Please try again.'
} finally {
loading.value = false
}
} }
const handleCancel = () => { const handleCancel = () => {
dialogValue.value = false dialogValue.value = false
resetState()
} }
</script> </script>

View File

@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
const emit = defineEmits<{ const emit = defineEmits<{
select: [folder: CollectionObject] select: [folder: CollectionObject]
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null] createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
editFolder: [service: ServiceObject, folder: CollectionObject] editFolder: [folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject] moveFolder: [folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject] deleteFolder: [folder: CollectionObject]
}>() }>()
// Page-based navigation state per service account // Page-based navigation state per service account
@@ -283,7 +283,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list density="compact"> <v-list density="compact">
<v-list-item <v-list-item
prepend-icon="mdi-pencil" prepend-icon="mdi-pencil"
@click="emit('editFolder', group.service, folder)" @click="emit('editFolder', folder)"
> >
<v-list-item-title>Edit Folder Name</v-list-item-title> <v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item> </v-list-item>
@@ -295,7 +295,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
</v-list-item> </v-list-item>
<v-list-item <v-list-item
prepend-icon="mdi-folder-move" prepend-icon="mdi-folder-move"
@click="emit('moveFolder', group.service, folder)" @click="emit('moveFolder', folder)"
> >
<v-list-item-title>Move Folder</v-list-item-title> <v-list-item-title>Move Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -303,7 +303,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-if="canDeleteFolder(folder)" v-if="canDeleteFolder(folder)"
prepend-icon="mdi-delete" prepend-icon="mdi-delete"
base-color="error" base-color="error"
@click="emit('deleteFolder', group.service, folder)" @click="emit('deleteFolder', folder)"
> >
<v-list-item-title>Delete Folder</v-list-item-title> <v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -446,7 +446,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list density="compact"> <v-list density="compact">
<v-list-item <v-list-item
prepend-icon="mdi-pencil" prepend-icon="mdi-pencil"
@click="emit('editFolder', group.service, folder)" @click="emit('editFolder', folder)"
> >
<v-list-item-title>Edit Folder Name</v-list-item-title> <v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item> </v-list-item>
@@ -458,7 +458,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
</v-list-item> </v-list-item>
<v-list-item <v-list-item
prepend-icon="mdi-folder-move" prepend-icon="mdi-folder-move"
@click="emit('moveFolder', group.service, folder)" @click="emit('moveFolder', folder)"
> >
<v-list-item-title>Move Folder</v-list-item-title> <v-list-item-title>Move Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -466,7 +466,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-if="canDeleteFolder(folder)" v-if="canDeleteFolder(folder)"
prepend-icon="mdi-delete" prepend-icon="mdi-delete"
base-color="error" base-color="error"
@click="emit('deleteFolder', group.service, folder)" @click="emit('deleteFolder', folder)"
> >
<v-list-item-title>Delete Folder</v-list-item-title> <v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item> </v-list-item>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import type { ServiceObject, CollectionObject } from '@MailManager/models' import type { ServiceObject, CollectionObject } from '@MailManager/models'
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue' import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
@@ -31,9 +30,8 @@ const emit = defineEmits<{
cancel: [] cancel: []
}>() }>()
const collectionsStore = useCollectionsStore()
const servicesStore = useServicesStore()
const mailStore = useMailStore() const mailStore = useMailStore()
const collectionsStore = useCollectionsStore()
const selectedFolderKey = ref<string | null>(null) const selectedFolderKey = ref<string | null>(null)
@@ -42,10 +40,6 @@ const dialogValue = computed({
set: (value: boolean) => emit('update:modelValue', value), set: (value: boolean) => emit('update:modelValue', value),
}) })
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
interface ServiceGroup { interface ServiceGroup {
service: ServiceObject service: ServiceObject
loading: boolean loading: boolean
@@ -54,9 +48,7 @@ interface ServiceGroup {
} }
const serviceGroups = computed<ServiceGroup[]>(() => { const serviceGroups = computed<ServiceGroup[]>(() => {
const service = props.service ?? const service = props.service
(mailStore.moveDialogService ? servicesStore.serviceByIdentifier(mailStore.moveDialogService) : null)
if (!service) { if (!service) {
return [] return []
} }
@@ -73,6 +65,34 @@ const serviceGroups = computed<ServiceGroup[]>(() => {
}] }]
}) })
const selectedFolder = computed(() => {
if (!selectedFolderKey.value) {
return null
}
const group = serviceGroups.value[0]
if (!group) {
return null
}
return getServiceFolders(group.service).find(folder => folder.identifier === selectedFolderKey.value) ?? null
})
const canConfirm = computed(() => {
return selectedFolder.value !== null && !props.loading
})
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
return
}
selectedFolderKey.value = null
},
)
const getRootFolders = (service: ServiceObject): CollectionObject[] => { const getRootFolders = (service: ServiceObject): CollectionObject[] => {
if (service.identifier === null) { if (service.identifier === null) {
return [] return []
@@ -89,36 +109,8 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
return collectionsStore.collectionsForService(service.provider, service.identifier) return collectionsStore.collectionsForService(service.provider, service.identifier)
} }
const selectedFolder = computed(() => {
if (!selectedFolderKey.value) {
return null
}
const group = serviceGroups.value[0]
if (!group) {
return null
}
return getServiceFolders(group.service).find(folder => folderKeyFor(folder) === selectedFolderKey.value) ?? null
})
const canConfirm = computed(() => {
return selectedFolder.value !== null && !props.loading
})
watch(
() => [props.modelValue, mailStore.moveDialogService],
([isOpen]) => {
if (!isOpen) {
return
}
selectedFolderKey.value = null
},
)
const handleSelect = (folder: CollectionObject) => { const handleSelect = (folder: CollectionObject) => {
selectedFolderKey.value = folderKeyFor(folder) selectedFolderKey.value = folder.identifier
} }
const handleCancel = () => { const handleCancel = () => {
@@ -168,7 +160,7 @@ const handleConfirm = () => {
<FolderSelectionTreeNode <FolderSelectionTreeNode
v-for="folder in getRootFolders(group.service)" v-for="folder in getRootFolders(group.service)"
:key="folderKeyFor(folder)" :key="folder.identifier"
:folder="folder" :folder="folder"
:service="group.service" :service="group.service"
:selected-folder-key="selectedFolderKey" :selected-folder-key="selectedFolderKey"

View File

@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models' import type { ServiceObject } from '@MailManager/models'
import type { CollectionIdentifier } from '@MailManager/types/common'
interface Props { interface Props {
folder: CollectionObject folder: CollectionObject
@@ -20,9 +21,7 @@ const emit = defineEmits<{
const expanded = ref(false) const expanded = ref(false)
const folderKeyFor = (folder: CollectionObject): string => { const folderKeyFor = (folder: CollectionObject): string => String(folder.identifier)
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
const folderLabelFor = (folder: CollectionObject): string => { const folderLabelFor = (folder: CollectionObject): string => {
return folder.properties.label || String(folder.identifier) return folder.properties.label || String(folder.identifier)
@@ -76,7 +75,11 @@ const childFolders = computed(() => {
return [] return []
} }
return collectionsStore.collectionsInCollection(props.service.provider, serviceIdentifier, props.folder.identifier) return collectionsStore.collectionsInCollection(
props.service.provider,
serviceIdentifier,
props.folder.identifier as CollectionIdentifier,
)
}) })
const hasChildren = computed(() => { const hasChildren = computed(() => {
@@ -86,7 +89,7 @@ const hasChildren = computed(() => {
return false return false
} }
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier) return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier as CollectionIdentifier)
}) })
const isSelected = computed(() => props.selectedFolderKey === key.value) const isSelected = computed(() => props.selectedFolderKey === key.value)

View File

@@ -1,278 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore'
import { useUser } from '@KTXC'
import FolderTreeView from './FolderTreeView.vue'
import FolderPageView from './FolderPageView.vue'
import CreateFolderDialog from './CreateFolderDialog.vue'
import DeleteFolderDialog from './DeleteFolderDialog.vue'
import FolderSelectionDialog from './FolderSelectionDialog.vue'
import RenameFolderDialog from './RenameFolderDialog.vue'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
type FolderViewMode = 'tree' | 'page'
const props = defineProps<{
selectedFolder?: CollectionObject | null
}>()
// Emits
const emit = defineEmits<{
select: [folder: CollectionObject]
folderCreated: [folder: CollectionObject]
}>()
// Stores
const collectionsStore = useCollectionsStore()
const servicesStore = useServicesStore()
const mailStore = useMailStore()
// User settings
const { settings } = useUser()
// Folder view mode from user settings
const folderViewMode = computed(() => {
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
})
// Create folder dialog state
const createDialogVisible = ref(false)
const createDialogService = ref<ServiceObject | null>(null)
const createDialogParent = ref<CollectionObject | null>(null)
const renameDialogVisible = ref(false)
const renameDialogService = ref<ServiceObject | null>(null)
const renameDialogFolder = ref<CollectionObject | null>(null)
const moveDialogVisible = ref(false)
const moveDialogService = ref<ServiceObject | null>(null)
const moveDialogFolder = ref<CollectionObject | null>(null)
const deleteDialogVisible = ref(false)
const deleteDialogService = ref<ServiceObject | null>(null)
const deleteDialogFolder = ref<CollectionObject | null>(null)
// Handle create folder event from child components
const handleCreateFolder = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
createDialogService.value = service
createDialogParent.value = parentFolder
createDialogVisible.value = true
}
// Handle folder created
const handleFolderCreated = (newFolder: CollectionObject) => {
emit('folderCreated', newFolder)
emit('select', newFolder)
}
const handleEditFolder = (service: ServiceObject, folder: CollectionObject) => {
renameDialogService.value = service
renameDialogFolder.value = folder
renameDialogVisible.value = true
}
const handleMoveFolder = (service: ServiceObject, folder: CollectionObject) => {
moveDialogService.value = service
moveDialogFolder.value = folder
moveDialogVisible.value = true
}
const handleDeleteFolder = (service: ServiceObject, folder: CollectionObject) => {
deleteDialogService.value = service
deleteDialogFolder.value = folder
deleteDialogVisible.value = true
}
const handleFolderRenamed = (updatedFolder: CollectionObject) => {
emit('select', updatedFolder)
}
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
const moveDialogInvalidFolderKeys = computed(() => {
const sourceFolder = moveDialogFolder.value
if (!sourceFolder) {
return []
}
const invalidKeys = new Set<string>()
const queue: CollectionObject[] = [sourceFolder]
while (queue.length > 0) {
const currentFolder = queue.shift()
if (!currentFolder) {
continue
}
invalidKeys.add(folderKeyFor(currentFolder))
collectionsStore
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
.forEach(childFolder => {
queue.push(childFolder)
})
}
return Array.from(invalidKeys)
})
const isSameFolder = (left: CollectionObject | null | undefined, right: CollectionObject | null | undefined) => {
if (!left || !right) {
return false
}
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
}
const handleFolderDeleted = (deletedFolder: CollectionObject) => {
if (isSameFolder(props.selectedFolder, deletedFolder)) {
mailStore.clearSelectedFolder()
}
mailStore.notify(`Folder "${deletedFolder.properties.label || String(deletedFolder.identifier)}" deleted`, 'success')
}
const handleMoveDialogCancel = () => {
moveDialogVisible.value = false
}
const handleFolderMove = async (targetFolder: CollectionObject) => {
const sourceFolder = moveDialogFolder.value
if (!sourceFolder) {
return
}
try {
const movedFolder = await collectionsStore.move(folderKeyFor(targetFolder), folderKeyFor(sourceFolder))
moveDialogVisible.value = false
if (isSameFolder(props.selectedFolder, sourceFolder)) {
emit('select', movedFolder)
}
mailStore.notify(
`Folder "${sourceFolder.properties.label || String(sourceFolder.identifier)}" moved to "${targetFolder.properties.label || String(targetFolder.identifier)}"`,
'success',
)
} catch (error: unknown) {
console.error('[FolderTree] Failed to move folder:', error)
mailStore.notify(error instanceof Error ? error.message : 'Failed to move folder', 'error')
}
}
// Computed: all folders for validation
const allFolders = computed(() =>
servicesStore.servicesEnabled.flatMap(service =>
collectionsStore.collectionsForService(service.provider, service.identifier),
)
)
interface ServiceGroup {
service: ServiceObject
loading: boolean
loaded: boolean
error: string | null
}
const serviceGroups = computed(() => {
const groups: ServiceGroup[] = []
servicesStore.servicesEnabled.forEach(service => {
groups.push({
service,
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailStore.getServiceFolderError(service.provider, service.identifier),
})
})
return groups
})
</script>
<template>
<v-list density="compact" nav>
<!-- Tree View -->
<FolderTreeView
v-if="folderViewMode === 'tree'"
:selected-folder="selectedFolder"
:service-groups="serviceGroups"
@select="emit('select', $event)"
@create-folder="handleCreateFolder"
@edit-folder="handleEditFolder"
@move-folder="handleMoveFolder"
@delete-folder="handleDeleteFolder"
/>
<!-- Page-based View -->
<FolderPageView
v-else
:selected-folder="selectedFolder"
:service-groups="serviceGroups"
@select="emit('select', $event)"
@create-folder="handleCreateFolder"
@edit-folder="handleEditFolder"
@move-folder="handleMoveFolder"
@delete-folder="handleDeleteFolder"
/>
<!-- Empty state -->
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
<v-list-item-title class="text-center text-medium-emphasis">
No mail accounts configured
</v-list-item-title>
</v-list-item>
</v-list>
<!-- Create Folder Dialog -->
<CreateFolderDialog
v-if="createDialogService"
v-model="createDialogVisible"
:service="createDialogService"
:parent-folder="createDialogParent"
:all-folders="allFolders"
@created="handleFolderCreated"
/>
<RenameFolderDialog
v-if="renameDialogService && renameDialogFolder"
v-model="renameDialogVisible"
:service="renameDialogService"
:folder="renameDialogFolder"
:all-folders="allFolders"
@updated="handleFolderRenamed"
/>
<FolderSelectionDialog
v-if="moveDialogService && moveDialogFolder"
v-model="moveDialogVisible"
:service="moveDialogService"
:loading="collectionsStore.transceiving"
title="Move Folder"
confirm-text="Move Folder"
empty-text="No valid target folders are available."
:disabled-folder-keys="moveDialogInvalidFolderKeys"
@cancel="handleMoveDialogCancel"
@select="handleFolderMove"
/>
<DeleteFolderDialog
v-if="deleteDialogService && deleteDialogFolder"
v-model="deleteDialogVisible"
:service="deleteDialogService"
:folder="deleteDialogFolder"
@deleted="handleFolderDeleted"
/>
</template>
<style scoped>
.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.12);
}
</style>

View File

@@ -17,17 +17,17 @@ const expanded = ref(false)
const emit = defineEmits<{ const emit = defineEmits<{
select: [folder: CollectionObject] select: [folder: CollectionObject]
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject] createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject] editFolder: [folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject] moveFolder: [folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject] deleteFolder: [folder: CollectionObject]
}>() }>()
const childFolders = computed(() => { const childFolders = computed(() => {
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier, props.folder.identifier) return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
}) })
const hasChildren = computed(() => { const hasChildren = computed(() => {
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier, props.folder.identifier) return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
}) })
const canDeleteFolder = computed(() => !props.folder.properties.role) const canDeleteFolder = computed(() => !props.folder.properties.role)
@@ -131,7 +131,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact"> <v-list density="compact">
<v-list-item <v-list-item
prepend-icon="mdi-pencil" prepend-icon="mdi-pencil"
@click="emit('editFolder', service, folder)" @click="emit('editFolder', folder)"
> >
<v-list-item-title>Edit Folder Name</v-list-item-title> <v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item> </v-list-item>
@@ -143,7 +143,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item> </v-list-item>
<v-list-item <v-list-item
prepend-icon="mdi-folder-move" prepend-icon="mdi-folder-move"
@click="emit('moveFolder', service, folder)" @click="emit('moveFolder', folder)"
> >
<v-list-item-title>Move Folder</v-list-item-title> <v-list-item-title>Move Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -151,7 +151,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder" v-if="canDeleteFolder"
prepend-icon="mdi-delete" prepend-icon="mdi-delete"
base-color="error" base-color="error"
@click="emit('deleteFolder', service, folder)" @click="emit('deleteFolder', folder)"
> >
<v-list-item-title>Delete Folder</v-list-item-title> <v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -170,9 +170,9 @@ const isSelected = (folder: CollectionObject): boolean => {
:selected-folder="selectedFolder" :selected-folder="selectedFolder"
@select="emit('select', $event)" @select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)" @create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)" @edit-folder="(folder) => emit('editFolder', folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)" @move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)" @delete-folder="(folder) => emit('deleteFolder', folder)"
/> />
</div> </div>
</v-list-group> </v-list-group>
@@ -217,7 +217,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact"> <v-list density="compact">
<v-list-item <v-list-item
prepend-icon="mdi-pencil" prepend-icon="mdi-pencil"
@click="emit('editFolder', service, folder)" @click="emit('editFolder', folder)"
> >
<v-list-item-title>Edit Folder Name</v-list-item-title> <v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item> </v-list-item>
@@ -229,7 +229,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item> </v-list-item>
<v-list-item <v-list-item
prepend-icon="mdi-folder-move" prepend-icon="mdi-folder-move"
@click="emit('moveFolder', service, folder)" @click="emit('moveFolder', folder)"
> >
<v-list-item-title>Move Folder</v-list-item-title> <v-list-item-title>Move Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -237,7 +237,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder" v-if="canDeleteFolder"
prepend-icon="mdi-delete" prepend-icon="mdi-delete"
base-color="error" base-color="error"
@click="emit('deleteFolder', service, folder)" @click="emit('deleteFolder', folder)"
> >
<v-list-item-title>Delete Folder</v-list-item-title> <v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item> </v-list-item>

View File

@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
const emit = defineEmits<{ const emit = defineEmits<{
select: [folder: CollectionObject] select: [folder: CollectionObject]
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null] createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
editFolder: [service: ServiceObject, folder: CollectionObject] editFolder: [folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject] moveFolder: [folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject] deleteFolder: [folder: CollectionObject]
}>() }>()
const getRootFolders = (service: ServiceObject): CollectionObject[] => { const getRootFolders = (service: ServiceObject): CollectionObject[] => {
@@ -75,9 +75,9 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
:selected-folder="selectedFolder" :selected-folder="selectedFolder"
@select="emit('select', $event)" @select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)" @create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)" @edit-folder="(folder) => emit('editFolder', folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)" @move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)" @delete-folder="(folder) => emit('deleteFolder', folder)"
/> />
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item"> <v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore'
import { useMailUiStore } from '@/stores/mailUiStore'
import { useUser } from '@KTXC'
import FolderTreeView from './FolderTreeView.vue'
import FolderPageView from './FolderPageView.vue'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
type FolderViewMode = 'tree' | 'page'
interface ServiceGroup {
service: ServiceObject
loading: boolean
loaded: boolean
error: string | null
}
const props = defineProps<{
selectedFolder?: CollectionObject | null
}>()
// Emits
const emit = defineEmits<{
select: [folder: CollectionObject]
}>()
// Stores
const servicesStore = useServicesStore()
const mailStore = useMailStore()
const mailUiStore = useMailUiStore()
const { settings } = useUser()
// Computed
const folderViewMode = computed(() => {
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
})
const serviceGroups = computed(() => {
const groups: ServiceGroup[] = []
servicesStore.servicesEnabled.forEach(service => {
if (service.identifier === null) {
return
}
groups.push({
service,
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
error: mailStore.getServiceFolderError(service.provider, service.identifier),
})
})
return groups
})
// Handlers
const handleFolderCreate = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
mailUiStore.openCreateFolderDialog(service, parentFolder)
}
const handleFolderEdit = (folder: CollectionObject) => {
mailUiStore.openRenameFolderDialog(folder)
}
const handleFolderDelete = (folder: CollectionObject) => {
mailUiStore.openDeleteFolderDialog(folder)
}
const handleFolderMove = (folder: CollectionObject) => {
mailUiStore.openMoveFolderDialog(folder)
}
</script>
<template>
<v-list density="compact" nav>
<!-- Tree View -->
<FolderTreeView
v-if="folderViewMode === 'tree'"
:selected-folder="selectedFolder"
:service-groups="serviceGroups"
@select="emit('select', $event)"
@create-folder="handleFolderCreate"
@edit-folder="handleFolderEdit"
@move-folder="handleFolderMove"
@delete-folder="handleFolderDelete"
/>
<!-- Page-based View -->
<FolderPageView
v-else
:selected-folder="selectedFolder"
:service-groups="serviceGroups"
@select="emit('select', $event)"
@create-folder="handleFolderCreate"
@edit-folder="handleFolderEdit"
@move-folder="handleFolderMove"
@delete-folder="handleFolderDelete"
/>
<!-- Empty state -->
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
<v-list-item-title class="text-center text-medium-emphasis">
No mail accounts configured
</v-list-item-title>
</v-list-item>
</v-list>
</template>
<style scoped>
.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.12);
}
</style>

View File

@@ -1,22 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue' import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3' import { storeToRefs } from 'pinia'
import { useEditor } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline' import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align' import TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import { entityService } from '@MailManager/services'
import type { EntityInterface } from '@MailManager/types/entity'
import type { MessageInterface } from '@MailManager/types/message'
import type { CollectionInterface } from '@MailManager/types/collection'
import { MessageObject } from '@MailManager/models/message'
import { EntityObject } from '@MailManager/models/entity' import { EntityObject } from '@MailManager/models/entity'
import type { CollectionObject } from '@MailManager/models'
import { useMailStore } from '@/stores/mailStore'
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
// Props // Props
interface Props { interface Props {
replyTo?: EntityInterface<MessageInterface> | null mode: 'new' | 'reply' | 'forward'
folder?: CollectionInterface | null source?: EntityObject | null
folder?: CollectionObject | null
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -27,6 +29,13 @@ const emit = defineEmits<{
sent: [] sent: []
}>() }>()
const mailStore = useMailStore()
const {
composerSending: sending,
composerSaving: saving,
composerLastSaved: lastSaved,
} = storeToRefs(mailStore)
// State // State
const to = ref<string[]>([]) const to = ref<string[]>([])
const cc = ref<string[]>([]) const cc = ref<string[]>([])
@@ -34,10 +43,6 @@ const bcc = ref<string[]>([])
const subject = ref('') const subject = ref('')
const showCc = ref(false) const showCc = ref(false)
const showBcc = ref(false) const showBcc = ref(false)
const sending = ref(false)
const saving = ref(false)
const lastSaved = ref<Date | null>(null)
const draftId = ref<string | null>(null)
// Auto-save timer // Auto-save timer
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
@@ -65,25 +70,65 @@ const editor = useEditor({
}, },
}) })
// Initialize from reply-to message function resetComposerFields() {
if (props.replyTo) { to.value = []
const replyMessage = new MessageObject(props.replyTo.properties) cc.value = []
bcc.value = []
subject.value = ''
showCc.value = false
showBcc.value = false
editor.value?.commands.setContent('')
}
const fromEmail = replyMessage.from?.address function initializeComposerFromProps() {
mailStore.resetComposerState()
resetComposerFields()
if (!props.source) {
return
}
const sourceMessage = props.source.properties
const originalSubject = sourceMessage.subject || ''
const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || ''
const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown'
const sentAt = sourceMessage.sent || props.source.created || ''
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
if (props.mode === 'reply') {
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
to.value = fromEmail ? [fromEmail] : [] to.value = fromEmail ? [fromEmail] : []
subject.value = /^Re:/i.test(originalSubject)
const originalSubject = replyMessage.subject || ''
subject.value = originalSubject.startsWith('Re:')
? originalSubject ? originalSubject
: `Re: ${originalSubject}` : `Re: ${originalSubject}`
editor.value?.commands.setContent(
// Add quoted reply - prefer HTML content, fallback to text `<p><br></p><p>On ${sentLabel}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`,
const originalBody = replyMessage.getHtmlContent() || replyMessage.getTextContent() || '' )
const senderName = replyMessage.from?.label || replyMessage.from?.address || 'Unknown' return
const quotedReply = `<p><br></p><p>On ${new Date(replyMessage.date || '').toLocaleString()}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`
editor.value?.commands.setContent(quotedReply)
} }
if (props.mode === 'forward') {
subject.value = /^Fwd:/i.test(originalSubject)
? originalSubject
: `Fwd: ${originalSubject}`
editor.value?.commands.setContent(
`<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
)
}
}
watch(
[() => props.mode, () => props.source, () => editor.value],
([, , currentEditor]) => {
if (!currentEditor) {
return
}
initializeComposerFromProps()
},
{ immediate: true },
)
// Computed // Computed
const canSend = computed(() => { const canSend = computed(() => {
return to.value.length > 0 && subject.value.trim().length > 0 return to.value.length > 0 && subject.value.trim().length > 0
@@ -110,10 +155,8 @@ const saveDraft = async () => {
return return
} }
saving.value = true
try { try {
const draftData = { await mailStore.saveComposerDraft(props.folder, {
to: to.value, to: to.value,
cc: cc.value, cc: cc.value,
bcc: bcc.value, bcc: bcc.value,
@@ -122,27 +165,9 @@ const saveDraft = async () => {
html: editor.value?.getHTML() || '', html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '', text: editor.value?.getText() || '',
}, },
}
// Find drafts folder for this service
// For now, we'll use the current folder's service
// In a real implementation, you'd find the actual Drafts folder
const response = await entityService.create({
provider: props.folder.provider,
service: props.folder.service,
collection: props.folder.identifier, // Should be drafts folder ID
properties: draftData,
}) })
if (response) {
draftId.value = String(response.identifier)
}
lastSaved.value = new Date()
} catch (error) { } catch (error) {
console.error('[MessageComposer] Failed to save draft:', error) console.error('[Mail][Composer] Failed to save draft:', error)
} finally {
saving.value = false
} }
} }
@@ -173,53 +198,33 @@ onBeforeUnmount(() => {
if (autoSaveTimer) { if (autoSaveTimer) {
clearTimeout(autoSaveTimer) clearTimeout(autoSaveTimer)
} }
mailStore.resetComposerState()
editor.value?.destroy() editor.value?.destroy()
}) })
// Handlers // Handlers
const handleClose = () => { const handleClose = () => {
mailStore.resetComposerState()
emit('close') emit('close')
} }
const handleSend = async () => { const handleSend = async () => {
if (!canSend.value || sending.value) return if (!canSend.value || sending.value) return
sending.value = true
try { try {
await entityService.transmit({ await mailStore.sendComposerMessage({
message: {
to: to.value, to: to.value,
cc: cc.value.length > 0 ? cc.value : undefined, cc: cc.value,
bcc: bcc.value.length > 0 ? bcc.value : undefined, bcc: bcc.value,
subject: subject.value, subject: subject.value,
body: { body: {
html: editor.value?.getHTML() || '', html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '', text: editor.value?.getText() || '',
}, },
},
}) })
// Delete draft if it was saved
if (draftId.value && props.folder) {
try {
await entityService.delete({
provider: props.folder.provider,
service: props.folder.service,
collection: props.folder.identifier,
identifier: draftId.value,
})
} catch (error) {
console.error('[MessageComposer] Failed to delete draft:', error)
}
}
emit('sent') emit('sent')
} catch (error) { } catch (error) {
console.error('[MessageComposer] Failed to send message:', error) console.error('[Mail][Composer] Failed to send message:', error)
alert('Failed to send message. Please try again.')
} finally {
sending.value = false
} }
} }
@@ -248,192 +253,61 @@ const removeLink = () => editor.value?.chain().focus().unsetLink().run()
const isActive = (name: string, attrs?: any) => { const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false return editor.value?.isActive(name, attrs) || false
} }
const toggleLink = () => {
if (isActive('link')) {
removeLink()
return
}
setLink()
}
</script> </script>
<template> <template>
<div class="message-composer"> <div class="message-composer">
<!-- Toolbar --> <ComposerToolbar
<v-toolbar density="compact" elevation="0" class="composer-toolbar"> :mode="mode"
<v-btn :save-status="saveStatus"
variant="text" :can-send="canSend"
@click="handleClose" :sending="sending"
icon="mdi-close" @close="handleClose"
> @send="handleSend"
<v-icon>mdi-close</v-icon> />
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
</v-btn>
<v-toolbar-title>
{{ replyTo ? 'Reply' : 'New Message' }}
</v-toolbar-title>
<v-spacer />
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
{{ saveStatus }}
</span>
<v-btn
color="primary"
:disabled="!canSend"
:loading="sending"
@click="handleSend"
prepend-icon="mdi-send"
>
Send
</v-btn>
</v-toolbar>
<!-- Composer content -->
<div class="composer-content"> <div class="composer-content">
<!-- Recipients --> <ComposerRecipients
<div class="composer-fields pa-4"> :to="to"
<v-combobox :cc="cc"
v-model="to" :bcc="bcc"
label="To" :subject="subject"
chips :show-cc="showCc"
multiple :show-bcc="showBcc"
closable-chips @update:to="to = $event"
variant="outlined" @update:cc="cc = $event"
density="compact" @update:bcc="bcc = $event"
class="mb-2" @update:subject="subject = $event"
> @toggle:cc="toggleCc"
<template v-slot:append-inner> @toggle:bcc="toggleBcc"
<v-btn
size="x-small"
variant="text"
@click="toggleCc"
class="mr-1"
>
Cc
</v-btn>
<v-btn
size="x-small"
variant="text"
@click="toggleBcc"
>
Bcc
</v-btn>
</template>
</v-combobox>
<v-combobox
v-if="showCc"
v-model="cc"
label="Cc"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
/> />
<v-combobox
v-if="showBcc"
v-model="bcc"
label="Bcc"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
/>
<v-text-field
v-model="subject"
label="Subject"
variant="outlined"
density="compact"
/>
</div>
<v-divider /> <v-divider />
<!-- Editor toolbar --> <ComposerEditor
<v-toolbar density="compact" elevation="0" class="editor-toolbar"> :editor="editor"
<v-btn :is-bold-active="isActive('bold')"
icon :is-italic-active="isActive('italic')"
size="small" :is-underline-active="isActive('underline')"
:class="{ 'v-btn--active': isActive('bold') }" :is-bullet-list-active="isActive('bulletList')"
@click="toggleBold" :is-ordered-list-active="isActive('orderedList')"
> :is-link-active="isActive('link')"
<v-icon>mdi-format-bold</v-icon> @bold="toggleBold"
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip> @italic="toggleItalic"
</v-btn> @underline="toggleUnderline"
@bullet-list="toggleBulletList"
<v-btn @ordered-list="toggleOrderedList"
icon @link="toggleLink"
size="small" />
:class="{ 'v-btn--active': isActive('italic') }"
@click="toggleItalic"
>
<v-icon>mdi-format-italic</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('underline') }"
@click="toggleUnderline"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('bulletList') }"
@click="toggleBulletList"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('orderedList') }"
@click="toggleOrderedList"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('link') }"
@click="isActive('link') ? removeLink() : setLink()"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn>
<v-spacer />
<v-btn
icon
size="small"
>
<v-icon>mdi-paperclip</v-icon>
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
</v-btn>
</v-toolbar>
<v-divider />
<!-- Editor -->
<div class="editor-container">
<editor-content :editor="editor" />
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -457,65 +331,4 @@ const isActive = (name: string, attrs?: any) => {
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.composer-fields {
flex-shrink: 0;
}
.editor-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
}
.editor-container {
flex: 1;
overflow-y: auto;
background-color: rgb(var(--v-theme-background));
}
.v-btn--active {
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
:deep(.tiptap-editor) {
outline: none;
min-height: 300px;
p.is-editor-empty:first-child::before {
color: rgb(var(--v-theme-on-surface-variant));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
margin-top: 1em;
margin-bottom: 0.5em;
}
p {
margin-bottom: 0.5em;
}
ul, ol {
padding-left: 1.5em;
margin-bottom: 0.5em;
}
blockquote {
border-left: 3px solid rgb(var(--v-border-color));
padding-left: 1em;
margin-left: 0;
margin-bottom: 0.5em;
color: rgb(var(--v-theme-on-surface-variant));
}
a {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
}
}
</style> </style>

View File

@@ -38,34 +38,26 @@ const LONG_PRESS_MS = 450
const selectedIdSet = computed(() => new Set(props.selectionList)) const selectedIdSet = computed(() => new Set(props.selectionList))
const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false
return (
message.provider === props.selectedMessage.provider &&
message.service === props.selectedMessage.service &&
message.collection === props.selectedMessage.collection &&
message.identifier === props.selectedMessage.identifier
)
}
const isSelected = (message: EntityObject): boolean => {
return selectedIdSet.value.has(
`${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier,
)
}
// Check if message is unread
const isUnread = (message: EntityObject): boolean => {
return !message.properties.flags?.read
}
// Check if message is flagged
const isFlagged = (message: EntityObject): boolean => {
return message.properties.flags?.flagged || false
}
const currentMessages = computed(() => props.messages ?? []) const currentMessages = computed(() => props.messages ?? [])
const getMessageTimestamp = (message: EntityObject): string | null => {
return message.properties.received
|| message.properties.sent
|| message.modified
|| message.created
|| null
}
const getMessageTimeValue = (message: EntityObject): number => {
const timestamp = getMessageTimestamp(message)
if (!timestamp) {
return 0
}
const timeValue = new Date(timestamp).getTime()
return Number.isNaN(timeValue) ? 0 : timeValue
}
const selectionCount = computed(() => props.selectionList.length) const selectionCount = computed(() => props.selectionList.length)
const hasSelection = computed(() => selectionCount.value > 0) const hasSelection = computed(() => selectionCount.value > 0)
@@ -74,11 +66,45 @@ const allCurrentMessagesSelected = computed(() => {
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message)) return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
}) })
// Sorted messages (newest first)
const sortedMessages = computed(() => {
return [...currentMessages.value].sort((a, b) => {
const dateA = getMessageTimeValue(a)
const dateB = getMessageTimeValue(b)
return dateB - dateA
})
})
// Read/Unread counts from collection properties
const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0
})
const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0
})
// True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => {
return props.selectedCollection?.properties.total != null
})
const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false
return (message.identifier === props.selectedMessage.identifier)
}
const isSelected = (message: EntityObject): boolean => {
return selectedIdSet.value.has(message.identifier)
}
// Format date for display // Format date for display
const formatDate = (date: Date | string | null | undefined): string => { const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return '' if (!date) return ''
const messageDate = new Date(date) const messageDate = new Date(date)
if (Number.isNaN(messageDate.getTime())) return ''
const now = new Date() const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today) const yesterday = new Date(today)
@@ -121,11 +147,24 @@ const truncate = (text: string | null | undefined, length: number = 100): string
return text.length > length ? text.substring(0, length) + '...' : text return text.length > length ? text.substring(0, length) + '...' : text
} }
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
return event.target instanceof Element && event.target.closest('.message-selection-checkbox') !== null
}
const handleSelectionToggle = (message: EntityObject) => { const handleSelectionToggle = (message: EntityObject) => {
emit('toggleSelection', message) emit('toggleSelection', message)
} }
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => { const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (event.shiftKey && !props.selectionMode) { if (event.shiftKey && !props.selectionMode) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -138,11 +177,6 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
return return
} }
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (props.selectionMode) { if (props.selectionMode) {
emit('toggleSelection', message) emit('toggleSelection', message)
return return
@@ -152,6 +186,10 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
} }
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => { const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (!event.shiftKey || props.selectionMode) { if (!event.shiftKey || props.selectionMode) {
return return
} }
@@ -200,29 +238,6 @@ onBeforeUnmount(() => {
const handleSelectAllToggle = (value: boolean | null) => { const handleSelectAllToggle = (value: boolean | null) => {
emit('toggleSelectAll', value === true) emit('toggleSelectAll', value === true)
} }
// Sorted messages (newest first)
const sortedMessages = computed(() => {
return [...currentMessages.value].sort((a, b) => {
const dateA = a.properties.date ? new Date(a.properties.date).getTime() : 0
const dateB = b.properties.date ? new Date(b.properties.date).getTime() : 0
return dateB - dateA
})
})
// Read/Unread counts from collection properties
const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0
})
const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0
})
// True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => {
return props.selectedCollection?.properties.total != null
})
</script> </script>
<template> <template>
@@ -282,7 +297,6 @@ const hasCountData = computed(() => {
size="small" size="small"
icon="mdi-close" icon="mdi-close"
variant="text" variant="text"
:disabled="!hasSelection"
@click="emit('clearSelection')" @click="emit('clearSelection')"
> >
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
@@ -320,13 +334,13 @@ const hasCountData = computed(() => {
> >
<template v-slot:default="{ item: message }"> <template v-slot:default="{ item: message }">
<v-list-item <v-list-item
:key="`${message.provider}:${message.service}:${message.collection}:${message.identifier}`" :key="message.identifier"
class="message-item" class="message-item"
:class="{ :class="{
'opened': isOpened(message), 'opened': isOpened(message),
'selected': isSelected(message), 'selected': isSelected(message),
'selection-mode': selectionMode, 'selection-mode': selectionMode,
'unread': isUnread(message) 'unread': !message.properties.isRead
}" }"
@mousedown="handleMessageMouseDown($event, message)" @mousedown="handleMessageMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)" @click="handleMessageMouseClick($event, message)"
@@ -360,7 +374,7 @@ const hasCountData = computed(() => {
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }} {{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
</span> </span>
<span class="text-caption text-medium-emphasis ml-2"> <span class="text-caption text-medium-emphasis ml-2">
{{ formatDate(message.properties.date) }} {{ formatDate(getMessageTimestamp(message)) }}
</span> </span>
</v-list-item-title> </v-list-item-title>
@@ -369,13 +383,13 @@ const hasCountData = computed(() => {
</v-list-item-subtitle> </v-list-item-subtitle>
<v-list-item-subtitle class="text-caption text-truncate"> <v-list-item-subtitle class="text-caption text-truncate">
{{ truncate(message.properties.snippet, 80) }} {{ '' }}
</v-list-item-subtitle> </v-list-item-subtitle>
<template v-slot:append> <template v-slot:append>
<div class="d-flex flex-column align-center"> <div class="d-flex flex-column align-center">
<v-icon <v-icon
v-if="isFlagged(message)" v-if="message.properties.isFlagged"
size="small" size="small"
color="warning" color="warning"
class="mb-1" class="mb-1"
@@ -383,7 +397,7 @@ const hasCountData = computed(() => {
mdi-star mdi-star
</v-icon> </v-icon>
<v-icon <v-icon
v-if="message.properties.attachments && message.properties.attachments.length > 0" v-if="message.properties.hasAttachments"
size="small" size="small"
color="grey" color="grey"
> >

View File

@@ -1,7 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models' import type { ServiceObject } from '@MailManager/models'
@@ -9,23 +7,25 @@ interface Props {
modelValue: boolean modelValue: boolean
service: ServiceObject service: ServiceObject
folder: CollectionObject folder: CollectionObject
allFolders?: CollectionObject[] parentFolderLabel?: string
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
allFolders: () => [] parentFolderLabel: 'Root',
validateName: () => [],
loading: false,
errorMessage: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
updated: [folder: CollectionObject] confirm: [folderName: string]
}>() }>()
const collectionsStore = useCollectionsStore()
const folderName = ref('') const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([]) const validationErrors = ref<string[]>([])
const dialogValue = computed({ const dialogValue = computed({
@@ -37,74 +37,13 @@ const isValid = computed(() => {
return folderName.value.trim().length > 0 && validationErrors.value.length === 0 return folderName.value.trim().length > 0 && validationErrors.value.length === 0
}) })
const parentFolderLabel = computed(() => {
const parentId = props.folder.collection
if (parentId === null || parentId === undefined) return 'Root'
const parent = props.allFolders.find(
f =>
String(f.identifier) === String(parentId) &&
f.provider === props.folder.provider &&
String(f.service) === String(props.folder.service)
)
return parent?.properties.label || 'Root'
})
const validateFolderName = (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 (props.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
}
const checkDuplicateName = (name: string): boolean => {
const parentId = props.folder.collection ?? null
return props.allFolders.some(f => {
if (String(f.identifier) === String(props.folder.identifier)) return false
return (
f.properties.label === name &&
String(f.collection) === String(parentId) &&
f.provider === props.folder.provider &&
String(f.service) === String(props.folder.service)
)
})
}
watch(folderName, (newName) => { watch(folderName, (newName) => {
errorMessage.value = '' validationErrors.value = props.validateName(newName)
validationErrors.value = validateFolderName(newName)
if (validationErrors.value.length === 0 && newName.trim().length > 0 && checkDuplicateName(newName)) {
validationErrors.value.push('A folder with this name already exists in this location')
}
}) })
function resetForm() { function resetForm() {
folderName.value = props.folder.properties.label || '' folderName.value = props.folder.properties.label || ''
errorMessage.value = ''
validationErrors.value = [] validationErrors.value = []
loading.value = false
} }
watch(dialogValue, (isOpen) => { watch(dialogValue, (isOpen) => {
@@ -114,54 +53,27 @@ watch(dialogValue, (isOpen) => {
}, { immediate: true }) }, { immediate: true })
const handleRename = async () => { const handleRename = async () => {
const errors = validateFolderName(folderName.value) const errors = props.validateName(folderName.value)
if (errors.length > 0) { if (errors.length > 0) {
validationErrors.value = errors validationErrors.value = errors
return return
} }
if (checkDuplicateName(folderName.value)) {
validationErrors.value = ['A folder with this name already exists in this location']
return
}
const newName = folderName.value.trim() const newName = folderName.value.trim()
if (newName === props.folder.properties.label) { if (newName === props.folder.properties.label) {
dialogValue.value = false dialogValue.value = false
return return
} }
loading.value = true emit('confirm', newName)
errorMessage.value = ''
try {
const properties = new CollectionPropertiesObject()
properties.label = newName
properties.rank = props.folder.properties.rank ?? 0
properties.subscribed = props.folder.properties.subscribed ?? true
const updatedFolder = await collectionsStore.update(
props.folder.provider,
props.folder.service,
props.folder.identifier,
properties
)
emit('updated', updatedFolder)
dialogValue.value = false
resetForm()
} catch (error: any) {
console.error('[RenameFolderDialog] Failed to rename folder:', error)
errorMessage.value = error.message || 'Failed to rename folder. Please try again.'
} finally {
loading.value = false
}
} }
const handleCancel = () => { const handleCancel = () => {
dialogValue.value = false dialogValue.value = false
resetForm() resetForm()
} }
</script> </script>
<template> <template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import { EditorContent, type Editor } from '@tiptap/vue-3'
defineProps({
editor: {
type: Object as PropType<Editor | null>,
default: null,
},
isBoldActive: {
type: Boolean,
required: true,
},
isItalicActive: {
type: Boolean,
required: true,
},
isUnderlineActive: {
type: Boolean,
required: true,
},
isBulletListActive: {
type: Boolean,
required: true,
},
isOrderedListActive: {
type: Boolean,
required: true,
},
isLinkActive: {
type: Boolean,
required: true,
},
})
defineEmits<{
bold: []
italic: []
underline: []
bulletList: []
orderedList: []
link: []
}>()
</script>
<template>
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBoldActive }"
@click="$emit('bold')"
>
<v-icon>mdi-format-bold</v-icon>
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isItalicActive }"
@click="$emit('italic')"
>
<v-icon>mdi-format-italic</v-icon>
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isUnderlineActive }"
@click="$emit('underline')"
>
<v-icon>mdi-format-underline</v-icon>
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isBulletListActive }"
@click="$emit('bulletList')"
>
<v-icon>mdi-format-list-bulleted</v-icon>
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isOrderedListActive }"
@click="$emit('orderedList')"
>
<v-icon>mdi-format-list-numbered</v-icon>
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
</v-btn>
<v-divider vertical class="mx-2" />
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isLinkActive }"
@click="$emit('link')"
>
<v-icon>mdi-link</v-icon>
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
</v-btn>
<v-spacer />
<v-btn icon size="small">
<v-icon>mdi-paperclip</v-icon>
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
</v-btn>
</v-toolbar>
<v-divider />
<div class="editor-container">
<EditorContent :editor="editor" />
</div>
</template>
<style scoped lang="scss">
.editor-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
}
.editor-container {
flex: 1;
overflow-y: auto;
background-color: rgb(var(--v-theme-background));
}
.v-btn--active {
background-color: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
:deep(.tiptap-editor) {
outline: none;
min-height: 300px;
p.is-editor-empty:first-child::before {
color: rgb(var(--v-theme-on-surface-variant));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
margin-top: 1em;
margin-bottom: 0.5em;
}
p {
margin-bottom: 0.5em;
}
ul, ol {
padding-left: 1.5em;
margin-bottom: 0.5em;
}
blockquote {
border-left: 3px solid rgb(var(--v-border-color));
padding-left: 1em;
margin-left: 0;
margin-bottom: 0.5em;
color: rgb(var(--v-theme-on-surface-variant));
}
a {
color: rgb(var(--v-theme-primary));
text-decoration: underline;
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
interface Props {
to: string[]
cc: string[]
bcc: string[]
subject: string
showCc: boolean
showBcc: boolean
}
defineProps<Props>()
defineEmits<{
'update:to': [value: string[]]
'update:cc': [value: string[]]
'update:bcc': [value: string[]]
'update:subject': [value: string]
'toggle:cc': []
'toggle:bcc': []
}>()
</script>
<template>
<div class="composer-fields pa-4">
<v-combobox
:model-value="to"
label="To"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="$emit('update:to', $event)"
>
<template #append-inner>
<v-btn
size="x-small"
variant="text"
class="mr-1"
@click="$emit('toggle:cc')"
>
Cc
</v-btn>
<v-btn
size="x-small"
variant="text"
@click="$emit('toggle:bcc')"
>
Bcc
</v-btn>
</template>
</v-combobox>
<v-combobox
v-if="showCc"
:model-value="cc"
label="Cc"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="$emit('update:cc', $event)"
/>
<v-combobox
v-if="showBcc"
:model-value="bcc"
label="Bcc"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
@update:model-value="$emit('update:bcc', $event)"
/>
<v-text-field
:model-value="subject"
label="Subject"
variant="outlined"
density="compact"
@update:model-value="$emit('update:subject', $event)"
/>
</div>
</template>
<style scoped lang="scss">
.composer-fields {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
interface Props {
mode: 'new' | 'reply' | 'forward'
saveStatus: string
canSend: boolean
sending: boolean
}
defineProps<Props>()
defineEmits<{
close: []
send: []
}>()
</script>
<template>
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
<v-btn
variant="text"
icon="mdi-close"
@click="$emit('close')"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
</v-btn>
<v-toolbar-title>
{{ mode === 'reply' ? 'Reply' : mode === 'forward' ? 'Forward' : 'New Message' }}
</v-toolbar-title>
<v-spacer />
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
{{ saveStatus }}
</span>
<v-btn
color="primary"
:disabled="!canSend"
:loading="sending"
prepend-icon="mdi-send"
@click="$emit('send')"
>
Send
</v-btn>
</v-toolbar>
</template>
<style scoped lang="scss">
.composer-toolbar {
flex-shrink: 0;
border-bottom: 1px solid rgb(var(--v-border-color));
}
</style>

View File

@@ -1,17 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted, unref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC' import { useModuleStore } from '@KTXC'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import { useMailUiStore } from '@/stores/mailUiStore'
import type { CollectionObject, EntityObject } from '@MailManager/models' import type { CollectionObject, EntityObject } from '@MailManager/models'
import type { EntityIdentifier } from '@MailManager/types/common'
import FolderTree from '@/components/FolderTree.vue'
import MessageList from '@/components/MessageList.vue' import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue' import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue' import MessageComposer from '@/components/MessageComposer.vue'
import CreateFolderDialog from '@/components/CreateFolderDialog.vue'
import DeleteFolderDialog from '@/components/DeleteFolderDialog.vue'
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue' import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
import RenameFolderDialog from '@/components/RenameFolderDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue' import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import FolderView from '@/components/FolderView.vue'
// Vuetify display for responsive behavior // Vuetify display for responsive behavior
const display = useDisplay() const display = useDisplay()
@@ -19,94 +23,175 @@ const isMobile = computed(() => display.mdAndDown.value)
// Check if mail manager is available // Check if mail manager is available
const moduleStore = useModuleStore() const moduleStore = useModuleStore()
const isMailManagerAvailable = computed(() => { const isManagerAvailable = computed(() => {
return moduleStore.has('mail_manager') || moduleStore.has('MailManager') return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
}) })
const collectionsStore = useCollectionsStore()
// Mail module store // Mail module store
const mailStore = useMailStore() const mailStore = useMailStore()
const mailUiStore = useMailUiStore()
// storeToRefs preserves reactivity for state and computed properties // storeToRefs preserves reactivity for state and computed properties
const { const {
sidebarVisible,
settingsDialogVisible,
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
currentMessages,
} = storeToRefs(mailStore)
const {
sidebarVisible,
settingsDialogVisible,
composeMode,
composeSource,
composeVisible,
selectionList, selectionList,
selectionMode, selectionMode,
composeMode, moveMessagesDialogVisible,
composeReplyTo, moveMessagesDialogService,
currentMessages, createFolderDialogVisible,
moveDialogVisible, createFolderDialogService,
moveDialogCandidates, createFolderDialogParent,
} = storeToRefs(mailStore) createFolderDialogLoading,
createFolderDialogError,
renameFolderDialogVisible,
renameFolderDialogService,
renameFolderDialogFolder,
renameFolderDialogLoading,
renameFolderDialogError,
moveFolderDialogVisible,
moveFolderDialogService,
moveFolderDialogSource,
deleteFolderDialogVisible,
deleteFolderDialogService,
deleteFolderDialogFolder,
deleteFolderDialogLoading,
deleteFolderDialogError,
} = storeToRefs(mailUiStore)
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailStore const { mailSync, entitiesStore } = mailStore
const lastSyncLabel = computed(() => { const lastSyncLabel = computed(() => {
if (!mailSync.lastSync) return '' const lastSync = unref(unref(mailSync.lastSync))
return `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})`
if (!(lastSync instanceof Date)) return ''
return `(Last: ${lastSync.toLocaleTimeString()})`
}) })
// Initialize // Initialize
onMounted(async () => { onMounted(async () => {
if (!isMailManagerAvailable.value) return if (!isManagerAvailable.value) return
await mailStore.initialize() await mailStore.initialize()
}) })
// Handlers — thin wrappers that delegate to the store // Handlers — thin wrappers that delegate to the store
const {
validateCreateFolderName,
validateRenameFolderName,
} = mailUiStore
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder) const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
const handleMessageOpen = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value) const handleMessageOpen = (message: EntityObject) => {
mailStore.selectMessage(message)
const handleMessageSelectionToggle = (message: EntityObject) => mailStore.toggleMessageSelection(message) if (isMobile.value) {
mailUiStore.closeSidebar()
}
}
const handleSelectionModeActivate = (message: EntityObject) => mailStore.activateSelectionMode(message) const handleMessageSelectionToggle = (message: EntityObject) => mailUiStore.toggleMessageSelection(message)
const handleSelectionModeActivate = (message: EntityObject) => mailUiStore.activateSelectionMode(message)
const handleSelectAllToggle = (value: boolean) => { const handleSelectAllToggle = (value: boolean) => {
if (value) { if (value) {
mailStore.selectAllCurrentMessages() mailUiStore.selectAllCurrentMessages()
return return
} }
mailStore.clearSelection() mailUiStore.clearSelection()
} }
const handleSelectionClear = () => mailStore.deactivateSelectionMode() const handleSelectionClear = () => mailUiStore.deactivateSelectionMode()
const handleSelectionMove = () => mailStore.openMoveDialog() const handleSelectionMove = () => mailUiStore.openMoveMessagesDialog()
const handleSelectionDelete = () => mailStore.deleteMessages([...selectionList.value]) const handleSelectionDelete = () => mailUiStore.deleteSelectedMessages()
const handleCompose = (message?: EntityObject) => mailStore.openCompose(message) const handleCompose = () => mailUiStore.openCompose()
const handleComposeClose = () => mailStore.closeCompose() const handleComposeReply = (message: EntityObject) => mailUiStore.openCompose(message, 'reply')
const handleComposeSent = () => mailStore.afterSent() const handleComposeForward = (message: EntityObject) => mailUiStore.openCompose(message, 'forward')
const handleComposeClose = () => mailUiStore.closeCompose()
const handleComposeSent = () => mailUiStore.afterSent()
const handleDelete = (message: EntityObject) => { const handleDelete = (message: EntityObject) => {
const id = `${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier mailStore.deleteMessages([message.identifier])
mailStore.deleteMessages([id])
} }
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message) const handleMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
const handleMoveConfirm = async (target: CollectionObject) => { await mailStore.moveMessages(target, moveDialogCandidates.value ?? []) } const handleMoveConfirm = async (target: CollectionObject) => { await mailUiStore.confirmMoveMessages(target) }
const handleMoveCancel = () => mailStore.closeMoveDialog() const handleMoveCancel = () => mailUiStore.closeMoveMessagesDialog()
const toggleSidebar = () => mailStore.toggleSidebar() const handleFolderCreateConfirm = async (folderName: string) => {
try {
const mutatedFolder = await mailUiStore.confirmCreateFolder(folderName)
const handleSettingsOpen = () => mailStore.openSettings() if (mutatedFolder) {
handleFolderSelect(mutatedFolder)
}
} catch (error: unknown) {
console.error('[MailPage] Failed to create folder:', error)
}
}
const handleFolderRenameConfirm = async (folderName: string) => {
try {
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
if (mutatedFolder) {
handleFolderSelect(mutatedFolder)
}
} catch (error: unknown) {
console.error('[MailPage] Failed to rename folder:', error)
}
}
const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
try {
await mailUiStore.confirmMoveFolder(targetFolder)
} catch (error: unknown) {
console.error('[MailPage] Failed to move folder:', error)
}
}
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
const handleFolderDeleteConfirm = async () => {
try {
await mailUiStore.confirmDeleteFolder()
} catch (error: unknown) {
console.error('[MailPage] Failed to delete folder:', error)
}
}
const toggleSidebar = () => mailUiStore.toggleSidebar()
const handleSettingsOpen = () => mailUiStore.openSettings()
const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Folder "${folder.properties.label}" created`, 'success')
</script> </script>
<template> <template>
<!-- Manager Unavailable --> <!-- Manager Unavailable -->
<div v-if="!isMailManagerAvailable" class="mail-unavailable"> <div v-if="!isManagerAvailable" class="mail-unavailable">
<v-alert <v-alert
type="warning" type="warning"
variant="outlined" variant="outlined"
@@ -180,10 +265,10 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
width="280" width="280"
class="mail-sidebar" class="mail-sidebar"
> >
<FolderTree
<FolderView
:selected-folder="selectedFolder" :selected-folder="selectedFolder"
@select="handleFolderSelect" @select="handleFolderSelect"
@folder-created="handleFolderCreated"
/> />
<template #append> <template #append>
@@ -225,8 +310,9 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<!-- Reader/Composer panel --> <!-- Reader/Composer panel -->
<div class="mail-reader-panel"> <div class="mail-reader-panel">
<MessageComposer <MessageComposer
v-if="composeMode" v-if="composeVisible"
:reply-to="composeReplyTo" :mode="composeMode"
:source="composeSource"
:folder="selectedFolder" :folder="selectedFolder"
@close="handleComposeClose" @close="handleComposeClose"
@sent="handleComposeSent" @sent="handleComposeSent"
@@ -236,7 +322,8 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
v-else v-else
:entity="selectedMessage" :entity="selectedMessage"
@compose="handleCompose" @compose="handleCompose"
@reply="handleCompose" @reply="handleComposeReply"
@forward="handleComposeForward"
@move="handleMove" @move="handleMove"
@delete="handleDelete" @delete="handleDelete"
/> />
@@ -249,14 +336,62 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<SettingsDialog v-model="settingsDialogVisible" /> <SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog <FolderSelectionDialog
v-model="moveDialogVisible" v-if="moveMessagesDialogService && moveFolderDialogSource"
v-model="moveMessagesDialogVisible"
:service="moveMessagesDialogService"
:loading="loading" :loading="loading"
title="Move To" title="Move Messages To"
confirm-text="Move" confirm-text="Move"
empty-text="No other folders are available in this account." empty-text="No other folders are available in this account."
@select="handleMoveConfirm" @select="handleMoveConfirm"
@cancel="handleMoveCancel" @cancel="handleMoveCancel"
/> />
<FolderSelectionDialog
v-if="moveFolderDialogService && moveFolderDialogSource"
v-model="moveFolderDialogVisible"
:service="moveFolderDialogService"
:loading="collectionsStore.transceiving"
title="Move Folder To"
confirm-text="Move"
empty-text="No other folders are available in this account."
:disabled-folder-keys="mailUiStore.moveFolderDialogInvalidFolderKeys"
@select="handleFolderMoveConfirm"
@cancel="handleFolderMoveCancel"
/>
<CreateFolderDialog
v-if="createFolderDialogService"
v-model="createFolderDialogVisible"
:service="createFolderDialogService"
:parent-folder-label="mailUiStore.createFolderDialogParentLabel"
:validate-name="validateCreateFolderName"
:loading="createFolderDialogLoading"
:error-message="createFolderDialogError"
@confirm="handleFolderCreateConfirm"
/>
<RenameFolderDialog
v-if="renameFolderDialogService && renameFolderDialogFolder"
v-model="renameFolderDialogVisible"
:service="renameFolderDialogService"
:folder="renameFolderDialogFolder"
:parent-folder-label="mailUiStore.renameFolderDialogParentLabel"
:validate-name="validateRenameFolderName"
:loading="renameFolderDialogLoading"
:error-message="renameFolderDialogError"
@confirm="handleFolderRenameConfirm"
/>
<DeleteFolderDialog
v-if="deleteFolderDialogService && deleteFolderDialogFolder"
v-model="deleteFolderDialogVisible"
:service="deleteFolderDialogService"
:folder="deleteFolderDialogFolder"
:loading="deleteFolderDialogLoading"
:error-message="deleteFolderDialogError"
@confirm="handleFolderDeleteConfirm"
/>
</div> </div>
</template> </template>

View File

@@ -1,4 +1,4 @@
import { ref, computed, shallowRef, watch } from 'vue' import { ref, computed, shallowRef } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
@@ -6,7 +6,21 @@ import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync' import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC' import { useSnackbar } from '@KTXC'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' 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', () => { export const useMailStore = defineStore('mailStore', () => {
const servicesStore = useServicesStore() const servicesStore = useServicesStore()
@@ -31,27 +45,17 @@ export const useMailStore = defineStore('mailStore', () => {
} }
// ── General State ─────────────────-─────────────────────────────────────── // ── General State ─────────────────-───────────────────────────────────────
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
const loading = ref(false) const loading = ref(false)
const serviceFolderLoadingState = ref<Record<string, boolean>>({}) const serviceFolderLoadingState = ref<Record<string, boolean>>({})
const serviceFolderLoadedState = ref<Record<string, boolean>>({}) const serviceFolderLoadedState = ref<Record<string, boolean>>({})
const serviceFolderErrorState = ref<Record<string, string | null>>({}) const serviceFolderErrorState = ref<Record<string, string | null>>({})
// ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null) const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const selectionMode = ref(false) const composerSaving = ref(false)
const selectionList = ref<EntityIdentifier[]>([]) const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null)
// ── Compose State ───────────────────────────────────────────────────────── const composerDraftIdentifier = ref<EntityIdentifier | null>(null)
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 ────────────────────────────────────────────────────────────── // ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => { const currentMessages = computed(() => {
@@ -98,11 +102,7 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
// retrieve folders for service // retrieve folders for service
const collections = await collectionsStore.list({ const collections = await collectionsStore.collectionsForService(service.provider, service.identifier, true)
[service.provider]: {
[String(service.identifier)]: true,
},
})
_setServiceFolderLoaded(service.provider, service.identifier, true) _setServiceFolderLoaded(service.provider, service.identifier, true)
@@ -121,7 +121,6 @@ export const useMailStore = defineStore('mailStore', () => {
} }
_updateSyncSources() _updateSyncSources()
return collections
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders' const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message) _setServiceFolderError(service.provider, service.identifier, message)
@@ -139,15 +138,20 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier { 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 return `${item.provider}:${String(item.service)}` as ServiceIdentifier
} }
function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier { function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier if (!left || !right) {
return false
} }
function _entityIdentifier(item: EntityObject): EntityIdentifier { return left.provider === right.provider &&
return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
} }
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) { function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
@@ -176,15 +180,18 @@ export const useMailStore = defineStore('mailStore', () => {
// Track the currently selected folder // Track the currently selected folder
if (selectedFolder.value) { if (selectedFolder.value) {
mailSyncController.addSource({ //mailSyncController.addSource({
provider: selectedFolder.value.provider, // provider: selectedFolder.value.provider,
service: selectedFolder.value.service, // service: selectedFolder.value.service,
collections: [selectedFolder.value.identifier], // collections: [selectedFolder.value.identifier],
}) //})
} }
// Always track inboxes for each account (for new-mail notifications) // Always track inboxes for each account (for new-mail notifications)
servicesStore.servicesEnabled.forEach(service => { servicesStore.servicesEnabled.forEach(service => {
if (service.identifier === null) {
return
}
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter( const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
c => c =>
String(c.service) === String(service.identifier) && String(c.service) === String(service.identifier) &&
@@ -193,11 +200,11 @@ export const useMailStore = defineStore('mailStore', () => {
) )
if (inboxes.length > 0) { if (inboxes.length > 0) {
mailSyncController.addSource({ //mailSyncController.addSource({
provider: service.provider, // provider: service.provider,
service: service.identifier as string | number, // service: service.identifier as string | number,
collections: inboxes.map(inbox => inbox.identifier), // collections: inboxes.map(inbox => inbox.identifier),
}) //})
} }
}) })
@@ -218,58 +225,113 @@ export const useMailStore = defineStore('mailStore', () => {
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
} }
function _reloadFolderMessages(folder: CollectionObject) { function _findDraftFolder(folder: CollectionObject): CollectionObject {
return entitiesStore.list({ return collectionsStore.collectionsForService(folder.provider, folder.service).find(
[folder.provider]: { candidate =>
[String(folder.service)]: { candidate.provider === folder.provider &&
[String(folder.identifier)]: true, 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,
}) })
} }
function _setSelectionList(nextIds: EntityIdentifier[]) { if (html.length > 0) {
selectionList.value = Array.from(new Set(nextIds)) parts.push({
type: 'text/html',
if (selectionList.value.length === 0) { content: html,
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
}
}
watch(currentMessages, () => {
_reconcileSelection()
}) })
}
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 ─────────────────────────────────────────────────────────────── // ── 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) { async function selectFolder(folder: CollectionObject) {
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
try { try {
await _reloadFolderMessages(folder) await entitiesStore.list([folder.identifier])
} catch (error) { } catch (error) {
console.error('[Mail] Failed to load messages:', error) console.error('[Mail] Failed to load messages:', error)
} }
@@ -280,140 +342,216 @@ export const useMailStore = defineStore('mailStore', () => {
function clearSelectedFolder() { function clearSelectedFolder() {
selectedFolder.value = null selectedFolder.value = null
selectedMessage.value = null selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
composeReplyTo.value = null
_updateSyncSources() _updateSyncSources()
} }
function selectMessage(entity: EntityObject, closeSidebar = false) { function selectMessage(entity: EntityObject) {
selectedMessage.value = entity selectedMessage.value = entity
composeMode.value = false
if (closeSidebar) {
sidebarVisible.value = false
}
} }
function openCompose(replyTo?: EntityObject) { function clearSelectedMessage() {
composeMode.value = true
composeReplyTo.value = replyTo ?? null
selectedMessage.value = null selectedMessage.value = null
} }
function closeCompose() { async function reloadSelectedFolder() {
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 // Reload the current folder so the sent message appears in Sent
if (selectedFolder.value) { if (selectedFolder.value) {
await selectFolder(selectedFolder.value) await selectFolder(selectedFolder.value)
} }
} }
function isMessageSelected(message: EntityObject) { async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
return selectionList.value.includes(_entityIdentifier(message)) composerSaving.value = true
}
function toggleMessageSelection(message: EntityObject) { try {
const identifier = _entityIdentifier(message) const targetFolder = _findDraftFolder(folder)
const properties = _toDraftProperties(message)
const draft = composerDraftIdentifier.value
? await entitiesStore.update(composerDraftIdentifier.value, properties)
: await entitiesStore.create(targetFolder.identifier, properties)
selectionMode.value = true composerDraftIdentifier.value = draft.identifier
composerLastSaved.value = new Date()
if (selectionList.value.includes(identifier)) { return draft
_setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier)) } catch (error) {
console.error('[Mail] Failed to save draft:', error)
return throw error
} } finally {
composerSaving.value = false
_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])
} }
} }
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,
},
},
} }
function deactivateSelectionMode() { if (transmitRequest.message.cc?.length === 0) {
selectionMode.value = false delete transmitRequest.message.cc
clearSelection()
} }
function clearSelection() { if (transmitRequest.message.bcc?.length === 0) {
_setSelectionList([]) delete transmitRequest.message.bcc
} }
function openMoveDialog(entities?: EntityObject | EntityObject[]) { try {
const response = await entitiesStore.transmit(transmitRequest)
moveDialogCandidates.value = [] if (composerDraftIdentifier.value) {
try {
if (entities) { await entitiesStore.delete([composerDraftIdentifier.value])
if (Array.isArray(entities)) { } catch (error) {
moveDialogCandidates.value = entities.map(entity => _entityIdentifier(entity)) console.error('[Mail] Failed to delete draft after send:', error)
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 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 closeMoveDialog() { async function createFolder(
moveDialogVisible.value = false service: ServiceObject,
moveDialogService.value = null label: string,
moveDialogCandidates.value = null parentFolder: CollectionObject | null = null,
): Promise<CollectionObject> {
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<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[]) { async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const movableIdentifiers = entityIdentifiers.filter(identifier => { const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
const entity = entitiesStore.entityByIdentifier(identifier) (accumulator, identifier) => {
const entity = entitiesStore.entity(identifier)
if (!entity) { if (!entity) {
return false return accumulator
} }
// Only allow moving messages within the same service and disallow moving into the same folder // Only allow moving messages within the same service and disallow moving into the same folder
return entity.provider === target.provider && const canMove = entity.provider === target.provider &&
String(entity.service) === String(target.service) && String(entity.service) === String(target.service) &&
String(entity.collection) !== String(target.identifier) 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) { if (movableIdentifiers.length === 0) {
closeMoveDialog()
return return
} }
loading.value = true loading.value = true
try { try {
const [successes, failures] = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers) const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
clearSelection()
closeMoveDialog()
if (failures.length === 0) { if (failures.length === 0) {
notify( notify(
@@ -430,6 +568,10 @@ export const useMailStore = defineStore('mailStore', () => {
successes.length === 0 ? 'error' : 'warning', successes.length === 0 ? 'error' : 'warning',
) )
} }
// update source collections to reflect moved messages
sourceCollections.push(target.identifier)
await collectionsStore.fetch(sourceCollections)
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages' const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move messages:', error) console.error('[Mail] Failed to move messages:', error)
@@ -448,9 +590,7 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true loading.value = true
try { try {
const [successes, failures] = await entitiesStore.delete(entityIdentifiers) const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
clearSelection()
if (failures.length === 0) { if (failures.length === 0) {
notify( 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') { function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color }) showSnackbar({ message, color })
} }
@@ -499,18 +631,13 @@ export const useMailStore = defineStore('mailStore', () => {
mailSync, mailSync,
// State // State
sidebarVisible,
settingsDialogVisible,
loading, loading,
selectedFolder, selectedFolder,
selectedMessage, selectedMessage,
selectionList, composerSaving,
selectionMode, composerSending,
composeMode, composerLastSaved,
composeReplyTo, composerDraftIdentifier,
moveDialogVisible,
moveDialogService,
moveDialogCandidates,
serviceFolderLoadingState, serviceFolderLoadingState,
serviceFolderLoadedState, serviceFolderLoadedState,
serviceFolderErrorState, serviceFolderErrorState,
@@ -519,24 +646,21 @@ export const useMailStore = defineStore('mailStore', () => {
currentMessages, currentMessages,
// Actions // Actions
retrieveService,
selectFolder, selectFolder,
clearSelectedFolder, clearSelectedFolder,
selectMessage, selectMessage,
isMessageSelected, clearSelectedMessage,
activateSelectionMode, createFolder,
deactivateSelectionMode, reloadSelectedFolder,
toggleMessageSelection, saveComposerDraft,
selectAllCurrentMessages, sendComposerMessage,
clearSelection, resetComposerState,
openCompose,
openMoveDialog,
closeMoveDialog,
closeCompose,
afterSent,
deleteMessages, deleteMessages,
deleteFolder,
moveMessages, moveMessages,
toggleSidebar, moveFolder,
openSettings, renameFolder,
notify, notify,
isServiceFolderLoading, isServiceFolderLoading,
hasServiceFoldersLoaded, 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,
}
})

View File

@@ -1,6 +1,12 @@
{ {
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["src/**/*", "src/**/*.vue", "../../core/src/**/*.ts"], "include": [
"src/**/*",
"src/**/*.vue",
"../../core/src/**/*.ts",
"../mail_manager/src/**/*.ts",
"../mail_manager/src/**/*.vue"
],
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,