diff --git a/webmail/src/api/client.ts b/webmail/src/api/client.ts
index 0b1ebe2..bcd6122 100644
--- a/webmail/src/api/client.ts
+++ b/webmail/src/api/client.ts
@@ -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
\ No newline at end of file
+export default apiClient
diff --git a/webmail/src/components/mail/ComposeDialog.vue b/webmail/src/components/mail/ComposeDialog.vue
index d74b826..100174b 100644
--- a/webmail/src/components/mail/ComposeDialog.vue
+++ b/webmail/src/components/mail/ComposeDialog.vue
@@ -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
diff --git a/webmail/src/components/mail/FolderNav.vue b/webmail/src/components/mail/FolderNav.vue
index b7771b5..141e962 100644
--- a/webmail/src/components/mail/FolderNav.vue
+++ b/webmail/src/components/mail/FolderNav.vue
@@ -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')
}
}
diff --git a/webmail/src/components/mail/MailLayout.vue b/webmail/src/components/mail/MailLayout.vue
index 8660735..0834c0f 100644
--- a/webmail/src/components/mail/MailLayout.vue
+++ b/webmail/src/components/mail/MailLayout.vue
@@ -220,7 +220,7 @@ const compose = () => {
class="flex flex-col overflow-hidden"
>
-
+
@@ -236,7 +236,7 @@ const compose = () => {
-
+
diff --git a/webmail/src/components/mail/MessageDisplay.vue b/webmail/src/components/mail/MessageDisplay.vue
index 11c69e0..813f439 100644
--- a/webmail/src/components/mail/MessageDisplay.vue
+++ b/webmail/src/components/mail/MessageDisplay.vue
@@ -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, '"')
+
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 = () => {
@@ -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
}
diff --git a/webmail/src/components/mail/MessageList.vue b/webmail/src/components/mail/MessageList.vue
index d678db4..4eeb250 100644
--- a/webmail/src/components/mail/MessageList.vue
+++ b/webmail/src/components/mail/MessageList.vue
@@ -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
}
}
@@ -148,19 +150,24 @@ const performEmptyTrash = async () => {
-
-
-
-
- No messages found.
-
+
+
+
+
+
+
+
+
+ No messages found.
+
+
@@ -173,19 +180,24 @@ const performEmptyTrash = async () => {
-
-
-
-
- No unread messages.
-
+
+
+
+
+
+
+
+
+ No unread messages.
+
+
@@ -198,19 +210,24 @@ const performEmptyTrash = async () => {
-
-
-
-
- No starred messages.
-
+
+
+
+
+
+
+
+
+ No starred messages.
+
+
diff --git a/webmail/src/composables/useMailPolling.ts b/webmail/src/composables/useMailPolling.ts
index 94f83db..467e22d 100644
--- a/webmail/src/composables/useMailPolling.ts
+++ b/webmail/src/composables/useMailPolling.ts
@@ -96,6 +96,6 @@ export function useMailPolling() {
{ immediate: true }
)
- const interval = setInterval(poll, 60_000)
+ const interval = setInterval(poll, 30_000)
onUnmounted(() => clearInterval(interval))
}
diff --git a/webmail/src/stores/mail.ts b/webmail/src/stores/mail.ts
index 7cfa59f..195c04a 100644
--- a/webmail/src/stores/mail.ts
+++ b/webmail/src/stores/mail.ts
@@ -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([])
+ const mailboxes = ref([])
const currentMailbox = ref('')
- const folders = ref([])
+ const folders = ref([])
const currentFolder = ref('')
- const messages = ref([])
- const currentMessage = ref(null)
+ const messages = ref([])
+ const currentMessage = ref(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(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(null)
+
+ let toastTimer: ReturnType | 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,
}
})
diff --git a/webmail/src/types/mail.ts b/webmail/src/types/mail.ts
new file mode 100644
index 0000000..0ca594d
--- /dev/null
+++ b/webmail/src/types/mail.ts
@@ -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
+}
diff --git a/webmail/src/views/MailView.vue b/webmail/src/views/MailView.vue
index 7280f15..3a0caec 100644
--- a/webmail/src/views/MailView.vue
+++ b/webmail/src/views/MailView.vue
@@ -1,14 +1,14 @@
@@ -148,5 +163,19 @@ watch(() => store.currentMessage, async (msg) => {
+
+
+
+ {{ store.toast.message }}
+
+