44 Commits

Author SHA1 Message Date
7736d26f49 chore(deps): update dependency vite to v8.0.16 2026-06-11 03:04:50 +00:00
f379e40de4 Merge pull request 'chore(deps): update vue-language-tools monorepo to v3.3.3' (#56) from renovate/vue-language-tools-monorepo into main
All checks were successful
Renovate / renovate (push) Successful in 3m14s
Reviewed-on: #56
2026-06-06 04:08:05 +00:00
19464beba1 Merge pull request 'chore(deps): update dependency vue-router to v5.1.0' (#57) from renovate/vue-router-5.x-lockfile into main
Reviewed-on: #57
2026-06-06 04:07:48 +00:00
6e7592d39d Merge pull request 'chore(deps): update dependency vuetify to v4.1.0' (#58) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #58
2026-06-06 04:07:38 +00:00
37d723cbdb chore(deps): update dependency vuetify to v4.1.0 2026-06-06 03:02:54 +00:00
461cd5dd92 chore(deps): update dependency vue-router to v5.1.0 2026-06-06 03:02:51 +00:00
805863f084 chore(deps): update dependency vue-tsc to v3.3.3 2026-06-02 03:01:55 +00:00
7a3d90d0cd Merge pull request 'feat: message list message menu' (#54) from feat/message-list-message-menu into main
All checks were successful
Renovate / renovate (push) Successful in 2m50s
Reviewed-on: #54
2026-05-30 22:30:59 +00:00
d24e6110b9 feat: message list message menu
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-30 18:30:43 -04:00
ec776bbb78 Merge pull request 'chore(deps): update dependency vue to v3.5.35' (#52) from renovate/vue-monorepo into main
Some checks failed
Renovate / renovate (push) Failing after 1m49s
Reviewed-on: #52
2026-05-29 03:11:03 +00:00
4f9a6cc5bd Merge pull request 'chore(deps): update dependency vuetify to v4.0.8' (#53) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #53
2026-05-29 03:10:56 +00:00
09da7b4770 chore(deps): update dependency vuetify to v4.0.8 2026-05-29 03:02:26 +00:00
41c6c82546 chore(deps): update dependency vue to v3.5.35 2026-05-29 03:02:20 +00:00
967dadf864 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.2' (#51) from renovate/vue-tsc-3.x-lockfile into main
All checks were successful
Renovate / renovate (push) Successful in 2m21s
Reviewed-on: #51
2026-05-27 03:20:53 +00:00
c1c334fe22 chore(deps): update dependency vue-tsc to v3.3.2 2026-05-27 03:20:01 +00:00
5a355c1536 Merge pull request 'fix: recipient click' (#50) from fix/recipient-click into main
Reviewed-on: #50
2026-05-27 03:17:28 +00:00
3a3cf77da1 fix: recipient click
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-26 23:17:03 -04:00
800d6bce83 Merge pull request 'chore(deps): update dependency vite to v8.0.14' (#49) from renovate/vite-8.x-lockfile into main
Some checks failed
Renovate / renovate (push) Failing after 1m45s
Reviewed-on: #49
2026-05-24 03:21:35 +00:00
6745361a5c chore(deps): update dependency vite to v8.0.14 2026-05-24 03:12:00 +00:00
9a98042fa0 Merge pull request 'chore(deps): update tiptap monorepo to v3.23.6' (#47) from renovate/tiptap-monorepo into main
Reviewed-on: #47
2026-05-24 03:10:36 +00:00
42c24642ce Merge pull request 'feat: message and attachment download' (#48) from feat/download into main
Reviewed-on: #48
2026-05-24 03:10:08 +00:00
b66ebbd078 feat: message and attachment download
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-23 23:09:40 -04:00
4b9a3b7945 chore(deps): update tiptap monorepo to v3.23.6 2026-05-22 15:59:15 +00:00
cdff4d0d3f Merge pull request 'feat: recipient details' (#46) from feat/address-menu into main
Some checks failed
Renovate / renovate (push) Failing after 1m34s
Reviewed-on: #46
2026-05-22 15:57:50 +00:00
bafb294e1a Merge pull request 'fix: read and unread' (#45) from fix/read-unread into main
Reviewed-on: #45
2026-05-22 15:57:27 +00:00
f1d0511cbb feat: recipient details
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-22 11:56:29 -04:00
897a03578e fix: read and unread
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-21 18:52:21 -04:00
4367fcfe9a Merge pull request 'chore(deps): update tiptap monorepo to v3.23.5' (#43) from renovate/tiptap-monorepo into main
Some checks failed
Renovate / renovate (push) Failing after 2m19s
Reviewed-on: #43
2026-05-21 03:41:11 +00:00
b7fcc0a368 Merge pull request 'chore(deps): update dependency vue-tsc to v3.3.1' (#44) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #44
2026-05-21 03:40:48 +00:00
0db23271e5 chore(deps): update dependency vue-tsc to v3.3.1 2026-05-20 02:29:54 +00:00
3f4aeb99c7 chore(deps): update tiptap monorepo to v3.23.5 2026-05-20 02:29:52 +00:00
7e544d16f4 Merge pull request 'feat: implement patch and settings store' (#42) from feat/patch-and-settings into main
Some checks failed
Renovate / renovate (push) Failing after 1m29s
Reviewed-on: #42
2026-05-20 02:28:14 +00:00
8ba40a971e feat: implement patch and settings store
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-19 22:27:51 -04:00
c3c41f7ba0 Merge pull request 'refactor: clean up event methods' (#41) from chore/clean-up-methods into main
All checks were successful
Renovate / renovate (push) Successful in 1m18s
Reviewed-on: #41
2026-05-17 21:52:34 +00:00
5a58c3c7ac refactor: clean up event methods
Signed-off-by: Sebastian <krupinski01@gmail.com>
2026-05-17 17:52:16 -04:00
7853a21288 Merge pull request 'chore(deps): update dependency @vitejs/plugin-vue to v6.0.7' (#40) from renovate/vitejs-plugin-vue-6.x-lockfile into main
All checks were successful
Renovate / renovate (push) Successful in 2m5s
Reviewed-on: #40
2026-05-15 13:40:08 +00:00
f1823a246c chore(deps): update dependency @vitejs/plugin-vue to v6.0.7 2026-05-15 13:39:23 +00:00
820c2c812f Merge pull request 'chore(deps): update tiptap monorepo to v3.23.4' (#39) from renovate/tiptap-monorepo into main
Reviewed-on: #39
2026-05-15 13:25:07 +00:00
6e76bec190 chore(deps): update tiptap monorepo to v3.23.4 2026-05-15 13:21:52 +00:00
c304ab6b6e Merge pull request 'chore(deps): update dependency vue-tsc to v3.2.9' (#38) from renovate/vue-tsc-3.x-lockfile into main
Reviewed-on: #38
2026-05-15 03:37:58 +00:00
12e0c7b428 Merge pull request 'chore(deps): update dependency vite to v8.0.13' (#37) from renovate/vite-8.x-lockfile into main
Reviewed-on: #37
2026-05-15 03:09:53 +00:00
7c366fdd96 Merge pull request 'fix(deps): update dependency vue-router to v5' (#30) from renovate/vue-router-5.x into main
Reviewed-on: #30
2026-05-15 03:09:42 +00:00
1d62412407 chore(deps): update dependency vue-tsc to v3.2.9 2026-05-15 03:04:15 +00:00
d76166addd chore(deps): update dependency vite to v8.0.13 2026-05-15 03:04:13 +00:00
18 changed files with 1799 additions and 1028 deletions

835
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useServicesStore } from '@MailManager/stores/servicesStore' import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { useMailUiStore } from '@/stores/mailUiStore' import { useMailUiStore } from '@/stores/mailUiStore'
import { useUser } from '@KTXC'
import FolderTreeView from './FolderTreeView.vue' import FolderTreeView from './FolderTreeView.vue'
import FolderPageView from './FolderPageView.vue' import FolderPageView from './FolderPageView.vue'
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'
type FolderViewMode = 'tree' | 'page'
interface ServiceGroup { interface ServiceGroup {
service: ServiceObject service: ServiceObject
loading: boolean loading: boolean
@@ -31,12 +30,8 @@ const emit = defineEmits<{
const servicesStore = useServicesStore() const servicesStore = useServicesStore()
const mailStore = useMailStore() const mailStore = useMailStore()
const mailUiStore = useMailUiStore() const mailUiStore = useMailUiStore()
const { settings } = useUser() const mailSettingsStore = useMailSettingsStore()
const { folderViewMode } = storeToRefs(mailSettingsStore)
// Computed
const folderViewMode = computed(() => {
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
})
const serviceGroups = computed(() => { const serviceGroups = computed(() => {
const groups: ServiceGroup[] = [] const groups: ServiceGroup[] = []

View File

@@ -10,14 +10,16 @@ import Placeholder from '@tiptap/extension-placeholder'
import { EntityObject } from '@MailManager/models/entity' import { EntityObject } from '@MailManager/models/entity'
import type { CollectionObject } from '@MailManager/models' import type { CollectionObject } from '@MailManager/models'
import { useMailStore } from '@/stores/mailStore' 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 ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue' import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
import ComposerEditor from '@/components/composer/ComposerEditor.vue' import ComposerEditor from '@/components/composer/ComposerEditor.vue'
// Props // Props
interface Props { interface Props {
mode: 'new' | 'reply' | 'forward' mode: ComposerMode
source?: EntityObject | null source?: EntityObject | MessageAddressInterface | null
folder?: CollectionObject | null folder?: CollectionObject | null
} }
@@ -26,7 +28,6 @@ const props = defineProps<Props>()
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
sent: []
}>() }>()
const mailStore = useMailStore() const mailStore = useMailStore()
@@ -84,7 +85,15 @@ function initializeComposerFromProps() {
mailStore.resetComposerState() mailStore.resetComposerState()
resetComposerFields() resetComposerFields()
if (!props.source) { 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 return
} }
@@ -95,19 +104,19 @@ function initializeComposerFromProps() {
const sentAt = sourceMessage.sent || props.source.created || '' const sentAt = sourceMessage.sent || props.source.created || ''
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time' const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
if (props.mode === 'reply') { if (props.mode === ComposerMode.Reply) {
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
to.value = fromEmail ? [fromEmail] : [] to.value = fromEmail ? [fromEmail] : []
subject.value = /^Re:/i.test(originalSubject) subject.value = /^Re:/i.test(originalSubject)
? originalSubject ? originalSubject
: `Re: ${originalSubject}` : `Re: ${originalSubject}`
editor.value?.commands.setContent( editor.value?.commands.setContent(
`<p><br></p><p>On ${sentLabel}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`, `<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
) )
return return
} }
if (props.mode === 'forward') { if (props.mode === ComposerMode.Forward) {
subject.value = /^Fwd:/i.test(originalSubject) subject.value = /^Fwd:/i.test(originalSubject)
? originalSubject ? originalSubject
: `Fwd: ${originalSubject}` : `Fwd: ${originalSubject}`
@@ -222,7 +231,6 @@ const handleSend = async () => {
text: editor.value?.getText() || '', text: editor.value?.getText() || '',
}, },
}) })
emit('sent')
} catch (error) { } catch (error) {
console.error('[Mail][Composer] Failed to send message:', error) console.error('[Mail][Composer] Failed to send message:', error)
} }

View File

@@ -3,6 +3,8 @@ 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 {
@@ -23,72 +25,39 @@ const props = withDefaults(defineProps<Props>(), {
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
open: [message: EntityObject] open: [message: EntityObject]
toggleSelection: [message: EntityObject] reply: [message: EntityObject]
activateSelectionMode: [message: EntityObject] forward: [message: EntityObject]
toggleSelectAll: [value: boolean] move: [message: EntityObject]
clearSelection: [] delete: [message: EntityObject]
moveSelection: [] flag: [message: EntityObject, flag: string, value: boolean]
deleteSelection: [] selectionMode: [message: EntityObject]
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 LONG_PRESS_MS = 450 const contextMenuVisible = ref(false)
const contextMenuTarget = ref<[number, number] | undefined>(undefined)
const contextMenuMessage = ref<EntityObject | null>(null)
const LONG_PRESS_MS = 400
const selectedIdSet = computed(() => new Set(props.selectionList)) const selectedIdSet = computed(() => new Set(props.selectionList))
const selectionCount = computed(() => props.selectionList.length ?? 0)
const currentMessages = computed(() => props.messages ?? [])
const getMessageTimestamp = (message: EntityObject): string | null => {
return message.properties.received
|| message.properties.sent
|| message.modified
|| message.created
|| null
}
const getMessageTimeValue = (message: EntityObject): number => {
const timestamp = getMessageTimestamp(message)
if (!timestamp) {
return 0
}
const timeValue = new Date(timestamp).getTime()
return Number.isNaN(timeValue) ? 0 : timeValue
}
const selectionCount = computed(() => props.selectionList.length)
const hasSelection = computed(() => selectionCount.value > 0)
const allCurrentMessagesSelected = computed(() => {
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
})
// Sorted messages (newest first) // Sorted messages (newest first)
const sortedMessages = computed(() => { const sortedMessages = computed(() => {
return [...currentMessages.value].sort((a, b) => { return [...props.messages].sort((a, b) => {
const dateA = getMessageTimeValue(a) const dateA = timeStamp(a) ?? 0
const dateB = getMessageTimeValue(b) const dateB = timeStamp(b) ?? 0
return dateB - dateA return dateB - dateA
}) })
}) })
// Read/Unread counts from collection properties
const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0
})
const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0
})
// True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => {
return props.selectedCollection?.properties.total != null
})
const isOpened = (message: EntityObject): boolean => { const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false if (!props.selectedMessage) return false
return (message.identifier === props.selectedMessage.identifier) return (message.identifier === props.selectedMessage.identifier)
@@ -98,8 +67,23 @@ const isSelected = (message: EntityObject): boolean => {
return selectedIdSet.value.has(message.identifier) return selectedIdSet.value.has(message.identifier)
} }
const timeStamp = (message: EntityObject): number | null => {
const timestamp = message.properties.received
|| message.properties.sent
|| message.modified
|| message.created
|| null
if (!timestamp) {
return null
}
const timeValue = new Date(timestamp).getTime()
return Number.isNaN(timeValue) ? null : timeValue
}
// Format date for display // Format date for display
const formatDate = (date: Date | string | null | undefined): string => { const formatDate = (date: Date | string | number | null | undefined): string => {
if (!date) return '' if (!date) return ''
const messageDate = new Date(date) const messageDate = new Date(date)
@@ -141,21 +125,19 @@ const formatDate = (date: Date | string | null | undefined): string => {
}) })
} }
// Truncate text
const truncate = (text: string | null | undefined, length: number = 100): string => {
if (!text) return ''
return text.length > length ? text.substring(0, length) + '...' : text
}
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => { const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
return event.target instanceof Element && event.target.closest('.message-selection-checkbox') !== null return event.target instanceof Element && event.target.closest('.message-selection-checkbox, .message-item-menu-trigger, .message-item-menu-content') !== null
} }
const handleSelectionToggle = (message: EntityObject) => { const handleSelectionToggleOne = (message: EntityObject) => {
emit('toggleSelection', message) emit('selectionToggleOne', message)
} }
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => { const handleSelectionToggleAll = (value: boolean | null) => {
emit('selectionToggleAll', value === true)
}
const handleMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) { if (isSelectionControlClick(event)) {
return return
} }
@@ -168,7 +150,7 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
if (event.shiftKey && !props.selectionMode) { if (event.shiftKey && !props.selectionMode) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
emit('activateSelectionMode', message) emit('selectionMode', message)
return return
} }
@@ -178,14 +160,14 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
} }
if (props.selectionMode) { if (props.selectionMode) {
emit('toggleSelection', message) emit('selectionToggleOne', message)
return return
} }
emit('open', message) emit('open', message)
} }
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => { const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) { if (isSelectionControlClick(event)) {
return return
} }
@@ -197,14 +179,44 @@ const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
suppressNextClick.value = true suppressNextClick.value = true
emit('activateSelectionMode', message) emit('selectionMode', message)
} }
const clearLongPressTimer = () => { const openContextMenu = (event: MouseEvent, message: EntityObject) => {
if (longPressTimer.value !== null) { if (isSelectionControlClick(event)) {
window.clearTimeout(longPressTimer.value) return
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) => {
@@ -213,9 +225,9 @@ const handleTouchStart = (message: EntityObject) => {
longPressTimer.value = window.setTimeout(() => { longPressTimer.value = window.setTimeout(() => {
if (!props.selectionMode) { if (!props.selectionMode) {
emit('activateSelectionMode', message) emit('selectionMode', message)
} else { } else {
emit('toggleSelection', message) emit('selectionToggleOne', message)
} }
longPressActivated.value = true longPressActivated.value = true
@@ -231,13 +243,32 @@ 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)
}
</script> </script>
<template> <template>
@@ -245,12 +276,12 @@ const handleSelectAllToggle = (value: boolean | null) => {
<!-- 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="hasCountData"> <span v-if="selectedCollection?.properties.total != null">
<span class="unread-count">{{ unreadCount }}</span> <span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span>
<span class="mx-1">/</span> <span class="mx-1">/</span>
<span>{{ totalCount }}</span> <span>{{ selectedCollection?.properties.total ?? 0 }}</span>
</span> </span>
<span v-else-if="messages.length > 0"> <span v-else-if="messages.length > 0">
{{ messages.length }} loaded {{ messages.length }} loaded
@@ -261,15 +292,12 @@ const handleSelectAllToggle = (value: boolean | null) => {
<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="allCurrentMessagesSelected" :model-value="selectionCount !== 0"
:indeterminate="hasSelection && !allCurrentMessagesSelected" :indeterminate="selectionCount > 0 && selectionCount !== messages.length"
density="compact" density="compact"
hide-details hide-details
@update:model-value="handleSelectAllToggle" @update:model-value="handleSelectionToggleAll"
/> />
<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">
@@ -277,8 +305,8 @@ const handleSelectAllToggle = (value: boolean | null) => {
size="small" size="small"
icon="mdi-folder-move-outline" icon="mdi-folder-move-outline"
variant="text" variant="text"
:disabled="!hasSelection" :disabled="selectionCount === 0"
@click="emit('moveSelection')" @click="emit('selectionMove')"
> >
<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>
@@ -287,17 +315,37 @@ const handleSelectAllToggle = (value: boolean | null) => {
size="small" size="small"
icon="mdi-delete-outline" icon="mdi-delete-outline"
variant="text" variant="text"
:disabled="!hasSelection" :disabled="selectionCount === 0"
@click="emit('deleteSelection')" @click="emit('selectionDelete')"
> >
<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('clearSelection')" @click="emit('selectionClear')"
> >
<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>
@@ -317,7 +365,7 @@ const handleSelectAllToggle = (value: boolean | null) => {
</div> </div>
<!-- Empty state --> <!-- Empty state -->
<div v-else-if="currentMessages.length === 0" class="pa-8 text-center"> <div v-else-if="messages.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">
@@ -342,8 +390,9 @@ const handleSelectAllToggle = (value: boolean | null) => {
'selection-mode': selectionMode, 'selection-mode': selectionMode,
'unread': !message.properties.isRead 'unread': !message.properties.isRead
}" }"
@mousedown="handleMessageMouseDown($event, message)" @mousedown="handleMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)" @click="handleMouseClick($event, message)"
@contextmenu="openContextMenu($event, message)"
@touchstart.passive="handleTouchStart(message)" @touchstart.passive="handleTouchStart(message)"
@touchend="handleTouchEnd" @touchend="handleTouchEnd"
@touchcancel="handleTouchEnd" @touchcancel="handleTouchEnd"
@@ -358,7 +407,7 @@ const handleSelectAllToggle = (value: boolean | null) => {
density="compact" density="compact"
hide-details hide-details
@click.stop @click.stop
@update:model-value="handleSelectionToggle(message)" @update:model-value="handleSelectionToggleOne(message)"
/> />
<v-avatar size="40" color="primary"> <v-avatar size="40" color="primary">
@@ -371,10 +420,17 @@ const handleSelectAllToggle = (value: boolean | null) => {
<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">
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }} <RecipientDetails
: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(getMessageTimestamp(message)) }} {{ formatDate(timeStamp(message)) }}
</span> </span>
</v-list-item-title> </v-list-item-title>
@@ -387,28 +443,52 @@ const handleSelectAllToggle = (value: boolean | null) => {
</v-list-item-subtitle> </v-list-item-subtitle>
<template v-slot:append> <template v-slot:append>
<div class="d-flex flex-column align-center"> <div class="message-item-append">
<v-icon <div class="d-flex flex-column align-center">
v-if="message.properties.isFlagged" <v-icon
size="small" v-if="message.properties.isFlagged"
color="warning" size="small"
class="mb-1" color="warning"
> class="mb-1"
mdi-star >
</v-icon> mdi-star
<v-icon </v-icon>
v-if="message.properties.hasAttachments" <v-icon
size="small" v-if="message.properties.hasAttachments"
color="grey" size="small"
> color="grey"
mdi-paperclip >
</v-icon> mdi-paperclip
</v-icon>
</div>
<MessageListItemMenu
:message="message"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div> </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>
@@ -493,6 +573,31 @@ const handleSelectAllToggle = (value: boolean | null) => {
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);
} }
@@ -511,6 +616,33 @@ const handleSelectAllToggle = (value: boolean | null) => {
: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

@@ -0,0 +1,138 @@
<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,6 +3,7 @@ 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'
@@ -25,6 +26,7 @@ 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)
@@ -94,6 +96,14 @@ 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)
@@ -125,13 +135,17 @@ 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 :message="message!" /> <ReaderHeader
:entity="props.entity"
@download-attachment="handleDownload"
/>
<v-divider /> <v-divider />

View File

@@ -0,0 +1,192 @@
<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,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ComposerMode } from '@/types/composer'
interface Props { interface Props {
mode: 'new' | 'reply' | 'forward' mode: ComposerMode
saveStatus: string saveStatus: string
canSend: boolean canSend: boolean
sending: boolean sending: boolean
@@ -26,7 +28,7 @@ defineEmits<{
</v-btn> </v-btn>
<v-toolbar-title> <v-toolbar-title>
{{ mode === 'reply' ? 'Reply' : mode === 'forward' ? 'Forward' : 'New Message' }} {{ mode === ComposerMode.Reply ? 'Reply' : mode === ComposerMode.Forward ? 'Forward' : 'New Message' }}
</v-toolbar-title> </v-toolbar-title>
<v-spacer /> <v-spacer />

View File

@@ -1,12 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { MessageObject } from '@MailManager/models/message' import { computed } from 'vue'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import type { EntityObject } from '@MailManager/models';
interface Props { interface Props {
message: MessageObject entity: EntityObject | null
} }
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 ''
@@ -31,6 +45,10 @@ 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>
@@ -46,10 +64,14 @@ const formatFileSize = (bytes: number | undefined): string => {
<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">
{{ message?.from?.label || message?.from?.address || 'Unknown Sender' }} <RecipientDetails :address="message?.from">
<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?.date) }} {{ formatDate(message?.received || message?.sent) }}
</div> </div>
</div> </div>
</div> </div>
@@ -57,12 +79,26 @@ const formatFileSize = (bytes: number | undefined): string => {
<!-- 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>
{{ message?.to.map(t => t.label || t.address).join(', ') }} <template v-for="(recipient, index) in message.to" :key="randomKey">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.to.length - 1">, </span>
</template>
</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>
{{ message?.cc.map(c => c.label || c.address).join(', ') }} <template v-for="(recipient, index) in message.cc" :key="randomKey">
<RecipientDetails :address="recipient">
<template #default="{ label }">
<span class="contact-link">{{ label }}</span>
</template>
</RecipientDetails>
<span v-if="index < message.cc.length - 1">, </span>
</template>
</div> </div>
<!-- Attachments --> <!-- Attachments -->
@@ -71,19 +107,24 @@ const formatFileSize = (bytes: number | undefined): string => {
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">
<v-chip <div
v-for="(attachment, index) in message?.attachments" v-for="(attachment, index) in message?.attachments"
:key="index" :key="randomKey"
prepend-icon="mdi-paperclip" class="attachment-item"
size="small"
variant="outlined"
class="attachment-chip"
> >
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span> <v-chip
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1"> prepend-icon="mdi-paperclip"
({{ formatFileSize(attachment.size) }}) size="small"
</span> variant="outlined"
</v-chip> class="attachment-chip"
@click="download(index)"
>
<span class="attachment-name">{{ attachment.name || 'Untitled' }}</span>
<span v-if="attachment.size" class="text-caption text-medium-emphasis ml-1">
({{ formatFileSize(attachment.size ?? undefined) }})
</span>
</v-chip>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -98,8 +139,15 @@ const formatFileSize = (bytes: number | undefined): string => {
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;
@@ -107,4 +155,21 @@ const formatFileSize = (bytes: number | undefined): string => {
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,6 +17,7 @@ const emit = defineEmits<{
move: [] move: []
delete: [] delete: []
flag: [] flag: []
download: []
toggleImages: [] toggleImages: []
setSecurityLevel: [level: SecurityLevel] setSecurityLevel: [level: SecurityLevel]
}>() }>()
@@ -161,6 +162,28 @@ 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

@@ -0,0 +1,44 @@
<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,67 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { storeToRefs } from 'pinia'
import { useUser } from '@KTXC' import { folderViewModeOptions, useMailSettingsStore } from '@/stores/mailSettingsStore'
type FolderViewMode = 'tree' | 'page' const mailSettingsStore = useMailSettingsStore()
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="[ :items="folderViewModeOptions"
{ 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,6 +2,7 @@
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 {
@@ -51,6 +52,10 @@ 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
@@ -68,6 +73,10 @@ 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

@@ -7,6 +7,7 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import { useMailUiStore } from '@/stores/mailUiStore' 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 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'
@@ -35,7 +36,6 @@ const mailUiStore = useMailUiStore()
// storeToRefs preserves reactivity for state and computed properties // storeToRefs preserves reactivity for state and computed properties
const { const {
loading, loading,
selectedFolder,
selectedMessage, selectedMessage,
currentMessages, currentMessages,
} = storeToRefs(mailStore) } = storeToRefs(mailStore)
@@ -43,16 +43,16 @@ const {
const { const {
sidebarVisible, sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
composeMode, selectedFolder,
composeSource, composerMode,
composeVisible, composerSource,
composerVisible,
selectionList, selectionList,
selectionMode, selectionMode,
moveMessagesDialogVisible, moveMessagesDialogVisible,
moveMessagesDialogService, moveMessagesDialogService,
createFolderDialogVisible, createFolderDialogVisible,
createFolderDialogService, createFolderDialogService,
createFolderDialogParent,
createFolderDialogLoading, createFolderDialogLoading,
createFolderDialogError, createFolderDialogError,
renameFolderDialogVisible, renameFolderDialogVisible,
@@ -62,7 +62,6 @@ const {
renameFolderDialogError, renameFolderDialogError,
moveFolderDialogVisible, moveFolderDialogVisible,
moveFolderDialogService, moveFolderDialogService,
moveFolderDialogSource,
deleteFolderDialogVisible, deleteFolderDialogVisible,
deleteFolderDialogService, deleteFolderDialogService,
deleteFolderDialogFolder, deleteFolderDialogFolder,
@@ -83,7 +82,7 @@ const lastSyncLabel = computed(() => {
// Initialize // Initialize
onMounted(async () => { onMounted(async () => {
if (!isManagerAvailable.value) return if (!isManagerAvailable.value) return
await mailStore.initialize() await mailUiStore.initialize()
}) })
// Handlers — thin wrappers that delegate to the store // Handlers — thin wrappers that delegate to the store
@@ -92,54 +91,11 @@ const {
validateRenameFolderName, validateRenameFolderName,
} = mailUiStore } = mailUiStore
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder) const sidebarToggle = () => mailUiStore.sidebarToggle()
const handleMessageOpen = (message: EntityObject) => { const handleSettingsOpen = () => mailUiStore.settingsOpen()
mailStore.selectMessage(message)
if (isMobile.value) { const handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
mailUiStore.closeSidebar()
}
}
const handleMessageSelectionToggle = (message: EntityObject) => mailUiStore.toggleMessageSelection(message)
const handleSelectionModeActivate = (message: EntityObject) => mailUiStore.activateSelectionMode(message)
const handleSelectAllToggle = (value: boolean) => {
if (value) {
mailUiStore.selectAllCurrentMessages()
return
}
mailUiStore.clearSelection()
}
const handleSelectionClear = () => mailUiStore.deactivateSelectionMode()
const handleSelectionMove = () => mailUiStore.openMoveMessagesDialog()
const handleSelectionDelete = () => mailUiStore.deleteSelectedMessages()
const handleCompose = () => mailUiStore.openCompose()
const handleComposeReply = (message: EntityObject) => mailUiStore.openCompose(message, 'reply')
const handleComposeForward = (message: EntityObject) => mailUiStore.openCompose(message, 'forward')
const handleComposeClose = () => mailUiStore.closeCompose()
const handleComposeSent = () => mailUiStore.afterSent()
const handleDelete = (message: EntityObject) => {
mailStore.deleteMessages([message.identifier])
}
const handleMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
const handleMoveConfirm = async (target: CollectionObject) => { await mailUiStore.confirmMoveMessages(target) }
const handleMoveCancel = () => mailUiStore.closeMoveMessagesDialog()
const handleFolderCreateConfirm = async (folderName: string) => { const handleFolderCreateConfirm = async (folderName: string) => {
try { try {
@@ -153,7 +109,7 @@ const handleFolderCreateConfirm = async (folderName: string) => {
} }
} }
const handleFolderRenameConfirm = async (folderName: string) => { const handleFolderEditConfirm = async (folderName: string) => {
try { try {
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName) const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
@@ -165,6 +121,14 @@ const handleFolderRenameConfirm = async (folderName: string) => {
} }
} }
const handleFolderDeleteConfirm = async () => {
try {
await mailUiStore.confirmDeleteFolder()
} catch (error: unknown) {
console.error('[MailPage] Failed to delete folder:', error)
}
}
const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => { const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
try { try {
await mailUiStore.confirmMoveFolder(targetFolder) await mailUiStore.confirmMoveFolder(targetFolder)
@@ -175,17 +139,52 @@ const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog() const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
const handleFolderDeleteConfirm = async () => {
try { const handleMessageOpen = (message: EntityObject) => {
await mailUiStore.confirmDeleteFolder() mailStore.selectMessage(message)
} catch (error: unknown) {
console.error('[MailPage] Failed to delete folder:', error) if (isMobile.value) {
mailUiStore.sidebarHide()
} }
} }
const toggleSidebar = () => mailUiStore.toggleSidebar() const handleMessageComposeFresh = () => mailUiStore.openComposer()
const handleSettingsOpen = () => mailUiStore.openSettings() 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()
</script> </script>
@@ -216,7 +215,7 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
<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="toggleSidebar" @click="sidebarToggle"
/> />
<v-app-bar-title>Mail</v-app-bar-title> <v-app-bar-title>Mail</v-app-bar-title>
@@ -225,7 +224,7 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
<v-btn <v-btn
icon="mdi-pencil" icon="mdi-pencil"
@click="handleCompose()" @click="handleMessageComposeFresh()"
color="primary" color="primary"
variant="text" variant="text"
> >
@@ -298,34 +297,39 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
:selection-mode="selectionMode" :selection-mode="selectionMode"
:loading="loading" :loading="loading"
@open="handleMessageOpen" @open="handleMessageOpen"
@toggle-selection="handleMessageSelectionToggle" @reply="handleMessageComposeReply"
@activate-selection-mode="handleSelectionModeActivate" @forward="handleMessageComposeForward"
@toggle-select-all="handleSelectAllToggle" @move="handleMessageMove"
@clear-selection="handleSelectionClear" @delete="handleMessageDelete"
@move-selection="handleSelectionMove" @flag="handleMessageFlag"
@delete-selection="handleSelectionDelete" @selection-mode="handleMessageSelectionMode"
@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="composeVisible" v-if="composerVisible"
:mode="composeMode" :mode="composerMode"
:source="composeSource" :source="composerSource"
:folder="selectedFolder" :folder="selectedFolder"
@close="handleComposeClose" @close="handleMessageComposeClose"
@sent="handleComposeSent"
/> />
<MessageReader <MessageReader
v-else v-else
:entity="selectedMessage" :entity="selectedMessage"
@compose="handleCompose" @compose="handleMessageComposeFresh"
@reply="handleComposeReply" @reply="handleMessageComposeReply"
@forward="handleComposeForward" @forward="handleMessageComposeForward"
@move="handleMove" @move="handleMessageMove"
@delete="handleDelete" @delete="handleMessageDelete"
/> />
</div> </div>
</div> </div>
@@ -336,19 +340,19 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
<SettingsDialog v-model="settingsDialogVisible" /> <SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog <FolderSelectionDialog
v-if="moveMessagesDialogService && moveFolderDialogSource" v-if="moveMessagesDialogService"
v-model="moveMessagesDialogVisible" v-model="moveMessagesDialogVisible"
:service="moveMessagesDialogService" :service="moveMessagesDialogService"
:loading="loading" :loading="loading"
title="Move Messages To" title="Move Messages To"
confirm-text="Move" confirm-text="Move"
empty-text="No other folders are available in this account." empty-text="No other folders are available in this account."
@select="handleMoveConfirm" @select="handleMessageMoveConfirm"
@cancel="handleMoveCancel" @cancel="handleMessageMoveCancel"
/> />
<FolderSelectionDialog <FolderSelectionDialog
v-if="moveFolderDialogService && moveFolderDialogSource" v-if="moveFolderDialogService"
v-model="moveFolderDialogVisible" v-model="moveFolderDialogVisible"
:service="moveFolderDialogService" :service="moveFolderDialogService"
:loading="collectionsStore.transceiving" :loading="collectionsStore.transceiving"
@@ -380,7 +384,7 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
:validate-name="validateRenameFolderName" :validate-name="validateRenameFolderName"
:loading="renameFolderDialogLoading" :loading="renameFolderDialogLoading"
:error-message="renameFolderDialogError" :error-message="renameFolderDialogError"
@confirm="handleFolderRenameConfirm" @confirm="handleFolderEditConfirm"
/> />
<DeleteFolderDialog <DeleteFolderDialog

View File

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

@@ -78,20 +78,15 @@ export const useMailStore = defineStore('mailStore', () => {
await servicesStore.list() await servicesStore.list()
const services = [...servicesStore.servicesEnabled] const services = [...servicesStore.servicesEnabled]
services.forEach(service => { await Promise.all(services.map(service => loadFoldersForService(service)))
void loadFoldersForService(service,{ selectInbox: true })
})
} catch (error) { } catch (error) {
console.error('[Mail] Failed to initialize:', error) console.error('[Mail][Operations] Failed to initialize:', error)
} finally { } finally {
loading.value = false loading.value = false
} }
} }
async function loadFoldersForService( async function loadFoldersForService(service: ServiceObject) {
service: ServiceObject,
options: { selectInbox?: boolean } = {},
) {
if (service.identifier === null) { if (service.identifier === null) {
return return
@@ -102,32 +97,15 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
// retrieve folders for service // retrieve folders for service
const collections = await collectionsStore.collectionsForService(service.provider, service.identifier, true) await collectionsStore.collectionsForService(service.provider, 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()
} 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( console.error(`[Mail][Operations] Failed to load folders for ${service.provider}:${String(service.identifier)}:`, error)
`[Mail] Failed to load folders for ${service.provider}:${String(service.identifier)}:`,
error,
)
_updateSyncSources() _updateSyncSources()
return {} return {}
} finally { } finally {
@@ -313,54 +291,38 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
service = await servicesStore.serviceByIdentifier(identifier, true) service = await servicesStore.serviceByIdentifier(identifier, true)
} catch (error) { } catch (error) {
console.error(`[Mail] Failed to retrieve service ${identifier}:`, error) console.error(`[Mail][Operations] Failed to retrieve service ${identifier}:`, error)
throw error throw error
} }
if (!service) { if (!service) {
const message = `Service ${identifier} not found` const message = `Service ${identifier} not found`
console.error(`[Mail] ${message}`) console.error(`[Mail][Operations] ${message}`)
throw new Error(message) throw new Error(message)
} }
return service return service
} }
async function selectFolder(folder: CollectionObject) { async function selectFolder(folder: CollectionObject | null) {
selectedFolder.value = folder selectedFolder.value = folder
selectedMessage.value = null selectedMessage.value = null
try { if (folder) {
await entitiesStore.list([folder.identifier]) try {
} catch (error) { await entitiesStore.list([folder.identifier])
console.error('[Mail] Failed to load messages:', error) } catch (error) {
console.error('[Mail][Operations] Failed to load messages:', error)
}
} }
_updateSyncSources() _updateSyncSources()
} }
function clearSelectedFolder() { function selectMessage(entity: EntityObject | null) {
selectedFolder.value = null
selectedMessage.value = null
_updateSyncSources()
}
function selectMessage(entity: EntityObject) {
selectedMessage.value = entity selectedMessage.value = entity
} }
function clearSelectedMessage() {
selectedMessage.value = null
}
async function reloadSelectedFolder() {
// Reload the current folder so the sent message appears in Sent
if (selectedFolder.value) {
await selectFolder(selectedFolder.value)
}
}
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) { async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
composerSaving.value = true composerSaving.value = true
@@ -376,13 +338,31 @@ export const useMailStore = defineStore('mailStore', () => {
return draft return draft
} catch (error) { } catch (error) {
console.error('[Mail] Failed to save draft:', error) console.error('[Mail][Operations] Failed to save draft:', error)
throw error throw error
} finally { } finally {
composerSaving.value = false composerSaving.value = false
} }
} }
function findFoldersByRole(role: string): CollectionObject[] {
const normalizedRole = role.toLowerCase()
return servicesStore.servicesEnabled.flatMap(service => {
if (service.identifier === null) {
return []
}
return collectionsStore.collectionsForService(service.provider, service.identifier).filter(
folder =>
folder.provider === service.provider &&
String(folder.service) === String(service.identifier) &&
(folder.properties.role === normalizedRole ||
String(folder.identifier).toLowerCase() === normalizedRole),
)
})
}
async function sendComposerMessage(message: ComposerMessageInput) { async function sendComposerMessage(message: ComposerMessageInput) {
composerSending.value = true composerSending.value = true
@@ -414,7 +394,7 @@ export const useMailStore = defineStore('mailStore', () => {
try { try {
await entitiesStore.delete([composerDraftIdentifier.value]) await entitiesStore.delete([composerDraftIdentifier.value])
} catch (error) { } catch (error) {
console.error('[Mail] Failed to delete draft after send:', error) console.error('[Mail][Operations] Failed to delete draft after send:', error)
} }
} }
@@ -423,7 +403,7 @@ export const useMailStore = defineStore('mailStore', () => {
return response return response
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to send message' const messageText = error instanceof Error ? error.message : 'Failed to send message'
console.error('[Mail] Failed to send message:', error) console.error('[Mail][Operations] Failed to send message:', error)
notify(messageText, 'error') notify(messageText, 'error')
throw error throw error
} finally { } finally {
@@ -431,11 +411,7 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
async function createFolder( async function createFolder(service: ServiceObject, label: string, parentFolder: CollectionObject | null = null): Promise<CollectionObject> {
service: ServiceObject,
label: string,
parentFolder: CollectionObject | null = null,
): Promise<CollectionObject> {
if (service.identifier === null) { if (service.identifier === null) {
throw new Error('Cannot create folder for a service without an identifier') throw new Error('Cannot create folder for a service without an identifier')
} }
@@ -499,7 +475,7 @@ export const useMailStore = defineStore('mailStore', () => {
const deletedFolder = await collectionsStore.delete(folder.identifier) const deletedFolder = await collectionsStore.delete(folder.identifier)
if (_sameCollection(selectedFolder.value, folder)) { if (_sameCollection(selectedFolder.value, folder)) {
clearSelectedFolder() await selectFolder(null)
} }
notify( notify(
@@ -510,6 +486,77 @@ export const useMailStore = defineStore('mailStore', () => {
return deletedFolder return deletedFolder
} }
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
if (entityIdentifiers.length === 0) {
return
}
loading.value = true
try {
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
if (failures.length === 0) {
notify(
successes.length === 1 ? 'Message deleted' : `${successes.length} messages deleted`,
'success',
)
}
if (failures.length > 0) {
notify(
successes.length === 0
? `Delete failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Deleted ${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 delete messages'
console.error('[Mail][Operations] Failed to delete messages:', error)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
}
}
async function flagMessages(entityIdentifiers: EntityIdentifier[], flags: Partial<MessageInterface['flags']>, options: { notify?: boolean } = {}) {
if (entityIdentifiers.length === 0) {
return
}
const shouldNotify = options.notify ?? true
try {
const patch = entitiesStore.fresh().properties
patch.flags = flags
const { successes, failures } = await entitiesStore.patch(patch, entityIdentifiers)
if (shouldNotify && successes.length > 0) {
notify(
successes.length === 1 ? 'Message updated' : `${successes.length} messages updated`,
'success',
)
}
if (shouldNotify && failures.length > 0) {
notify(
successes.length === 0
? `Update failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Updated ${successes.length} ${successes.length === 1 ? 'message' : 'messages'}. ${failures.length} failed.`,
successes.length === 0 ? 'error' : 'warning',
)
}
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to update messages'
console.error('[Mail][Operations] Failed to update messages:', error)
notify(messageText, 'error')
throw error
}
}
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) { async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce( const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
(accumulator, identifier) => { (accumulator, identifier) => {
@@ -574,7 +621,7 @@ export const useMailStore = defineStore('mailStore', () => {
await collectionsStore.fetch(sourceCollections) await collectionsStore.fetch(sourceCollections)
} catch (error) { } catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages' const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move messages:', error) console.error('[Mail][Operations] Failed to move messages:', error)
notify(messageText, 'error') notify(messageText, 'error')
throw error throw error
} finally { } finally {
@@ -582,38 +629,30 @@ export const useMailStore = defineStore('mailStore', () => {
} }
} }
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) { async function downloadMessage(entity: EntityObject, index?: number) {
if (entityIdentifiers.length === 0) { const target = entity.identifier
return let part = null
if (index !== undefined) {
part = entity.properties.attachments?.[Number(index)] ?? null
} }
loading.value = true
try { try {
const { successes, failures } = await entitiesStore.delete(entityIdentifiers) await entitiesStore.download(target, part)
if (failures.length === 0) {
notify(
successes.length === 1 ? 'Message deleted' : `${successes.length} messages deleted`,
'success',
)
}
if (failures.length > 0) {
notify(
successes.length === 0
? `Delete failed for ${failures.length === 1 ? '1 message' : `${failures.length} messages`}`
: `Deleted ${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 delete messages' const messageText = error instanceof Error
console.error('[Mail] Failed to delete messages:', 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') notify(messageText, 'error')
throw error throw error
} finally {
loading.value = false
} }
} }
@@ -632,7 +671,6 @@ export const useMailStore = defineStore('mailStore', () => {
// State // State
loading, loading,
selectedFolder,
selectedMessage, selectedMessage,
composerSaving, composerSaving,
composerSending, composerSending,
@@ -648,23 +686,23 @@ export const useMailStore = defineStore('mailStore', () => {
// Actions // Actions
retrieveService, retrieveService,
selectFolder, selectFolder,
clearSelectedFolder,
selectMessage, selectMessage,
clearSelectedMessage,
createFolder, createFolder,
reloadSelectedFolder,
saveComposerDraft, saveComposerDraft,
sendComposerMessage, sendComposerMessage,
resetComposerState, resetComposerState,
flagMessages,
deleteMessages, deleteMessages,
deleteFolder, deleteFolder,
moveMessages, moveMessages,
downloadMessage,
moveFolder, moveFolder,
renameFolder, renameFolder,
notify, notify,
isServiceFolderLoading, isServiceFolderLoading,
hasServiceFoldersLoaded, hasServiceFoldersLoaded,
getServiceFolderError, getServiceFolderError,
findFoldersByRole,
loadFoldersForService, loadFoldersForService,
initialize, initialize,
} }

View File

@@ -2,19 +2,25 @@ import { computed, ref, 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 { useMailStore } from '@/stores/mailStore' import { useMailStore } from '@/stores/mailStore'
import { useMailSettingsStore } from '@/stores/mailSettingsStore'
import { ComposerMode } from '@/types/composer'
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common' import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
import type { EntityObject, ServiceObject } from '@MailManager/models' import { EntityObject, type ServiceObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection' import type { CollectionObject } from '@MailManager/models/collection'
import type { MessageAddressInterface } from '@MailManager/types/message'
export const useMailUiStore = defineStore('mailUiStore', () => { export const useMailUiStore = defineStore('mailUiStore', () => {
const collectionsStore = useCollectionsStore() const collectionsStore = useCollectionsStore()
const mailStore = useMailStore() const mailStore = useMailStore()
const mailSettingsStore = useMailSettingsStore()
const sidebarVisible = ref(true) const sidebarVisible = ref(true)
const settingsDialogVisible = ref(false) const settingsDialogVisible = ref(false)
const composeMode = ref<'new' | 'reply' | 'forward'>('new') const selectedFolder = shallowRef<CollectionObject | null>(null)
const composeSource = shallowRef<EntityObject | null>(null) const selectedMessage = shallowRef<EntityObject | null>(null)
const composeVisible = ref(false) const composerMode = ref<ComposerMode>(ComposerMode.Fresh)
const composerSource = shallowRef<EntityObject | MessageAddressInterface | null>(null)
const composerVisible = ref(false)
const selectionMode = ref(false) const selectionMode = ref(false)
const selectionList = ref<EntityIdentifier[]>([]) const selectionList = ref<EntityIdentifier[]>([])
const moveMessagesDialogVisible = ref(false) const moveMessagesDialogVisible = ref(false)
@@ -38,6 +44,8 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null) const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null)
const deleteFolderDialogLoading = ref(false) const deleteFolderDialogLoading = ref(false)
const deleteFolderDialogError = ref('') const deleteFolderDialogError = ref('')
const messageReadIdentifier = ref<EntityIdentifier | null>(null)
const messageReadTimer = ref<ReturnType<typeof setTimeout> | null>(null)
const createFolderDialogParentLabel = computed(() => { const createFolderDialogParentLabel = computed(() => {
return createFolderDialogParent.value?.properties.label || 'Root' return createFolderDialogParent.value?.properties.label || 'Root'
@@ -86,30 +94,278 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
return Array.from(invalidKeys) return Array.from(invalidKeys)
}) })
watch(
() => mailStore.selectedFolder,
() => {
closeCompose()
deactivateSelectionMode()
},
)
watch( watch(
() => mailStore.selectedMessage, () => mailStore.selectedMessage,
selectedMessage => { message => {
if (selectedMessage) { if (message) {
closeCompose() closeComposer()
} }
selectMessage(message)
}, },
) )
watch( watch(
() => mailStore.currentMessages, () => mailStore.currentMessages,
() => { () => {
reconcileSelection() 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[] { function validateFolderNameBase(service: ServiceObject, name: string): string[] {
const errors: string[] = [] const errors: string[] = []
@@ -195,29 +451,24 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
selectionList.value = Array.from(new Set(nextIds)) selectionList.value = Array.from(new Set(nextIds))
} }
function openCompose(source?: EntityObject, mode: 'reply' | 'forward' = 'reply') { function openComposer(source?: EntityObject | MessageAddressInterface, mode: ComposerMode = ComposerMode.Fresh) {
mailStore.clearSelectedMessage() mailStore.selectMessage(null)
composeSource.value = source ?? null composerSource.value = source ?? null
composeMode.value = mode composerMode.value = mode
composeVisible.value = true composerVisible.value = true
} }
function closeCompose() { function closeComposer() {
composeMode.value = 'new' composerMode.value = ComposerMode.Fresh
composeSource.value = null composerSource.value = null
composeVisible.value = false composerVisible.value = false
} }
async function afterSent() { function messageSelectionClear() {
closeCompose()
await mailStore.reloadSelectedFolder()
}
function clearSelection() {
setSelectionList([]) setSelectionList([])
} }
function activateSelectionMode(message?: EntityObject) { function messageSelectionModeActivate(message?: EntityObject) {
selectionMode.value = true selectionMode.value = true
if (!message) { if (!message) {
@@ -231,12 +482,12 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
} }
} }
function deactivateSelectionMode() { function messageSelectionModeDeactivate() {
selectionMode.value = false selectionMode.value = false
clearSelection() messageSelectionClear()
} }
function toggleMessageSelection(message: EntityObject) { function messageSelectionToggleOne(message: EntityObject) {
const identifier = message.identifier const identifier = message.identifier
selectionMode.value = true selectionMode.value = true
@@ -249,14 +500,18 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
setSelectionList([...selectionList.value, identifier]) setSelectionList([...selectionList.value, identifier])
} }
function selectAllCurrentMessages() { function messageSelectionToggleAll(value: boolean) {
selectionMode.value = true selectionMode.value = true
setSelectionList(mailStore.currentMessages.map(message => message.identifier)) if (value) {
setSelectionList(mailStore.currentMessages.map(message => message.identifier))
} else {
setSelectionList([])
}
} }
function reconcileSelection() { function messageSelectionReconcile() {
if (!mailStore.selectedFolder) { if (!selectedFolder.value) {
clearSelection() messageSelectionClear()
return return
} }
@@ -272,22 +527,6 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
} }
} }
function toggleSidebar() {
sidebarVisible.value = !sidebarVisible.value
}
function closeSidebar() {
sidebarVisible.value = false
}
function openSettings() {
settingsDialogVisible.value = true
}
function closeSettings() {
settingsDialogVisible.value = false
}
async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) { async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) {
let moveMessagesServiceIdentifier = null as ServiceIdentifier | null let moveMessagesServiceIdentifier = null as ServiceIdentifier | null
@@ -303,10 +542,10 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
} }
} else { } else {
moveMessagesDialogCandidates.value = [...selectionList.value] moveMessagesDialogCandidates.value = [...selectionList.value]
moveMessagesServiceIdentifier = mailStore.selectedFolder?.service as ServiceIdentifier || null moveMessagesServiceIdentifier = selectedFolder.value?.service as ServiceIdentifier || null
} }
moveMessagesDialogService.value = await mailStore.retrieveService(moveMessagesServiceIdentifier); moveMessagesDialogService.value = await mailStore.retrieveService(moveMessagesServiceIdentifier)
moveMessagesDialogVisible.value = true moveMessagesDialogVisible.value = true
} }
@@ -318,162 +557,28 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailStore.moveMessages>[0]) { async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailStore.moveMessages>[0]) {
await mailStore.moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? []) await mailStore.moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
deactivateSelectionMode() messageSelectionModeDeactivate()
closeMoveMessagesDialog() closeMoveMessagesDialog()
} }
async function deleteSelectedMessages() { async function deleteSelectedMessages() {
await mailStore.deleteMessages([...selectionList.value]) await mailStore.deleteMessages([...selectionList.value])
deactivateSelectionMode() messageSelectionModeDeactivate()
} }
function openCreateFolderDialog(service: ServiceObject, parentFolder: CollectionObject | null = null) { async function flagSelectedMessages(flag: string, value: boolean) {
createFolderDialogService.value = service await mailStore.flagMessages([...selectionList.value], { [flag]: value })
createFolderDialogParent.value = parentFolder messageSelectionModeDeactivate()
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
createFolderDialogVisible.value = true
}
function closeCreateFolderDialog() {
createFolderDialogVisible.value = false
createFolderDialogService.value = null
createFolderDialogParent.value = null
createFolderDialogError.value = ''
createFolderDialogLoading.value = false
}
async function confirmCreateFolder(label: string) {
const service = createFolderDialogService.value
if (!service) {
return null
}
createFolderDialogLoading.value = true
createFolderDialogError.value = ''
try {
const folder = await mailStore.createFolder(service, label, createFolderDialogParent.value)
closeCreateFolderDialog()
return folder
} catch (error) {
createFolderDialogError.value = error instanceof Error ? error.message : 'Failed to create folder. Please try again.'
throw error
} finally {
createFolderDialogLoading.value = false
}
}
async function openRenameFolderDialog(target: CollectionObject) {
const service = await mailStore.retrieveService(target.service)
renameFolderDialogService.value = service
renameFolderDialogFolder.value = target
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
renameFolderDialogVisible.value = true
}
function closeRenameFolderDialog() {
renameFolderDialogVisible.value = false
renameFolderDialogService.value = null
renameFolderDialogFolder.value = null
renameFolderDialogError.value = ''
renameFolderDialogLoading.value = false
}
async function confirmRenameFolder(label: string) {
const folder = renameFolderDialogFolder.value
if (!folder) {
return null
}
renameFolderDialogLoading.value = true
renameFolderDialogError.value = ''
try {
const updatedFolder = await mailStore.renameFolder(folder, label)
closeRenameFolderDialog()
return updatedFolder
} catch (error) {
renameFolderDialogError.value = error instanceof Error ? error.message : 'Failed to rename folder. Please try again.'
throw error
} finally {
renameFolderDialogLoading.value = false
}
}
async function openMoveFolderDialog(source: CollectionObject) {
const service = await mailStore.retrieveService(source.service)
moveFolderDialogService.value = service
moveFolderDialogSource.value = source
moveFolderDialogVisible.value = true
}
function closeMoveFolderDialog() {
moveFolderDialogVisible.value = false
moveFolderDialogService.value = null
moveFolderDialogSource.value = null
}
async function confirmMoveFolder(target: CollectionObject) {
const source = moveFolderDialogSource.value
if (!source) {
return null
}
const movedFolder = await mailStore.moveFolder(source, target)
closeMoveFolderDialog()
return movedFolder
}
async function openDeleteFolderDialog(target: CollectionObject) {
const service = await mailStore.retrieveService(target.service)
deleteFolderDialogService.value = service
deleteFolderDialogFolder.value = target
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
deleteFolderDialogVisible.value = true
}
function closeDeleteFolderDialog() {
deleteFolderDialogVisible.value = false
deleteFolderDialogService.value = null
deleteFolderDialogFolder.value = null
deleteFolderDialogError.value = ''
deleteFolderDialogLoading.value = false
}
async function confirmDeleteFolder() {
const folder = deleteFolderDialogFolder.value
if (!folder) {
return null
}
deleteFolderDialogLoading.value = true
deleteFolderDialogError.value = ''
try {
const deleted = await mailStore.deleteFolder(folder)
closeDeleteFolderDialog()
return deleted
} catch (error) {
deleteFolderDialogError.value = error instanceof Error ? error.message : 'Failed to delete folder. Please try again.'
throw error
} finally {
deleteFolderDialogLoading.value = false
}
} }
return { return {
sidebarVisible, sidebarVisible,
settingsDialogVisible, settingsDialogVisible,
composeMode, selectedFolder,
composeSource, selectedMessage,
composeVisible, composerMode,
composerSource,
composerVisible,
selectionMode, selectionMode,
selectionList, selectionList,
moveMessagesDialogVisible, moveMessagesDialogVisible,
@@ -500,18 +605,19 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
deleteFolderDialogFolder, deleteFolderDialogFolder,
deleteFolderDialogLoading, deleteFolderDialogLoading,
deleteFolderDialogError, deleteFolderDialogError,
toggleSidebar, sidebarToggle,
closeSidebar, sidebarHide,
openSettings, settingsOpen,
closeSettings, settingsClose,
openCompose, initialize,
closeCompose, selectFolder,
afterSent, openComposer,
activateSelectionMode, closeComposer,
deactivateSelectionMode, messageSelectionModeActivate,
toggleMessageSelection, messageSelectionModeDeactivate,
selectAllCurrentMessages, messageSelectionToggleOne,
clearSelection, messageSelectionToggleAll,
messageSelectionClear,
validateCreateFolderName, validateCreateFolderName,
validateRenameFolderName, validateRenameFolderName,
openMoveMessagesDialog, openMoveMessagesDialog,
@@ -530,5 +636,6 @@ export const useMailUiStore = defineStore('mailUiStore', () => {
openDeleteFolderDialog, openDeleteFolderDialog,
closeDeleteFolderDialog, closeDeleteFolderDialog,
confirmDeleteFolder, confirmDeleteFolder,
flagSelectedMessages,
} }
}) })

5
src/types/composer.ts Normal file
View File

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