webmail - bugfixes and improvements, read / unread logic, refresh to 20s, race condition fixes..

This commit is contained in:
ChrispyBacon-dev 2026-04-14 19:24:46 +02:00
parent 35b449a478
commit 17c2741f41
10 changed files with 240 additions and 116 deletions

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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, '&lt;').replace(/>/g, '&gt;')}</div></div>
<div class="meta"><div class="label">To:</div><div class="val">${to.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</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
}

View file

@ -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">

View file

@ -96,6 +96,6 @@ export function useMailPolling() {
{ immediate: true }
)
const interval = setInterval(poll, 60_000)
const interval = setInterval(poll, 30_000)
onUnmounted(() => clearInterval(interval))
}

View file

@ -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
View 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
}

View file

@ -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>