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 = () => {
-

${subject}

-
From:
${from.replace(//g, '>')}
-
To:
${to.replace(//g, '>')}
+

${escapeHtml(subject)}

+
From:
${escapeHtml(from)}
+
To:
${escapeHtml(to)}
Date:
${date}
@@ -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. -
+ +
@@ -173,19 +180,24 @@ const performEmptyTrash = async () => {
- - - -
- No unread messages. -
+ +
@@ -198,19 +210,24 @@ const performEmptyTrash = async () => {
- - - -
- 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 }} +
+