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 1870 additions and 3779 deletions

1339
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", "@tiptap/extension-underline": "^3.0.0",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^5.0.0", "vue-router": "^5.0.0",
"pinia": "^3.0.0", "pinia": "^2.1.7",
"vuetify": "^4.0.0" "vuetify": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models' import type { ServiceObject } from '@MailManager/models'
@@ -7,27 +9,28 @@ import type { ServiceObject } from '@MailManager/models'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
service: ServiceObject service: ServiceObject
parentFolderLabel?: string parentFolder?: CollectionObject | null
validateName?: (name: string) => string[] allFolders?: CollectionObject[]
loading?: boolean
errorMessage?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
parentFolderLabel: 'Root', parentFolder: null,
validateName: () => [], allFolders: () => []
loading: false,
errorMessage: '',
}) })
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
confirm: [folderName: string] 'created': [folder: CollectionObject]
}>() }>()
// Store
const collectionsStore = useCollectionsStore()
// Form state // Form state
const folderName = ref('') const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([]) const validationErrors = ref<string[]>([])
// Computed // Computed
@@ -36,13 +39,67 @@ const dialogValue = computed({
set: (value: boolean) => emit('update:modelValue', value) set: (value: boolean) => emit('update:modelValue', value)
}) })
const parentFolderLabel = computed(() => {
if (!props.parentFolder) return 'Root'
return props.parentFolder.properties.label
})
const isValid = computed(() => { const isValid = computed(() => {
return folderName.value.trim().length > 0 && validationErrors.value.length === 0 return folderName.value.trim().length > 0 && validationErrors.value.length === 0
}) })
// Validation functions
const validateFolderName = (name: string): string[] => {
const errors: string[] = []
if (!name || name.trim().length === 0) {
errors.push('Folder name is required')
return errors
}
if (name.length > 255) {
errors.push('Folder name too long (max 255 characters)')
}
// No special characters that might cause issues
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
// Provider-specific rules
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
// Leading/trailing spaces
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
const checkDuplicateName = (name: string): boolean => {
const parentId = props.parentFolder?.identifier ?? null
return props.allFolders.some(f =>
f.properties.label === name &&
String(f.collection) === String(parentId) &&
String(f.service) === String(props.service.identifier)
)
}
// Watch folder name for validation // Watch folder name for validation
watch(folderName, (newName) => { watch(folderName, (newName) => {
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 // Reset form when dialog opens/closes
@@ -54,25 +111,59 @@ watch(dialogValue, (isOpen) => {
const resetForm = () => { const resetForm = () => {
folderName.value = '' folderName.value = ''
errorMessage.value = ''
validationErrors.value = [] validationErrors.value = []
loading.value = false
} }
const handleCreate = async () => { const handleCreate = async () => {
const errors = props.validateName(folderName.value) // Final validation
const errors = validateFolderName(folderName.value)
if (errors.length > 0) { if (errors.length > 0) {
validationErrors.value = errors validationErrors.value = errors
return 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 = () => { const handleCancel = () => {
dialogValue.value = false dialogValue.value = false
resetForm() resetForm()
} }
</script> </script>
<template> <template>

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import { computed, ref } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models' import type { ServiceObject } from '@MailManager/models'
import type { CollectionIdentifier } from '@MailManager/types/common'
interface Props { interface Props {
folder: CollectionObject folder: CollectionObject
@@ -21,7 +20,9 @@ const emit = defineEmits<{
const expanded = ref(false) 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 => { const folderLabelFor = (folder: CollectionObject): string => {
return folder.properties.label || String(folder.identifier) return folder.properties.label || String(folder.identifier)
@@ -75,11 +76,7 @@ const childFolders = computed(() => {
return [] return []
} }
return collectionsStore.collectionsInCollection( return collectionsStore.collectionsInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
props.service.provider,
serviceIdentifier,
props.folder.identifier as CollectionIdentifier,
)
}) })
const hasChildren = computed(() => { const hasChildren = computed(() => {
@@ -89,7 +86,7 @@ const hasChildren = computed(() => {
return false 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) 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<{ const emit = defineEmits<{
select: [folder: CollectionObject] select: [folder: CollectionObject]
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject] createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
editFolder: [folder: CollectionObject] editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [folder: CollectionObject] moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [folder: CollectionObject] deleteFolder: [service: ServiceObject, folder: CollectionObject]
}>() }>()
const childFolders = computed(() => { const childFolders = computed(() => {
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier) return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
}) })
const hasChildren = computed(() => { const hasChildren = computed(() => {
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier) return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
}) })
const canDeleteFolder = computed(() => !props.folder.properties.role) const canDeleteFolder = computed(() => !props.folder.properties.role)
@@ -131,7 +131,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact"> <v-list density="compact">
<v-list-item <v-list-item
prepend-icon="mdi-pencil" prepend-icon="mdi-pencil"
@click="emit('editFolder', folder)" @click="emit('editFolder', service, folder)"
> >
<v-list-item-title>Edit Folder Name</v-list-item-title> <v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item> </v-list-item>
@@ -143,7 +143,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item> </v-list-item>
<v-list-item <v-list-item
prepend-icon="mdi-folder-move" prepend-icon="mdi-folder-move"
@click="emit('moveFolder', folder)" @click="emit('moveFolder', service, folder)"
> >
<v-list-item-title>Move Folder</v-list-item-title> <v-list-item-title>Move Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -151,7 +151,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder" v-if="canDeleteFolder"
prepend-icon="mdi-delete" prepend-icon="mdi-delete"
base-color="error" base-color="error"
@click="emit('deleteFolder', folder)" @click="emit('deleteFolder', service, folder)"
> >
<v-list-item-title>Delete Folder</v-list-item-title> <v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -170,9 +170,9 @@ const isSelected = (folder: CollectionObject): boolean => {
:selected-folder="selectedFolder" :selected-folder="selectedFolder"
@select="emit('select', $event)" @select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)" @create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
@edit-folder="(folder) => emit('editFolder', folder)" @edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(folder) => emit('moveFolder', folder)" @move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)" @delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
/> />
</div> </div>
</v-list-group> </v-list-group>
@@ -217,7 +217,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact"> <v-list density="compact">
<v-list-item <v-list-item
prepend-icon="mdi-pencil" prepend-icon="mdi-pencil"
@click="emit('editFolder', folder)" @click="emit('editFolder', service, folder)"
> >
<v-list-item-title>Edit Folder Name</v-list-item-title> <v-list-item-title>Edit Folder Name</v-list-item-title>
</v-list-item> </v-list-item>
@@ -229,7 +229,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item> </v-list-item>
<v-list-item <v-list-item
prepend-icon="mdi-folder-move" prepend-icon="mdi-folder-move"
@click="emit('moveFolder', folder)" @click="emit('moveFolder', service, folder)"
> >
<v-list-item-title>Move Folder</v-list-item-title> <v-list-item-title>Move Folder</v-list-item-title>
</v-list-item> </v-list-item>
@@ -237,7 +237,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder" v-if="canDeleteFolder"
prepend-icon="mdi-delete" prepend-icon="mdi-delete"
base-color="error" base-color="error"
@click="emit('deleteFolder', folder)" @click="emit('deleteFolder', service, folder)"
> >
<v-list-item-title>Delete Folder</v-list-item-title> <v-list-item-title>Delete Folder</v-list-item-title>
</v-list-item> </v-list-item>

View File

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

View File

@@ -3,8 +3,6 @@ import { computed, onBeforeUnmount, ref } from 'vue'
import type { EntityIdentifier } from '@MailManager/types/common' import type { EntityIdentifier } from '@MailManager/types/common'
import type { EntityObject } from '@MailManager/models' import type { EntityObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import MessageListItemMenu from '@/components/MessageListItemMenu.vue'
// Props // Props
interface Props { interface Props {
@@ -25,70 +23,62 @@ const props = withDefaults(defineProps<Props>(), {
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
open: [message: EntityObject] open: [message: EntityObject]
reply: [message: EntityObject] toggleSelection: [message: EntityObject]
forward: [message: EntityObject] activateSelectionMode: [message: EntityObject]
move: [message: EntityObject] toggleSelectAll: [value: boolean]
delete: [message: EntityObject] clearSelection: []
flag: [message: EntityObject, flag: string, value: boolean] moveSelection: []
selectionMode: [message: EntityObject] deleteSelection: []
selectionToggleOne: [message: EntityObject]
selectionToggleAll: [value: boolean]
selectionClear: []
selectionMove: []
selectionDelete: []
selectionFlag: [flag: string, value: boolean]
}>() }>()
const longPressTimer = ref<number | null>(null) const longPressTimer = ref<number | null>(null)
const longPressActivated = ref(false) const longPressActivated = ref(false)
const suppressNextClick = ref(false) const suppressNextClick = ref(false)
const contextMenuVisible = ref(false) const LONG_PRESS_MS = 450
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)
// Sorted messages (newest first) const selectedIdSet = computed(() => new Set(props.selectionList))
const sortedMessages = computed(() => {
return [...props.messages].sort((a, b) => {
const dateA = timeStamp(a) ?? 0
const dateB = timeStamp(b) ?? 0
return dateB - dateA
})
})
const isOpened = (message: EntityObject): boolean => { const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false 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 => { 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 => { // Check if message is unread
const timestamp = message.properties.received const isUnread = (message: EntityObject): boolean => {
|| message.properties.sent return !message.properties.flags?.read
|| 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 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 // Format date for display
const formatDate = (date: Date | string | number | null | undefined): string => { const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return '' if (!date) return ''
const messageDate = new Date(date) const messageDate = new Date(date)
if (Number.isNaN(messageDate.getTime())) return ''
const now = new Date() const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today) const yesterday = new Date(today)
@@ -125,32 +115,21 @@ const formatDate = (date: Date | string | number | null | undefined): string =>
}) })
} }
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => { // Truncate text
return event.target instanceof Element && event.target.closest('.message-selection-checkbox, .message-item-menu-trigger, .message-item-menu-content') !== null 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) => { const handleSelectionToggle = (message: EntityObject) => {
emit('selectionToggleOne', message) emit('toggleSelection', message)
} }
const handleSelectionToggleAll = (value: boolean | null) => { const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
emit('selectionToggleAll', value === true)
}
const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (event.shiftKey && !props.selectionMode) { if (event.shiftKey && !props.selectionMode) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
emit('selectionMode', message) emit('activateSelectionMode', message)
return return
} }
@@ -159,19 +138,20 @@ const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObje
return return
} }
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (props.selectionMode) { if (props.selectionMode) {
emit('selectionToggleOne', message) emit('toggleSelection', message)
return return
} }
emit('open', message) emit('open', message)
} }
const handleMouseDown = (event: MouseEvent, message: EntityObject) => { const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (!event.shiftKey || props.selectionMode) { if (!event.shiftKey || props.selectionMode) {
return return
} }
@@ -179,44 +159,14 @@ const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
suppressNextClick.value = true suppressNextClick.value = true
emit('selectionMode', message) emit('activateSelectionMode', message)
} }
const openContextMenu = (event: MouseEvent, message: EntityObject) => { const clearLongPressTimer = () => {
if (isSelectionControlClick(event)) { if (longPressTimer.value !== null) {
return 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) => { const handleTouchStart = (message: EntityObject) => {
@@ -225,9 +175,9 @@ const handleTouchStart = (message: EntityObject) => {
longPressTimer.value = window.setTimeout(() => { longPressTimer.value = window.setTimeout(() => {
if (!props.selectionMode) { if (!props.selectionMode) {
emit('selectionMode', message) emit('activateSelectionMode', message)
} else { } else {
emit('selectionToggleOne', message) emit('toggleSelection', message)
} }
longPressActivated.value = true longPressActivated.value = true
@@ -243,32 +193,36 @@ const handleTouchMove = () => {
clearLongPressTimer() 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(() => { onBeforeUnmount(() => {
clearLongPressTimer() 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> </script>
<template> <template>
@@ -276,12 +230,12 @@ onBeforeUnmount(() => {
<!-- Header with folder name and counts --> <!-- Header with folder name and counts -->
<div v-if="selectedCollection" class="message-list-header"> <div v-if="selectedCollection" class="message-list-header">
<div class="message-list-heading"> <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"> <div class="folder-counts text-caption text-medium-emphasis">
<span v-if="selectedCollection?.properties.total != null"> <span v-if="hasCountData">
<span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span> <span class="unread-count">{{ unreadCount }}</span>
<span class="mx-1">/</span> <span class="mx-1">/</span>
<span>{{ selectedCollection?.properties.total ?? 0 }}</span> <span>{{ totalCount }}</span>
</span> </span>
<span v-else-if="messages.length > 0"> <span v-else-if="messages.length > 0">
{{ messages.length }} loaded {{ messages.length }} loaded
@@ -292,12 +246,15 @@ onBeforeUnmount(() => {
<div v-if="selectionMode && messages.length > 0" class="selection-summary"> <div v-if="selectionMode && messages.length > 0" class="selection-summary">
<div class="selection-controls"> <div class="selection-controls">
<v-checkbox-btn <v-checkbox-btn
:model-value="selectionCount !== 0" :model-value="allCurrentMessagesSelected"
:indeterminate="selectionCount > 0 && selectionCount !== messages.length" :indeterminate="hasSelection && !allCurrentMessagesSelected"
density="compact" density="compact"
hide-details 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>
<div class="selection-actions"> <div class="selection-actions">
@@ -305,8 +262,8 @@ onBeforeUnmount(() => {
size="small" size="small"
icon="mdi-folder-move-outline" icon="mdi-folder-move-outline"
variant="text" variant="text"
:disabled="selectionCount === 0" :disabled="!hasSelection"
@click="emit('selectionMove')" @click="emit('moveSelection')"
> >
<v-icon>mdi-folder-move-outline</v-icon> <v-icon>mdi-folder-move-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip> <v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip>
@@ -315,37 +272,18 @@ onBeforeUnmount(() => {
size="small" size="small"
icon="mdi-delete-outline" icon="mdi-delete-outline"
variant="text" variant="text"
:disabled="selectionCount === 0" :disabled="!hasSelection"
@click="emit('selectionDelete')" @click="emit('deleteSelection')"
> >
<v-icon>mdi-delete-outline</v-icon> <v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip> <v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip>
</v-btn> </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 <v-btn
size="small" size="small"
icon="mdi-close" icon="mdi-close"
variant="text" variant="text"
@click="emit('selectionClear')" :disabled="!hasSelection"
@click="emit('clearSelection')"
> >
<v-icon>mdi-close</v-icon> <v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip> <v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip>
@@ -365,7 +303,7 @@ onBeforeUnmount(() => {
</div> </div>
<!-- Empty state --> <!-- 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> <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-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
@@ -382,17 +320,16 @@ onBeforeUnmount(() => {
> >
<template v-slot:default="{ item: message }"> <template v-slot:default="{ item: message }">
<v-list-item <v-list-item
:key="message.identifier" :key="`${message.provider}:${message.service}:${message.collection}:${message.identifier}`"
class="message-item" class="message-item"
:class="{ :class="{
'opened': isOpened(message), 'opened': isOpened(message),
'selected': isSelected(message), 'selected': isSelected(message),
'selection-mode': selectionMode, 'selection-mode': selectionMode,
'unread': !message.properties.isRead 'unread': isUnread(message)
}" }"
@mousedown="handleMouseDown($event, message)" @mousedown="handleMessageMouseDown($event, message)"
@click="handleMouseClick($event, message)" @click="handleMessageMouseClick($event, message)"
@contextmenu="openContextMenu($event, message)"
@touchstart.passive="handleTouchStart(message)" @touchstart.passive="handleTouchStart(message)"
@touchend="handleTouchEnd" @touchend="handleTouchEnd"
@touchcancel="handleTouchEnd" @touchcancel="handleTouchEnd"
@@ -407,7 +344,7 @@ onBeforeUnmount(() => {
density="compact" density="compact"
hide-details hide-details
@click.stop @click.stop
@update:model-value="handleSelectionToggleOne(message)" @update:model-value="handleSelectionToggle(message)"
/> />
<v-avatar size="40" color="primary"> <v-avatar size="40" color="primary">
@@ -420,17 +357,10 @@ onBeforeUnmount(() => {
<v-list-item-title class="d-flex align-center"> <v-list-item-title class="d-flex align-center">
<span class="flex-grow-1 text-truncate"> <span class="flex-grow-1 text-truncate">
<RecipientDetails {{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
:address="message.properties.from"
@clicked="handleRecipientClick(message)"
>
<template #default="{ label }">
<span class="message-person-link text-truncate">{{ label }}</span>
</template>
</RecipientDetails>
</span> </span>
<span class="text-caption text-medium-emphasis ml-2"> <span class="text-caption text-medium-emphasis ml-2">
{{ formatDate(timeStamp(message)) }} {{ formatDate(message.properties.date) }}
</span> </span>
</v-list-item-title> </v-list-item-title>
@@ -439,14 +369,13 @@ onBeforeUnmount(() => {
</v-list-item-subtitle> </v-list-item-subtitle>
<v-list-item-subtitle class="text-caption text-truncate"> <v-list-item-subtitle class="text-caption text-truncate">
{{ '' }} {{ truncate(message.properties.snippet, 80) }}
</v-list-item-subtitle> </v-list-item-subtitle>
<template v-slot:append> <template v-slot:append>
<div class="message-item-append">
<div class="d-flex flex-column align-center"> <div class="d-flex flex-column align-center">
<v-icon <v-icon
v-if="message.properties.isFlagged" v-if="isFlagged(message)"
size="small" size="small"
color="warning" color="warning"
class="mb-1" class="mb-1"
@@ -454,41 +383,18 @@ onBeforeUnmount(() => {
mdi-star mdi-star
</v-icon> </v-icon>
<v-icon <v-icon
v-if="message.properties.hasAttachments" v-if="message.properties.attachments && message.properties.attachments.length > 0"
size="small" size="small"
color="grey" color="grey"
> >
mdi-paperclip mdi-paperclip
</v-icon> </v-icon>
</div> </div>
<MessageListItemMenu
:message="message"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div>
</template> </template>
</v-list-item> </v-list-item>
<v-divider /> <v-divider />
</template> </template>
</v-virtual-scroll> </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> </div>
</template> </template>
@@ -573,31 +479,6 @@ onBeforeUnmount(() => {
min-width: 72px; 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 { .message-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04); background-color: rgba(var(--v-theme-on-surface), 0.04);
} }
@@ -616,33 +497,6 @@ onBeforeUnmount(() => {
:deep(.v-list-item-subtitle:first-of-type) { :deep(.v-list-item-subtitle:first-of-type) {
font-weight: 600; 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) { @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 { useUser } from '@KTXC'
import type { EntityObject, MessageObject } from '@MailManager/models' import type { EntityObject, MessageObject } from '@MailManager/models'
import { SecurityLevel } from '@/utile/emailSanitizer' import { SecurityLevel } from '@/utile/emailSanitizer'
import { useMailStore } from '@/stores/mailStore'
import ReaderEmpty from './reader/ReaderEmpty.vue' import ReaderEmpty from './reader/ReaderEmpty.vue'
import ReaderToolbar from './reader/ReaderToolbar.vue' import ReaderToolbar from './reader/ReaderToolbar.vue'
import ReaderHeader from './reader/ReaderHeader.vue' import ReaderHeader from './reader/ReaderHeader.vue'
@@ -26,7 +25,6 @@ const emit = defineEmits<{
// User settings // User settings
const { getSetting } = useUser() const { getSetting } = useUser()
const mailStore = useMailStore()
// Per-message overrides // Per-message overrides
const allowImages = ref(false) 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 = () => { const handleFlag = () => {
if (props.entity) { if (props.entity) {
emit('flag', props.entity) emit('flag', props.entity)
@@ -135,17 +125,13 @@ const handleCompose = () => {
@move="handleMove" @move="handleMove"
@delete="handleDelete" @delete="handleDelete"
@flag="handleFlag" @flag="handleFlag"
@download="handleDownload()"
@toggle-images="toggleImages" @toggle-images="toggleImages"
@set-security-level="setSecurityLevel" @set-security-level="setSecurityLevel"
/> />
<!-- Message content --> <!-- Message content -->
<div class="message-content"> <div class="message-content">
<ReaderHeader <ReaderHeader :message="message!" />
:entity="props.entity"
@download-attachment="handleDownload"
/>
<v-divider /> <v-divider />

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models' import type { ServiceObject } from '@MailManager/models'
@@ -7,25 +9,23 @@ interface Props {
modelValue: boolean modelValue: boolean
service: ServiceObject service: ServiceObject
folder: CollectionObject folder: CollectionObject
parentFolderLabel?: string allFolders?: CollectionObject[]
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
parentFolderLabel: 'Root', allFolders: () => []
validateName: () => [],
loading: false,
errorMessage: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
confirm: [folderName: string] updated: [folder: CollectionObject]
}>() }>()
const collectionsStore = useCollectionsStore()
const folderName = ref('') const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([]) const validationErrors = ref<string[]>([])
const dialogValue = computed({ const dialogValue = computed({
@@ -37,13 +37,74 @@ const isValid = computed(() => {
return folderName.value.trim().length > 0 && validationErrors.value.length === 0 return folderName.value.trim().length > 0 && validationErrors.value.length === 0
}) })
const parentFolderLabel = computed(() => {
const parentId = props.folder.collection
if (parentId === null || parentId === undefined) return 'Root'
const parent = props.allFolders.find(
f =>
String(f.identifier) === String(parentId) &&
f.provider === props.folder.provider &&
String(f.service) === String(props.folder.service)
)
return parent?.properties.label || 'Root'
})
const validateFolderName = (name: string): string[] => {
const errors: string[] = []
if (!name || name.trim().length === 0) {
errors.push('Folder name is required')
return errors
}
if (name.length > 255) {
errors.push('Folder name too long (max 255 characters)')
}
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
const checkDuplicateName = (name: string): boolean => {
const parentId = props.folder.collection ?? null
return props.allFolders.some(f => {
if (String(f.identifier) === String(props.folder.identifier)) return false
return (
f.properties.label === name &&
String(f.collection) === String(parentId) &&
f.provider === props.folder.provider &&
String(f.service) === String(props.folder.service)
)
})
}
watch(folderName, (newName) => { watch(folderName, (newName) => {
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() { function resetForm() {
folderName.value = props.folder.properties.label || '' folderName.value = props.folder.properties.label || ''
errorMessage.value = ''
validationErrors.value = [] validationErrors.value = []
loading.value = false
} }
watch(dialogValue, (isOpen) => { watch(dialogValue, (isOpen) => {
@@ -53,27 +114,54 @@ watch(dialogValue, (isOpen) => {
}, { immediate: true }) }, { immediate: true })
const handleRename = async () => { const handleRename = async () => {
const errors = props.validateName(folderName.value) const errors = validateFolderName(folderName.value)
if (errors.length > 0) { if (errors.length > 0) {
validationErrors.value = errors validationErrors.value = errors
return return
} }
if (checkDuplicateName(folderName.value)) {
validationErrors.value = ['A folder with this name already exists in this location']
return
}
const newName = folderName.value.trim() const newName = folderName.value.trim()
if (newName === props.folder.properties.label) { if (newName === props.folder.properties.label) {
dialogValue.value = false dialogValue.value = false
return return
} }
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 = () => { const handleCancel = () => {
dialogValue.value = false dialogValue.value = false
resetForm() resetForm()
} }
</script> </script>
<template> <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"> <script setup lang="ts">
import { computed } from 'vue' import { MessageObject } from '@MailManager/models/message'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import type { EntityObject } from '@MailManager/models';
interface Props { interface Props {
entity: EntityObject | null message: MessageObject
} }
const props = defineProps<Props>() 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 // Format date for display
const formatDate = (date: Date | string | null | undefined): string => { const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return '' if (!date) return ''
@@ -45,10 +31,6 @@ const formatFileSize = (bytes: number | undefined): string => {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB' if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB' return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
} }
const download = async (index: number): Promise<void> => {
emit('downloadAttachment', index)
}
</script> </script>
<template> <template>
@@ -64,14 +46,10 @@ const download = async (index: number): Promise<void> => {
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="text-body-1 font-weight-medium"> <div class="text-body-1 font-weight-medium">
<RecipientDetails :address="message?.from"> {{ message?.from?.label || message?.from?.address || 'Unknown Sender' }}
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
</div> </div>
<div class="text-caption text-medium-emphasis"> <div class="text-caption text-medium-emphasis">
{{ formatDate(message?.received || message?.sent) }} {{ formatDate(message?.date) }}
</div> </div>
</div> </div>
</div> </div>
@@ -79,26 +57,12 @@ const download = async (index: number): Promise<void> => {
<!-- Recipients --> <!-- Recipients -->
<div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1"> <div v-if="message?.to && message?.to.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">To:</span> <span class="text-medium-emphasis">To:</span>
<template v-for="(recipient, index) in message.to" :key="randomKey"> {{ message?.to.map(t => t.label || t.address).join(', ') }}
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.to.length - 1">, </span>
</template>
</div> </div>
<div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1"> <div v-if="message?.cc && message?.cc.length > 0" class="text-body-2 mb-1">
<span class="text-medium-emphasis">Cc:</span> <span class="text-medium-emphasis">Cc:</span>
<template v-for="(recipient, index) in message.cc" :key="randomKey"> {{ message?.cc.map(c => c.label || c.address).join(', ') }}
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.cc.length - 1">, </span>
</template>
</div> </div>
<!-- Attachments --> <!-- Attachments -->
@@ -107,27 +71,22 @@ const download = async (index: number): Promise<void> => {
Attachments ({{ message?.attachments.length }}) Attachments ({{ message?.attachments.length }})
</div> </div>
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<div
v-for="(attachment, index) in message?.attachments"
:key="randomKey"
class="attachment-item"
>
<v-chip <v-chip
v-for="(attachment, index) in message?.attachments"
:key="index"
prepend-icon="mdi-paperclip" prepend-icon="mdi-paperclip"
size="small" size="small"
variant="outlined" variant="outlined"
class="attachment-chip" class="attachment-chip"
@click="download(index)"
> >
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span> <span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1"> <span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size ?? undefined) }}) ({{ formatFileSize(attachment.size) }})
</span> </span>
</v-chip> </v-chip>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -139,15 +98,8 @@ const download = async (index: number): Promise<void> => {
gap: 0.5rem; gap: 0.5rem;
} }
.attachment-item {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.attachment-chip { .attachment-chip {
max-width: 300px; max-width: 300px;
cursor: pointer;
.attachment-name { .attachment-name {
overflow: hidden; overflow: hidden;
@@ -155,21 +107,4 @@ const download = async (index: number): Promise<void> => {
white-space: nowrap; 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> </style>

View File

@@ -17,7 +17,6 @@ const emit = defineEmits<{
move: [] move: []
delete: [] delete: []
flag: [] flag: []
download: []
toggleImages: [] toggleImages: []
setSecurityLevel: [level: SecurityLevel] setSecurityLevel: [level: SecurityLevel]
}>() }>()
@@ -162,28 +161,6 @@ const currentSecurityLevel = computed(() => {
<v-icon>mdi-delete-outline</v-icon> <v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete</v-tooltip> <v-tooltip activator="parent" location="bottom">Delete</v-tooltip>
</v-btn> </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> </v-toolbar>
</template> </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"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { ref, computed } from 'vue'
import { folderViewModeOptions, useMailSettingsStore } from '@/stores/mailSettingsStore' import { useUser } from '@KTXC'
const mailSettingsStore = useMailSettingsStore() type FolderViewMode = 'tree' | 'page'
const { folderViewMode } = storeToRefs(mailSettingsStore)
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> </script>
<template> <template>
<div class="pa-4"> <div class="pa-4">
<h3 class="text-h6 mb-4">Display Settings</h3> <h3 class="text-h6 mb-4">Display Settings</h3>
<v-list> <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>
<v-list-item-title>Folder navigation style</v-list-item-title> <v-list-item-title>Folder navigation style</v-list-item-title>
<v-list-item-subtitle>Choose how folders are displayed</v-list-item-subtitle> <v-list-item-subtitle>Choose how folders are displayed</v-list-item-subtitle>
<template #append> <template #append>
<v-select <v-select
v-model="folderViewMode" v-model="folderViewMode"
:items="folderViewModeOptions" :items="[
{ value: 'tree', title: 'Tree' },
{ value: 'page', title: 'Page' }
]"
item-value="value" item-value="value"
item-title="title" item-title="title"
density="compact" density="compact"

View File

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

View File

@@ -1,22 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, unref } from 'vue' import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC' import { useModuleStore } from '@KTXC'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import { useMailUiStore } from '@/stores/mailUiStore'
import type { CollectionObject, EntityObject } from '@MailManager/models' import type { CollectionObject, EntityObject } from '@MailManager/models'
import { ComposerMode } from '@/types/composer' import type { EntityIdentifier } from '@MailManager/types/common'
import FolderTree from '@/components/FolderTree.vue'
import MessageList from '@/components/MessageList.vue' import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue' import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue' import MessageComposer from '@/components/MessageComposer.vue'
import CreateFolderDialog from '@/components/CreateFolderDialog.vue'
import DeleteFolderDialog from '@/components/DeleteFolderDialog.vue'
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue' import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
import RenameFolderDialog from '@/components/RenameFolderDialog.vue'
import SettingsDialog from '@/components/settings/SettingsDialog.vue' import SettingsDialog from '@/components/settings/SettingsDialog.vue'
import FolderView from '@/components/FolderView.vue'
// Vuetify display for responsive behavior // Vuetify display for responsive behavior
const display = useDisplay() const display = useDisplay()
@@ -24,173 +19,94 @@ const isMobile = computed(() => display.mdAndDown.value)
// Check if mail manager is available // Check if mail manager is available
const moduleStore = useModuleStore() const moduleStore = useModuleStore()
const isManagerAvailable = computed(() => { const isMailManagerAvailable = computed(() => {
return moduleStore.has('mail_manager') || moduleStore.has('MailManager') return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
}) })
const collectionsStore = useCollectionsStore()
// Mail module store // Mail module store
const mailStore = useMailStore() const mailStore = useMailStore()
const mailUiStore = useMailUiStore()
// storeToRefs preserves reactivity for state and computed properties // storeToRefs preserves reactivity for state and computed properties
const {
loading,
selectedMessage,
currentMessages,
} = storeToRefs(mailStore)
const { const {
sidebarVisible, sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
loading,
selectedFolder, selectedFolder,
composerMode, selectedMessage,
composerSource,
composerVisible,
selectionList, selectionList,
selectionMode, selectionMode,
moveMessagesDialogVisible, composeMode,
moveMessagesDialogService, composeReplyTo,
createFolderDialogVisible, currentMessages,
createFolderDialogService, moveDialogVisible,
createFolderDialogLoading, moveDialogCandidates,
createFolderDialogError, } = storeToRefs(mailStore)
renameFolderDialogVisible,
renameFolderDialogService,
renameFolderDialogFolder,
renameFolderDialogLoading,
renameFolderDialogError,
moveFolderDialogVisible,
moveFolderDialogService,
deleteFolderDialogVisible,
deleteFolderDialogService,
deleteFolderDialogFolder,
deleteFolderDialogLoading,
deleteFolderDialogError,
} = storeToRefs(mailUiStore)
// Complex store/composable objects accessed directly (not simple refs) // Complex store/composable objects accessed directly (not simple refs)
const { mailSync, entitiesStore } = mailStore const { mailSync, entitiesStore } = mailStore
const lastSyncLabel = computed(() => { const lastSyncLabel = computed(() => {
const lastSync = unref(unref(mailSync.lastSync)) if (!mailSync.lastSync) return ''
return `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})`
if (!(lastSync instanceof Date)) return ''
return `(Last: ${lastSync.toLocaleTimeString()})`
}) })
// Initialize // Initialize
onMounted(async () => { onMounted(async () => {
if (!isManagerAvailable.value) return if (!isMailManagerAvailable.value) return
await mailUiStore.initialize() await mailStore.initialize()
}) })
// Handlers — thin wrappers that delegate to the store // Handlers — thin wrappers that delegate to the store
const { const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
validateCreateFolderName,
validateRenameFolderName,
} = mailUiStore
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) => { const handleSelectAllToggle = (value: boolean) => {
try { if (value) {
const mutatedFolder = await mailUiStore.confirmCreateFolder(folderName) mailStore.selectAllCurrentMessages()
return
if (mutatedFolder) {
handleFolderSelect(mutatedFolder)
}
} catch (error: unknown) {
console.error('[MailPage] Failed to create folder:', error)
} }
mailStore.clearSelection()
} }
const handleFolderEditConfirm = async (folderName: string) => { const handleSelectionClear = () => mailStore.deactivateSelectionMode()
try {
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
if (mutatedFolder) { const handleSelectionMove = () => mailStore.openMoveDialog()
handleFolderSelect(mutatedFolder)
} const handleSelectionDelete = () => mailStore.deleteMessages([...selectionList.value])
} catch (error: unknown) {
console.error('[MailPage] Failed to rename folder:', error) 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 () => { const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
try {
await mailUiStore.confirmDeleteFolder()
} catch (error: unknown) {
console.error('[MailPage] Failed to delete folder:', error)
}
}
const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => { const handleMoveConfirm = async (target: CollectionObject) => { await mailStore.moveMessages(target, moveDialogCandidates.value ?? []) }
try {
await mailUiStore.confirmMoveFolder(targetFolder)
} catch (error: unknown) {
console.error('[MailPage] Failed to move folder:', error)
}
}
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog() const handleMoveCancel = () => mailStore.closeMoveDialog()
const toggleSidebar = () => mailStore.toggleSidebar()
const handleMessageOpen = (message: EntityObject) => { const handleSettingsOpen = () => mailStore.openSettings()
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 handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Folder "${folder.properties.label}" created`, 'success')
</script> </script>
<template> <template>
<!-- Manager Unavailable --> <!-- Manager Unavailable -->
<div v-if="!isManagerAvailable" class="mail-unavailable"> <div v-if="!isMailManagerAvailable" class="mail-unavailable">
<v-alert <v-alert
type="warning" type="warning"
variant="outlined" variant="outlined"
@@ -215,7 +131,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
<v-app-bar class="mail-toolbar" elevation="0" density="compact"> <v-app-bar class="mail-toolbar" elevation="0" density="compact">
<v-app-bar-nav-icon <v-app-bar-nav-icon
v-if="isMobile" v-if="isMobile"
@click="sidebarToggle" @click="toggleSidebar"
/> />
<v-app-bar-title>Mail</v-app-bar-title> <v-app-bar-title>Mail</v-app-bar-title>
@@ -224,7 +140,7 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
<v-btn <v-btn
icon="mdi-pencil" icon="mdi-pencil"
@click="handleMessageComposeFresh()" @click="handleCompose()"
color="primary" color="primary"
variant="text" variant="text"
> >
@@ -264,10 +180,10 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
width="280" width="280"
class="mail-sidebar" class="mail-sidebar"
> >
<FolderTree
<FolderView
:selected-folder="selectedFolder" :selected-folder="selectedFolder"
@select="handleFolderSelect" @select="handleFolderSelect"
@folder-created="handleFolderCreated"
/> />
<template #append> <template #append>
@@ -297,39 +213,32 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
:selection-mode="selectionMode" :selection-mode="selectionMode"
:loading="loading" :loading="loading"
@open="handleMessageOpen" @open="handleMessageOpen"
@reply="handleMessageComposeReply" @toggle-selection="handleMessageSelectionToggle"
@forward="handleMessageComposeForward" @activate-selection-mode="handleSelectionModeActivate"
@move="handleMessageMove" @toggle-select-all="handleSelectAllToggle"
@delete="handleMessageDelete" @clear-selection="handleSelectionClear"
@flag="handleMessageFlag" @move-selection="handleSelectionMove"
@selection-mode="handleMessageSelectionMode" @delete-selection="handleSelectionDelete"
@selection-toggle-one="handleMessageSelectionToggleOne"
@selection-toggle-all="handleMessageSelectionToggleAll"
@selection-clear="handleMessageSelectionClear"
@selection-flag="handleMessageSelectionFlag"
@selection-move="handleMessageSelectionMove"
@selection-delete="handleMessageSelectionDelete"
/> />
</div> </div>
<!-- Reader/Composer panel --> <!-- Reader/Composer panel -->
<div class="mail-reader-panel"> <div class="mail-reader-panel">
<MessageComposer <MessageComposer
v-if="composerVisible" v-if="composeMode"
:mode="composerMode" :reply-to="composeReplyTo"
:source="composerSource"
:folder="selectedFolder" :folder="selectedFolder"
@close="handleMessageComposeClose" @close="handleComposeClose"
@sent="handleComposeSent"
/> />
<MessageReader <MessageReader
v-else v-else
:entity="selectedMessage" :entity="selectedMessage"
@compose="handleMessageComposeFresh" @compose="handleCompose"
@reply="handleMessageComposeReply" @reply="handleCompose"
@forward="handleMessageComposeForward" @move="handleMove"
@move="handleMessageMove" @delete="handleDelete"
@delete="handleMessageDelete"
/> />
</div> </div>
</div> </div>
@@ -340,61 +249,13 @@ const handleMessageSelectionDelete = () => mailUiStore.deleteSelectedMessages()
<SettingsDialog v-model="settingsDialogVisible" /> <SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog <FolderSelectionDialog
v-if="moveMessagesDialogService" v-model="moveDialogVisible"
v-model="moveMessagesDialogVisible"
:service="moveMessagesDialogService"
:loading="loading" :loading="loading"
title="Move Messages To" title="Move To"
confirm-text="Move" confirm-text="Move"
empty-text="No other folders are available in this account." empty-text="No other folders are available in this account."
@select="handleMessageMoveConfirm" @select="handleMoveConfirm"
@cancel="handleMessageMoveCancel" @cancel="handleMoveCancel"
/>
<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"
/> />
</div> </div>
</template> </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 { defineStore } from 'pinia'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore' import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useEntitiesStore } from '@MailManager/stores/entitiesStore' import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
@@ -6,21 +6,7 @@ import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailSync } from '@MailManager/composables/useMailSync' import { useMailSync } from '@MailManager/composables/useMailSync'
import { useSnackbar } from '@KTXC' import { useSnackbar } from '@KTXC'
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { EntityTransmitRequest } from '@MailManager/types/entity' import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
interface ComposerMessageInput {
to: string[]
cc: string[]
bcc: string[]
subject: string
body: {
html: string
text: string
}
}
export const useMailStore = defineStore('mailStore', () => { export const useMailStore = defineStore('mailStore', () => {
const servicesStore = useServicesStore() const servicesStore = useServicesStore()
@@ -45,17 +31,27 @@ export const useMailStore = defineStore('mailStore', () => {
} }
// ── General State ─────────────────-─────────────────────────────────────── // ── General State ─────────────────-───────────────────────────────────────
const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false)
const loading = ref(false) const loading = ref(false)
const serviceFolderLoadingState = ref<Record<string, boolean>>({}) const serviceFolderLoadingState = ref<Record<string, boolean>>({})
const serviceFolderLoadedState = ref<Record<string, boolean>>({}) const serviceFolderLoadedState = ref<Record<string, boolean>>({})
const serviceFolderErrorState = ref<Record<string, string | null>>({}) const serviceFolderErrorState = ref<Record<string, string | null>>({})
// ── Selection State ───────────────────────────────────────────────────────
const selectedFolder = shallowRef<CollectionObject | null>(null) const selectedFolder = shallowRef<CollectionObject | null>(null)
const selectedMessage = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const composerSaving = ref(false) const selectionMode = ref(false)
const composerSending = ref(false) const selectionList = ref<EntityIdentifier[]>([])
const composerLastSaved = ref<Date | null>(null)
const composerDraftIdentifier = ref<EntityIdentifier | null>(null) // ── 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 ────────────────────────────────────────────────────────────── // ── Computed ──────────────────────────────────────────────────────────────
const currentMessages = computed(() => { const currentMessages = computed(() => {
@@ -78,15 +74,20 @@ export const useMailStore = defineStore('mailStore', () => {
await servicesStore.list() await servicesStore.list()
const services = [...servicesStore.servicesEnabled] const services = [...servicesStore.servicesEnabled]
await Promise.all(services.map(service => loadFoldersForService(service))) services.forEach(service => {
void loadFoldersForService(service,{ selectInbox: true })
})
} catch (error) { } catch (error) {
console.error('[Mail][Operations] Failed to initialize:', error) console.error('[Mail] Failed to initialize:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function loadFoldersForService(service: ServiceObject) { async function loadFoldersForService(
service: ServiceObject,
options: { selectInbox?: boolean } = {},
) {
if (service.identifier === null) { if (service.identifier === null) {
return return
@@ -97,15 +98,37 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
// retrieve folders for service // 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) _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() _updateSyncSources()
return collections
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders' const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message) _setServiceFolderError(service.provider, service.identifier, message)
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() _updateSyncSources()
return {} return {}
} finally { } finally {
@@ -116,20 +139,15 @@ export const useMailStore = defineStore('mailStore', () => {
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier { function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
if (item instanceof ServiceObject) {
return `${item.provider}:${String(item.identifier)}` as ServiceIdentifier
}
return `${item.provider}:${String(item.service)}` as ServiceIdentifier return `${item.provider}:${String(item.service)}` as ServiceIdentifier
} }
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean { function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier {
if (!left || !right) { return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier
return false
} }
return left.provider === right.provider && function _entityIdentifier(item: EntityObject): EntityIdentifier {
String(left.service) === String(right.service) && return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier
String(left.identifier) === String(right.identifier)
} }
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) { function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
@@ -158,18 +176,15 @@ export const useMailStore = defineStore('mailStore', () => {
// Track the currently selected folder // Track the currently selected folder
if (selectedFolder.value) { if (selectedFolder.value) {
//mailSyncController.addSource({ mailSyncController.addSource({
// provider: selectedFolder.value.provider, provider: selectedFolder.value.provider,
// service: selectedFolder.value.service, service: selectedFolder.value.service,
// collections: [selectedFolder.value.identifier], collections: [selectedFolder.value.identifier],
//}) })
} }
// Always track inboxes for each account (for new-mail notifications) // Always track inboxes for each account (for new-mail notifications)
servicesStore.servicesEnabled.forEach(service => { servicesStore.servicesEnabled.forEach(service => {
if (service.identifier === null) {
return
}
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter( const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
c => c =>
String(c.service) === String(service.identifier) && String(c.service) === String(service.identifier) &&
@@ -178,11 +193,11 @@ export const useMailStore = defineStore('mailStore', () => {
) )
if (inboxes.length > 0) { if (inboxes.length > 0) {
//mailSyncController.addSource({ mailSyncController.addSource({
// provider: service.provider, provider: service.provider,
// service: service.identifier as string | number, service: service.identifier as string | number,
// collections: inboxes.map(inbox => inbox.identifier), collections: inboxes.map(inbox => inbox.identifier),
//}) })
} }
}) })
@@ -203,289 +218,228 @@ export const useMailStore = defineStore('mailStore', () => {
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
} }
function _findDraftFolder(folder: CollectionObject): CollectionObject { function _reloadFolderMessages(folder: CollectionObject) {
return collectionsStore.collectionsForService(folder.provider, folder.service).find( return entitiesStore.list({
candidate => [folder.provider]: {
candidate.provider === folder.provider && [String(folder.service)]: {
String(candidate.service) === String(folder.service) && [String(folder.identifier)]: true,
(candidate.properties.role === 'drafts' ||
String(candidate.identifier).toLowerCase() === 'drafts' ||
candidate.properties.label.toLowerCase() === 'drafts'),
) ?? folder
}
function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined {
const normalized = addresses
.map(address => address.trim())
.filter(address => address.length > 0)
if (normalized.length === 0) {
return undefined
}
return normalized.map(address => ({ address }))
}
function _toDraftBody(body: ComposerMessageInput['body']): MessagePartInterface | null {
const parts: MessagePartInterface[] = []
const text = body.text.trim()
const html = body.html.trim()
if (text.length > 0) {
parts.push({
type: 'text/plain',
content: text,
})
}
if (html.length > 0) {
parts.push({
type: 'text/html',
content: html,
})
}
if (parts.length === 0) {
return null
}
if (parts.length === 1) {
return parts[0]
}
return {
type: 'multipart/alternative',
subParts: parts,
}
}
function _toDraftProperties(message: ComposerMessageInput): MessageInterface {
return {
'@type': 'mail:message',
to: _toMessageAddresses(message.to),
cc: _toMessageAddresses(message.cc),
bcc: _toMessageAddresses(message.bcc),
subject: message.subject.trim() || null,
body: _toDraftBody(message.body),
flags: {
draft: true,
}, },
},
})
}
function _setSelectionList(nextIds: EntityIdentifier[]) {
selectionList.value = Array.from(new Set(nextIds))
if (selectionList.value.length === 0) {
selectionMode.value = false
} }
} }
function resetComposerState() { function _reconcileSelection() {
composerSaving.value = false if (!selectedFolder.value) {
composerSending.value = false clearSelection()
composerLastSaved.value = null selectedMessage.value = null
composerDraftIdentifier.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 ─────────────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────────────
async function retrieveService(identifier: ServiceIdentifier, force: boolean = false): Promise<ServiceObject | null> { async function selectFolder(folder: CollectionObject) {
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) {
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
clearSelection()
selectionMode.value = false
composeMode.value = false
if (folder) {
try { try {
await entitiesStore.list([folder.identifier]) await _reloadFolderMessages(folder)
} catch (error) { } catch (error) {
console.error('[Mail][Operations] Failed to load messages:', error) console.error('[Mail] Failed to load messages:', error)
}
} }
_updateSyncSources() _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 selectedMessage.value = entity
} composeMode.value = false
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) { if (closeSidebar) {
composerSaving.value = true sidebarVisible.value = false
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
} }
} }
function findFoldersByRole(role: string): CollectionObject[] { function openCompose(replyTo?: EntityObject) {
const normalizedRole = role.toLowerCase() composeMode.value = true
composeReplyTo.value = replyTo ?? null
return servicesStore.servicesEnabled.flatMap(service => { selectedMessage.value = null
if (service.identifier === null) {
return []
} }
return collectionsStore.collectionsForService(service.provider, service.identifier).filter( function closeCompose() {
folder => composeMode.value = false
folder.provider === service.provider && composeReplyTo.value = null
String(folder.service) === String(service.identifier) && }
(folder.properties.role === normalizedRole ||
String(folder.identifier).toLowerCase() === normalizedRole), 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
}
// 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)
}) })
if (movableIdentifiers.length === 0) {
closeMoveDialog()
return
} }
async function sendComposerMessage(message: ComposerMessageInput) { loading.value = true
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 (transmitRequest.message.cc?.length === 0) {
delete transmitRequest.message.cc
}
if (transmitRequest.message.bcc?.length === 0) {
delete transmitRequest.message.bcc
}
try { try {
const response = await entitiesStore.transmit(transmitRequest) const [successes, failures] = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
if (composerDraftIdentifier.value) { clearSelection()
try { closeMoveDialog()
await entitiesStore.delete([composerDraftIdentifier.value])
} catch (error) { if (failures.length === 0) {
console.error('[Mail][Operations] Failed to delete draft after send:', error) notify(
} successes.length === 1 ? 'Message moved' : `${successes.length} messages moved`,
'success',
)
} }
notify('Message sent', 'success') if (failures.length > 0) {
resetComposerState() notify(
return response 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',
)
}
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to send message' const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail][Operations] Failed to send message:', error) console.error('[Mail] Failed to move messages:', error)
notify(messageText, 'error') notify(messageText, 'error')
throw error throw error
} finally { } 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[]) { async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
if (entityIdentifiers.length === 0) { if (entityIdentifiers.length === 0) {
return return
@@ -494,7 +448,9 @@ export const useMailStore = defineStore('mailStore', () => {
loading.value = true loading.value = true
try { try {
const { successes, failures } = await entitiesStore.delete(entityIdentifiers) const [successes, failures] = await entitiesStore.delete(entityIdentifiers)
clearSelection()
if (failures.length === 0) { if (failures.length === 0) {
notify( notify(
@@ -513,7 +469,7 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages' 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') notify(messageText, 'error')
throw error throw error
} finally { } finally {
@@ -521,139 +477,12 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<MessageInterface['flags']>, options: { notify?: boolean } = {}) { function toggleSidebar() {
if (entityIdentifiers.length === 0) { sidebarVisible.value = !sidebarVisible.value
return
} }
const shouldNotify = options.notify ?? true function openSettings() {
settingsDialogVisible.value = 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
}
}
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 notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') { function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
@@ -670,12 +499,18 @@ export const useMailStore = defineStore('mailStore', () => {
mailSync, mailSync,
// State // State
sidebarVisible,
settingsDialogVisible,
loading, loading,
selectedFolder,
selectedMessage, selectedMessage,
composerSaving, selectionList,
composerSending, selectionMode,
composerLastSaved, composeMode,
composerDraftIdentifier, composeReplyTo,
moveDialogVisible,
moveDialogService,
moveDialogCandidates,
serviceFolderLoadingState, serviceFolderLoadingState,
serviceFolderLoadedState, serviceFolderLoadedState,
serviceFolderErrorState, serviceFolderErrorState,
@@ -684,25 +519,28 @@ export const useMailStore = defineStore('mailStore', () => {
currentMessages, currentMessages,
// Actions // Actions
retrieveService,
selectFolder, selectFolder,
clearSelectedFolder,
selectMessage, selectMessage,
createFolder, isMessageSelected,
saveComposerDraft, activateSelectionMode,
sendComposerMessage, deactivateSelectionMode,
resetComposerState, toggleMessageSelection,
flagMessages, selectAllCurrentMessages,
clearSelection,
openCompose,
openMoveDialog,
closeMoveDialog,
closeCompose,
afterSent,
deleteMessages, deleteMessages,
deleteFolder,
moveMessages, moveMessages,
downloadMessage, toggleSidebar,
moveFolder, openSettings,
renameFolder,
notify, notify,
isServiceFolderLoading, isServiceFolderLoading,
hasServiceFoldersLoaded, hasServiceFoldersLoaded,
getServiceFolderError, getServiceFolderError,
findFoldersByRole,
loadFoldersForService, loadFoldersForService,
initialize, 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", "extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [ "include": ["src/**/*", "src/**/*.vue", "../../core/src/**/*.ts"],
"src/**/*",
"src/**/*.vue",
"../../core/src/**/*.ts",
"../mail_manager/src/**/*.ts",
"../mail_manager/src/**/*.vue"
],
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,