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">
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 { ServiceObject } from '@MailManager/models'
@@ -9,28 +7,27 @@ import type { ServiceObject } from '@MailManager/models'
interface Props {
modelValue: boolean
service: ServiceObject
parentFolder?: CollectionObject | null
allFolders?: CollectionObject[]
parentFolderLabel?: string
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
}
const props = withDefaults(defineProps<Props>(), {
parentFolder: null,
allFolders: () => []
parentFolderLabel: 'Root',
validateName: () => [],
loading: false,
errorMessage: '',
})
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'created': [folder: CollectionObject]
confirm: [folderName: string]
}>()
// Store
const collectionsStore = useCollectionsStore()
// Form state
const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([])
// Computed
@@ -39,67 +36,13 @@ const dialogValue = computed({
set: (value: boolean) => emit('update:modelValue', value)
})
const parentFolderLabel = computed(() => {
if (!props.parentFolder) return 'Root'
return props.parentFolder.properties.label
})
const isValid = computed(() => {
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(folderName, (newName) => {
errorMessage.value = ''
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')
}
}
validationErrors.value = props.validateName(newName)
})
// Reset form when dialog opens/closes
@@ -111,59 +54,25 @@ watch(dialogValue, (isOpen) => {
const resetForm = () => {
folderName.value = ''
errorMessage.value = ''
validationErrors.value = []
loading.value = false
}
const handleCreate = async () => {
// Final validation
const errors = validateFolderName(folderName.value)
const errors = props.validateName(folderName.value)
if (errors.length > 0) {
validationErrors.value = errors
return
}
if (checkDuplicateName(folderName.value)) {
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
}
emit('confirm', folderName.value.trim())
}
const handleCancel = () => {
dialogValue.value = false
resetForm()
}
</script>
<template>

View File

@@ -8,20 +8,22 @@ interface Props {
modelValue: boolean
service: ServiceObject
folder: CollectionObject
loading?: boolean
errorMessage?: string
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
loading: false,
errorMessage: '',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
deleted: [folder: CollectionObject]
confirm: []
}>()
const collectionsStore = useCollectionsStore()
const loading = ref(false)
const errorMessage = ref('')
const dialogValue = computed({
get: () => props.modelValue,
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)
})
const resetState = () => {
loading.value = false
errorMessage.value = ''
}
watch(dialogValue, isOpen => {
if (isOpen) {
resetState()
}
})
const handleDelete = async () => {
loading.value = true
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 handleDelete = () => {
emit('confirm')
}
const handleCancel = () => {
dialogValue.value = false
resetState()
}
</script>

View File

@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
const emit = defineEmits<{
select: [folder: CollectionObject]
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
}>()
// Page-based navigation state per service account
@@ -283,7 +283,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list density="compact">
<v-list-item
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>
@@ -295,7 +295,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
</v-list-item>
<v-list-item
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>
@@ -303,7 +303,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-if="canDeleteFolder(folder)"
prepend-icon="mdi-delete"
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>
@@ -446,7 +446,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list density="compact">
<v-list-item
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>
@@ -458,7 +458,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
</v-list-item>
<v-list-item
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>
@@ -466,7 +466,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-if="canDeleteFolder(folder)"
prepend-icon="mdi-delete"
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>

View File

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

View File

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

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<{
select: [folder: CollectionObject]
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
}>()
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(() => {
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)
@@ -131,7 +131,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact">
<v-list-item
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>
@@ -143,7 +143,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item>
<v-list-item
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>
@@ -151,7 +151,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder"
prepend-icon="mdi-delete"
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>
@@ -170,9 +170,9 @@ const isSelected = (folder: CollectionObject): boolean => {
:selected-folder="selectedFolder"
@select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
@edit-folder="(folder) => emit('editFolder', folder)"
@move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)"
/>
</div>
</v-list-group>
@@ -217,7 +217,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact">
<v-list-item
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>
@@ -229,7 +229,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item>
<v-list-item
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>
@@ -237,7 +237,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder"
prepend-icon="mdi-delete"
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>

View File

@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
const emit = defineEmits<{
select: [folder: CollectionObject]
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
}>()
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
@@ -75,9 +75,9 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
:selected-folder="selectedFolder"
@select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
@edit-folder="(folder) => emit('editFolder', folder)"
@move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)"
/>
<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">
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 Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
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 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
interface Props {
replyTo?: EntityInterface<MessageInterface> | null
folder?: CollectionInterface | null
mode: 'new' | 'reply' | 'forward'
source?: EntityObject | null
folder?: CollectionObject | null
}
const props = defineProps<Props>()
@@ -27,6 +29,13 @@ const emit = defineEmits<{
sent: []
}>()
const mailStore = useMailStore()
const {
composerSending: sending,
composerSaving: saving,
composerLastSaved: lastSaved,
} = storeToRefs(mailStore)
// State
const to = ref<string[]>([])
const cc = ref<string[]>([])
@@ -34,10 +43,6 @@ const bcc = ref<string[]>([])
const subject = ref('')
const showCc = 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
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
@@ -65,25 +70,65 @@ const editor = useEditor({
},
})
// Initialize from reply-to message
if (props.replyTo) {
const replyMessage = new MessageObject(props.replyTo.properties)
function resetComposerFields() {
to.value = []
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] : []
const originalSubject = replyMessage.subject || ''
subject.value = originalSubject.startsWith('Re:')
subject.value = /^Re:/i.test(originalSubject)
? originalSubject
: `Re: ${originalSubject}`
editor.value?.commands.setContent(
`<p><br></p><p>On ${sentLabel}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`,
)
return
}
// Add quoted reply - prefer HTML content, fallback to text
const originalBody = replyMessage.getHtmlContent() || replyMessage.getTextContent() || ''
const senderName = replyMessage.from?.label || replyMessage.from?.address || 'Unknown'
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
const canSend = computed(() => {
return to.value.length > 0 && subject.value.trim().length > 0
@@ -110,10 +155,8 @@ const saveDraft = async () => {
return
}
saving.value = true
try {
const draftData = {
await mailStore.saveComposerDraft(props.folder, {
to: to.value,
cc: cc.value,
bcc: bcc.value,
@@ -122,27 +165,9 @@ const saveDraft = async () => {
html: editor.value?.getHTML() || '',
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) {
console.error('[MessageComposer] Failed to save draft:', error)
} finally {
saving.value = false
console.error('[Mail][Composer] Failed to save draft:', error)
}
}
@@ -173,53 +198,33 @@ onBeforeUnmount(() => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
}
mailStore.resetComposerState()
editor.value?.destroy()
})
// Handlers
const handleClose = () => {
mailStore.resetComposerState()
emit('close')
}
const handleSend = async () => {
if (!canSend.value || sending.value) return
sending.value = true
try {
await entityService.transmit({
message: {
await mailStore.sendComposerMessage({
to: to.value,
cc: cc.value.length > 0 ? cc.value : undefined,
bcc: bcc.value.length > 0 ? bcc.value : undefined,
cc: cc.value,
bcc: bcc.value,
subject: subject.value,
body: {
html: editor.value?.getHTML() || '',
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')
} catch (error) {
console.error('[MessageComposer] Failed to send message:', error)
alert('Failed to send message. Please try again.')
} finally {
sending.value = false
console.error('[Mail][Composer] Failed to send message:', error)
}
}
@@ -248,192 +253,61 @@ const removeLink = () => editor.value?.chain().focus().unsetLink().run()
const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false
}
const toggleLink = () => {
if (isActive('link')) {
removeLink()
return
}
setLink()
}
</script>
<template>
<div class="message-composer">
<!-- Toolbar -->
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
<v-btn
variant="text"
@click="handleClose"
icon="mdi-close"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
</v-btn>
<ComposerToolbar
:mode="mode"
:save-status="saveStatus"
:can-send="canSend"
:sending="sending"
@close="handleClose"
@send="handleSend"
/>
<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">
<!-- Recipients -->
<div class="composer-fields pa-4">
<v-combobox
v-model="to"
label="To"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
>
<template v-slot:append-inner>
<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"
<ComposerRecipients
:to="to"
:cc="cc"
:bcc="bcc"
:subject="subject"
:show-cc="showCc"
:show-bcc="showBcc"
@update:to="to = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:subject="subject = $event"
@toggle:cc="toggleCc"
@toggle:bcc="toggleBcc"
/>
<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 />
<!-- Editor toolbar -->
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('bold') }"
@click="toggleBold"
>
<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': 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>
<ComposerEditor
:editor="editor"
:is-bold-active="isActive('bold')"
:is-italic-active="isActive('italic')"
:is-underline-active="isActive('underline')"
:is-bullet-list-active="isActive('bulletList')"
:is-ordered-list-active="isActive('orderedList')"
:is-link-active="isActive('link')"
@bold="toggleBold"
@italic="toggleItalic"
@underline="toggleUnderline"
@bullet-list="toggleBulletList"
@ordered-list="toggleOrderedList"
@link="toggleLink"
/>
</div>
</div>
</template>
@@ -457,65 +331,4 @@ const isActive = (name: string, attrs?: any) => {
flex-direction: column;
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>

View File

@@ -38,34 +38,26 @@ const LONG_PRESS_MS = 450
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 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 hasSelection = computed(() => selectionCount.value > 0)
@@ -74,11 +66,45 @@ const allCurrentMessagesSelected = computed(() => {
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
const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return ''
const messageDate = new Date(date)
if (Number.isNaN(messageDate.getTime())) return ''
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
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
}
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
return event.target instanceof Element && event.target.closest('.message-selection-checkbox') !== null
}
const handleSelectionToggle = (message: EntityObject) => {
emit('toggleSelection', message)
}
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (event.shiftKey && !props.selectionMode) {
event.preventDefault()
event.stopPropagation()
@@ -138,11 +177,6 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (props.selectionMode) {
emit('toggleSelection', message)
return
@@ -152,6 +186,10 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
}
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (!event.shiftKey || props.selectionMode) {
return
}
@@ -200,29 +238,6 @@ onBeforeUnmount(() => {
const handleSelectAllToggle = (value: boolean | null) => {
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>
<template>
@@ -282,7 +297,6 @@ const hasCountData = computed(() => {
size="small"
icon="mdi-close"
variant="text"
:disabled="!hasSelection"
@click="emit('clearSelection')"
>
<v-icon>mdi-close</v-icon>
@@ -320,13 +334,13 @@ const hasCountData = computed(() => {
>
<template v-slot:default="{ item: message }">
<v-list-item
:key="`${message.provider}:${message.service}:${message.collection}:${message.identifier}`"
:key="message.identifier"
class="message-item"
:class="{
'opened': isOpened(message),
'selected': isSelected(message),
'selection-mode': selectionMode,
'unread': isUnread(message)
'unread': !message.properties.isRead
}"
@mousedown="handleMessageMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)"
@@ -360,7 +374,7 @@ const hasCountData = computed(() => {
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
</span>
<span class="text-caption text-medium-emphasis ml-2">
{{ formatDate(message.properties.date) }}
{{ formatDate(getMessageTimestamp(message)) }}
</span>
</v-list-item-title>
@@ -369,13 +383,13 @@ const hasCountData = computed(() => {
</v-list-item-subtitle>
<v-list-item-subtitle class="text-caption text-truncate">
{{ truncate(message.properties.snippet, 80) }}
{{ '' }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="d-flex flex-column align-center">
<v-icon
v-if="isFlagged(message)"
v-if="message.properties.isFlagged"
size="small"
color="warning"
class="mb-1"
@@ -383,7 +397,7 @@ const hasCountData = computed(() => {
mdi-star
</v-icon>
<v-icon
v-if="message.properties.attachments && message.properties.attachments.length > 0"
v-if="message.properties.hasAttachments"
size="small"
color="grey"
>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
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 { ServiceObject } from '@MailManager/models'
@@ -9,23 +7,25 @@ interface Props {
modelValue: boolean
service: ServiceObject
folder: CollectionObject
allFolders?: CollectionObject[]
parentFolderLabel?: string
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
}
const props = withDefaults(defineProps<Props>(), {
allFolders: () => []
parentFolderLabel: 'Root',
validateName: () => [],
loading: false,
errorMessage: '',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: [folder: CollectionObject]
confirm: [folderName: string]
}>()
const collectionsStore = useCollectionsStore()
const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([])
const dialogValue = computed({
@@ -37,74 +37,13 @@ const isValid = computed(() => {
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) => {
errorMessage.value = ''
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')
}
validationErrors.value = props.validateName(newName)
})
function resetForm() {
folderName.value = props.folder.properties.label || ''
errorMessage.value = ''
validationErrors.value = []
loading.value = false
}
watch(dialogValue, (isOpen) => {
@@ -114,54 +53,27 @@ watch(dialogValue, (isOpen) => {
}, { immediate: true })
const handleRename = async () => {
const errors = validateFolderName(folderName.value)
const errors = props.validateName(folderName.value)
if (errors.length > 0) {
validationErrors.value = errors
return
}
if (checkDuplicateName(folderName.value)) {
validationErrors.value = ['A folder with this name already exists in this location']
return
}
const newName = folderName.value.trim()
if (newName === props.folder.properties.label) {
dialogValue.value = false
return
}
loading.value = true
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
}
emit('confirm', newName)
}
const handleCancel = () => {
dialogValue.value = false
resetForm()
}
</script>
<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">
import { computed, onMounted } from 'vue'
import { computed, onMounted, unref } from 'vue'
import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore'
import { useMailUiStore } from '@/stores/mailUiStore'
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 MessageReader from '@/components/MessageReader.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 RenameFolderDialog from '@/components/RenameFolderDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import FolderView from '@/components/FolderView.vue'
// Vuetify display for responsive behavior
const display = useDisplay()
@@ -19,94 +23,175 @@ const isMobile = computed(() => display.mdAndDown.value)
// Check if mail manager is available
const moduleStore = useModuleStore()
const isMailManagerAvailable = computed(() => {
const isManagerAvailable = computed(() => {
return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
})
const collectionsStore = useCollectionsStore()
// Mail module store
const mailStore = useMailStore()
const mailUiStore = useMailUiStore()
// storeToRefs preserves reactivity for state and computed properties
const {
sidebarVisible,
settingsDialogVisible,
loading,
selectedFolder,
selectedMessage,
currentMessages,
} = storeToRefs(mailStore)
const {
sidebarVisible,
settingsDialogVisible,
composeMode,
composeSource,
composeVisible,
selectionList,
selectionMode,
composeMode,
composeReplyTo,
currentMessages,
moveDialogVisible,
moveDialogCandidates,
} = storeToRefs(mailStore)
moveMessagesDialogVisible,
moveMessagesDialogService,
createFolderDialogVisible,
createFolderDialogService,
createFolderDialogParent,
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)
const { mailSync, entitiesStore } = mailStore
const lastSyncLabel = computed(() => {
if (!mailSync.lastSync) return ''
return `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})`
const lastSync = unref(unref(mailSync.lastSync))
if (!(lastSync instanceof Date)) return ''
return `(Last: ${lastSync.toLocaleTimeString()})`
})
// Initialize
onMounted(async () => {
if (!isMailManagerAvailable.value) return
if (!isManagerAvailable.value) return
await mailStore.initialize()
})
// Handlers — thin wrappers that delegate to the store
const {
validateCreateFolderName,
validateRenameFolderName,
} = mailUiStore
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) => {
if (value) {
mailStore.selectAllCurrentMessages()
mailUiStore.selectAllCurrentMessages()
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 id = `${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier
mailStore.deleteMessages([id])
mailStore.deleteMessages([message.identifier])
}
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>
<template>
<!-- Manager Unavailable -->
<div v-if="!isMailManagerAvailable" class="mail-unavailable">
<div v-if="!isManagerAvailable" class="mail-unavailable">
<v-alert
type="warning"
variant="outlined"
@@ -180,10 +265,10 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
width="280"
class="mail-sidebar"
>
<FolderTree
<FolderView
:selected-folder="selectedFolder"
@select="handleFolderSelect"
@folder-created="handleFolderCreated"
/>
<template #append>
@@ -225,8 +310,9 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<!-- Reader/Composer panel -->
<div class="mail-reader-panel">
<MessageComposer
v-if="composeMode"
:reply-to="composeReplyTo"
v-if="composeVisible"
:mode="composeMode"
:source="composeSource"
:folder="selectedFolder"
@close="handleComposeClose"
@sent="handleComposeSent"
@@ -236,7 +322,8 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
v-else
:entity="selectedMessage"
@compose="handleCompose"
@reply="handleCompose"
@reply="handleComposeReply"
@forward="handleComposeForward"
@move="handleMove"
@delete="handleDelete"
/>
@@ -249,14 +336,62 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
<SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog
v-model="moveDialogVisible"
v-if="moveMessagesDialogService && moveFolderDialogSource"
v-model="moveMessagesDialogVisible"
:service="moveMessagesDialogService"
:loading="loading"
title="Move To"
title="Move Messages To"
confirm-text="Move"
empty-text="No other folders are available in this account."
@select="handleMoveConfirm"
@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>
</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 { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
@@ -6,7 +6,21 @@ import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
import type { EntityTransmitRequest } from '@MailManager/types/entity'
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
interface ComposerMessageInput {
to: string[]
cc: string[]
bcc: string[]
subject: string
body: {
html: string
text: string
}
}
export const useMailStore = defineStore('mailStore', () => {
const servicesStore = useServicesStore()
@@ -31,27 +45,17 @@ export const useMailStore = defineStore('mailStore', () => {
}
// ── General State ─────────────────-───────────────────────────────────────
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
const loading = ref(false)
const serviceFolderLoadingState = ref<Record<string, boolean>>({})
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
const serviceFolderErrorState = ref<Record<string, string | null>>({})
// ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([])
// ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false)
const composeReplyTo = shallowRef<EntityObject | null>(null)
// ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false)
const moveDialogService = ref<ServiceIdentifier | null>(null)
const moveDialogCandidates = ref<EntityIdentifier[] | null>(null)
const composerSaving = ref(false)
const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null)
const composerDraftIdentifier = ref<EntityIdentifier | null>(null)
// ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => {
@@ -98,11 +102,7 @@ export const useMailStore = defineStore('mailStore', () => {
try {
// retrieve folders for service
const collections = await collectionsStore.list({
[service.provider]: {
[String(service.identifier)]: true,
},
})
const collections = await collectionsStore.collectionsForService(service.provider, service.identifier, true)
_setServiceFolderLoaded(service.provider, service.identifier, true)
@@ -121,7 +121,6 @@ export const useMailStore = defineStore('mailStore', () => {
}
_updateSyncSources()
return collections
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message)
@@ -139,15 +138,20 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Helpers ──────────────────────────────────────────────────────────
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
if (item instanceof ServiceObject) {
return `${item.provider}:${String(item.identifier)}` as ServiceIdentifier
}
return `${item.provider}:${String(item.service)}` as ServiceIdentifier
}
function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier {
return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
}
function _entityIdentifier(item: EntityObject): EntityIdentifier {
return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
}
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
@@ -176,15 +180,18 @@ export const useMailStore = defineStore('mailStore', () => {
// Track the currently selected folder
if (selectedFolder.value) {
mailSyncController.addSource({
provider: selectedFolder.value.provider,
service: selectedFolder.value.service,
collections: [selectedFolder.value.identifier],
})
//mailSyncController.addSource({
// provider: selectedFolder.value.provider,
// service: selectedFolder.value.service,
// collections: [selectedFolder.value.identifier],
//})
}
// Always track inboxes for each account (for new-mail notifications)
servicesStore.servicesEnabled.forEach(service => {
if (service.identifier === null) {
return
}
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
c =>
String(c.service) === String(service.identifier) &&
@@ -193,11 +200,11 @@ export const useMailStore = defineStore('mailStore', () => {
)
if (inboxes.length > 0) {
mailSyncController.addSource({
provider: service.provider,
service: service.identifier as string | number,
collections: inboxes.map(inbox => inbox.identifier),
})
//mailSyncController.addSource({
// provider: service.provider,
// service: service.identifier as string | number,
// collections: inboxes.map(inbox => inbox.identifier),
//})
}
})
@@ -218,58 +225,113 @@ export const useMailStore = defineStore('mailStore', () => {
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
}
function _reloadFolderMessages(folder: CollectionObject) {
return entitiesStore.list({
[folder.provider]: {
[String(folder.service)]: {
[String(folder.identifier)]: true,
},
},
function _findDraftFolder(folder: CollectionObject): CollectionObject {
return collectionsStore.collectionsForService(folder.provider, folder.service).find(
candidate =>
candidate.provider === folder.provider &&
String(candidate.service) === String(folder.service) &&
(candidate.properties.role === 'drafts' ||
String(candidate.identifier).toLowerCase() === 'drafts' ||
candidate.properties.label.toLowerCase() === 'drafts'),
) ?? folder
}
function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined {
const normalized = addresses
.map(address => address.trim())
.filter(address => address.length > 0)
if (normalized.length === 0) {
return undefined
}
return normalized.map(address => ({ address }))
}
function _toDraftBody(body: ComposerMessageInput['body']): MessagePartInterface | null {
const parts: MessagePartInterface[] = []
const text = body.text.trim()
const html = body.html.trim()
if (text.length > 0) {
parts.push({
type: 'text/plain',
content: text,
})
}
function _setSelectionList(nextIds: EntityIdentifier[]) {
selectionList.value = Array.from(new Set(nextIds))
if (selectionList.value.length === 0) {
selectionMode.value = false
}
}
function _reconcileSelection() {
if (!selectedFolder.value) {
clearSelection()
selectedMessage.value = null
return
}
const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message)))
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
if (nextSelectedIds.length !== selectionList.value.length) {
_setSelectionList(nextSelectedIds)
}
if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) {
selectedMessage.value = null
}
}
watch(currentMessages, () => {
_reconcileSelection()
if (html.length > 0) {
parts.push({
type: 'text/html',
content: html,
})
}
if (parts.length === 0) {
return null
}
if (parts.length === 1) {
return parts[0]
}
return {
type: 'multipart/alternative',
subParts: parts,
}
}
function _toDraftProperties(message: ComposerMessageInput): MessageInterface {
return {
'@type': 'mail:message',
to: _toMessageAddresses(message.to),
cc: _toMessageAddresses(message.cc),
bcc: _toMessageAddresses(message.bcc),
subject: message.subject.trim() || null,
body: _toDraftBody(message.body),
flags: {
draft: true,
},
}
}
function resetComposerState() {
composerSaving.value = false
composerSending.value = false
composerLastSaved.value = null
composerDraftIdentifier.value = null
}
// ── Actions ───────────────────────────────────────────────────────────────
async function retrieveService(identifier: ServiceIdentifier, force: boolean = false): Promise<ServiceObject | null> {
let service = servicesStore.serviceByIdentifier(identifier)
if (service && !force) {
return service
}
try {
service = await servicesStore.serviceByIdentifier(identifier, true)
} catch (error) {
console.error(`[Mail] Failed to retrieve service ${identifier}:`, error)
throw error
}
if (!service) {
const message = `Service ${identifier} not found`
console.error(`[Mail] ${message}`)
throw new Error(message)
}
return service
}
async function selectFolder(folder: CollectionObject) {
selectedFolder.value = folder
selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
try {
await _reloadFolderMessages(folder)
await entitiesStore.list([folder.identifier])
} catch (error) {
console.error('[Mail] Failed to load messages:', error)
}
@@ -280,140 +342,216 @@ export const useMailStore = defineStore('mailStore', () => {
function clearSelectedFolder() {
selectedFolder.value = null
selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
composeReplyTo.value = null
_updateSyncSources()
}
function selectMessage(entity: EntityObject, closeSidebar = false) {
function selectMessage(entity: EntityObject) {
selectedMessage.value = entity
composeMode.value = false
if (closeSidebar) {
sidebarVisible.value = false
}
}
function openCompose(replyTo?: EntityObject) {
composeMode.value = true
composeReplyTo.value = replyTo ?? null
function clearSelectedMessage() {
selectedMessage.value = null
}
function closeCompose() {
composeMode.value = false
composeReplyTo.value = null
}
async function afterSent() {
composeMode.value = false
composeReplyTo.value = null
async function reloadSelectedFolder() {
// Reload the current folder so the sent message appears in Sent
if (selectedFolder.value) {
await selectFolder(selectedFolder.value)
}
}
function isMessageSelected(message: EntityObject) {
return selectionList.value.includes(_entityIdentifier(message))
}
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
composerSaving.value = true
function toggleMessageSelection(message: EntityObject) {
const identifier = _entityIdentifier(message)
try {
const targetFolder = _findDraftFolder(folder)
const properties = _toDraftProperties(message)
const draft = composerDraftIdentifier.value
? await entitiesStore.update(composerDraftIdentifier.value, properties)
: await entitiesStore.create(targetFolder.identifier, properties)
selectionMode.value = true
composerDraftIdentifier.value = draft.identifier
composerLastSaved.value = new Date()
if (selectionList.value.includes(identifier)) {
_setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
return
}
_setSelectionList([...selectionList.value, identifier])
}
function selectAllCurrentMessages() {
selectionMode.value = true
_setSelectionList(currentMessages.value.map(message => _entityIdentifier(message)))
}
function activateSelectionMode(message?: EntityObject) {
selectionMode.value = true
if (message) {
const identifier = _entityIdentifier(message)
if (!selectionList.value.includes(identifier)) {
_setSelectionList([...selectionList.value, identifier])
return draft
} catch (error) {
console.error('[Mail] Failed to save draft:', error)
throw error
} finally {
composerSaving.value = false
}
}
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() {
selectionMode.value = false
clearSelection()
if (transmitRequest.message.cc?.length === 0) {
delete transmitRequest.message.cc
}
function clearSelection() {
_setSelectionList([])
if (transmitRequest.message.bcc?.length === 0) {
delete transmitRequest.message.bcc
}
function openMoveDialog(entities?: EntityObject | EntityObject[]) {
try {
const response = await entitiesStore.transmit(transmitRequest)
moveDialogCandidates.value = []
if (entities) {
if (Array.isArray(entities)) {
moveDialogCandidates.value = entities.map(entity => _entityIdentifier(entity))
moveDialogService.value = _serviceIdentifier(entities[0])
} else {
moveDialogCandidates.value = [_entityIdentifier(entities)]
moveDialogService.value = _serviceIdentifier(entities)
if (composerDraftIdentifier.value) {
try {
await entitiesStore.delete([composerDraftIdentifier.value])
} catch (error) {
console.error('[Mail] Failed to delete draft after send:', error)
}
} 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() {
moveDialogVisible.value = false
moveDialogService.value = null
moveDialogCandidates.value = null
async function createFolder(
service: ServiceObject,
label: string,
parentFolder: CollectionObject | null = null,
): Promise<CollectionObject> {
if (service.identifier === null) {
throw new Error('Cannot create folder for a service without an identifier')
}
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[]) {
const movableIdentifiers = entityIdentifiers.filter(identifier => {
const entity = entitiesStore.entityByIdentifier(identifier)
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
(accumulator, identifier) => {
const entity = entitiesStore.entity(identifier)
if (!entity) {
return false
return accumulator
}
// 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.collection) !== String(target.identifier)
})
if (!canMove) {
return accumulator
}
accumulator.movableIdentifiers.push(identifier)
if (!accumulator.sourceCollections.some(
collection => String(collection) === String(entity.collection),
)) {
accumulator.sourceCollections.push(entity.collection)
}
return accumulator
},
{
movableIdentifiers: [] as EntityIdentifier[],
sourceCollections: [] as CollectionIdentifier[],
},
)
if (movableIdentifiers.length === 0) {
closeMoveDialog()
return
}
loading.value = true
try {
const [successes, failures] = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
clearSelection()
closeMoveDialog()
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
if (failures.length === 0) {
notify(
@@ -430,6 +568,10 @@ export const useMailStore = defineStore('mailStore', () => {
successes.length === 0 ? 'error' : 'warning',
)
}
// update source collections to reflect moved messages
sourceCollections.push(target.identifier)
await collectionsStore.fetch(sourceCollections)
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move messages:', error)
@@ -448,9 +590,7 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true
try {
const [successes, failures] = await entitiesStore.delete(entityIdentifiers)
clearSelection()
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
if (failures.length === 0) {
notify(
@@ -477,14 +617,6 @@ export const useMailStore = defineStore('mailStore', () => {
}
}
function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value
}
function openSettings() {
settingsDialogVisible.value = true
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
showSnackbar({ message, color })
}
@@ -499,18 +631,13 @@ export const useMailStore = defineStore('mailStore', () => {
mailSync,
// State
sidebarVisible,
settingsDialogVisible,
loading,
selectedFolder,
selectedMessage,
selectionList,
selectionMode,
composeMode,
composeReplyTo,
moveDialogVisible,
moveDialogService,
moveDialogCandidates,
composerSaving,
composerSending,
composerLastSaved,
composerDraftIdentifier,
serviceFolderLoadingState,
serviceFolderLoadedState,
serviceFolderErrorState,
@@ -519,24 +646,21 @@ export const useMailStore = defineStore('mailStore', () => {
currentMessages,
// Actions
retrieveService,
selectFolder,
clearSelectedFolder,
selectMessage,
isMessageSelected,
activateSelectionMode,
deactivateSelectionMode,
toggleMessageSelection,
selectAllCurrentMessages,
clearSelection,
openCompose,
openMoveDialog,
closeMoveDialog,
closeCompose,
afterSent,
clearSelectedMessage,
createFolder,
reloadSelectedFolder,
saveComposerDraft,
sendComposerMessage,
resetComposerState,
deleteMessages,
deleteFolder,
moveMessages,
toggleSidebar,
openSettings,
moveFolder,
renameFolder,
notify,
isServiceFolderLoading,
hasServiceFoldersLoaded,

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

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

View File

@@ -1,6 +1,12 @@
{
"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__/*"],
"compilerOptions": {
"composite": true,