webmail - bugfixes - refinement - attachment support

This commit is contained in:
ChrispyBacon-dev 2026-04-06 16:49:21 +02:00
parent 2a91b438e4
commit b3d77c659b
11 changed files with 746 additions and 124 deletions

View file

@ -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),
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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,
}
})
})

View file

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