1 Commits

Author SHA1 Message Date
ab14345684 fix(deps): update dependency vue-router to v5
Some checks failed
renovate/artifacts Artifact file update failure
2026-05-10 03:03:22 +00:00
31 changed files with 1874 additions and 3783 deletions

1347
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@
"@tiptap/extension-underline": "^3.0.0",
"vue": "^3.4.0",
"vue-router": "^5.0.0",
"pinia": "^3.0.0",
"pinia": "^2.1.7",
"vuetify": "^4.0.0"
},
"devDependencies": {

View File

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

View File

@@ -8,22 +8,20 @@ interface Props {
modelValue: boolean
service: ServiceObject
folder: CollectionObject
loading?: boolean
errorMessage?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
errorMessage: '',
})
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
deleted: [folder: CollectionObject]
}>()
const collectionsStore = useCollectionsStore()
const loading = ref(false)
const errorMessage = ref('')
const dialogValue = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
@@ -35,17 +33,37 @@ 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 = () => {
emit('confirm')
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 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: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, 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', folder)"
@click="emit('editFolder', group.service, 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', folder)"
@click="emit('moveFolder', group.service, 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', folder)"
@click="emit('deleteFolder', group.service, 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', folder)"
@click="emit('editFolder', group.service, 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', folder)"
@click="emit('moveFolder', group.service, 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', folder)"
@click="emit('deleteFolder', group.service, folder)"
>
<v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item>

View File

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

View File

@@ -3,7 +3,6 @@ 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
@@ -21,7 +20,9 @@ const emit = defineEmits<{
const expanded = ref(false)
const folderKeyFor = (folder: CollectionObject): string => String(folder.identifier)
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
const folderLabelFor = (folder: CollectionObject): string => {
return folder.properties.label || String(folder.identifier)
@@ -75,11 +76,7 @@ const childFolders = computed(() => {
return []
}
return collectionsStore.collectionsInCollection(
props.service.provider,
serviceIdentifier,
props.folder.identifier as CollectionIdentifier,
)
return collectionsStore.collectionsInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
})
const hasChildren = computed(() => {
@@ -89,7 +86,7 @@ const hasChildren = computed(() => {
return false
}
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier as CollectionIdentifier)
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
})
const isSelected = computed(() => props.selectedFolderKey === key.value)

View File

@@ -0,0 +1,278 @@
<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: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, 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', folder)"
@click="emit('editFolder', service, 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', folder)"
@click="emit('moveFolder', service, 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', folder)"
@click="emit('deleteFolder', service, 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="(folder) => emit('editFolder', folder)"
@move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, 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', folder)"
@click="emit('editFolder', service, 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', folder)"
@click="emit('moveFolder', service, 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', folder)"
@click="emit('deleteFolder', service, 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: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, 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="(folder) => emit('editFolder', folder)"
@move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
/>
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">

View File

@@ -1,113 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { useMailUiStore } from '@/stores/mailUiStore'
import FolderTreeView from './FolderTreeView.vue'
import FolderPageView from './FolderPageView.vue'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
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 mailSettingsStore = useMailSettingsStore()
const { folderViewMode } = storeToRefs(mailSettingsStore)
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,26 +1,22 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useEditor } from '@tiptap/vue-3'
import { useEditor, EditorContent } 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 type { MessageAddressInterface } from '@MailManager/types/message'
import { ComposerMode } from '@/types/composer'
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
// Props
interface Props {
mode: ComposerMode
source?: EntityObject | MessageAddressInterface | null
folder?: CollectionObject | null
replyTo?: EntityInterface<MessageInterface> | null
folder?: CollectionInterface | null
}
const props = defineProps<Props>()
@@ -28,15 +24,9 @@ const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
close: []
sent: []
}>()
const mailStore = useMailStore()
const {
composerSending: sending,
composerSaving: saving,
composerLastSaved: lastSaved,
} = storeToRefs(mailStore)
// State
const to = ref<string[]>([])
const cc = ref<string[]>([])
@@ -44,6 +34,10 @@ 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
@@ -71,73 +65,25 @@ const editor = useEditor({
},
})
function resetComposerFields() {
to.value = []
cc.value = []
bcc.value = []
subject.value = ''
showCc.value = false
showBcc.value = false
editor.value?.commands.setContent('')
// Initialize from reply-to message
if (props.replyTo) {
const replyMessage = new MessageObject(props.replyTo.properties)
const fromEmail = replyMessage.from?.address
to.value = fromEmail ? [fromEmail] : []
const originalSubject = replyMessage.subject || ''
subject.value = originalSubject.startsWith('Re:')
? originalSubject
: `Re: ${originalSubject}`
// 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)
}
function initializeComposerFromProps() {
mailStore.resetComposerState()
resetComposerFields()
if (props.mode === ComposerMode.Fresh) {
if (props.source && 'address' in props.source) {
// If source is an email address, pre-fill the "To" field
to.value = [props.source.address]
}
return
}
if (props.source instanceof EntityObject == false) {
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 === ComposerMode.Reply) {
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
to.value = fromEmail ? [fromEmail] : []
subject.value = /^Re:/i.test(originalSubject)
? originalSubject
: `Re: ${originalSubject}`
editor.value?.commands.setContent(
`<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
)
return
}
if (props.mode === ComposerMode.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
@@ -164,8 +110,10 @@ const saveDraft = async () => {
return
}
saving.value = true
try {
await mailStore.saveComposerDraft(props.folder, {
const draftData = {
to: to.value,
cc: cc.value,
bcc: bcc.value,
@@ -174,9 +122,27 @@ 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('[Mail][Composer] Failed to save draft:', error)
console.error('[MessageComposer] Failed to save draft:', error)
} finally {
saving.value = false
}
}
@@ -207,32 +173,53 @@ 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 mailStore.sendComposerMessage({
to: to.value,
cc: cc.value,
bcc: bcc.value,
subject: subject.value,
body: {
html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '',
await entityService.transmit({
message: {
to: to.value,
cc: cc.value.length > 0 ? cc.value : undefined,
bcc: bcc.value.length > 0 ? bcc.value : undefined,
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('[Mail][Composer] Failed to send message:', error)
console.error('[MessageComposer] Failed to send message:', error)
alert('Failed to send message. Please try again.')
} finally {
sending.value = false
}
}
@@ -261,61 +248,192 @@ 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">
<ComposerToolbar
:mode="mode"
:save-status="saveStatus"
:can-send="canSend"
:sending="sending"
@close="handleClose"
@send="handleSend"
/>
<!-- 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>
<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">
<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"
/>
<!-- 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"
/>
<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 />
<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"
/>
<!-- 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>
</div>
</div>
</template>
@@ -339,4 +457,65 @@ const toggleLink = () => {
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

@@ -3,8 +3,6 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import type { EntityIdentifier } from '@MailManager/types/common'
import type { EntityObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import MessageListItemMenu from '@/components/MessageListItemMenu.vue'
// Props
interface Props {
@@ -25,70 +23,62 @@ const props = withDefaults(defineProps<Props>(), {
// Emits
const emit = defineEmits<{
open: [message: EntityObject]
reply: [message: EntityObject]
forward: [message: EntityObject]
move: [message: EntityObject]
delete: [message: EntityObject]
flag: [message: EntityObject, flag: string, value: boolean]
selectionMode: [message: EntityObject]
selectionToggleOne: [message: EntityObject]
selectionToggleAll: [value: boolean]
selectionClear: []
selectionMove: []
selectionDelete: []
selectionFlag: [flag: string, value: boolean]
toggleSelection: [message: EntityObject]
activateSelectionMode: [message: EntityObject]
toggleSelectAll: [value: boolean]
clearSelection: []
moveSelection: []
deleteSelection: []
}>()
const longPressTimer = ref<number | null>(null)
const longPressActivated = ref(false)
const suppressNextClick = ref(false)
const contextMenuVisible = ref(false)
const contextMenuTarget = ref<[number, number] | undefined>(undefined)
const contextMenuMessage = ref<EntityObject | null>(null)
const LONG_PRESS_MS = 400
const selectedIdSet = computed(() => new Set(props.selectionList))
const selectionCount = computed(() => props.selectionList.length ?? 0)
const LONG_PRESS_MS = 450
// Sorted messages (newest first)
const sortedMessages = computed(() => {
return [...props.messages].sort((a, b) => {
const dateA = timeStamp(a) ?? 0
const dateB = timeStamp(b) ?? 0
return dateB - dateA
})
})
const selectedIdSet = computed(() => new Set(props.selectionList))
const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false
return (message.identifier === props.selectedMessage.identifier)
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.identifier)
return selectedIdSet.value.has(
`${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier,
)
}
const timeStamp = (message: EntityObject): number | null => {
const timestamp = message.properties.received
|| message.properties.sent
|| message.modified
|| message.created
|| null
if (!timestamp) {
return null
}
const timeValue = new Date(timestamp).getTime()
return Number.isNaN(timeValue) ? null : timeValue
// 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 selectionCount = computed(() => props.selectionList.length)
const hasSelection = computed(() => selectionCount.value > 0)
const allCurrentMessagesSelected = computed(() => {
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
})
// Format date for display
const formatDate = (date: Date | string | number | null | undefined): string => {
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)
@@ -125,32 +115,21 @@ const formatDate = (date: Date | string | number | null | undefined): string =>
})
}
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
return event.target instanceof Element && event.target.closest('.message-selection-checkbox, .message-item-menu-trigger, .message-item-menu-content') !== null
// Truncate text
const truncate = (text: string | null | undefined, length: number = 100): string => {
if (!text) return ''
return text.length > length ? text.substring(0, length) + '...' : text
}
const handleSelectionToggleOne = (message: EntityObject) => {
emit('selectionToggleOne', message)
const handleSelectionToggle = (message: EntityObject) => {
emit('toggleSelection', message)
}
const handleSelectionToggleAll = (value: boolean | null) => {
emit('selectionToggleAll', value === true)
}
const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (event.shiftKey && !props.selectionMode) {
event.preventDefault()
event.stopPropagation()
emit('selectionMode', message)
emit('activateSelectionMode', message)
return
}
@@ -159,19 +138,20 @@ const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObje
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (props.selectionMode) {
emit('selectionToggleOne', message)
emit('toggleSelection', message)
return
}
emit('open', message)
}
const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
if (!event.shiftKey || props.selectionMode) {
return
}
@@ -179,44 +159,14 @@ const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
event.preventDefault()
event.stopPropagation()
suppressNextClick.value = true
emit('selectionMode', message)
emit('activateSelectionMode', message)
}
const openContextMenu = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
event.preventDefault()
event.stopPropagation()
contextMenuMessage.value = message
contextMenuTarget.value = [event.clientX, event.clientY]
contextMenuVisible.value = true
}
const handleContextMenuReply = (message: EntityObject) => {
emit('reply', message)
}
const handleContextMenuForward = (message: EntityObject) => {
emit('forward', message)
}
const handleContextMenuMove = (message: EntityObject) => {
emit('move', message)
}
const handleContextMenuDelete = (message: EntityObject) => {
emit('delete', message)
}
const handleContextMenuFlag = (message: EntityObject, flag: string, value: boolean) => {
emit('flag', message, flag, value)
}
const getContextMenuMessage = (): EntityObject => {
return contextMenuMessage.value as EntityObject
}
const handleTouchStart = (message: EntityObject) => {
@@ -225,9 +175,9 @@ const handleTouchStart = (message: EntityObject) => {
longPressTimer.value = window.setTimeout(() => {
if (!props.selectionMode) {
emit('selectionMode', message)
emit('activateSelectionMode', message)
} else {
emit('selectionToggleOne', message)
emit('toggleSelection', message)
}
longPressActivated.value = true
@@ -243,32 +193,36 @@ const handleTouchMove = () => {
clearLongPressTimer()
}
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
const handleFlag = (flag: string, value: boolean) => {
if (props.selectionMode && selectionCount.value > 0) {
emit('selectionFlag', flag, value)
}
}
const handleRecipientClick = (message: EntityObject) => {
if (props.selectionMode) {
emit('selectionToggleOne', message)
return
}
emit('open', message)
}
onBeforeUnmount(() => {
clearLongPressTimer()
})
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>
@@ -276,12 +230,12 @@ onBeforeUnmount(() => {
<!-- Header with folder name and counts -->
<div v-if="selectedCollection" class="message-list-header">
<div class="message-list-heading">
<h2 class="text-h6">{{ selectedCollection?.properties.label || 'Folder' }}</h2>
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2>
<div class="folder-counts text-caption text-medium-emphasis">
<span v-if="selectedCollection?.properties.total != null">
<span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span>
<span v-if="hasCountData">
<span class="unread-count">{{ unreadCount }}</span>
<span class="mx-1">/</span>
<span>{{ selectedCollection?.properties.total ?? 0 }}</span>
<span>{{ totalCount }}</span>
</span>
<span v-else-if="messages.length > 0">
{{ messages.length }} loaded
@@ -292,12 +246,15 @@ onBeforeUnmount(() => {
<div v-if="selectionMode && messages.length > 0" class="selection-summary">
<div class="selection-controls">
<v-checkbox-btn
:model-value="selectionCount !== 0"
:indeterminate="selectionCount > 0 && selectionCount !== messages.length"
:model-value="allCurrentMessagesSelected"
:indeterminate="hasSelection && !allCurrentMessagesSelected"
density="compact"
hide-details
@update:model-value="handleSelectionToggleAll"
@update:model-value="handleSelectAllToggle"
/>
<span class="text-caption text-medium-emphasis">
{{ selectionCount > 0 ? `${selectionCount} selected` : 'Select all loaded' }}
</span>
</div>
<div class="selection-actions">
@@ -305,8 +262,8 @@ onBeforeUnmount(() => {
size="small"
icon="mdi-folder-move-outline"
variant="text"
:disabled="selectionCount === 0"
@click="emit('selectionMove')"
:disabled="!hasSelection"
@click="emit('moveSelection')"
>
<v-icon>mdi-folder-move-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip>
@@ -315,37 +272,18 @@ onBeforeUnmount(() => {
size="small"
icon="mdi-delete-outline"
variant="text"
:disabled="selectionCount === 0"
@click="emit('selectionDelete')"
:disabled="!hasSelection"
@click="emit('deleteSelection')"
>
<v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-email-open-outline"
variant="text"
:disabled="selectionCount === 0"
@click="handleFlag('read', true)"
>
<v-icon>mdi-email-open-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Mark as Read</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-email-outline"
variant="text"
:disabled="selectionCount === 0"
@click="handleFlag('read', false)"
>
<v-icon>mdi-email-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Mark as Unread</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-close"
variant="text"
@click="emit('selectionClear')"
:disabled="!hasSelection"
@click="emit('clearSelection')"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip>
@@ -365,7 +303,7 @@ onBeforeUnmount(() => {
</div>
<!-- Empty state -->
<div v-else-if="messages.length === 0" class="pa-8 text-center">
<div v-else-if="currentMessages.length === 0" class="pa-8 text-center">
<v-icon size="64" color="grey-lighten-1">mdi-email-outline</v-icon>
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis">
@@ -382,17 +320,16 @@ onBeforeUnmount(() => {
>
<template v-slot:default="{ item: message }">
<v-list-item
:key="message.identifier"
:key="`${message.provider}:${message.service}:${message.collection}:${message.identifier}`"
class="message-item"
:class="{
'opened': isOpened(message),
'selected': isSelected(message),
'selection-mode': selectionMode,
'unread': !message.properties.isRead
'unread': isUnread(message)
}"
@mousedown="handleMouseDown($event, message)"
@click="handleMouseClick($event, message)"
@contextmenu="openContextMenu($event, message)"
@mousedown="handleMessageMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)"
@touchstart.passive="handleTouchStart(message)"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
@@ -407,7 +344,7 @@ onBeforeUnmount(() => {
density="compact"
hide-details
@click.stop
@update:model-value="handleSelectionToggleOne(message)"
@update:model-value="handleSelectionToggle(message)"
/>
<v-avatar size="40" color="primary">
@@ -420,17 +357,10 @@ onBeforeUnmount(() => {
<v-list-item-title class="d-flex align-center">
<span class="flex-grow-1 text-truncate">
<RecipientDetails
:address="message.properties.from"
@clicked="handleRecipientClick(message)"
>
<template #default="{ label }">
<span class="message-person-link text-truncate">{{ label }}</span>
</template>
</RecipientDetails>
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
</span>
<span class="text-caption text-medium-emphasis ml-2">
{{ formatDate(timeStamp(message)) }}
{{ formatDate(message.properties.date) }}
</span>
</v-list-item-title>
@@ -439,56 +369,32 @@ onBeforeUnmount(() => {
</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="message-item-append">
<div class="d-flex flex-column align-center">
<v-icon
v-if="message.properties.isFlagged"
size="small"
color="warning"
class="mb-1"
>
mdi-star
</v-icon>
<v-icon
v-if="message.properties.hasAttachments"
size="small"
color="grey"
>
mdi-paperclip
</v-icon>
</div>
<MessageListItemMenu
:message="message"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
<div class="d-flex flex-column align-center">
<v-icon
v-if="isFlagged(message)"
size="small"
color="warning"
class="mb-1"
>
mdi-star
</v-icon>
<v-icon
v-if="message.properties.attachments && message.properties.attachments.length > 0"
size="small"
color="grey"
>
mdi-paperclip
</v-icon>
</div>
</template>
</v-list-item>
<v-divider />
</template>
</v-virtual-scroll>
<MessageListItemMenu
v-if="contextMenuMessage"
v-model="contextMenuVisible"
:message="getContextMenuMessage()"
:target="contextMenuTarget"
:show-activator="false"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div>
</template>
@@ -573,31 +479,6 @@ onBeforeUnmount(() => {
min-width: 72px;
}
.message-item-append {
display: flex;
align-items: center;
gap: 4px;
}
@media (hover: hover) {
.message-item :deep(.message-item-menu-trigger) {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.message-item:hover :deep(.message-item-menu-trigger),
.message-item:focus-within :deep(.message-item-menu-trigger),
.message-item.opened :deep(.message-item-menu-trigger),
.message-item.selected :deep(.message-item-menu-trigger),
.message-item :deep(.message-item-menu-trigger[aria-expanded='true']) {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
.message-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04);
}
@@ -616,33 +497,6 @@ onBeforeUnmount(() => {
:deep(.v-list-item-subtitle:first-of-type) {
font-weight: 600;
}
:deep(.v-list-item-title),
:deep(.v-list-item-subtitle:first-of-type),
:deep(.v-list-item-title .text-caption) {
color: rgb(var(--v-theme-on-surface));
}
}
.message-item.unread:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
}
.message-item.unread.selected:not(.opened) {
background-color: rgba(var(--v-theme-primary), 0.14);
}
.message-person-link {
display: inline-block;
max-width: 100%;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.message-person-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
@media (max-width: 960px) {

View File

@@ -1,138 +0,0 @@
<script setup lang="ts">
import { computed, getCurrentInstance, ref } from 'vue'
import type { EntityObject } from '@MailManager/models'
interface Props {
message: EntityObject
modelValue?: boolean
target?: [number, number] | undefined
showActivator?: boolean
}
const props = withDefaults(defineProps<Props>(), {
target: undefined,
showActivator: true,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
reply: [message: EntityObject]
forward: [message: EntityObject]
move: [message: EntityObject]
delete: [message: EntityObject]
flag: [message: EntityObject, flag: string, value: boolean]
}>()
const isRead = computed(() => props.message.properties.isRead === true)
const isFlagged = computed(() => props.message.properties.isFlagged === true)
const localIsOpen = ref(false)
const instance = getCurrentInstance()
const isControlled = computed(() => {
const vnodeProps = instance?.vnode.props ?? {}
return Object.prototype.hasOwnProperty.call(vnodeProps, 'modelValue')
|| Object.prototype.hasOwnProperty.call(vnodeProps, 'onUpdate:modelValue')
})
const isOpen = computed({
get: () => (isControlled.value ? props.modelValue ?? false : localIsOpen.value),
set: (value: boolean) => {
if (!isControlled.value) {
localIsOpen.value = value
}
emit('update:modelValue', value)
},
})
const handleFlag = (flag: string, value: boolean) => {
emit('flag', props.message, flag, value)
isOpen.value = false
}
const handleReply = () => {
emit('reply', props.message)
isOpen.value = false
}
const handleForward = () => {
emit('forward', props.message)
isOpen.value = false
}
const handleMove = () => {
emit('move', props.message)
isOpen.value = false
}
const handleDelete = () => {
emit('delete', props.message)
isOpen.value = false
}
</script>
<template>
<v-menu
v-model="isOpen"
:target="target"
:absolute="target !== undefined"
:close-on-content-click="true"
location="bottom end"
content-class="message-item-menu-content"
>
<template v-if="showActivator" #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="message-item-menu-trigger"
icon="mdi-dots-vertical"
size="small"
variant="text"
@click.stop
@mousedown.stop
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
</template>
<v-list class="message-item-menu" density="compact" min-width="220">
<v-list-item
:prepend-icon="isRead ? 'mdi-email-outline' : 'mdi-email-open-outline'"
@click="handleFlag('read', !isRead)"
>
<v-list-item-title>
{{ isRead ? 'Mark as unread' : 'Mark as read' }}
</v-list-item-title>
</v-list-item>
<v-list-item
:prepend-icon="isFlagged ? 'mdi-star' : 'mdi-star-outline'"
@click="handleFlag('flagged', !isFlagged)"
>
<v-list-item-title>
{{ isFlagged ? 'Unstar' : 'Star' }}
</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item prepend-icon="mdi-folder-move-outline" @click="handleMove">
<v-list-item-title>Move to...</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-reply" @click="handleReply">
<v-list-item-title>Reply</v-list-item-title>
</v-list-item>
<v-list-item prepend-icon="mdi-share" @click="handleForward">
<v-list-item-title>Forward</v-list-item-title>
</v-list-item>
<v-divider class="my-1" />
<v-list-item prepend-icon="mdi-delete-outline" @click="handleDelete">
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>

View File

@@ -3,7 +3,6 @@ import { computed, ref, watch } from 'vue'
import { useUser } from '@KTXC'
import type { EntityObject, MessageObject } from '@MailManager/models'
import { SecurityLevel } from '@/utile/emailSanitizer'
import { useMailStore } from '@/stores/mailStore'
import ReaderEmpty from './reader/ReaderEmpty.vue'
import ReaderToolbar from './reader/ReaderToolbar.vue'
import ReaderHeader from './reader/ReaderHeader.vue'
@@ -26,7 +25,6 @@ const emit = defineEmits<{
// User settings
const { getSetting } = useUser()
const mailStore = useMailStore()
// Per-message overrides
const allowImages = ref(false)
@@ -96,14 +94,6 @@ const handleMove = () => {
}
}
const handleDownload = async (partIndex?: number) => {
if (!props.entity) {
return
}
await mailStore.downloadMessage(props.entity, partIndex)
}
const handleFlag = () => {
if (props.entity) {
emit('flag', props.entity)
@@ -135,17 +125,13 @@ const handleCompose = () => {
@move="handleMove"
@delete="handleDelete"
@flag="handleFlag"
@download="handleDownload()"
@toggle-images="toggleImages"
@set-security-level="setSecurityLevel"
/>
<!-- Message content -->
<div class="message-content">
<ReaderHeader
:entity="props.entity"
@download-attachment="handleDownload"
/>
<ReaderHeader :message="message!" />
<v-divider />

View File

@@ -1,5 +1,7 @@
<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'
@@ -7,25 +9,23 @@ interface Props {
modelValue: boolean
service: ServiceObject
folder: CollectionObject
parentFolderLabel?: string
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
allFolders?: CollectionObject[]
}
const props = withDefaults(defineProps<Props>(), {
parentFolderLabel: 'Root',
validateName: () => [],
loading: false,
errorMessage: '',
allFolders: () => []
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: [folderName: string]
updated: [folder: CollectionObject]
}>()
const collectionsStore = useCollectionsStore()
const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([])
const dialogValue = computed({
@@ -37,13 +37,74 @@ 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) => {
validationErrors.value = props.validateName(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')
}
})
function resetForm() {
folderName.value = props.folder.properties.label || ''
errorMessage.value = ''
validationErrors.value = []
loading.value = false
}
watch(dialogValue, (isOpen) => {
@@ -53,27 +114,54 @@ watch(dialogValue, (isOpen) => {
}, { immediate: true })
const handleRename = async () => {
const errors = props.validateName(folderName.value)
const errors = validateFolderName(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
}
emit('confirm', newName)
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
}
}
const handleCancel = () => {
dialogValue.value = false
resetForm()
}
</script>
<template>

View File

@@ -1,192 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSnackbar } from '@KTXC'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { useMailUiStore } from '@/stores/mailUiStore'
import { ComposerMode } from '@/types/composer'
interface Props {
address?: MessageAddressInterface | null
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
clicked: []
}>()
const mailUiStore = useMailUiStore()
const { showSnackbar } = useSnackbar()
const recipientLabel = computed(() => props.address?.label?.trim() || '')
const recipientAddress = computed(() => props.address?.address?.trim() || '')
const displayLabel = computed(() => recipientLabel.value || recipientAddress.value || 'Unknown Sender')
const formattedAddress = computed(() => {
if (recipientLabel.value && recipientAddress.value && recipientLabel.value !== recipientAddress.value) {
return `${recipientLabel.value} <${recipientAddress.value}>`
}
return recipientAddress.value || recipientLabel.value
})
const hasEmail = computed(() => recipientAddress.value.length > 0)
const showCopyResult = (message: string, color: 'success' | 'error') => {
showSnackbar({ message, color })
}
const copy = async (value: string, label: string) => {
if (!value) {
return
}
try {
await navigator.clipboard.writeText(value)
showCopyResult(`${label} copied`, 'success')
} catch (error) {
console.error('[Mail][RecipientDetails] Failed to copy text:', error)
showCopyResult(`Unable to copy ${label.toLowerCase()}`, 'error')
}
}
const handleCompose = () => {
if (!props.address) {
return
}
mailUiStore.openComposer(props.address, ComposerMode.Fresh)
}
const handleClick = () => {
emit('clicked')
}
</script>
<template>
<v-menu
:open-on-hover="true"
:open-on-click="false"
:open-delay="1000"
location="bottom start"
transition="slide-y-transition"
>
<template #activator="{ props: activatorProps }">
<span
v-bind="activatorProps"
class="address-activator"
@click="handleClick"
>
<slot
:label="displayLabel"
:name="recipientLabel"
:email="recipientAddress"
>
{{ displayLabel }}
</slot>
</span>
</template>
<v-card class="address-card" elevation="8" rounded="lg">
<div class="address-card-header">
<v-avatar size="40" color="primary">
<span class="text-white text-subtitle-2">
{{ displayLabel[0]?.toUpperCase() || 'U' }}
</span>
</v-avatar>
<div class="address-card-meta">
<div class="text-subtitle-2 font-weight-medium">{{ displayLabel }}</div>
<div v-if="recipientAddress" class="text-body-2 text-medium-emphasis">{{ recipientAddress }}</div>
</div>
</div>
<v-divider class="my-3" />
<div class="address-card-actions">
<v-btn
class="address-action-button"
size="small"
variant="tonal"
prepend-icon="mdi-pencil"
:disabled="!hasEmail"
@click="handleCompose"
>
Send Email
</v-btn>
<v-btn
class="address-action-button"
size="small"
variant="text"
:disabled="!hasEmail"
@click="copy(recipientAddress, 'Email address')"
>
<v-icon>mdi-content-copy</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Email</v-tooltip>
</v-btn>
<v-btn
class="address-action-button"
size="small"
variant="text"
:disabled="!formattedAddress"
@click="copy(formattedAddress, 'Address')"
>
<v-icon>mdi-card-account-details-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Address</v-tooltip>
</v-btn>
<v-btn
v-if="recipientLabel"
class="address-action-button"
size="small"
variant="text"
@click="copy(recipientLabel, 'Name')"
>
<v-icon>mdi-account-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Copy Name</v-tooltip>
</v-btn>
</div>
</v-card>
</v-menu>
</template>
<style scoped lang="scss">
.address-activator {
display: inline-flex;
align-items: center;
max-width: 100%;
}
.address-card {
width: min(320px, calc(100vw - 32px));
padding: 16px;
}
.address-card-header {
display: flex;
align-items: center;
gap: 12px;
}
.address-card-meta {
min-width: 0;
}
.address-card-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.address-card-actions :deep(.address-action-button) {
height: 36px;
min-height: 36px;
width: 36px;
min-width: 36px;
padding: 0;
}
.address-card-actions :deep(.address-action-button:first-child) {
width: auto;
min-width: 0;
padding-inline: 12px;
}
</style>

View File

@@ -1,184 +0,0 @@
<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

@@ -1,95 +0,0 @@
<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

@@ -1,57 +0,0 @@
<script setup lang="ts">
import { ComposerMode } from '@/types/composer'
interface Props {
mode: ComposerMode
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 === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.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,26 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import type { EntityObject } from '@MailManager/models';
import { MessageObject } from '@MailManager/models/message'
interface Props {
entity: EntityObject | null
message: MessageObject
}
const props = defineProps<Props>()
const emit = defineEmits<{
downloadAttachment: [index: number]
}>()
const message = computed(() => {
return props.entity?.properties ?? null
})
const randomKey = computed(() => {
return Math.random().toString(36).substring(2, 15)
})
// Format date for display
const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return ''
@@ -45,10 +31,6 @@ const formatFileSize = (bytes: number | undefined): string => {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const download = async (index: number): Promise<void> => {
emit('downloadAttachment', index)
}
</script>
<template>
@@ -64,14 +46,10 @@ const download = async (index: number): Promise<void> => {
<div class="flex-grow-1">
<div class="text-body-1 font-weight-medium">
<RecipientDetails :address="message?.from">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
{{ message?.from?.label || message?.from?.address || 'Unknown Sender' }}
</div>
<div class="text-caption text-medium-emphasis">
{{ formatDate(message?.received || message?.sent) }}
{{ formatDate(message?.date) }}
</div>
</div>
</div>
@@ -79,26 +57,12 @@ const download = async (index: number): Promise<void> => {
<!-- Recipients -->
<div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">To:</span>
<template v-for="(recipient, index) in message.to" :key="randomKey">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.to.length - 1">, </span>
</template>
{{ message?.to.map(t => t.label || t.address).join(', ') }}
</div>
<div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">Cc:</span>
<template v-for="(recipient, index) in message.cc" :key="randomKey">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.cc.length - 1">, </span>
</template>
{{ message?.cc.map(c => c.label || c.address).join(', ') }}
</div>
<!-- Attachments -->
@@ -107,24 +71,19 @@ const download = async (index: number): Promise<void> => {
Attachments ({{ message?.attachments.length }})
</div>
<div class="d-flex flex-wrap gap-2">
<div
<v-chip
v-for="(attachment, index) in message?.attachments"
:key="randomKey"
class="attachment-item"
:key="index"
prepend-icon="mdi-paperclip"
size="small"
variant="outlined"
class="attachment-chip"
>
<v-chip
prepend-icon="mdi-paperclip"
size="small"
variant="outlined"
class="attachment-chip"
@click="download(index)"
>
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size ?? undefined) }})
</span>
</v-chip>
</div>
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size) }})
</span>
</v-chip>
</div>
</div>
</div>
@@ -139,15 +98,8 @@ const download = async (index: number): Promise<void> => {
gap: 0.5rem;
}
.attachment-item {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.attachment-chip {
max-width: 300px;
cursor: pointer;
.attachment-name {
overflow: hidden;
@@ -155,21 +107,4 @@ const download = async (index: number): Promise<void> => {
white-space: nowrap;
}
}
.attachment-error {
color: rgb(var(--v-theme-error));
margin-top: 0.25rem;
}
.contact-link {
display: inline-block;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.contact-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
</style>

View File

@@ -17,7 +17,6 @@ const emit = defineEmits<{
move: []
delete: []
flag: []
download: []
toggleImages: []
setSecurityLevel: [level: SecurityLevel]
}>()
@@ -162,28 +161,6 @@ const currentSecurityLevel = computed(() => {
<v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
</v-btn>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon="mdi-dots-vertical"
variant="text"
>
<v-icon>mdi-dots-vertical</v-icon>
<v-tooltip activator="parent" location="bottom">More Actions</v-tooltip>
</v-btn>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-download"
@click="emit('download')"
>
<v-list-item-title>Download</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-toolbar>
</template>

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { messageReadDelayOptions, useMailSettingsStore } from '@/stores/mailSettingsStore'
const mailSettingsStore = useMailSettingsStore()
const { messageReadEnabled, messageReadDelay } = storeToRefs(mailSettingsStore)
</script>
<template>
<div class="pa-4">
<h3 class="text-h6 mb-4">Behaviours</h3>
<v-list>
<v-list-item>
<v-list-item-title>Mark messages as read automatically</v-list-item-title>
<v-list-item-subtitle>
Mark a message as read after it stays open for the configured delay
</v-list-item-subtitle>
<template #append>
<v-switch v-model="messageReadEnabled" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Read delay</v-list-item-title>
<v-list-item-subtitle>
Choose how long a message must stay open before it is marked as read
</v-list-item-subtitle>
<template #append>
<v-select
v-model="messageReadDelay"
:items="messageReadDelayOptions"
item-title="title"
item-value="value"
density="compact"
variant="outlined"
:disabled="!messageReadEnabled"
style="width: 180px"
/>
</template>
</v-list-item>
</v-list>
</div>
</template>

View File

@@ -1,23 +1,67 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { folderViewModeOptions, useMailSettingsStore } from '@/stores/mailSettingsStore'
import { ref, computed } from 'vue'
import { useUser } from '@KTXC'
const mailSettingsStore = useMailSettingsStore()
const { folderViewMode } = storeToRefs(mailSettingsStore)
type FolderViewMode = 'tree' | 'page'
const { settings, setSetting } = useUser()
const theme = ref('Auto')
const showPreview = ref(true)
const compactMode = ref(false)
const folderViewMode = computed({
get: () => {
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
},
set: (value: FolderViewMode) => setSetting('mail.folderViewMode', value)
})
</script>
<template>
<div class="pa-4">
<h3 class="text-h6 mb-4">Display Settings</h3>
<v-list>
<v-list-item>
<v-list-item-title>Theme</v-list-item-title>
<v-list-item-subtitle>Choose your preferred theme</v-list-item-subtitle>
<template #append>
<v-select
v-model="theme"
:items="['Light', 'Dark', 'Auto']"
density="compact"
variant="outlined"
style="width: 150px"
/>
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Message preview</v-list-item-title>
<v-list-item-subtitle>Show message preview in list</v-list-item-subtitle>
<template #append>
<v-switch v-model="showPreview" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Compact mode</v-list-item-title>
<v-list-item-subtitle>Use compact message list layout</v-list-item-subtitle>
<template #append>
<v-switch v-model="compactMode" color="primary" hide-details />
</template>
</v-list-item>
<v-list-item>
<v-list-item-title>Folder navigation style</v-list-item-title>
<v-list-item-subtitle>Choose how folders are displayed</v-list-item-subtitle>
<template #append>
<v-select
v-model="folderViewMode"
:items="folderViewModeOptions"
:items="[
{ value: 'tree', title: 'Tree' },
{ value: 'page', title: 'Page' }
]"
item-value="value"
item-title="title"
density="compact"

View File

@@ -2,7 +2,6 @@
import { ref } from 'vue'
import DisplaySettings from './DisplaySettings.vue'
import AccountsSettings from './AccountsSettings.vue'
import BehaviorSettings from './BehaviorSettings.vue'
import SecuritySettings from './SecuritySettings.vue'
interface Props {
@@ -52,10 +51,6 @@ const handleClose = () => {
<v-icon start>mdi-palette</v-icon>
Display
</v-tab>
<v-tab value="behaviour">
<v-icon start>mdi-timer-cog-outline</v-icon>
Behaviours
</v-tab>
<v-tab value="security">
<v-icon start>mdi-shield-account</v-icon>
Security
@@ -73,10 +68,6 @@ const handleClose = () => {
<DisplaySettings />
</v-window-item>
<v-window-item value="behaviour">
<BehaviorSettings />
</v-window-item>
<v-window-item value="security">
<SecuritySettings />
</v-window-item>

View File

@@ -1,22 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, unref } from 'vue'
import { computed, onMounted } 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 { ComposerMode } from '@/types/composer'
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()
@@ -24,173 +19,94 @@ const isMobile = computed(() => display.mdAndDown.value)
// Check if mail manager is available
const moduleStore = useModuleStore()
const isManagerAvailable = computed(() => {
const isMailManagerAvailable = 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 {
loading,
selectedMessage,
currentMessages,
} = storeToRefs(mailStore)
const {
sidebarVisible,
settingsDialogVisible,
loading,
selectedFolder,
composerMode,
composerSource,
composerVisible,
selectedMessage,
selectionList,
selectionMode,
moveMessagesDialogVisible,
moveMessagesDialogService,
createFolderDialogVisible,
createFolderDialogService,
createFolderDialogLoading,
createFolderDialogError,
renameFolderDialogVisible,
renameFolderDialogService,
renameFolderDialogFolder,
renameFolderDialogLoading,
renameFolderDialogError,
moveFolderDialogVisible,
moveFolderDialogService,
deleteFolderDialogVisible,
deleteFolderDialogService,
deleteFolderDialogFolder,
deleteFolderDialogLoading,
deleteFolderDialogError,
} = storeToRefs(mailUiStore)
composeMode,
composeReplyTo,
currentMessages,
moveDialogVisible,
moveDialogCandidates,
} = storeToRefs(mailStore)
// Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailStore
const lastSyncLabel = computed(() => {
const lastSync = unref(unref(mailSync.lastSync))
if (!(lastSync instanceof Date)) return ''
return `(Last: ${lastSync.toLocaleTimeString()})`
if (!mailSync.lastSync) return ''
return `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})`
})
// Initialize
onMounted(async () => {
if (!isManagerAvailable.value) return
await mailUiStore.initialize()
if (!isMailManagerAvailable.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 sidebarToggle = () => mailUiStore.sidebarToggle()
const handleMessageOpen = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value)
const handleSettingsOpen = () => mailUiStore.settingsOpen()
const handleMessageSelectionToggle = (message: EntityObject) => mailStore.toggleMessageSelection(message)
const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
const handleSelectionModeActivate = (message: EntityObject) => mailStore.activateSelectionMode(message)
const handleFolderCreateConfirm = async (folderName: string) => {
try {
const mutatedFolder = await mailUiStore.confirmCreateFolder(folderName)
if (mutatedFolder) {
handleFolderSelect(mutatedFolder)
}
} catch (error: unknown) {
console.error('[MailPage] Failed to create folder:', error)
const handleSelectAllToggle = (value: boolean) => {
if (value) {
mailStore.selectAllCurrentMessages()
return
}
mailStore.clearSelection()
}
const handleFolderEditConfirm = async (folderName: string) => {
try {
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
const handleSelectionClear = () => mailStore.deactivateSelectionMode()
if (mutatedFolder) {
handleFolderSelect(mutatedFolder)
}
} catch (error: unknown) {
console.error('[MailPage] Failed to rename folder:', error)
}
const handleSelectionMove = () => mailStore.openMoveDialog()
const handleSelectionDelete = () => mailStore.deleteMessages([...selectionList.value])
const handleCompose = (message?: EntityObject) => mailStore.openCompose(message)
const handleComposeClose = () => mailStore.closeCompose()
const handleComposeSent = () => mailStore.afterSent()
const handleDelete = (message: EntityObject) => {
const id = `${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier
mailStore.deleteMessages([id])
}
const handleFolderDeleteConfirm = async () => {
try {
await mailUiStore.confirmDeleteFolder()
} catch (error: unknown) {
console.error('[MailPage] Failed to delete folder:', error)
}
}
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
try {
await mailUiStore.confirmMoveFolder(targetFolder)
} catch (error: unknown) {
console.error('[MailPage] Failed to move folder:', error)
}
}
const handleMoveConfirm = async (target: CollectionObject) => { await mailStore.moveMessages(target, moveDialogCandidates.value ?? []) }
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
const handleMoveCancel = () => mailStore.closeMoveDialog()
const toggleSidebar = () => mailStore.toggleSidebar()
const handleMessageOpen = (message: EntityObject) => {
mailStore.selectMessage(message)
if (isMobile.value) {
mailUiStore.sidebarHide()
}
}
const handleMessageComposeFresh = () => mailUiStore.openComposer()
const handleMessageComposeReply = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Reply)
const handleMessageComposeForward = (message: EntityObject) => mailUiStore.openComposer(message, ComposerMode.Forward)
const handleMessageComposeClose = () => mailUiStore.closeComposer()
const handleMessageFlag = (message: EntityObject, flag: string, value: boolean) => {
mailStore.flagMessages([message.identifier], { [flag]: value })
}
const handleMessageDelete = (message: EntityObject) => {
mailStore.deleteMessages([message.identifier])
}
const handleMessageMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
const handleMessageMoveConfirm = async (target: CollectionObject) => { await mailUiStore.confirmMoveMessages(target) }
const handleMessageMoveCancel = () => mailUiStore.closeMoveMessagesDialog()
const handleMessageSelectionMode = (message: EntityObject) => mailUiStore.messageSelectionModeActivate(message)
const handleMessageSelectionToggleOne = (message: EntityObject) => mailUiStore.messageSelectionToggleOne(message)
const handleMessageSelectionToggleAll = (value: boolean) => {
mailUiStore.messageSelectionToggleAll(value)
}
const handleMessageSelectionClear = () => mailUiStore.messageSelectionModeDeactivate()
const handleMessageSelectionMove = () => mailUiStore.openMoveMessagesDialog()
const handleMessageSelectionFlag = (flag: string, value: boolean) => mailUiStore.flagSelectedMessages(flag, value)
const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
const handleSettingsOpen = () => mailStore.openSettings()
const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Folder "${folder.properties.label}" created`, 'success')
</script>
<template>
<!-- Manager Unavailable -->
<div v-if="!isManagerAvailable" class="mail-unavailable">
<div v-if="!isMailManagerAvailable" class="mail-unavailable">
<v-alert
type="warning"
variant="outlined"
@@ -215,7 +131,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
<v-app-bar class="mail-toolbar" elevation="0" density="compact">
<v-app-bar-nav-icon
v-if="isMobile"
@click="sidebarToggle"
@click="toggleSidebar"
/>
<v-app-bar-title>Mail</v-app-bar-title>
@@ -224,7 +140,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
<v-btn
icon="mdi-pencil"
@click="handleMessageComposeFresh()"
@click="handleCompose()"
color="primary"
variant="text"
>
@@ -264,10 +180,10 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
width="280"
class="mail-sidebar"
>
<FolderView
<FolderTree
:selected-folder="selectedFolder"
@select="handleFolderSelect"
@folder-created="handleFolderCreated"
/>
<template #append>
@@ -297,39 +213,32 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
:selection-mode="selectionMode"
:loading="loading"
@open="handleMessageOpen"
@reply="handleMessageComposeReply"
@forward="handleMessageComposeForward"
@move="handleMessageMove"
@delete="handleMessageDelete"
@flag="handleMessageFlag"
@selection-mode="handleMessageSelectionMode"
@selection-toggle-one="handleMessageSelectionToggleOne"
@selection-toggle-all="handleMessageSelectionToggleAll"
@selection-clear="handleMessageSelectionClear"
@selection-flag="handleMessageSelectionFlag"
@selection-move="handleMessageSelectionMove"
@selection-delete="handleMessageSelectionDelete"
@toggle-selection="handleMessageSelectionToggle"
@activate-selection-mode="handleSelectionModeActivate"
@toggle-select-all="handleSelectAllToggle"
@clear-selection="handleSelectionClear"
@move-selection="handleSelectionMove"
@delete-selection="handleSelectionDelete"
/>
</div>
<!-- Reader/Composer panel -->
<div class="mail-reader-panel">
<MessageComposer
v-if="composerVisible"
:mode="composerMode"
:source="composerSource"
v-if="composeMode"
:reply-to="composeReplyTo"
:folder="selectedFolder"
@close="handleMessageComposeClose"
@close="handleComposeClose"
@sent="handleComposeSent"
/>
<MessageReader
v-else
:entity="selectedMessage"
@compose="handleMessageComposeFresh"
@reply="handleMessageComposeReply"
@forward="handleMessageComposeForward"
@move="handleMessageMove"
@delete="handleMessageDelete"
@compose="handleCompose"
@reply="handleCompose"
@move="handleMove"
@delete="handleDelete"
/>
</div>
</div>
@@ -340,61 +249,13 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
<SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog
v-if="moveMessagesDialogService"
v-model="moveMessagesDialogVisible"
:service="moveMessagesDialogService"
v-model="moveDialogVisible"
:loading="loading"
title="Move Messages To"
title="Move To"
confirm-text="Move"
empty-text="No other folders are available in this account."
@select="handleMessageMoveConfirm"
@cancel="handleMessageMoveCancel"
/>
<FolderSelectionDialog
v-if="moveFolderDialogService"
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="handleFolderEditConfirm"
/>
<DeleteFolderDialog
v-if="deleteFolderDialogService && deleteFolderDialogFolder"
v-model="deleteFolderDialogVisible"
:service="deleteFolderDialogService"
:folder="deleteFolderDialogFolder"
:loading="deleteFolderDialogLoading"
:error-message="deleteFolderDialogError"
@confirm="handleFolderDeleteConfirm"
@select="handleMoveConfirm"
@cancel="handleMoveCancel"
/>
</div>
</template>

View File

@@ -1,74 +0,0 @@
import { computed } from 'vue'
import { defineStore } from 'pinia'
import { useUserStore } from '@KTXC'
const MESSAGE_READ_ENABLED_KEY = 'mail.behaviour.messageReadEnabled'
const MESSAGE_READ_DELAY_KEY = 'mail.behaviour.messageReadDelay'
const FOLDER_VIEW_MODE_KEY = 'mail.folderViewMode'
const DEFAULT_MESSAGE_READ_ENABLED = false
const DEFAULT_MESSAGE_READ_DELAY = 5
const DEFAULT_FOLDER_VIEW_MODE = 'tree'
export type FolderViewMode = 'tree' | 'page'
export const messageReadDelayOptions = [
{ value: 2, title: '2 seconds' },
{ value: 5, title: '5 seconds' },
{ value: 10, title: '10 seconds' },
{ value: 30, title: '30 seconds' },
]
export const folderViewModeOptions = [
{ value: 'tree', title: 'Tree' },
{ value: 'page', title: 'Page' },
]
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') {
return value
}
return fallback
}
function normalizePositiveNumber(value: unknown, fallback: number): number {
const normalized = Number(value)
return Number.isFinite(normalized) && normalized > 0 ? normalized : fallback
}
function normalizeFolderViewMode(value: unknown, fallback: FolderViewMode): FolderViewMode {
return value === 'tree' || value === 'page' ? value : fallback
}
export const useMailSettingsStore = defineStore('mailSettingsStore', () => {
const userStore = useUserStore()
const messageReadEnabled = computed({
get: () => normalizeBoolean(userStore.getSetting(MESSAGE_READ_ENABLED_KEY), DEFAULT_MESSAGE_READ_ENABLED),
set: (value: boolean) => userStore.setSetting(MESSAGE_READ_ENABLED_KEY, value),
})
const messageReadDelay = computed({
get: () => normalizePositiveNumber(userStore.getSetting(MESSAGE_READ_DELAY_KEY), DEFAULT_MESSAGE_READ_DELAY),
set: (value: number) => userStore.setSetting(
MESSAGE_READ_DELAY_KEY,
normalizePositiveNumber(value, DEFAULT_MESSAGE_READ_DELAY),
),
})
const folderViewMode = computed({
get: () => normalizeFolderViewMode(userStore.getSetting(FOLDER_VIEW_MODE_KEY), DEFAULT_FOLDER_VIEW_MODE),
set: (value: FolderViewMode) => userStore.setSetting(
FOLDER_VIEW_MODE_KEY,
normalizeFolderViewMode(value, DEFAULT_FOLDER_VIEW_MODE),
),
})
return {
folderViewMode,
messageReadEnabled,
messageReadDelay,
}
})

View File

@@ -1,4 +1,4 @@
import { ref, computed, shallowRef } from 'vue'
import { ref, computed, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
@@ -6,21 +6,7 @@ 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 { 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
}
}
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
export const useMailStore = defineStore('mailStore', () => {
const servicesStore = useServicesStore()
@@ -45,17 +31,27 @@ 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 composerSaving = ref(false)
const composerSending = ref(false)
const composerLastSaved = ref<Date | null>(null)
const composerDraftIdentifier = ref<EntityIdentifier | null>(null)
const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([])
// ── Compose State ─────────────────────────────────────────────────────────
const composeMode = ref(false)
const composeReplyTo = shallowRef<EntityObject | null>(null)
// ── Move State ────────────────────────────────────────────────────────────
const moveDialogVisible = ref(false)
const moveDialogService = ref<ServiceIdentifier | null>(null)
const moveDialogCandidates = ref<EntityIdentifier[] | null>(null)
// ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => {
@@ -78,15 +74,20 @@ export const useMailStore = defineStore('mailStore', () => {
await servicesStore.list()
const services = [...servicesStore.servicesEnabled]
await Promise.all(services.map(service => loadFoldersForService(service)))
services.forEach(service => {
void loadFoldersForService(service,{ selectInbox: true })
})
} catch (error) {
console.error('[Mail][Operations] Failed to initialize:', error)
console.error('[Mail] Failed to initialize:', error)
} finally {
loading.value = false
}
}
async function loadFoldersForService(service: ServiceObject) {
async function loadFoldersForService(
service: ServiceObject,
options: { selectInbox?: boolean } = {},
) {
if (service.identifier === null) {
return
@@ -97,15 +98,37 @@ export const useMailStore = defineStore('mailStore', () => {
try {
// retrieve folders for service
await collectionsStore.collectionsForService(service.provider, service.identifier, true)
const collections = await collectionsStore.list({
[service.provider]: {
[String(service.identifier)]: true,
},
})
_setServiceFolderLoaded(service.provider, service.identifier, true)
if (options.selectInbox && !selectedFolder.value) {
const inbox = Object.values(collections).find(
folder =>
folder.provider === service.provider &&
String(folder.service) === String(service.identifier) &&
(folder.properties.role === 'inbox' ||
String(folder.identifier).toLowerCase() === 'inbox'),
)
if (inbox) {
await selectFolder(inbox)
}
}
_updateSyncSources()
return collections
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message)
console.error(`[Mail][Operations] Failed to load folders for ${service.provider}:${String(service.identifier)}:`, error)
console.error(
`[Mail] Failed to load folders for ${service.provider}:${String(service.identifier)}:`,
error,
)
_updateSyncSources()
return {}
} finally {
@@ -116,20 +139,15 @@ 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 _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
}
function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier {
return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier
}
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
function _entityIdentifier(item: EntityObject): EntityIdentifier {
return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier
}
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
@@ -158,18 +176,15 @@ 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) &&
@@ -178,11 +193,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),
})
}
})
@@ -203,289 +218,228 @@ export const useMailStore = defineStore('mailStore', () => {
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
}
function _findDraftFolder(folder: CollectionObject): CollectionObject {
return collectionsStore.collectionsForService(folder.provider, folder.service).find(
candidate =>
candidate.provider === folder.provider &&
String(candidate.service) === String(folder.service) &&
(candidate.properties.role === 'drafts' ||
String(candidate.identifier).toLowerCase() === 'drafts' ||
candidate.properties.label.toLowerCase() === 'drafts'),
) ?? folder
}
function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined {
const normalized = addresses
.map(address => address.trim())
.filter(address => address.length > 0)
if (normalized.length === 0) {
return undefined
}
return normalized.map(address => ({ address }))
}
function _toDraftBody(body: ComposerMessageInput['body']): MessagePartInterface | null {
const parts: MessagePartInterface[] = []
const text = body.text.trim()
const html = body.html.trim()
if (text.length > 0) {
parts.push({
type: 'text/plain',
content: text,
})
}
if (html.length > 0) {
parts.push({
type: 'text/html',
content: html,
})
}
if (parts.length === 0) {
return null
}
if (parts.length === 1) {
return parts[0]
}
return {
type: 'multipart/alternative',
subParts: parts,
}
}
function _toDraftProperties(message: ComposerMessageInput): MessageInterface {
return {
'@type': 'mail:message',
to: _toMessageAddresses(message.to),
cc: _toMessageAddresses(message.cc),
bcc: _toMessageAddresses(message.bcc),
subject: message.subject.trim() || null,
body: _toDraftBody(message.body),
flags: {
draft: true,
function _reloadFolderMessages(folder: CollectionObject) {
return entitiesStore.list({
[folder.provider]: {
[String(folder.service)]: {
[String(folder.identifier)]: true,
},
},
})
}
function _setSelectionList(nextIds: EntityIdentifier[]) {
selectionList.value = Array.from(new Set(nextIds))
if (selectionList.value.length === 0) {
selectionMode.value = false
}
}
function resetComposerState() {
composerSaving.value = false
composerSending.value = false
composerLastSaved.value = null
composerDraftIdentifier.value = null
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()
})
// ── 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][Operations] Failed to retrieve service ${identifier}:`, error)
throw error
}
if (!service) {
const message = `Service ${identifier} not found`
console.error(`[Mail][Operations] ${message}`)
throw new Error(message)
}
return service
}
async function selectFolder(folder: CollectionObject | null) {
async function selectFolder(folder: CollectionObject) {
selectedFolder.value = folder
selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
if (folder) {
try {
await entitiesStore.list([folder.identifier])
} catch (error) {
console.error('[Mail][Operations] Failed to load messages:', error)
}
try {
await _reloadFolderMessages(folder)
} catch (error) {
console.error('[Mail] Failed to load messages:', error)
}
_updateSyncSources()
}
function selectMessage(entity: EntityObject | null) {
function clearSelectedFolder() {
selectedFolder.value = null
selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
composeReplyTo.value = null
_updateSyncSources()
}
function selectMessage(entity: EntityObject, closeSidebar = false) {
selectedMessage.value = entity
}
composeMode.value = false
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
composerSaving.value = true
try {
const targetFolder = _findDraftFolder(folder)
const properties = _toDraftProperties(message)
const draft = composerDraftIdentifier.value
? await entitiesStore.update(composerDraftIdentifier.value, properties)
: await entitiesStore.create(targetFolder.identifier, properties)
composerDraftIdentifier.value = draft.identifier
composerLastSaved.value = new Date()
return draft
} catch (error) {
console.error('[Mail][Operations] Failed to save draft:', error)
throw error
} finally {
composerSaving.value = false
if (closeSidebar) {
sidebarVisible.value = false
}
}
function findFoldersByRole(role: string): CollectionObject[] {
const normalizedRole = role.toLowerCase()
function openCompose(replyTo?: EntityObject) {
composeMode.value = true
composeReplyTo.value = replyTo ?? null
selectedMessage.value = null
}
function closeCompose() {
composeMode.value = false
composeReplyTo.value = null
}
return servicesStore.servicesEnabled.flatMap(service => {
if (service.identifier === null) {
return []
async function afterSent() {
composeMode.value = false
composeReplyTo.value = null
// Reload the current folder so the sent message appears in Sent
if (selectedFolder.value) {
await selectFolder(selectedFolder.value)
}
}
function isMessageSelected(message: EntityObject) {
return selectionList.value.includes(_entityIdentifier(message))
}
function toggleMessageSelection(message: EntityObject) {
const identifier = _entityIdentifier(message)
selectionMode.value = true
if (selectionList.value.includes(identifier)) {
_setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
return
}
_setSelectionList([...selectionList.value, identifier])
}
function selectAllCurrentMessages() {
selectionMode.value = true
_setSelectionList(currentMessages.value.map(message => _entityIdentifier(message)))
}
function activateSelectionMode(message?: EntityObject) {
selectionMode.value = true
if (message) {
const identifier = _entityIdentifier(message)
if (!selectionList.value.includes(identifier)) {
_setSelectionList([...selectionList.value, identifier])
}
}
}
function deactivateSelectionMode() {
selectionMode.value = false
clearSelection()
}
function clearSelection() {
_setSelectionList([])
}
function openMoveDialog(entities?: EntityObject | EntityObject[]) {
moveDialogCandidates.value = []
if (entities) {
if (Array.isArray(entities)) {
moveDialogCandidates.value = entities.map(entity => _entityIdentifier(entity))
moveDialogService.value = _serviceIdentifier(entities[0])
} else {
moveDialogCandidates.value = [_entityIdentifier(entities)]
moveDialogService.value = _serviceIdentifier(entities)
}
} else {
moveDialogCandidates.value = selectionList.value
moveDialogService.value = _serviceIdentifier(selectedFolder.value)
}
moveDialogVisible.value = true
}
function closeMoveDialog() {
moveDialogVisible.value = false
moveDialogService.value = null
moveDialogCandidates.value = null
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const movableIdentifiers = entityIdentifiers.filter(identifier => {
const entity = entitiesStore.entityByIdentifier(identifier)
if (!entity) {
return false
}
return collectionsStore.collectionsForService(service.provider, service.identifier).filter(
folder =>
folder.provider === service.provider &&
String(folder.service) === String(service.identifier) &&
(folder.properties.role === normalizedRole ||
String(folder.identifier).toLowerCase() === normalizedRole),
)
// Only allow moving messages within the same service and disallow moving into the same folder
return entity.provider === target.provider &&
String(entity.service) === String(target.service) &&
String(entity.collection) !== String(target.identifier)
})
}
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,
},
},
if (movableIdentifiers.length === 0) {
closeMoveDialog()
return
}
if (transmitRequest.message.cc?.length === 0) {
delete transmitRequest.message.cc
}
if (transmitRequest.message.bcc?.length === 0) {
delete transmitRequest.message.bcc
}
loading.value = true
try {
const response = await entitiesStore.transmit(transmitRequest)
const [successes, failures] = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
if (composerDraftIdentifier.value) {
try {
await entitiesStore.delete([composerDraftIdentifier.value])
} catch (error) {
console.error('[Mail][Operations] Failed to delete draft after send:', error)
}
clearSelection()
closeMoveDialog()
if (failures.length === 0) {
notify(
successes.length === 1 ? 'Message moved' : `${successes.length} messages moved`,
'success',
)
}
if (failures.length > 0) {
notify(
successes.length === 0
? `Move failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Moved ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
successes.length === 0 ? 'error' : 'warning',
)
}
notify('Message sent', 'success')
resetComposerState()
return response
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to send message'
console.error('[Mail][Operations] Failed to send message:', error)
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move messages:', error)
notify(messageText, 'error')
throw error
} finally {
composerSending.value = false
loading.value = false
}
}
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)) {
await selectFolder(null)
}
notify(
`Folder "${folder.properties.label || String(folder.identifier)}" deleted`,
'success',
)
return deletedFolder
}
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
if (entityIdentifiers.length === 0) {
return
@@ -494,7 +448,9 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true
try {
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
const [successes, failures] = await entitiesStore.delete(entityIdentifiers)
clearSelection()
if (failures.length === 0) {
notify(
@@ -513,7 +469,7 @@ export const useMailStore = defineStore('mailStore', () => {
}
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
console.error('[Mail][Operations] Failed to delete messages:', error)
console.error('[Mail] Failed to delete messages:', error)
notify(messageText, 'error')
throw error
} finally {
@@ -521,139 +477,12 @@ export const useMailStore = defineStore('mailStore', () => {
}
}
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<MessageInterface['flags']>, options: { notify?: boolean } = {}) {
if (entityIdentifiers.length === 0) {
return
}
const shouldNotify = options.notify ?? true
try {
const patch = entitiesStore.fresh().properties
patch.flags = flags
const { successes, failures } = await entitiesStore.patch(patch, entityIdentifiers)
if (shouldNotify && successes.length > 0) {
notify(
successes.length === 1 ? 'Message updated' : `${successes.length} messages updated`,
'success',
)
}
if (shouldNotify && failures.length > 0) {
notify(
successes.length === 0
? `Update failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Updated ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
successes.length === 0 ? 'error' : 'warning',
)
}
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to update messages'
console.error('[Mail][Operations] Failed to update messages:', error)
notify(messageText, 'error')
throw error
}
function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
(accumulator, identifier) => {
const entity = entitiesStore.entity(identifier)
if (!entity) {
return accumulator
}
// Only allow moving messages within the same service and disallow moving into the same folder
const canMove = entity.provider === target.provider &&
String(entity.service) === String(target.service) &&
String(entity.collection) !== String(target.identifier)
if (!canMove) {
return accumulator
}
accumulator.movableIdentifiers.push(identifier)
if (!accumulator.sourceCollections.some(
collection => String(collection) === String(entity.collection),
)) {
accumulator.sourceCollections.push(entity.collection)
}
return accumulator
},
{
movableIdentifiers: [] as EntityIdentifier[],
sourceCollections: [] as CollectionIdentifier[],
},
)
if (movableIdentifiers.length === 0) {
return
}
loading.value = true
try {
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
if (failures.length === 0) {
notify(
successes.length === 1 ? 'Message moved' : `${successes.length} messages moved`,
'success',
)
}
if (failures.length > 0) {
notify(
successes.length === 0
? `Move failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Moved ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
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][Operations] Failed to move messages:', error)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
}
}
async function downloadMessage(entity: EntityObject, index?: number) {
const target = entity.identifier
let part = null
if (index !== undefined) {
part = entity.properties.attachments?.[Number(index)] ?? null
}
try {
await entitiesStore.download(target, part)
} catch (error) {
const messageText = error instanceof Error
? error.message
: index === undefined
? 'Failed to download message'
: 'Failed to download attachment'
console.error(
index === undefined
? '[Mail][Operations] Failed to download message:'
: '[Mail][Operations] Failed to download attachment:',
error,
)
notify(messageText, 'error')
throw error
}
function openSettings() {
settingsDialogVisible.value = true
}
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
@@ -670,12 +499,18 @@ export const useMailStore = defineStore('mailStore', () => {
mailSync,
// State
sidebarVisible,
settingsDialogVisible,
loading,
selectedFolder,
selectedMessage,
composerSaving,
composerSending,
composerLastSaved,
composerDraftIdentifier,
selectionList,
selectionMode,
composeMode,
composeReplyTo,
moveDialogVisible,
moveDialogService,
moveDialogCandidates,
serviceFolderLoadingState,
serviceFolderLoadedState,
serviceFolderErrorState,
@@ -684,25 +519,28 @@ export const useMailStore = defineStore('mailStore', () => {
currentMessages,
// Actions
retrieveService,
selectFolder,
clearSelectedFolder,
selectMessage,
createFolder,
saveComposerDraft,
sendComposerMessage,
resetComposerState,
flagMessages,
isMessageSelected,
activateSelectionMode,
deactivateSelectionMode,
toggleMessageSelection,
selectAllCurrentMessages,
clearSelection,
openCompose,
openMoveDialog,
closeMoveDialog,
closeCompose,
afterSent,
deleteMessages,
deleteFolder,
moveMessages,
downloadMessage,
moveFolder,
renameFolder,
toggleSidebar,
openSettings,
notify,
isServiceFolderLoading,
hasServiceFoldersLoaded,
getServiceFolderError,
findFoldersByRole,
loadFoldersForService,
initialize,
}

View File

@@ -1,641 +0,0 @@
import { computed, ref, shallowRef, watch } from 'vue'
import { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { ComposerMode } from '@/types/composer'
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
import { EntityObject, type ServiceObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection'
import type { MessageAddressInterface } from '@MailManager/types/message'
export const useMailUiStore = defineStore('mailUiStore', () => {
const collectionsStore = useCollectionsStore()
const mailStore = useMailStore()
const mailSettingsStore = useMailSettingsStore()
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null)
const composerMode = ref<ComposerMode>(ComposerMode.Fresh)
const composerSource = shallowRef<EntityObject | MessageAddressInterface | null>(null)
const composerVisible = 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 messageReadIdentifier = ref<EntityIdentifier | null>(null)
const messageReadTimer = ref<ReturnType<typeof setTimeout> | null>(null)
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.selectedMessage,
message => {
if (message) {
closeComposer()
}
selectMessage(message)
},
)
watch(
() => mailStore.currentMessages,
() => {
messageSelectionReconcile()
},
)
function sidebarToggle() {
sidebarVisible.value = !sidebarVisible.value
}
function sidebarHide() {
sidebarVisible.value = false
}
function settingsOpen() {
settingsDialogVisible.value = true
}
function settingsClose() {
settingsDialogVisible.value = false
}
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
if (!left || !right) {
return false
}
return left.provider === right.provider &&
String(left.service) === String(right.service) &&
String(left.identifier) === String(right.identifier)
}
async function initialize() {
await mailStore.initialize()
if (!selectedFolder.value) {
const inbox = mailStore.findFoldersByRole('inbox')[0] ?? null
if (inbox) {
await selectFolder(inbox)
}
}
}
async function selectFolder(folder: CollectionObject | null) {
closeComposer()
messageSelectionModeDeactivate()
clearMessageReadTimer()
selectedMessage.value = null
selectedFolder.value = folder
await mailStore.selectFolder(folder)
}
async function selectMessage(message: EntityObject | null) {
messageSelectionModeDeactivate()
createMessageReadTimer(message)
selectedMessage.value = message
}
function createMessageReadTimer(entity: EntityObject | null) {
clearMessageReadTimer()
if (!entity) {
return
}
if (entity.properties.isRead || !mailSettingsStore.messageReadEnabled) {
return
}
const delayMilliseconds = mailSettingsStore.messageReadDelay * 1000
if (delayMilliseconds <= 0) {
return
}
messageReadIdentifier.value = entity.identifier
messageReadTimer.value = setTimeout(() => {
void completeMessageRead(entity.identifier)
}, delayMilliseconds)
}
function clearMessageReadTimer() {
if (messageReadTimer.value !== null) {
clearTimeout(messageReadTimer.value)
}
messageReadTimer.value = null
messageReadIdentifier.value = null
}
async function completeMessageRead(identifier: EntityIdentifier) {
try {
if (selectedMessage.value && selectedMessage.value.identifier === identifier && selectedMessage.value.properties.isRead === false) {
await mailStore.flagMessages([selectedMessage.value.identifier], { read: true }, { notify: false })
}
} catch (error) {
console.error('[Mail][UI] Failed to auto-mark message as read:', error)
} finally {
clearMessageReadTimer()
}
}
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)
if (_sameCollection(selectedFolder.value, folder)) {
selectedFolder.value = updatedFolder
}
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)
if (_sameCollection(selectedFolder.value, source)) {
selectedFolder.value = movedFolder
}
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)
if (_sameCollection(selectedFolder.value, folder)) {
selectFolder(null)
}
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
}
}
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 openComposer(source?: EntityObject | MessageAddressInterface, mode: ComposerMode = ComposerMode.Fresh) {
mailStore.selectMessage(null)
composerSource.value = source ?? null
composerMode.value = mode
composerVisible.value = true
}
function closeComposer() {
composerMode.value = ComposerMode.Fresh
composerSource.value = null
composerVisible.value = false
}
function messageSelectionClear() {
setSelectionList([])
}
function messageSelectionModeActivate(message?: EntityObject) {
selectionMode.value = true
if (!message) {
return
}
const identifier = message.identifier
if (!selectionList.value.includes(identifier)) {
setSelectionList([...selectionList.value, identifier])
}
}
function messageSelectionModeDeactivate() {
selectionMode.value = false
messageSelectionClear()
}
function messageSelectionToggleOne(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 messageSelectionToggleAll(value: boolean) {
selectionMode.value = true
if (value) {
setSelectionList(mailStore.currentMessages.map(message => message.identifier))
} else {
setSelectionList([])
}
}
function messageSelectionReconcile() {
if (!selectedFolder.value) {
messageSelectionClear()
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
}
}
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 = selectedFolder.value?.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 ?? [])
messageSelectionModeDeactivate()
closeMoveMessagesDialog()
}
async function deleteSelectedMessages() {
await mailStore.deleteMessages([...selectionList.value])
messageSelectionModeDeactivate()
}
async function flagSelectedMessages(flag: string, value: boolean) {
await mailStore.flagMessages([...selectionList.value], { [flag]: value })
messageSelectionModeDeactivate()
}
return {
sidebarVisible,
settingsDialogVisible,
selectedFolder,
selectedMessage,
composerMode,
composerSource,
composerVisible,
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,
sidebarToggle,
sidebarHide,
settingsOpen,
settingsClose,
initialize,
selectFolder,
openComposer,
closeComposer,
messageSelectionModeActivate,
messageSelectionModeDeactivate,
messageSelectionToggleOne,
messageSelectionToggleAll,
messageSelectionClear,
validateCreateFolderName,
validateRenameFolderName,
openMoveMessagesDialog,
closeMoveMessagesDialog,
confirmMoveMessages,
deleteSelectedMessages,
openCreateFolderDialog,
closeCreateFolderDialog,
confirmCreateFolder,
openRenameFolderDialog,
closeRenameFolderDialog,
confirmRenameFolder,
openMoveFolderDialog,
closeMoveFolderDialog,
confirmMoveFolder,
openDeleteFolderDialog,
closeDeleteFolderDialog,
confirmDeleteFolder,
flagSelectedMessages,
}
})

View File

@@ -1,5 +0,0 @@
export enum ComposerMode {
Fresh,
Reply,
Forward,
}

View File

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