mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +00:00
webmail - bugfixes - refinement - attachment support
This commit is contained in:
parent
2a91b438e4
commit
b3d77c659b
11 changed files with 746 additions and 124 deletions
|
|
@ -7,7 +7,11 @@ export const mailApi = {
|
|||
getMessage: (address: string, id: string) => apiClient.get(`/mailboxes/${address}/messages/${id}`),
|
||||
updateMessage: (address: string, id: string, data: any) => apiClient.patch(`/mailboxes/${address}/messages/${id}`, data),
|
||||
deleteMessage: (address: string, id: string) => apiClient.delete(`/mailboxes/${address}/messages/${id}`),
|
||||
moveMessages: (address: string, data: any) => apiClient.post(`/mailboxes/${address}/messages/move`, data),
|
||||
markMessages: (address: string, data: any) => apiClient.post(`/mailboxes/${address}/messages/mark`, data),
|
||||
sendMessage: (address: string, data: any) => apiClient.post(`/mailboxes/${address}/send`, data),
|
||||
searchMessages: (address: string, params: any) => apiClient.get(`/mailboxes/${address}/search`, { params }),
|
||||
getAttachmentUrl: (id: string) => `/api/v1/attachments/${id}/download`
|
||||
}
|
||||
getAttachmentUrl: (id: string) => `/api/v1/attachments/${id}/download`,
|
||||
downloadAttachment: (id: number | string) =>
|
||||
apiClient.get(`/attachments/${id}/download`, { responseType: 'blob' }).then(r => r.data as Blob),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Paperclip, Download } from 'lucide-vue-next'
|
||||
import { mailApi } from '../../api/mail'
|
||||
import Button from '../ui/Button.vue'
|
||||
|
||||
defineProps({
|
||||
attachments: { type: Array, default: () => [] }
|
||||
attachments: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const downloading = ref<number | null>(null)
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
|
||||
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
const download = async (att: any) => {
|
||||
downloading.value = att.id
|
||||
try {
|
||||
const blob = await mailApi.downloadAttachment(att.id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = att.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
console.error('Download failed', e)
|
||||
} finally {
|
||||
downloading.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 border-t p-4" v-if="attachments && attachments.length > 0">
|
||||
<div v-for="att in (attachments as any[])" :key="att.id" class="flex items-center gap-2 rounded-md border p-2 text-sm">
|
||||
<span class="truncate max-w-[200px]">{{ att.filename }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ Math.round(att.size_bytes / 1024) }} KB</span>
|
||||
<Button variant="ghost" size="sm" as="a" :href="mailApi.getAttachmentUrl(att.id)" target="_blank" download>
|
||||
DL
|
||||
<div v-if="attachments && attachments.length > 0" class="flex flex-wrap gap-2 border-t p-4">
|
||||
<div
|
||||
v-for="att in (attachments as any[])"
|
||||
:key="att.id"
|
||||
class="flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2 text-sm"
|
||||
>
|
||||
<Paperclip class="size-4 text-muted-foreground shrink-0" />
|
||||
<span class="truncate max-w-[180px]">{{ att.filename }}</span>
|
||||
<span class="text-xs text-muted-foreground whitespace-nowrap">{{ formatSize(att.size_bytes) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
:disabled="downloading === att.id"
|
||||
@click="download(att)"
|
||||
>
|
||||
<Download class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,145 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue'
|
||||
import {
|
||||
Inbox, FileText, Send, Trash2, AlertCircle, Archive, Folder,
|
||||
PenSquare, LogOut,
|
||||
} from 'lucide-vue-next'
|
||||
import { TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal } from 'radix-vue'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Separator from '../ui/Separator.vue'
|
||||
|
||||
defineProps({
|
||||
isCollapsed: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const store = useMailStore()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
const iconMap: Record<string, Component> = {
|
||||
Inbox, Drafts: FileText, Sent: Send,
|
||||
Trash: Trash2, Spam: AlertCircle, Junk: AlertCircle,
|
||||
Archive,
|
||||
}
|
||||
|
||||
const getIcon = (name: string): Component => iconMap[name] || Folder
|
||||
|
||||
const selectFolder = (name: string) => {
|
||||
store.currentFolder = name
|
||||
emit('select', name)
|
||||
}
|
||||
|
||||
const compose = () => {
|
||||
store.composeDefaults = null
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-1 p-2">
|
||||
<button v-for="f in store.folders" :key="f.name"
|
||||
@click="selectFolder(f.name)"
|
||||
:class="['flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground', store.currentFolder === f.name ? 'bg-accent text-accent-foreground' : 'transparent']">
|
||||
{{ f.name }}
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
<div
|
||||
:data-collapsed="isCollapsed"
|
||||
class="group flex flex-1 flex-col justify-between py-2"
|
||||
>
|
||||
<nav class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
|
||||
<template v-for="f in store.folders" :key="f.name">
|
||||
<TooltipRoot v-if="isCollapsed" :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
:class="cn(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
store.currentFolder === f.name
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)"
|
||||
@click="selectFolder(f.name)"
|
||||
>
|
||||
<component :is="getIcon(f.name)" class="size-4" />
|
||||
<span class="sr-only">{{ f.name }}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md flex items-center gap-4"
|
||||
>
|
||||
{{ f.name }}
|
||||
<span v-if="f.unread_count" class="ml-auto text-muted-foreground">
|
||||
{{ f.unread_count }}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
||||
<button
|
||||
v-else
|
||||
:class="cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground justify-start',
|
||||
store.currentFolder === f.name
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
|
||||
: 'transparent',
|
||||
)"
|
||||
@click="selectFolder(f.name)"
|
||||
>
|
||||
<component :is="getIcon(f.name)" class="size-4" />
|
||||
{{ f.name }}
|
||||
<span
|
||||
v-if="f.unread_count"
|
||||
:class="cn(
|
||||
'ml-auto text-xs',
|
||||
store.currentFolder === f.name ? 'text-primary-foreground' : 'text-muted-foreground',
|
||||
)"
|
||||
>
|
||||
{{ f.unread_count }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
|
||||
<Separator class="my-2" />
|
||||
<template v-if="isCollapsed">
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
@click="compose"
|
||||
>
|
||||
<PenSquare class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Compose
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-md text-sm font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
@click="logout"
|
||||
>
|
||||
<LogOut class="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right" class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Logout
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button class="justify-start gap-2" size="sm" @click="compose">
|
||||
<PenSquare class="size-4" />
|
||||
Compose
|
||||
</Button>
|
||||
<Button variant="ghost" class="justify-start gap-2" size="sm" @click="logout">
|
||||
<LogOut class="size-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,49 +1,78 @@
|
|||
<script setup lang="ts">
|
||||
import ResizablePanelGroup from '../ui/ResizablePanelGroup.vue'
|
||||
import ResizablePanel from '../ui/ResizablePanel.vue'
|
||||
import ResizableHandle from '../ui/ResizableHandle.vue'
|
||||
import {
|
||||
SplitterGroup, SplitterPanel, SplitterResizeHandle,
|
||||
} from 'radix-vue'
|
||||
import { TooltipProvider } from 'radix-vue'
|
||||
import { cn } from '../../lib/utils'
|
||||
import Separator from '../ui/Separator.vue'
|
||||
import MailboxSelector from './MailboxSelector.vue'
|
||||
import FolderNav from './FolderNav.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
import MessageDisplay from './MessageDisplay.vue'
|
||||
import MailboxSelector from './MailboxSelector.vue'
|
||||
import SearchBar from './SearchBar.vue'
|
||||
import ComposeDialog from './ComposeDialog.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { useAuth } from '../../composables/useAuth'
|
||||
|
||||
const store = useMailStore()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const onCollapse = () => { store.isCollapsed = true }
|
||||
const onExpand = () => { store.isCollapsed = false }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen w-screen overflow-hidden bg-background flex flex-col">
|
||||
<header class="flex h-14 items-center justify-between border-b px-4">
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
DockFlare Webmail
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" @click="store.isComposeOpen = true">Compose</Button>
|
||||
<Button variant="ghost" size="sm" @click="logout">Logout</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<SplitterGroup
|
||||
id="mail-layout"
|
||||
direction="horizontal"
|
||||
class="h-screen w-screen items-stretch"
|
||||
>
|
||||
<SplitterPanel
|
||||
id="sidebar"
|
||||
:default-size="20"
|
||||
:collapsed-size="4"
|
||||
collapsible
|
||||
:min-size="15"
|
||||
:max-size="22"
|
||||
:class="cn(
|
||||
'flex flex-col',
|
||||
store.isCollapsed && 'min-w-[50px] transition-all duration-300 ease-in-out',
|
||||
)"
|
||||
@collapse="onCollapse"
|
||||
@expand="onExpand"
|
||||
>
|
||||
<MailboxSelector :is-collapsed="store.isCollapsed" />
|
||||
<Separator />
|
||||
<FolderNav :is-collapsed="store.isCollapsed" />
|
||||
</SplitterPanel>
|
||||
|
||||
<ResizablePanelGroup class="flex-1">
|
||||
<ResizablePanel :defaultSize="20" :minSize="15" class="border-r flex flex-col hidden md:flex">
|
||||
<MailboxSelector />
|
||||
<FolderNav @select="() => {}" class="flex-1 overflow-auto" />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel :defaultSize="35" :minSize="25" class="border-r flex flex-col hidden sm:flex">
|
||||
<SearchBar />
|
||||
<MessageList class="flex-1 overflow-auto" />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel :defaultSize="45" :minSize="30" class="flex-1">
|
||||
<SplitterResizeHandle
|
||||
id="sidebar-handle"
|
||||
class="w-[3px] bg-border hover:bg-primary/50 active:bg-primary/70 transition-colors"
|
||||
/>
|
||||
|
||||
<SplitterPanel
|
||||
id="mail-list"
|
||||
:default-size="35"
|
||||
:min-size="25"
|
||||
class="flex flex-col overflow-hidden"
|
||||
>
|
||||
<MessageList />
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterResizeHandle
|
||||
id="display-handle"
|
||||
class="w-[3px] bg-border hover:bg-primary/50 active:bg-primary/70 transition-colors"
|
||||
/>
|
||||
|
||||
<SplitterPanel
|
||||
id="mail-display"
|
||||
:default-size="45"
|
||||
:min-size="30"
|
||||
class="flex flex-col overflow-hidden"
|
||||
>
|
||||
<MessageDisplay :message="store.currentMessage" />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</SplitterPanel>
|
||||
</SplitterGroup>
|
||||
|
||||
<ComposeDialog />
|
||||
</div>
|
||||
</template>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { computed } from 'vue'
|
||||
import { Mail, ChevronDown, Check } from 'lucide-vue-next'
|
||||
import {
|
||||
SelectRoot, SelectTrigger, SelectValue, SelectContent,
|
||||
SelectItem, SelectItemText, SelectItemIndicator,
|
||||
SelectPortal, SelectViewport,
|
||||
} from 'radix-vue'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
|
||||
defineProps({
|
||||
isCollapsed: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const store = useMailStore()
|
||||
|
||||
const currentAddress = computed({
|
||||
const selected = computed({
|
||||
get: () => store.currentMailbox,
|
||||
set: (val) => store.currentMailbox = val
|
||||
set: (val) => { store.currentMailbox = val },
|
||||
})
|
||||
|
||||
const currentDisplay = computed(() => {
|
||||
const mb = store.mailboxes.find((m: any) => m.address === store.currentMailbox)
|
||||
return mb?.display_name || store.currentMailbox
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 py-2 border-b">
|
||||
<select v-model="currentAddress" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option v-for="mb in store.mailboxes" :key="mb.address" :value="mb.address">
|
||||
{{ mb.display_name }} ({{ mb.address }})
|
||||
</option>
|
||||
</select>
|
||||
<div :class="cn('flex h-[52px] items-center justify-center', isCollapsed ? '' : 'px-2')">
|
||||
<SelectRoot v-model="selected">
|
||||
<SelectTrigger
|
||||
:class="cn(
|
||||
'flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring [&>span]:line-clamp-1 [&>span]:flex [&>span]:w-full [&>span]:items-center [&>span]:gap-2 [&>span]:truncate',
|
||||
isCollapsed ? 'flex h-9 w-9 shrink-0 items-center justify-center p-0 [&>span]:w-auto' : 'w-full',
|
||||
)"
|
||||
>
|
||||
<SelectValue :placeholder="isCollapsed ? '' : 'Select account'">
|
||||
<div class="flex items-center gap-2">
|
||||
<Mail class="size-4 shrink-0" />
|
||||
<span v-if="!isCollapsed" class="truncate">{{ currentDisplay }}</span>
|
||||
</div>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
class="z-50 min-w-[220px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
>
|
||||
<SelectViewport>
|
||||
<SelectItem
|
||||
v-for="mb in store.mailboxes"
|
||||
:key="mb.address"
|
||||
:value="mb.address"
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent focus:bg-accent"
|
||||
>
|
||||
<SelectItemIndicator class="absolute left-2">
|
||||
<Check class="size-4" />
|
||||
</SelectItemIndicator>
|
||||
<SelectItemText class="pl-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Mail class="size-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div>{{ mb.display_name || mb.address }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ mb.address }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
Archive, Trash2, Reply, ReplyAll, Forward,
|
||||
MoreVertical, MailOpen, Star,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
TooltipRoot, TooltipTrigger, TooltipContent, TooltipPortal,
|
||||
} from 'radix-vue'
|
||||
import {
|
||||
DropdownMenuRoot, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuItem, DropdownMenuPortal,
|
||||
} from 'radix-vue'
|
||||
import Avatar from '../ui/Avatar.vue'
|
||||
import Button from '../ui/Button.vue'
|
||||
import Separator from '../ui/Separator.vue'
|
||||
import Textarea from '../ui/Textarea.vue'
|
||||
import AttachmentBar from './AttachmentBar.vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import { mailApi } from '../../api/mail'
|
||||
|
||||
const props = defineProps({
|
||||
message: { type: Object, default: null }
|
||||
message: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const store = useMailStore()
|
||||
const replyText = ref('')
|
||||
const sendingReply = ref(false)
|
||||
|
||||
const safeHtml = computed(() => {
|
||||
if (!props.message?.html_body) return ''
|
||||
|
|
@ -23,27 +37,50 @@ const safeHtml = computed(() => {
|
|||
const quotedBody = computed(() => {
|
||||
if (!props.message) return ''
|
||||
const from = props.message.from_address || ''
|
||||
const date = props.message.received_at ? format(new Date(props.message.received_at), 'PPpp') : ''
|
||||
const date = props.message.received_at
|
||||
? format(new Date(props.message.received_at), 'PPpp')
|
||||
: ''
|
||||
const original = props.message.html_body || `<pre>${props.message.text_body || ''}</pre>`
|
||||
return `<p></p><blockquote style="border-left:2px solid #ccc;padding-left:1em;color:#555;"><p>On ${date}, ${from} wrote:</p>${original}</blockquote>`
|
||||
return `<br><blockquote style="border-left:2px solid #ccc;padding-left:1em;color:#555;margin:1em 0;"><p>On ${date}, ${from} wrote:</p>${original}</blockquote>`
|
||||
})
|
||||
|
||||
const reply = () => {
|
||||
const replyTo = () => {
|
||||
if (!props.message) return
|
||||
store.composeDefaults = {
|
||||
to: props.message.from_address,
|
||||
subject: props.message.subject?.startsWith('Re:') ? props.message.subject : `Re: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value
|
||||
subject: props.message.subject?.startsWith('Re:')
|
||||
? props.message.subject
|
||||
: `Re: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value,
|
||||
}
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
|
||||
const forward = () => {
|
||||
const replyAll = () => {
|
||||
if (!props.message) return
|
||||
const allAddresses = [
|
||||
props.message.from_address,
|
||||
...(JSON.parse(props.message.to_addresses || '[]')),
|
||||
...(JSON.parse(props.message.cc_addresses || '[]')),
|
||||
].filter((a: string) => a && a !== store.currentMailbox)
|
||||
store.composeDefaults = {
|
||||
to: allAddresses.join(', '),
|
||||
subject: props.message.subject?.startsWith('Re:')
|
||||
? props.message.subject
|
||||
: `Re: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value,
|
||||
}
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
|
||||
const forwardMsg = () => {
|
||||
if (!props.message) return
|
||||
store.composeDefaults = {
|
||||
to: '',
|
||||
subject: props.message.subject?.startsWith('Fwd:') ? props.message.subject : `Fwd: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value
|
||||
subject: props.message.subject?.startsWith('Fwd:')
|
||||
? props.message.subject
|
||||
: `Fwd: ${props.message.subject || ''}`,
|
||||
body: quotedBody.value,
|
||||
}
|
||||
store.isComposeOpen = true
|
||||
}
|
||||
|
|
@ -58,39 +95,202 @@ const trash = async () => {
|
|||
console.error('Failed to trash message', e)
|
||||
}
|
||||
}
|
||||
|
||||
const markUnread = async () => {
|
||||
if (!props.message || !store.currentMailbox) return
|
||||
try {
|
||||
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 = null
|
||||
} catch (e) {
|
||||
console.error('Failed to mark unread', e)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStar = async () => {
|
||||
if (!props.message || !store.currentMailbox) return
|
||||
const newVal = props.message.is_starred ? 0 : 1
|
||||
try {
|
||||
await mailApi.updateMessage(store.currentMailbox, props.message.id, { is_starred: newVal })
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const sendInlineReply = async () => {
|
||||
if (!props.message || !store.currentMailbox || !replyText.value.trim()) return
|
||||
sendingReply.value = true
|
||||
try {
|
||||
await mailApi.sendMessage(store.currentMailbox, {
|
||||
to: props.message.from_address,
|
||||
subject: props.message.subject?.startsWith('Re:')
|
||||
? props.message.subject
|
||||
: `Re: ${props.message.subject || ''}`,
|
||||
text: replyText.value,
|
||||
html: replyText.value.replace(/\n/g, '<br>'),
|
||||
in_reply_to: props.message.message_id,
|
||||
})
|
||||
replyText.value = ''
|
||||
} catch (e) {
|
||||
console.error('Failed to send reply', e)
|
||||
} finally {
|
||||
sendingReply.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message" class="flex h-full flex-col">
|
||||
<div class="flex items-start p-4">
|
||||
<div class="flex items-start gap-4 text-sm">
|
||||
<Avatar :initials="message.from_name?.[0] || message.from_address?.[0] || '?'" />
|
||||
<div class="grid gap-1">
|
||||
<div class="font-semibold">{{ message.from_name }}</div>
|
||||
<div class="line-clamp-1 text-xs">{{ message.subject }}</div>
|
||||
<div class="line-clamp-1 text-xs">
|
||||
<span class="font-medium">From:</span> {{ message.from_address }}
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message" @click="trash">
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Move to trash
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
||||
<Separator orientation="vertical" class="mx-1 h-6" />
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message" @click="replyTo">
|
||||
<Reply class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Reply
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message" @click="replyAll">
|
||||
<ReplyAll class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Reply all
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
||||
<TooltipRoot :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message" @click="forwardMsg">
|
||||
<Forward class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent class="z-50 rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md">
|
||||
Forward
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" class="mx-2 h-6" />
|
||||
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!message">
|
||||
<MoreVertical class="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
class="z-50 min-w-[160px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent"
|
||||
@click="markUnread"
|
||||
>
|
||||
<MailOpen class="mr-2 size-4" />
|
||||
Mark as unread
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
class="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent"
|
||||
@click="toggleStar"
|
||||
>
|
||||
<Star class="mr-2 size-4" />
|
||||
{{ message?.is_starred ? 'Unstar' : 'Star' }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<template v-if="message">
|
||||
<div class="flex items-start p-4">
|
||||
<div class="flex items-start gap-4 text-sm">
|
||||
<Avatar :initials="message.from_name?.[0] || message.from_address?.[0] || '?'" />
|
||||
<div class="grid gap-1">
|
||||
<div class="font-semibold">{{ message.from_name || message.from_address }}</div>
|
||||
<div class="line-clamp-1 text-xs">{{ message.subject }}</div>
|
||||
<div class="line-clamp-1 text-xs">
|
||||
<span class="font-medium">Reply-To:</span> {{ message.from_address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.received_at" class="ml-auto text-xs text-muted-foreground">
|
||||
{{ format(new Date(message.received_at), 'PPpp') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.received_at" class="ml-auto text-xs text-muted-foreground">
|
||||
{{ format(new Date(message.received_at), 'PPpp') }}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex-1 overflow-y-auto whitespace-pre-wrap p-4 text-sm">
|
||||
<div v-if="message.html_body" v-html="safeHtml" class="prose max-w-none dark:prose-invert"></div>
|
||||
<div v-else>{{ message.text_body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div class="flex-1 overflow-y-auto p-4 text-sm">
|
||||
<div v-if="message.html_body" v-html="safeHtml" class="prose max-w-none dark:prose-invert"></div>
|
||||
<div v-else class="whitespace-pre-wrap">{{ message.text_body }}</div>
|
||||
</div>
|
||||
<AttachmentBar :attachments="message.attachments" />
|
||||
<Separator />
|
||||
<div class="p-4 flex gap-2">
|
||||
<Button @click="reply">Reply</Button>
|
||||
<Button variant="outline" @click="forward">Forward</Button>
|
||||
<Button variant="destructive" @click="trash">Trash</Button>
|
||||
|
||||
<AttachmentBar :attachments="message.attachments" />
|
||||
|
||||
<Separator class="mt-auto" />
|
||||
|
||||
<div class="p-4">
|
||||
<form @submit.prevent="sendInlineReply">
|
||||
<div class="grid gap-4">
|
||||
<Textarea
|
||||
v-model="replyText"
|
||||
class="p-4 min-h-[100px]"
|
||||
:placeholder="`Reply ${message.from_name || message.from_address}...`"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
class="ml-auto"
|
||||
:disabled="sendingReply || !replyText.trim()"
|
||||
>
|
||||
{{ sendingReply ? 'Sending...' : 'Send' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="flex flex-1 items-center justify-center p-8 text-muted-foreground">
|
||||
No message selected
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex h-full items-center justify-center p-8 text-muted-foreground">
|
||||
No message selected
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,39 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import {
|
||||
TabsRoot, TabsList, TabsTrigger, TabsContent,
|
||||
} from 'radix-vue'
|
||||
import {
|
||||
ScrollAreaRoot, ScrollAreaViewport, ScrollAreaScrollbar, ScrollAreaThumb,
|
||||
} from 'radix-vue'
|
||||
import { useMailStore } from '../../stores/mail'
|
||||
import MessageListItem from './MessageListItem.vue'
|
||||
import Separator from '../ui/Separator.vue'
|
||||
import Input from '../ui/Input.vue'
|
||||
|
||||
const store = useMailStore()
|
||||
const searchValue = ref('')
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const filteredMessages = computed(() => {
|
||||
const q = searchValue.value.trim().toLowerCase()
|
||||
if (!q) return store.messages
|
||||
return store.messages.filter((m: any) =>
|
||||
(m.from_name || '').toLowerCase().includes(q) ||
|
||||
(m.from_address || '').toLowerCase().includes(q) ||
|
||||
(m.subject || '').toLowerCase().includes(q) ||
|
||||
(m.text_body || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const unreadMessages = computed(() =>
|
||||
filteredMessages.value.filter((m: any) => !m.is_read)
|
||||
)
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
store.activeTab === 'unread' ? unreadMessages.value : filteredMessages.value
|
||||
)
|
||||
|
||||
const selectMessage = (msg: any) => {
|
||||
store.currentMessage = msg
|
||||
|
|
@ -10,10 +41,94 @@ const selectMessage = (msg: any) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 p-4 pt-0">
|
||||
<MessageListItem v-for="msg in store.messages" :key="msg.id" :message="msg" :selected="store.currentMessage?.id === msg.id" @click="selectMessage(msg)" />
|
||||
<div v-if="store.messages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No messages found.
|
||||
<TabsRoot v-model="store.activeTab" class="flex h-full flex-col">
|
||||
<div class="flex items-center px-4 py-2">
|
||||
<h1 class="text-xl font-bold">{{ store.currentFolder || 'Inbox' }}</h1>
|
||||
<TabsList class="ml-auto inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
class="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow"
|
||||
>
|
||||
All mail
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="unread"
|
||||
class="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow"
|
||||
>
|
||||
Unread
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Separator />
|
||||
<div class="bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-2 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input v-model="searchValue" placeholder="Search" class="pl-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" class="m-0 flex-1 overflow-hidden">
|
||||
<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 filteredMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="filteredMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No messages found.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical" class="flex touch-none select-none bg-transparent p-0.5 transition-colors w-2.5">
|
||||
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unread" class="m-0 flex-1 overflow-hidden">
|
||||
<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 unreadMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
:selected="store.currentMessage?.id === msg.id"
|
||||
@click="selectMessage(msg)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="unreadMessages.length === 0" class="p-8 text-center text-muted-foreground">
|
||||
No unread messages.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical" class="flex touch-none select-none bg-transparent p-0.5 transition-colors w-2.5">
|
||||
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-move,
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,48 @@
|
|||
<script setup lang="ts">
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { Paperclip } from 'lucide-vue-next'
|
||||
import { cn } from '../../lib/utils'
|
||||
import Badge from '../ui/Badge.vue'
|
||||
|
||||
defineProps({
|
||||
message: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false }
|
||||
selected: { type: Boolean, default: false },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :class="['flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent', selected ? 'bg-muted' : 'bg-background']">
|
||||
<button
|
||||
:class="cn(
|
||||
'flex flex-col items-start gap-2 rounded-lg border p-3 text-left text-sm transition-all hover:bg-accent',
|
||||
selected && 'bg-muted',
|
||||
)"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold">{{ message.from_name || message.from_address }}</div>
|
||||
<div class="text-xs text-muted-foreground" v-if="message.received_at">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-semibold">{{ message.from_name || message.from_address }}</div>
|
||||
<span v-if="!message.is_read" class="flex h-2 w-2 rounded-full bg-blue-600" />
|
||||
</div>
|
||||
<div
|
||||
:class="cn(
|
||||
'ml-auto text-xs',
|
||||
selected ? 'text-foreground' : 'text-muted-foreground',
|
||||
)"
|
||||
v-if="message.received_at"
|
||||
>
|
||||
{{ formatDistanceToNow(new Date(message.received_at), { addSuffix: true }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-medium">{{ message.subject }}</div>
|
||||
<div class="text-xs font-medium">{{ message.subject }}</div>
|
||||
</div>
|
||||
<div class="line-clamp-2 text-xs text-muted-foreground">
|
||||
{{ message.text_body?.substring(0, 100) || 'No content' }}
|
||||
{{ message.text_body?.substring(0, 300) || 'No content' }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2" v-if="message.has_attachments">
|
||||
<Badge variant="secondary">Attachment</Badge>
|
||||
<div v-if="message.has_attachments" class="flex items-center gap-1">
|
||||
<Badge variant="secondary" class="gap-1">
|
||||
<Paperclip class="size-3" />
|
||||
Attachment
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
|
|
|||
20
webmail/src/components/ui/Textarea.vue
Normal file
20
webmail/src/components/ui/Textarea.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script setup lang="ts">
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
class: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:value="modelValue"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
:class="cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useMailStore = defineStore('mail', () => {
|
||||
const mailboxes = ref<any[]>([])
|
||||
|
|
@ -11,11 +11,18 @@ export const useMailStore = defineStore('mail', () => {
|
|||
const isComposeOpen = ref(false)
|
||||
const composeDefaults = ref<{ to?: string; subject?: string; body?: string } | null>(null)
|
||||
const composeBody = ref('')
|
||||
const activeTab = ref<'all' | 'unread'>('all')
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
const unreadMessages = computed(() =>
|
||||
messages.value.filter((m: any) => !m.is_read)
|
||||
)
|
||||
|
||||
return {
|
||||
mailboxes, currentMailbox,
|
||||
folders, currentFolder,
|
||||
messages, currentMessage,
|
||||
isComposeOpen, composeDefaults, composeBody
|
||||
isComposeOpen, composeDefaults, composeBody,
|
||||
activeTab, isCollapsed, unreadMessages,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -36,16 +36,26 @@ watch(() => [store.currentMailbox, store.currentFolder], async ([addr, folder])
|
|||
})
|
||||
|
||||
watch(() => store.currentMessage, async (msg) => {
|
||||
if (!msg || msg.html_body !== undefined) return
|
||||
if (!msg || msg.attachments !== undefined) return
|
||||
try {
|
||||
const res = await mailApi.getMessage(store.currentMailbox, msg.id)
|
||||
store.currentMessage = res.data
|
||||
const fullMsg = res.data
|
||||
store.currentMessage = fullMsg
|
||||
|
||||
const idx = store.messages.findIndex((m: any) => m.id === msg.id)
|
||||
if (idx !== -1) {
|
||||
store.messages[idx] = res.data
|
||||
store.messages[idx] = fullMsg
|
||||
}
|
||||
|
||||
if (!fullMsg.is_read) {
|
||||
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 }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load message body', e)
|
||||
console.error('Failed to load message', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue