mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +00:00
webmail - bugfixes and improvements, read / unread logic, refresh to 20s, race condition fixes..
This commit is contained in:
parent
35b449a478
commit
17c2741f41
10 changed files with 240 additions and 116 deletions
|
|
@ -14,7 +14,6 @@ apiClient.interceptors.request.use(config => {
|
|||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
// Let the browser set Content-Type (with boundary) for FormData
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type']
|
||||
}
|
||||
|
|
@ -26,10 +25,14 @@ apiClient.interceptors.response.use(
|
|||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('jwt_token')
|
||||
import('../stores/auth').then(({ useAuthStore }) => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.token = ''
|
||||
})
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
export default apiClient
|
||||
|
|
|
|||
|
|
@ -65,6 +65,19 @@ const editor = useEditor({
|
|||
},
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
to.value = ''
|
||||
subject.value = ''
|
||||
attachments.value = []
|
||||
error.value = ''
|
||||
minimized.value = false
|
||||
draftId.value = null
|
||||
savedDraft.value = false
|
||||
quotedHtml.value = ''
|
||||
editor.value?.commands.clearContent()
|
||||
store.composeDefaults = null
|
||||
}
|
||||
|
||||
watch(() => store.isComposeOpen, async (open) => {
|
||||
if (open && store.composeDefaults) {
|
||||
to.value = store.composeDefaults.to || ''
|
||||
|
|
@ -105,19 +118,6 @@ watch(quotedHtml, (val) => {
|
|||
|
||||
onUnmounted(() => editor.value?.destroy())
|
||||
|
||||
const reset = () => {
|
||||
to.value = ''
|
||||
subject.value = ''
|
||||
attachments.value = []
|
||||
error.value = ''
|
||||
minimized.value = false
|
||||
draftId.value = null
|
||||
savedDraft.value = false
|
||||
quotedHtml.value = ''
|
||||
editor.value?.commands.clearContent()
|
||||
store.composeDefaults = null
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
store.isComposeOpen = false
|
||||
store.isComposeFullView = false
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ const confirmNewFolder = async () => {
|
|||
const res = await mailApi.getFolders(store.currentMailbox)
|
||||
store.folders = res.data
|
||||
showNewFolder.value = false
|
||||
} catch (e) {
|
||||
console.error('Failed to create folder', e)
|
||||
} catch {
|
||||
store.showToast('Failed to create folder')
|
||||
} finally {
|
||||
creatingFolder.value = false
|
||||
}
|
||||
|
|
@ -77,8 +77,8 @@ const deleteFolder = async (f: any) => {
|
|||
if (store.currentFolder === f.name) {
|
||||
store.currentFolder = store.folders[0]?.name || ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete folder', e)
|
||||
} catch {
|
||||
store.showToast('Failed to delete folder')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,8 +109,8 @@ const confirmEdit = async () => {
|
|||
store.currentFolder = name
|
||||
}
|
||||
editingFolder.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to rename folder', e)
|
||||
} catch {
|
||||
store.showToast('Failed to rename folder')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ const compose = () => {
|
|||
class="flex flex-col overflow-hidden"
|
||||
>
|
||||
<ComposeDialog v-if="store.isComposeOpen && store.isComposeFullView" :panel-mode="true" />
|
||||
<MessageDisplay v-else :message="store.currentMessage" />
|
||||
<MessageDisplay v-else :message="store.currentMessage ?? undefined" />
|
||||
</SplitterPanel>
|
||||
</template>
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ const compose = () => {
|
|||
</template>
|
||||
<template v-else>
|
||||
<MessageList v-if="!store.currentMessage" />
|
||||
<MessageDisplay v-else :message="store.currentMessage" />
|
||||
<MessageDisplay v-else :message="store.currentMessage ?? undefined" />
|
||||
</template>
|
||||
</SplitterPanel>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import Textarea from '../ui/Textarea.vue'
|
|||
import AttachmentBar from './AttachmentBar.vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { mailApi } from '../../api/mail'
|
||||
import type { Message } from '../../types/mail'
|
||||
|
||||
const props = defineProps({
|
||||
message: { type: Object, default: null },
|
||||
|
|
@ -114,10 +115,14 @@ const replyTo = () => {
|
|||
|
||||
const replyAll = () => {
|
||||
if (!props.message) return
|
||||
let toList: string[] = []
|
||||
let ccList: string[] = []
|
||||
try { toList = JSON.parse(props.message.to_addresses || '[]') } catch { toList = [] }
|
||||
try { ccList = JSON.parse(props.message.cc_addresses || '[]') } catch { ccList = [] }
|
||||
const allAddresses = [
|
||||
props.message.from_address,
|
||||
...(JSON.parse(props.message.to_addresses || '[]')),
|
||||
...(JSON.parse(props.message.cc_addresses || '[]')),
|
||||
...toList,
|
||||
...ccList,
|
||||
].filter((a: string) => a && a !== store.currentMailbox)
|
||||
store.composeDefaults = {
|
||||
to: allAddresses.join(', '),
|
||||
|
|
@ -155,8 +160,8 @@ const trash = async () => {
|
|||
store.currentMessage = null
|
||||
const fRes = await mailApi.getFolders(store.currentMailbox)
|
||||
store.folders = fRes.data
|
||||
} catch (e) {
|
||||
console.error('Failed to trash message', e)
|
||||
} catch {
|
||||
store.showToast('Failed to move message to trash')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -166,11 +171,11 @@ const markUnread = async () => {
|
|||
await mailApi.updateMessage(store.currentMailbox, props.message.id, { is_read: false })
|
||||
const idx = store.messages.findIndex((m: any) => m.id === props.message!.id)
|
||||
if (idx !== -1) store.messages[idx] = { ...store.messages[idx], is_read: 0 }
|
||||
store.currentMessage = { ...store.currentMessage, is_read: 0 }
|
||||
store.currentMessage = { ...store.currentMessage!, is_read: 0 } as Message
|
||||
const fRes = await mailApi.getFolders(store.currentMailbox)
|
||||
store.folders = fRes.data
|
||||
} catch (e) {
|
||||
console.error('Failed to mark unread', e)
|
||||
} catch {
|
||||
store.showToast('Failed to mark as unread')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,11 +185,11 @@ const markRead = async () => {
|
|||
await mailApi.updateMessage(store.currentMailbox, props.message.id, { is_read: true })
|
||||
const idx = store.messages.findIndex((m: any) => m.id === props.message!.id)
|
||||
if (idx !== -1) store.messages[idx] = { ...store.messages[idx], is_read: 1 }
|
||||
store.currentMessage = { ...store.currentMessage, is_read: 1 }
|
||||
store.currentMessage = { ...store.currentMessage!, is_read: 1 } as Message
|
||||
const fRes = await mailApi.getFolders(store.currentMailbox)
|
||||
store.folders = fRes.data
|
||||
} catch (e) {
|
||||
console.error('Failed to mark read', e)
|
||||
} catch {
|
||||
store.showToast('Failed to mark as read')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,8 +201,8 @@ const toggleStar = async () => {
|
|||
const idx = store.messages.findIndex((m: any) => m.id === props.message!.id)
|
||||
if (idx !== -1) store.messages[idx] = { ...store.messages[idx], is_starred: newVal }
|
||||
if (store.currentMessage) store.currentMessage = { ...store.currentMessage, is_starred: newVal }
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle star', e)
|
||||
} catch {
|
||||
store.showToast('Failed to update star')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,17 +217,21 @@ const moveToFolder = async (targetFolder: any) => {
|
|||
store.currentMessage = null
|
||||
const fRes = await mailApi.getFolders(store.currentMailbox)
|
||||
store.folders = fRes.data
|
||||
} catch (e) {
|
||||
console.error('Failed to move message', e)
|
||||
} catch {
|
||||
store.showToast('Failed to move message')
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (str: string) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
const printMessage = () => {
|
||||
if (!props.message) return
|
||||
|
||||
|
||||
const from = props.message.from_name ? `${props.message.from_name} <${props.message.from_address}>` : props.message.from_address
|
||||
const toRaw = JSON.parse(props.message.to_addresses || '[]')
|
||||
const to = Array.isArray(toRaw) ? toRaw.join(', ') : toRaw
|
||||
let toRaw: string[] = []
|
||||
try { toRaw = JSON.parse(props.message.to_addresses || '[]') } catch { toRaw = [] }
|
||||
const to = Array.isArray(toRaw) ? toRaw.join(', ') : String(toRaw)
|
||||
const date = displayTimestamp.value
|
||||
const subject = props.message.subject || '(No Subject)'
|
||||
|
||||
|
|
@ -259,9 +268,9 @@ const printMessage = () => {
|
|||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 class="subject">${subject}</h1>
|
||||
<div class="meta"><div class="label">From:</div><div class="val">${from.replace(/</g, '<').replace(/>/g, '>')}</div></div>
|
||||
<div class="meta"><div class="label">To:</div><div class="val">${to.replace(/</g, '<').replace(/>/g, '>')}</div></div>
|
||||
<h1 class="subject">${escapeHtml(subject)}</h1>
|
||||
<div class="meta"><div class="label">From:</div><div class="val">${escapeHtml(from)}</div></div>
|
||||
<div class="meta"><div class="label">To:</div><div class="val">${escapeHtml(to)}</div></div>
|
||||
<div class="meta"><div class="label">Date:</div><div class="val">${date}</div></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
|
@ -284,6 +293,7 @@ const printMessage = () => {
|
|||
|
||||
const sendInlineReply = async () => {
|
||||
if (!props.message || !store.currentMailbox || !replyText.value.trim()) return
|
||||
if (!props.message.from_address) return
|
||||
sendingReply.value = true
|
||||
try {
|
||||
await mailApi.sendMessage(store.currentMailbox, {
|
||||
|
|
@ -296,8 +306,8 @@ const sendInlineReply = async () => {
|
|||
in_reply_to: props.message.message_id,
|
||||
})
|
||||
replyText.value = ''
|
||||
} catch (e) {
|
||||
console.error('Failed to send reply', e)
|
||||
} catch {
|
||||
store.showToast('Failed to send reply')
|
||||
} finally {
|
||||
sendingReply.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,15 +82,17 @@ const emptyTrash = () => {
|
|||
}
|
||||
|
||||
const performEmptyTrash = async () => {
|
||||
if (store.currentFolderObj) {
|
||||
try {
|
||||
await mailApi.emptyFolder(store.currentMailbox, store.currentFolderObj.id)
|
||||
store.messages = []
|
||||
store.currentMessage = null
|
||||
} catch (e) {
|
||||
} finally {
|
||||
showTrashConfirm.value = false
|
||||
}
|
||||
if (!store.currentFolderObj) return
|
||||
try {
|
||||
await mailApi.emptyFolder(store.currentMailbox, store.currentFolderObj.id)
|
||||
store.messages = []
|
||||
store.currentMessage = null
|
||||
const fRes = await mailApi.getFolders(store.currentMailbox)
|
||||
store.folders = fRes.data
|
||||
} catch {
|
||||
store.showToast('Failed to empty trash')
|
||||
} finally {
|
||||
showTrashConfirm.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -148,19 +150,24 @@ const performEmptyTrash = async () => {
|
|||
<ScrollAreaRoot class="h-full">
|
||||
<ScrollAreaViewport class="h-full">
|
||||
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
:folder-color="folderColor"
|
||||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No messages found.
|
||||
</div>
|
||||
<template v-if="store.messagesLoading">
|
||||
<div v-for="n in 6" :key="n" class="h-16 rounded-lg bg-muted animate-pulse" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
:folder-color="folderColor"
|
||||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No messages found.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical" class="flex touch-none select-none bg-transparent p-0.5 transition-colors w-2.5">
|
||||
|
|
@ -173,19 +180,24 @@ const performEmptyTrash = async () => {
|
|||
<ScrollAreaRoot class="h-full">
|
||||
<ScrollAreaViewport class="h-full">
|
||||
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
:folder-color="folderColor"
|
||||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No unread messages.
|
||||
</div>
|
||||
<template v-if="store.messagesLoading">
|
||||
<div v-for="n in 6" :key="n" class="h-16 rounded-lg bg-muted animate-pulse" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
:folder-color="folderColor"
|
||||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No unread messages.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical" class="flex touch-none select-none bg-transparent p-0.5 transition-colors w-2.5">
|
||||
|
|
@ -198,19 +210,24 @@ const performEmptyTrash = async () => {
|
|||
<ScrollAreaRoot class="h-full">
|
||||
<ScrollAreaViewport class="h-full">
|
||||
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
:folder-color="folderColor"
|
||||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No starred messages.
|
||||
</div>
|
||||
<template v-if="store.messagesLoading">
|
||||
<div v-for="n in 6" :key="n" class="h-16 rounded-lg bg-muted animate-pulse" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<TransitionGroup name="list" appear>
|
||||
<MessageListItem
|
||||
v-for="msg in displayMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
:folder-color="folderColor"
|
||||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="displayMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No starred messages.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical" class="flex touch-none select-none bg-transparent p-0.5 transition-colors w-2.5">
|
||||
|
|
|
|||
|
|
@ -96,6 +96,6 @@ export function useMailPolling() {
|
|||
{ immediate: true }
|
||||
)
|
||||
|
||||
const interval = setInterval(poll, 60_000)
|
||||
const interval = setInterval(poll, 30_000)
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,45 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Mailbox, Folder, Message, Toast, ComposeDefaults } from '../types/mail'
|
||||
|
||||
export const useMailStore = defineStore('mail', () => {
|
||||
const mailboxes = ref<any[]>([])
|
||||
const mailboxes = ref<Mailbox[]>([])
|
||||
const currentMailbox = ref<string>('')
|
||||
const folders = ref<any[]>([])
|
||||
const folders = ref<Folder[]>([])
|
||||
const currentFolder = ref<string>('')
|
||||
const messages = ref<any[]>([])
|
||||
const currentMessage = ref<any>(null)
|
||||
const messages = ref<Message[]>([])
|
||||
const currentMessage = ref<Message | null>(null)
|
||||
const messagesLoading = ref(false)
|
||||
const isComposeOpen = ref(false)
|
||||
const isComposeFullView = ref(false)
|
||||
const isSettingsOpen = ref(false)
|
||||
const composeDefaults = ref<{ to?: string; subject?: string; body?: string; quotedHtml?: string; draftId?: number } | null>(null)
|
||||
const composeDefaults = ref<ComposeDefaults | null>(null)
|
||||
const composeBody = ref('')
|
||||
const activeTab = ref<'all' | 'unread' | 'starred'>('all')
|
||||
const isCollapsed = ref(false)
|
||||
const sortOrder = ref<'asc' | 'desc'>('desc')
|
||||
const isDark = ref(localStorage.getItem('theme') === 'dark')
|
||||
const viewMode = ref<'split' | 'full'>((localStorage.getItem('viewMode') as 'split' | 'full') || 'split')
|
||||
const toast = ref<Toast | null>(null)
|
||||
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function showToast(message: string, type: Toast['type'] = 'error') {
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toast.value = { message, type }
|
||||
toastTimer = setTimeout(() => { toast.value = null }, 4000)
|
||||
}
|
||||
|
||||
const unreadMessages = computed(() =>
|
||||
messages.value.filter((m: any) => !m.is_read)
|
||||
messages.value.filter((m) => !m.is_read)
|
||||
)
|
||||
|
||||
const starredMessages = computed(() =>
|
||||
messages.value.filter((m: any) => m.is_starred)
|
||||
messages.value.filter((m) => m.is_starred)
|
||||
)
|
||||
|
||||
const currentFolderObj = computed(() =>
|
||||
folders.value.find((f: any) => f.name === currentFolder.value) || null
|
||||
folders.value.find((f) => f.name === currentFolder.value) || null
|
||||
)
|
||||
|
||||
function toggleTheme() {
|
||||
|
|
@ -50,11 +61,12 @@ export const useMailStore = defineStore('mail', () => {
|
|||
return {
|
||||
mailboxes, currentMailbox,
|
||||
folders, currentFolder, currentFolderObj,
|
||||
messages, currentMessage,
|
||||
messages, currentMessage, messagesLoading,
|
||||
isComposeOpen, isComposeFullView, isSettingsOpen, composeDefaults, composeBody,
|
||||
activeTab, isCollapsed,
|
||||
sortOrder, isDark, toggleTheme,
|
||||
viewMode, toggleViewMode,
|
||||
unreadMessages, starredMessages,
|
||||
toast, showToast,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
53
webmail/src/types/mail.ts
Normal file
53
webmail/src/types/mail.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
export interface Mailbox {
|
||||
address: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: number
|
||||
name: string
|
||||
system_folder: boolean
|
||||
color: string | null
|
||||
unread_count: number
|
||||
total_count: number
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
filename: string
|
||||
content_type: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
mailbox_address: string
|
||||
folder_id: number
|
||||
message_id: string | null
|
||||
from_name: string | null
|
||||
from_address: string
|
||||
to_addresses: string
|
||||
cc_addresses: string
|
||||
subject: string | null
|
||||
text_body: string | null
|
||||
html_body: string | null
|
||||
received_at: string | null
|
||||
sent_at: string | null
|
||||
is_read: 0 | 1
|
||||
is_starred: 0 | 1
|
||||
is_draft: boolean
|
||||
attachments?: Attachment[]
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
message: string
|
||||
type: 'error' | 'success' | 'info'
|
||||
}
|
||||
|
||||
export interface ComposeDefaults {
|
||||
to?: string
|
||||
subject?: string
|
||||
body?: string
|
||||
quotedHtml?: string
|
||||
draftId?: number
|
||||
}
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMail } from '../composables/useMail'
|
||||
import { useMailPolling } from '../composables/useMailPolling'
|
||||
import { useNotificationsStore } from '../stores/notifications'
|
||||
import { mailApi } from '../api/mail'
|
||||
import MailLayout from '../components/mail/MailLayout.vue'
|
||||
import type { Message } from '../types/mail'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { store, loadMailboxes } = useMail()
|
||||
const mailStore = store
|
||||
const notificationsStore = useNotificationsStore()
|
||||
|
|
@ -16,15 +16,20 @@ useMailPolling()
|
|||
|
||||
const showNotifPrompt = ref(false)
|
||||
|
||||
let mailboxLoadSeq = 0
|
||||
|
||||
const loadMessages = async (addr: string, folder: string) => {
|
||||
if (!addr || !folder) return
|
||||
store.messagesLoading = true
|
||||
try {
|
||||
const mRes = await mailApi.getMessages(addr, { folder, order: store.sortOrder })
|
||||
const payload = mRes.data
|
||||
store.messages = Array.isArray(payload) ? payload : payload.items || []
|
||||
store.currentMessage = null
|
||||
} catch (e) {
|
||||
console.error('Failed to load messages', e)
|
||||
} catch {
|
||||
store.showToast('Failed to load messages')
|
||||
} finally {
|
||||
store.messagesLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +52,7 @@ onMounted(async () => {
|
|||
|
||||
const mailboxParam = route.query.mailbox as string | undefined
|
||||
if (mailboxParam) {
|
||||
const found = store.mailboxes.find((b: any) => b.address === mailboxParam)
|
||||
const found = store.mailboxes.find((b) => b.address === mailboxParam)
|
||||
if (found) store.currentMailbox = mailboxParam
|
||||
}
|
||||
|
||||
|
|
@ -76,15 +81,18 @@ onMounted(async () => {
|
|||
|
||||
watch(() => store.currentMailbox, async (addr) => {
|
||||
if (!addr) return
|
||||
const seq = ++mailboxLoadSeq
|
||||
try {
|
||||
const fRes = await mailApi.getFolders(addr)
|
||||
if (seq !== mailboxLoadSeq) return
|
||||
store.folders = fRes.data
|
||||
if (store.folders.length > 0) {
|
||||
const inbox = store.folders.find((f: any) => f.name.toLowerCase() === 'inbox')
|
||||
const inbox = store.folders.find((f) => f.name.toLowerCase() === 'inbox')
|
||||
store.currentFolder = inbox ? inbox.name : store.folders[0].name
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load folders', e)
|
||||
} catch {
|
||||
if (seq !== mailboxLoadSeq) return
|
||||
store.showToast('Failed to load folders')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -96,12 +104,16 @@ watch(() => store.sortOrder, () => {
|
|||
loadMessages(store.currentMailbox, store.currentFolder)
|
||||
})
|
||||
|
||||
let openedMessageId: string | null = null
|
||||
|
||||
watch(() => store.currentMessage, async (msg) => {
|
||||
if (!msg) return
|
||||
try {
|
||||
const idx = store.messages.findIndex((m: any) => m.id === msg.id)
|
||||
const idx = store.messages.findIndex((m) => m.id === msg.id)
|
||||
let fullMsg = msg
|
||||
|
||||
const isUserOpen = msg.attachments === undefined || msg.id !== openedMessageId
|
||||
|
||||
if (msg.attachments === undefined) {
|
||||
const res = await mailApi.getMessage(store.currentMailbox, msg.id)
|
||||
fullMsg = res.data
|
||||
|
|
@ -109,17 +121,20 @@ watch(() => store.currentMessage, async (msg) => {
|
|||
if (idx !== -1) store.messages[idx] = fullMsg
|
||||
}
|
||||
|
||||
if (!fullMsg.is_read) {
|
||||
if (!fullMsg.is_read && isUserOpen) {
|
||||
openedMessageId = msg.id
|
||||
await mailApi.updateMessage(store.currentMailbox, msg.id, { is_read: true })
|
||||
if (idx !== -1) {
|
||||
store.messages[idx] = { ...store.messages[idx], is_read: 1 }
|
||||
}
|
||||
store.currentMessage = { ...store.currentMessage, is_read: 1 }
|
||||
store.currentMessage = { ...store.currentMessage!, is_read: 1 } as Message
|
||||
const fRes = await mailApi.getFolders(store.currentMailbox)
|
||||
store.folders = fRes.data
|
||||
} else {
|
||||
openedMessageId = msg.id
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load message', e)
|
||||
} catch {
|
||||
store.showToast('Failed to load message')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -148,5 +163,19 @@ watch(() => store.currentMessage, async (msg) => {
|
|||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="store.toast"
|
||||
class="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-xl border px-5 py-3.5 text-sm shadow-lg"
|
||||
:class="store.toast.type === 'error'
|
||||
? 'bg-destructive text-destructive-foreground border-destructive'
|
||||
: store.toast.type === 'success'
|
||||
? 'bg-green-600 text-white border-green-700'
|
||||
: 'bg-background text-foreground border-border'"
|
||||
>
|
||||
{{ store.toast.message }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue