44 Commits

Author SHA1 Message Date
307a2e3ef0 chore(deps): update dependency vuetify to v4.1.1 2026-06-09 03:04:17 +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 2m7s
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 1791 additions and 1020 deletions

819
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -10,14 +10,16 @@ import Placeholder from '@tiptap/extension-placeholder'
import { EntityObject } from '@MailManager/models/entity'
import type { CollectionObject } from '@MailManager/models'
import { useMailStore } from '@/stores/mailStore'
import type { MessageAddressInterface } from '@MailManager/types/message'
import { ComposerMode } from '@/types/composer'
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
// Props
interface Props {
mode: 'new' | 'reply' | 'forward'
source?: EntityObject | null
mode: ComposerMode
source?: EntityObject | MessageAddressInterface | null
folder?: CollectionObject | null
}
@@ -26,7 +28,6 @@ const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
close: []
sent: []
}>()
const mailStore = useMailStore()
@@ -84,7 +85,15 @@ function initializeComposerFromProps() {
mailStore.resetComposerState()
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
}
@@ -95,19 +104,19 @@ function initializeComposerFromProps() {
const sentAt = sourceMessage.sent || props.source.created || ''
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
to.value = fromEmail ? [fromEmail] : []
subject.value = /^Re:/i.test(originalSubject)
? originalSubject
: `Re: ${originalSubject}`
editor.value?.commands.setContent(
`<p><br></p><p>On ${sentLabel}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`,
`<p><br></p><p>---------- Original message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
)
return
}
if (props.mode === 'forward') {
if (props.mode === ComposerMode.Forward) {
subject.value = /^Fwd:/i.test(originalSubject)
? originalSubject
: `Fwd: ${originalSubject}`
@@ -222,7 +231,6 @@ const handleSend = async () => {
text: editor.value?.getText() || '',
},
})
emit('sent')
} catch (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 { EntityObject } from '@MailManager/models'
import type { CollectionObject } from '@MailManager/models/collection'
import RecipientDetails from '@/components/common/RecipientDetails.vue'
import MessageListItemMenu from '@/components/MessageListItemMenu.vue'
// Props
interface Props {
@@ -23,72 +25,39 @@ const props = withDefaults(defineProps<Props>(), {
// Emits
const emit = defineEmits<{
open: [message: EntityObject]
toggleSelection: [message: EntityObject]
activateSelectionMode: [message: EntityObject]
toggleSelectAll: [value: boolean]
clearSelection: []
moveSelection: []
deleteSelection: []
reply: [message: EntityObject]
forward: [message: EntityObject]
move: [message: EntityObject]
delete: [message: EntityObject]
flag: [message: EntityObject, flag: string, value: boolean]
selectionMode: [message: EntityObject]
selectionToggleOne: [message: EntityObject]
selectionToggleAll: [value: boolean]
selectionClear: []
selectionMove: []
selectionDelete: []
selectionFlag: [flag: string, value: boolean]
}>()
const longPressTimer = ref<number | null>(null)
const longPressActivated = 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 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))
})
const selectionCount = computed(() => props.selectionList.length ?? 0)
// Sorted messages (newest first)
const sortedMessages = computed(() => {
return [...currentMessages.value].sort((a, b) => {
const dateA = getMessageTimeValue(a)
const dateB = getMessageTimeValue(b)
return [...props.messages].sort((a, b) => {
const dateA = timeStamp(a) ?? 0
const dateB = timeStamp(b) ?? 0
return dateB - dateA
})
})
// Read/Unread counts from collection properties
const unreadCount = computed(() => {
return props.selectedCollection?.properties.unread ?? 0
})
const totalCount = computed(() => {
return props.selectedCollection?.properties.total ?? 0
})
// True only when the collection explicitly provides total/unread counts
const hasCountData = computed(() => {
return props.selectedCollection?.properties.total != null
})
const isOpened = (message: EntityObject): boolean => {
if (!props.selectedMessage) return false
return (message.identifier === props.selectedMessage.identifier)
@@ -98,8 +67,23 @@ const isSelected = (message: EntityObject): boolean => {
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
const formatDate = (date: Date | string | null | undefined): string => {
const formatDate = (date: Date | string | number | null | undefined): string => {
if (!date) return ''
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 => {
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) => {
emit('toggleSelection', message)
const handleSelectionToggleOne = (message: EntityObject) => {
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)) {
return
}
@@ -168,7 +150,7 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
if (event.shiftKey && !props.selectionMode) {
event.preventDefault()
event.stopPropagation()
emit('activateSelectionMode', message)
emit('selectionMode', message)
return
}
@@ -178,14 +160,14 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
}
if (props.selectionMode) {
emit('toggleSelection', message)
emit('selectionToggleOne', message)
return
}
emit('open', message)
}
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
const handleMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
@@ -197,14 +179,44 @@ const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
event.preventDefault()
event.stopPropagation()
suppressNextClick.value = true
emit('activateSelectionMode', message)
emit('selectionMode', message)
}
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
const openContextMenu = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
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) => {
@@ -213,9 +225,9 @@ const handleTouchStart = (message: EntityObject) => {
longPressTimer.value = window.setTimeout(() => {
if (!props.selectionMode) {
emit('activateSelectionMode', message)
emit('selectionMode', message)
} else {
emit('toggleSelection', message)
emit('selectionToggleOne', message)
}
longPressActivated.value = true
@@ -231,13 +243,32 @@ const handleTouchMove = () => {
clearLongPressTimer()
}
const clearLongPressTimer = () => {
if (longPressTimer.value !== null) {
window.clearTimeout(longPressTimer.value)
longPressTimer.value = null
}
}
const handleFlag = (flag: string, value: boolean) => {
if (props.selectionMode && selectionCount.value > 0) {
emit('selectionFlag', flag, value)
}
}
const handleRecipientClick = (message: EntityObject) => {
if (props.selectionMode) {
emit('selectionToggleOne', message)
return
}
emit('open', message)
}
onBeforeUnmount(() => {
clearLongPressTimer()
})
const handleSelectAllToggle = (value: boolean | null) => {
emit('toggleSelectAll', value === true)
}
</script>
<template>
@@ -245,12 +276,12 @@ const handleSelectAllToggle = (value: boolean | null) => {
<!-- Header with folder name and counts -->
<div v-if="selectedCollection" class="message-list-header">
<div class="message-list-heading">
<h2 class="text-h6">{{ selectedCollection.properties.label || 'Folder' }}</h2>
<h2 class="text-h6">{{ selectedCollection?.properties.label || 'Folder' }}</h2>
<div class="folder-counts text-caption text-medium-emphasis">
<span v-if="hasCountData">
<span class="unread-count">{{ unreadCount }}</span>
<span v-if="selectedCollection?.properties.total != null">
<span class="unread-count">{{ selectedCollection?.properties.unread ?? 0 }}</span>
<span class="mx-1">/</span>
<span>{{ totalCount }}</span>
<span>{{ selectedCollection?.properties.total ?? 0 }}</span>
</span>
<span v-else-if="messages.length > 0">
{{ messages.length }} loaded
@@ -261,15 +292,12 @@ const handleSelectAllToggle = (value: boolean | null) => {
<div v-if="selectionMode && messages.length > 0" class="selection-summary">
<div class="selection-controls">
<v-checkbox-btn
:model-value="allCurrentMessagesSelected"
:indeterminate="hasSelection && !allCurrentMessagesSelected"
:model-value="selectionCount !== 0"
:indeterminate="selectionCount > 0 && selectionCount !== messages.length"
density="compact"
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 class="selection-actions">
@@ -277,8 +305,8 @@ const handleSelectAllToggle = (value: boolean | null) => {
size="small"
icon="mdi-folder-move-outline"
variant="text"
:disabled="!hasSelection"
@click="emit('moveSelection')"
:disabled="selectionCount === 0"
@click="emit('selectionMove')"
>
<v-icon>mdi-folder-move-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Move Selected</v-tooltip>
@@ -287,17 +315,37 @@ const handleSelectAllToggle = (value: boolean | null) => {
size="small"
icon="mdi-delete-outline"
variant="text"
:disabled="!hasSelection"
@click="emit('deleteSelection')"
:disabled="selectionCount === 0"
@click="emit('selectionDelete')"
>
<v-icon>mdi-delete-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Delete Selected</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-email-open-outline"
variant="text"
:disabled="selectionCount === 0"
@click="handleFlag('read', true)"
>
<v-icon>mdi-email-open-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Mark as Read</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-email-outline"
variant="text"
:disabled="selectionCount === 0"
@click="handleFlag('read', false)"
>
<v-icon>mdi-email-outline</v-icon>
<v-tooltip activator="parent" location="bottom">Mark as Unread</v-tooltip>
</v-btn>
<v-btn
size="small"
icon="mdi-close"
variant="text"
@click="emit('clearSelection')"
@click="emit('selectionClear')"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Clear Selected</v-tooltip>
@@ -317,7 +365,7 @@ const handleSelectAllToggle = (value: boolean | null) => {
</div>
<!-- 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>
<div class="text-h6 mt-4 text-medium-emphasis">No messages</div>
<div class="text-body-2 text-medium-emphasis">
@@ -342,8 +390,9 @@ const handleSelectAllToggle = (value: boolean | null) => {
'selection-mode': selectionMode,
'unread': !message.properties.isRead
}"
@mousedown="handleMessageMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)"
@mousedown="handleMouseDown($event, message)"
@click="handleMouseClick($event, message)"
@contextmenu="openContextMenu($event, message)"
@touchstart.passive="handleTouchStart(message)"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
@@ -358,7 +407,7 @@ const handleSelectAllToggle = (value: boolean | null) => {
density="compact"
hide-details
@click.stop
@update:model-value="handleSelectionToggle(message)"
@update:model-value="handleSelectionToggleOne(message)"
/>
<v-avatar size="40" color="primary">
@@ -371,10 +420,17 @@ const handleSelectAllToggle = (value: boolean | null) => {
<v-list-item-title class="d-flex align-center">
<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 class="text-caption text-medium-emphasis ml-2">
{{ formatDate(getMessageTimestamp(message)) }}
{{ formatDate(timeStamp(message)) }}
</span>
</v-list-item-title>
@@ -387,28 +443,52 @@ const handleSelectAllToggle = (value: boolean | null) => {
</v-list-item-subtitle>
<template v-slot:append>
<div class="d-flex flex-column align-center">
<v-icon
v-if="message.properties.isFlagged"
size="small"
color="warning"
class="mb-1"
>
mdi-star
</v-icon>
<v-icon
v-if="message.properties.hasAttachments"
size="small"
color="grey"
>
mdi-paperclip
</v-icon>
<div class="message-item-append">
<div class="d-flex flex-column align-center">
<v-icon
v-if="message.properties.isFlagged"
size="small"
color="warning"
class="mb-1"
>
mdi-star
</v-icon>
<v-icon
v-if="message.properties.hasAttachments"
size="small"
color="grey"
>
mdi-paperclip
</v-icon>
</div>
<MessageListItemMenu
:message="message"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div>
</template>
</v-list-item>
<v-divider />
</template>
</v-virtual-scroll>
<MessageListItemMenu
v-if="contextMenuMessage"
v-model="contextMenuVisible"
:message="getContextMenuMessage()"
:target="contextMenuTarget"
:show-activator="false"
@reply="handleContextMenuReply"
@forward="handleContextMenuForward"
@move="handleContextMenuMove"
@delete="handleContextMenuDelete"
@flag="handleContextMenuFlag"
/>
</div>
</template>
@@ -493,6 +573,31 @@ const handleSelectAllToggle = (value: boolean | null) => {
min-width: 72px;
}
.message-item-append {
display: flex;
align-items: center;
gap: 4px;
}
@media (hover: hover) {
.message-item :deep(.message-item-menu-trigger) {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.message-item:hover :deep(.message-item-menu-trigger),
.message-item:focus-within :deep(.message-item-menu-trigger),
.message-item.opened :deep(.message-item-menu-trigger),
.message-item.selected :deep(.message-item-menu-trigger),
.message-item :deep(.message-item-menu-trigger[aria-expanded='true']) {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
.message-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04);
}
@@ -511,6 +616,33 @@ const handleSelectAllToggle = (value: boolean | null) => {
:deep(.v-list-item-subtitle:first-of-type) {
font-weight: 600;
}
:deep(.v-list-item-title),
:deep(.v-list-item-subtitle:first-of-type),
:deep(.v-list-item-title .text-caption) {
color: rgb(var(--v-theme-on-surface));
}
}
.message-item.unread:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
}
.message-item.unread.selected:not(.opened) {
background-color: rgba(var(--v-theme-primary), 0.14);
}
.message-person-link {
display: inline-block;
max-width: 100%;
border-radius: 4px;
padding: 1px 4px;
margin: -1px -4px;
transition: background-color 0.2s ease;
}
.message-person-link:hover {
background-color: rgba(var(--v-theme-primary), 0.08);
}
@media (max-width: 960px) {

View File

@@ -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 type { EntityObject, MessageObject } from '@MailManager/models'
import { SecurityLevel } from '@/utile/emailSanitizer'
import { useMailStore } from '@/stores/mailStore'
import ReaderEmpty from './reader/ReaderEmpty.vue'
import ReaderToolbar from './reader/ReaderToolbar.vue'
import ReaderHeader from './reader/ReaderHeader.vue'
@@ -25,6 +26,7 @@ const emit = defineEmits<{
// User settings
const { getSetting } = useUser()
const mailStore = useMailStore()
// Per-message overrides
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 = () => {
if (props.entity) {
emit('flag', props.entity)
@@ -125,13 +135,17 @@ const handleCompose = () => {
@move="handleMove"
@delete="handleDelete"
@flag="handleFlag"
@download="handleDownload()"
@toggle-images="toggleImages"
@set-security-level="setSecurityLevel"
/>
<!-- Message content -->
<div class="message-content">
<ReaderHeader :message="message!" />
<ReaderHeader
:entity="props.entity"
@download-attachment="handleDownload"
/>
<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">
import { ComposerMode } from '@/types/composer'
interface Props {
mode: 'new' | 'reply' | 'forward'
mode: ComposerMode
saveStatus: string
canSend: boolean
sending: boolean
@@ -26,7 +28,7 @@ defineEmits<{
</v-btn>
<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-spacer />

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useMailStore } from '@/stores/mailStore'
import { useMailUiStore } from '@/stores/mailUiStore'
import type { CollectionObject, EntityObject } from '@MailManager/models'
import { ComposerMode } from '@/types/composer'
import MessageList from '@/components/MessageList.vue'
import MessageReader from '@/components/MessageReader.vue'
import MessageComposer from '@/components/MessageComposer.vue'
@@ -35,7 +36,6 @@ const mailUiStore = useMailUiStore()
// storeToRefs preserves reactivity for state and computed properties
const {
loading,
selectedFolder,
selectedMessage,
currentMessages,
} = storeToRefs(mailStore)
@@ -43,16 +43,16 @@ const {
const {
sidebarVisible,
settingsDialogVisible,
composeMode,
composeSource,
composeVisible,
selectedFolder,
composerMode,
composerSource,
composerVisible,
selectionList,
selectionMode,
moveMessagesDialogVisible,
moveMessagesDialogService,
createFolderDialogVisible,
createFolderDialogService,
createFolderDialogParent,
createFolderDialogLoading,
createFolderDialogError,
renameFolderDialogVisible,
@@ -62,7 +62,6 @@ const {
renameFolderDialogError,
moveFolderDialogVisible,
moveFolderDialogService,
moveFolderDialogSource,
deleteFolderDialogVisible,
deleteFolderDialogService,
deleteFolderDialogFolder,
@@ -83,7 +82,7 @@ const lastSyncLabel = computed(() => {
// Initialize
onMounted(async () => {
if (!isManagerAvailable.value) return
await mailStore.initialize()
await mailUiStore.initialize()
})
// Handlers — thin wrappers that delegate to the store
@@ -92,54 +91,11 @@ const {
validateRenameFolderName,
} = mailUiStore
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
const sidebarToggle = () => mailUiStore.sidebarToggle()
const handleMessageOpen = (message: EntityObject) => {
mailStore.selectMessage(message)
const handleSettingsOpen = () => mailUiStore.settingsOpen()
if (isMobile.value) {
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 handleFolderSelect = (folder: CollectionObject) => mailUiStore.selectFolder(folder)
const handleFolderCreateConfirm = async (folderName: string) => {
try {
@@ -153,7 +109,7 @@ const handleFolderCreateConfirm = async (folderName: string) => {
}
}
const handleFolderRenameConfirm = async (folderName: string) => {
const handleFolderEditConfirm = async (folderName: string) => {
try {
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) => {
try {
await mailUiStore.confirmMoveFolder(targetFolder)
@@ -175,17 +139,52 @@ const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
const handleFolderDeleteConfirm = async () => {
try {
await mailUiStore.confirmDeleteFolder()
} catch (error: unknown) {
console.error('[MailPage] Failed to delete folder:', error)
const handleMessageOpen = (message: EntityObject) => {
mailStore.selectMessage(message)
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>
@@ -216,7 +215,7 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
<v-app-bar class="mail-toolbar" elevation="0" density="compact">
<v-app-bar-nav-icon
v-if="isMobile"
@click="toggleSidebar"
@click="sidebarToggle"
/>
<v-app-bar-title>Mail</v-app-bar-title>
@@ -225,7 +224,7 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
<v-btn
icon="mdi-pencil"
@click="handleCompose()"
@click="handleMessageComposeFresh()"
color="primary"
variant="text"
>
@@ -298,34 +297,39 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
:selection-mode="selectionMode"
:loading="loading"
@open="handleMessageOpen"
@toggle-selection="handleMessageSelectionToggle"
@activate-selection-mode="handleSelectionModeActivate"
@toggle-select-all="handleSelectAllToggle"
@clear-selection="handleSelectionClear"
@move-selection="handleSelectionMove"
@delete-selection="handleSelectionDelete"
@reply="handleMessageComposeReply"
@forward="handleMessageComposeForward"
@move="handleMessageMove"
@delete="handleMessageDelete"
@flag="handleMessageFlag"
@selection-mode="handleMessageSelectionMode"
@selection-toggle-one="handleMessageSelectionToggleOne"
@selection-toggle-all="handleMessageSelectionToggleAll"
@selection-clear="handleMessageSelectionClear"
@selection-flag="handleMessageSelectionFlag"
@selection-move="handleMessageSelectionMove"
@selection-delete="handleMessageSelectionDelete"
/>
</div>
<!-- Reader/Composer panel -->
<div class="mail-reader-panel">
<MessageComposer
v-if="composeVisible"
:mode="composeMode"
:source="composeSource"
v-if="composerVisible"
:mode="composerMode"
:source="composerSource"
:folder="selectedFolder"
@close="handleComposeClose"
@sent="handleComposeSent"
@close="handleMessageComposeClose"
/>
<MessageReader
v-else
:entity="selectedMessage"
@compose="handleCompose"
@reply="handleComposeReply"
@forward="handleComposeForward"
@move="handleMove"
@delete="handleDelete"
@compose="handleMessageComposeFresh"
@reply="handleMessageComposeReply"
@forward="handleMessageComposeForward"
@move="handleMessageMove"
@delete="handleMessageDelete"
/>
</div>
</div>
@@ -336,19 +340,19 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
<SettingsDialog v-model="settingsDialogVisible" />
<FolderSelectionDialog
v-if="moveMessagesDialogService && moveFolderDialogSource"
v-if="moveMessagesDialogService"
v-model="moveMessagesDialogVisible"
:service="moveMessagesDialogService"
:loading="loading"
title="Move Messages To"
confirm-text="Move"
empty-text="No other folders are available in this account."
@select="handleMoveConfirm"
@cancel="handleMoveCancel"
@select="handleMessageMoveConfirm"
@cancel="handleMessageMoveCancel"
/>
<FolderSelectionDialog
v-if="moveFolderDialogService && moveFolderDialogSource"
v-if="moveFolderDialogService"
v-model="moveFolderDialogVisible"
:service="moveFolderDialogService"
:loading="collectionsStore.transceiving"
@@ -380,7 +384,7 @@ const handleSettingsOpen = () => mailUiStore.openSettings()
:validate-name="validateRenameFolderName"
:loading="renameFolderDialogLoading"
:error-message="renameFolderDialogError"
@confirm="handleFolderRenameConfirm"
@confirm="handleFolderEditConfirm"
/>
<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()
const services = [...servicesStore.servicesEnabled]
services.forEach(service => {
void loadFoldersForService(service,{ selectInbox: true })
})
await Promise.all(services.map(service => loadFoldersForService(service)))
} catch (error) {
console.error('[Mail] Failed to initialize:', error)
console.error('[Mail][Operations] Failed to initialize:', error)
} finally {
loading.value = false
}
}
async function loadFoldersForService(
service: ServiceObject,
options: { selectInbox?: boolean } = {},
) {
async function loadFoldersForService(service: ServiceObject) {
if (service.identifier === null) {
return
@@ -102,32 +97,15 @@ export const useMailStore = defineStore('mailStore', () => {
try {
// 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)
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()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load folders'
_setServiceFolderError(service.provider, service.identifier, message)
console.error(
`[Mail] Failed to load folders for ${service.provider}:${String(service.identifier)}:`,
error,
)
console.error(`[Mail][Operations] Failed to load folders for ${service.provider}:${String(service.identifier)}:`, error)
_updateSyncSources()
return {}
} finally {
@@ -313,54 +291,38 @@ export const useMailStore = defineStore('mailStore', () => {
try {
service = await servicesStore.serviceByIdentifier(identifier, true)
} catch (error) {
console.error(`[Mail] Failed to retrieve service ${identifier}:`, error)
console.error(`[Mail][Operations] Failed to retrieve service ${identifier}:`, error)
throw error
}
if (!service) {
const message = `Service ${identifier} not found`
console.error(`[Mail] ${message}`)
console.error(`[Mail][Operations] ${message}`)
throw new Error(message)
}
return service
}
async function selectFolder(folder: CollectionObject) {
async function selectFolder(folder: CollectionObject | null) {
selectedFolder.value = folder
selectedMessage.value = null
try {
await entitiesStore.list([folder.identifier])
} catch (error) {
console.error('[Mail] Failed to load messages:', error)
if (folder) {
try {
await entitiesStore.list([folder.identifier])
} catch (error) {
console.error('[Mail][Operations] Failed to load messages:', error)
}
}
_updateSyncSources()
}
function clearSelectedFolder() {
selectedFolder.value = null
selectedMessage.value = null
_updateSyncSources()
}
function selectMessage(entity: EntityObject) {
function selectMessage(entity: EntityObject | null) {
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) {
composerSaving.value = true
@@ -376,13 +338,31 @@ export const useMailStore = defineStore('mailStore', () => {
return draft
} catch (error) {
console.error('[Mail] Failed to save draft:', error)
console.error('[Mail][Operations] Failed to save draft:', error)
throw error
} finally {
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) {
composerSending.value = true
@@ -414,7 +394,7 @@ export const useMailStore = defineStore('mailStore', () => {
try {
await entitiesStore.delete([composerDraftIdentifier.value])
} 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
} catch (error) {
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')
throw error
} finally {
@@ -431,11 +411,7 @@ export const useMailStore = defineStore('mailStore', () => {
}
}
async function createFolder(
service: ServiceObject,
label: string,
parentFolder: CollectionObject | null = null,
): Promise<CollectionObject> {
async function createFolder(service: ServiceObject, label: string, parentFolder: CollectionObject | null = null): Promise<CollectionObject> {
if (service.identifier === null) {
throw new Error('Cannot create folder for a service without an identifier')
}
@@ -499,7 +475,7 @@ export const useMailStore = defineStore('mailStore', () => {
const deletedFolder = await collectionsStore.delete(folder.identifier)
if (_sameCollection(selectedFolder.value, folder)) {
clearSelectedFolder()
await selectFolder(null)
}
notify(
@@ -510,6 +486,77 @@ export const useMailStore = defineStore('mailStore', () => {
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[]) {
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
(accumulator, identifier) => {
@@ -574,7 +621,7 @@ export const useMailStore = defineStore('mailStore', () => {
await collectionsStore.fetch(sourceCollections)
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
console.error('[Mail] Failed to move messages:', error)
console.error('[Mail][Operations] Failed to move messages:', error)
notify(messageText, 'error')
throw error
} finally {
@@ -582,38 +629,30 @@ export const useMailStore = defineStore('mailStore', () => {
}
}
async function deleteMessages(entityIdentifiers: EntityIdentifier[]) {
if (entityIdentifiers.length === 0) {
return
async function downloadMessage(entity: EntityObject, index?: number) {
const target = entity.identifier
let part = null
if (index !== undefined) {
part = entity.properties.attachments?.[Number(index)] ?? null
}
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',
)
}
await entitiesStore.download(target, part)
} catch (error) {
const messageText = error instanceof Error ? error.message : 'Failed to delete messages'
console.error('[Mail] Failed to delete messages:', error)
const messageText = error instanceof Error
? error.message
: index === undefined
? 'Failed to download message'
: 'Failed to download attachment'
console.error(
index === undefined
? '[Mail][Operations] Failed to download message:'
: '[Mail][Operations] Failed to download attachment:',
error,
)
notify(messageText, 'error')
throw error
} finally {
loading.value = false
}
}
@@ -632,7 +671,6 @@ export const useMailStore = defineStore('mailStore', () => {
// State
loading,
selectedFolder,
selectedMessage,
composerSaving,
composerSending,
@@ -648,23 +686,23 @@ export const useMailStore = defineStore('mailStore', () => {
// Actions
retrieveService,
selectFolder,
clearSelectedFolder,
selectMessage,
clearSelectedMessage,
createFolder,
reloadSelectedFolder,
saveComposerDraft,
sendComposerMessage,
resetComposerState,
flagMessages,
deleteMessages,
deleteFolder,
moveMessages,
downloadMessage,
moveFolder,
renameFolder,
notify,
isServiceFolderLoading,
hasServiceFoldersLoaded,
getServiceFolderError,
findFoldersByRole,
loadFoldersForService,
initialize,
}

View File

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

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

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