KI-AGENT: Ergänzt auf- und zuklappbare IMAP-Ordner in der E-Mail Übersicht. Elternordner bleiben initial sichtbar, ausgewählte Unterordner öffnen automatisch ihre übergeordneten Ordner.
693 lines
22 KiB
Vue
693 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { format, isToday, isYesterday } from "date-fns"
|
|
import { de as deLocale } from "date-fns/locale"
|
|
|
|
type EmailAccount = {
|
|
id: string
|
|
email: string
|
|
imapHost?: string | null
|
|
hasPassword?: boolean
|
|
}
|
|
|
|
type EmailMailbox = {
|
|
id: string
|
|
path: string
|
|
name: string
|
|
delimiter?: string | null
|
|
specialUse?: string | null
|
|
unseen?: number
|
|
}
|
|
|
|
type EmailMailboxNode = {
|
|
mailbox: EmailMailbox
|
|
children: EmailMailboxNode[]
|
|
}
|
|
|
|
type EmailAddress = {
|
|
name?: string | null
|
|
address?: string | null
|
|
}
|
|
|
|
type EmailMessage = {
|
|
id: string
|
|
accountId: string
|
|
mailboxPath: string
|
|
subject?: string | null
|
|
from?: EmailAddress[]
|
|
to?: EmailAddress[]
|
|
cc?: EmailAddress[]
|
|
preview?: string | null
|
|
seen?: boolean
|
|
flagged?: boolean
|
|
hasAttachments?: boolean
|
|
receivedAt?: string | null
|
|
sentAt?: string | null
|
|
body?: {
|
|
text?: string | null
|
|
html?: string | null
|
|
} | null
|
|
attachments?: Array<{
|
|
id: string
|
|
filename?: string | null
|
|
contentType?: string | null
|
|
size?: number | null
|
|
}>
|
|
}
|
|
|
|
const { $api } = useNuxtApp()
|
|
const toast = useToast()
|
|
|
|
const accounts = ref<EmailAccount[]>([])
|
|
const mailboxes = ref<EmailMailbox[]>([])
|
|
const messages = ref<EmailMessage[]>([])
|
|
const selectedAccountId = ref("")
|
|
const selectedMailboxPath = ref("INBOX")
|
|
const selectedMessage = ref<EmailMessage | null>(null)
|
|
const search = ref("")
|
|
const loadingAccounts = ref(true)
|
|
const loadingMailboxes = ref(false)
|
|
const loadingMessages = ref(false)
|
|
const loadingMessage = ref(false)
|
|
const syncing = ref(false)
|
|
const expandedMailboxPaths = ref<string[]>([])
|
|
|
|
const selectedAccount = computed(() =>
|
|
accounts.value.find((account) => account.id === selectedAccountId.value) || null
|
|
)
|
|
|
|
const selectedMailbox = computed(() =>
|
|
mailboxes.value.find((mailbox) => mailbox.path === selectedMailboxPath.value) || null
|
|
)
|
|
|
|
const mailboxPriority = (mailbox: EmailMailbox) => {
|
|
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return 0
|
|
if (mailbox.specialUse === "\\Sent") return 1
|
|
if (mailbox.specialUse === "\\Drafts") return 2
|
|
if (mailbox.specialUse === "\\Archive") return 3
|
|
if (mailbox.specialUse === "\\Junk") return 4
|
|
if (mailbox.specialUse === "\\Trash") return 5
|
|
return 9
|
|
}
|
|
|
|
const mailboxDelimiter = (mailbox: EmailMailbox) => {
|
|
if (mailbox.delimiter) return mailbox.delimiter
|
|
if (mailbox.path.includes("/")) return "/"
|
|
if (mailbox.path.includes(".")) return "."
|
|
return "/"
|
|
}
|
|
|
|
const parentMailboxPath = (mailbox: EmailMailbox, mailboxPaths: Set<string>) => {
|
|
const delimiter = mailboxDelimiter(mailbox)
|
|
const parts = mailbox.path.split(delimiter)
|
|
|
|
while (parts.length > 1) {
|
|
parts.pop()
|
|
const candidate = parts.join(delimiter)
|
|
if (mailboxPaths.has(candidate)) return candidate
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const sortMailboxNodes = (nodes: EmailMailboxNode[]) => {
|
|
nodes.sort((first, second) =>
|
|
mailboxPriority(first.mailbox) - mailboxPriority(second.mailbox)
|
|
|| mailboxLabel(first.mailbox).localeCompare(mailboxLabel(second.mailbox))
|
|
)
|
|
nodes.forEach((node) => sortMailboxNodes(node.children))
|
|
return nodes
|
|
}
|
|
|
|
const mailboxTree = computed(() => {
|
|
const mailboxPaths = new Set(mailboxes.value.map((mailbox) => mailbox.path))
|
|
const nodes = new Map<string, EmailMailboxNode>()
|
|
const roots: EmailMailboxNode[] = []
|
|
|
|
mailboxes.value.forEach((mailbox) => {
|
|
nodes.set(mailbox.path, { mailbox, children: [] })
|
|
})
|
|
|
|
mailboxes.value.forEach((mailbox) => {
|
|
const node = nodes.get(mailbox.path)
|
|
if (!node) return
|
|
|
|
const parentPath = parentMailboxPath(mailbox, mailboxPaths)
|
|
const parent = parentPath ? nodes.get(parentPath) : null
|
|
|
|
if (parent) {
|
|
parent.children.push(node)
|
|
} else {
|
|
roots.push(node)
|
|
}
|
|
})
|
|
|
|
return sortMailboxNodes(roots)
|
|
})
|
|
|
|
const mailboxRows = computed(() => {
|
|
const rows: Array<{ mailbox: EmailMailbox; depth: number; hasChildren: boolean }> = []
|
|
const append = (node: EmailMailboxNode, depth: number) => {
|
|
const expanded = expandedMailboxPaths.value.includes(node.mailbox.path)
|
|
rows.push({ mailbox: node.mailbox, depth, hasChildren: node.children.length > 0 })
|
|
if (expanded) {
|
|
node.children.forEach((child) => append(child, depth + 1))
|
|
}
|
|
}
|
|
|
|
mailboxTree.value.forEach((node) => append(node, 0))
|
|
return rows
|
|
})
|
|
|
|
const filteredMessages = computed(() => {
|
|
const needle = search.value.trim().toLowerCase()
|
|
if (!needle) return messages.value
|
|
|
|
return messages.value.filter((message) => [
|
|
message.subject,
|
|
message.preview,
|
|
formatAddressList(message.from),
|
|
formatAddressList(message.to),
|
|
].some((value) => String(value || "").toLowerCase().includes(needle)))
|
|
})
|
|
|
|
const mailboxIcon = (mailbox: EmailMailbox) => {
|
|
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return "i-heroicons-inbox"
|
|
if (mailbox.specialUse === "\\Sent") return "i-heroicons-paper-airplane"
|
|
if (mailbox.specialUse === "\\Drafts") return "i-heroicons-document"
|
|
if (mailbox.specialUse === "\\Archive") return "i-heroicons-archive-box"
|
|
if (mailbox.specialUse === "\\Junk") return "i-heroicons-no-symbol"
|
|
if (mailbox.specialUse === "\\Trash") return "i-heroicons-trash"
|
|
return "i-heroicons-folder"
|
|
}
|
|
|
|
const mailboxLabel = (mailbox: EmailMailbox) => {
|
|
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return "Posteingang"
|
|
if (mailbox.specialUse === "\\Sent") return "Gesendet"
|
|
if (mailbox.specialUse === "\\Drafts") return "Entwürfe"
|
|
if (mailbox.specialUse === "\\Archive") return "Archiv"
|
|
if (mailbox.specialUse === "\\Junk") return "Spam"
|
|
if (mailbox.specialUse === "\\Trash") return "Papierkorb"
|
|
return mailbox.name || mailbox.path
|
|
}
|
|
|
|
const isMailboxExpanded = (mailbox: EmailMailbox) => expandedMailboxPaths.value.includes(mailbox.path)
|
|
|
|
const expandMailboxPath = (path: string) => {
|
|
if (!expandedMailboxPaths.value.includes(path)) {
|
|
expandedMailboxPaths.value = [...expandedMailboxPaths.value, path]
|
|
}
|
|
}
|
|
|
|
const collapseMailboxPath = (path: string) => {
|
|
expandedMailboxPaths.value = expandedMailboxPaths.value.filter((item) => item !== path)
|
|
}
|
|
|
|
const toggleMailboxExpanded = (mailbox: EmailMailbox) => {
|
|
if (isMailboxExpanded(mailbox)) {
|
|
collapseMailboxPath(mailbox.path)
|
|
} else {
|
|
expandMailboxPath(mailbox.path)
|
|
}
|
|
}
|
|
|
|
const expandMailboxAncestors = (path: string) => {
|
|
const mailbox = mailboxes.value.find((item) => item.path === path)
|
|
if (!mailbox) return
|
|
|
|
const delimiter = mailboxDelimiter(mailbox)
|
|
const parts = path.split(delimiter)
|
|
|
|
while (parts.length > 1) {
|
|
parts.pop()
|
|
expandMailboxPath(parts.join(delimiter))
|
|
}
|
|
}
|
|
|
|
const resetExpandedMailboxes = () => {
|
|
const roots = mailboxTree.value.map((node) => node.mailbox.path)
|
|
expandedMailboxPaths.value = Array.from(new Set([
|
|
...roots,
|
|
...expandedMailboxPaths.value.filter((path) => mailboxes.value.some((mailbox) => mailbox.path === path)),
|
|
]))
|
|
}
|
|
|
|
const formatAddress = (address?: EmailAddress | null) => {
|
|
if (!address) return "Unbekannt"
|
|
return address.name || address.address || "Unbekannt"
|
|
}
|
|
|
|
const formatAddressList = (addresses?: EmailAddress[] | null) => {
|
|
return (addresses || []).map(formatAddress).filter(Boolean).join(", ")
|
|
}
|
|
|
|
const formatMessageDate = (value?: string | null) => {
|
|
if (!value) return ""
|
|
const date = new Date(value)
|
|
if (isToday(date)) return format(date, "HH:mm")
|
|
if (isYesterday(date)) return "Gestern"
|
|
return format(date, "dd. MMM", { locale: deLocale })
|
|
}
|
|
|
|
const formatDetailDate = (value?: string | null) => {
|
|
if (!value) return ""
|
|
return format(new Date(value), "dd. MMMM yyyy, HH:mm", { locale: deLocale })
|
|
}
|
|
|
|
const formatAttachmentSize = (size?: number | null) => {
|
|
if (!size) return ""
|
|
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`
|
|
return `${(size / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
|
|
const iframeContent = computed(() => {
|
|
const html = selectedMessage.value?.body?.html
|
|
if (html) {
|
|
return `<!doctype html><html><head><base target="_blank"><style>body{font-family:Arial,sans-serif;font-size:14px;line-height:1.5;color:#111827;margin:0;padding:0}img{max-width:100%;height:auto}table{max-width:100%}</style></head><body>${html}</body></html>`
|
|
}
|
|
|
|
const text = selectedMessage.value?.body?.text || selectedMessage.value?.preview || ""
|
|
return `<!doctype html><html><head><style>body{font-family:Arial,sans-serif;font-size:14px;line-height:1.5;color:#111827;margin:0;padding:0;white-space:pre-wrap}</style></head><body>${escapeHtml(text)}</body></html>`
|
|
})
|
|
|
|
function escapeHtml(value: string) {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
}
|
|
|
|
async function loadAccounts() {
|
|
loadingAccounts.value = true
|
|
try {
|
|
accounts.value = await $api("/api/email/accounts")
|
|
selectedAccountId.value = accounts.value[0]?.id || ""
|
|
if (selectedAccountId.value) {
|
|
await loadMailboxes()
|
|
}
|
|
} finally {
|
|
loadingAccounts.value = false
|
|
}
|
|
}
|
|
|
|
async function loadMailboxes() {
|
|
if (!selectedAccountId.value) return
|
|
|
|
loadingMailboxes.value = true
|
|
selectedMessage.value = null
|
|
|
|
try {
|
|
mailboxes.value = await $api(`/api/email/accounts/${selectedAccountId.value}/mailboxes`)
|
|
resetExpandedMailboxes()
|
|
const inbox = mailboxes.value.find((mailbox) => mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX")
|
|
selectedMailboxPath.value = inbox?.path || mailboxes.value[0]?.path || "INBOX"
|
|
expandMailboxAncestors(selectedMailboxPath.value)
|
|
await loadMessages()
|
|
} finally {
|
|
loadingMailboxes.value = false
|
|
}
|
|
}
|
|
|
|
async function loadMessages() {
|
|
if (!selectedAccountId.value || !selectedMailboxPath.value) return
|
|
|
|
loadingMessages.value = true
|
|
selectedMessage.value = null
|
|
|
|
try {
|
|
messages.value = await $api(`/api/email/accounts/${selectedAccountId.value}/messages`, {
|
|
query: {
|
|
mailbox: selectedMailboxPath.value,
|
|
limit: 100,
|
|
},
|
|
})
|
|
|
|
if (messages.value.length) {
|
|
await selectMessage(messages.value[0])
|
|
}
|
|
} finally {
|
|
loadingMessages.value = false
|
|
}
|
|
}
|
|
|
|
async function selectMailbox(mailbox: EmailMailbox) {
|
|
selectedMailboxPath.value = mailbox.path
|
|
expandMailboxAncestors(mailbox.path)
|
|
await loadMessages()
|
|
}
|
|
|
|
async function selectMessage(message: EmailMessage) {
|
|
loadingMessage.value = true
|
|
try {
|
|
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
|
if (!message.seen) {
|
|
await setMessageSeen(message.id, true)
|
|
}
|
|
} finally {
|
|
loadingMessage.value = false
|
|
}
|
|
}
|
|
|
|
async function setMessageSeen(messageId: string, seen: boolean) {
|
|
const previousMessage = messages.value.find((message) => message.id === messageId)
|
|
|
|
const res = await $api(`/api/email/messages/${messageId}/read`, {
|
|
method: "POST",
|
|
body: { seen },
|
|
})
|
|
|
|
messages.value = messages.value.map((message) =>
|
|
message.id === messageId ? { ...message, seen } : message
|
|
)
|
|
|
|
if (selectedMessage.value?.id === messageId) {
|
|
selectedMessage.value = {
|
|
...selectedMessage.value,
|
|
...(res.message || {}),
|
|
seen,
|
|
}
|
|
}
|
|
|
|
if (previousMessage && previousMessage.seen !== seen) {
|
|
mailboxes.value = mailboxes.value.map((mailbox) => {
|
|
if (mailbox.path !== previousMessage.mailboxPath) return mailbox
|
|
const delta = seen ? -1 : 1
|
|
return {
|
|
...mailbox,
|
|
unseen: Math.max(0, Number(mailbox.unseen || 0) + delta),
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
async function syncAccount() {
|
|
if (!selectedAccountId.value) return
|
|
|
|
syncing.value = true
|
|
try {
|
|
const res = await $api(`/api/email/accounts/${selectedAccountId.value}/sync`, {
|
|
method: "POST",
|
|
body: {
|
|
mailbox: selectedMailboxPath.value,
|
|
limit: 100,
|
|
},
|
|
})
|
|
|
|
toast.add({
|
|
title: "E-Mails synchronisiert",
|
|
description: `${res.synced?.[0]?.fetched || 0} neue Nachrichten geladen`,
|
|
color: "success",
|
|
})
|
|
|
|
await loadMailboxes()
|
|
} catch (err: any) {
|
|
toast.add({
|
|
title: "Synchronisation fehlgeschlagen",
|
|
description: err?.data?.error || err?.message || "Das Postfach konnte nicht synchronisiert werden.",
|
|
color: "error",
|
|
})
|
|
} finally {
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
watch(selectedAccountId, async (next, previous) => {
|
|
if (next && previous && next !== previous) {
|
|
await loadMailboxes()
|
|
}
|
|
})
|
|
|
|
onMounted(loadAccounts)
|
|
</script>
|
|
|
|
<template>
|
|
<UPage>
|
|
<UDashboardNavbar>
|
|
<template #title>
|
|
<div class="flex items-center gap-2">
|
|
<UIcon name="i-heroicons-envelope" class="size-5 text-primary" />
|
|
<span class="text-lg font-semibold">E-Mail</span>
|
|
</div>
|
|
</template>
|
|
|
|
<template #right>
|
|
<UButton
|
|
icon="i-heroicons-pencil-square"
|
|
size="sm"
|
|
@click="navigateTo('/email/new')"
|
|
>
|
|
Neue E-Mail
|
|
</UButton>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<UDashboardToolbar>
|
|
<template #left>
|
|
<UInput
|
|
v-model="search"
|
|
icon="i-heroicons-magnifying-glass"
|
|
size="sm"
|
|
class="w-72"
|
|
placeholder="E-Mails durchsuchen"
|
|
/>
|
|
</template>
|
|
|
|
<template #right>
|
|
<UButton
|
|
icon="i-heroicons-cog-6-tooth"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="navigateTo('/settings/emailaccounts')"
|
|
/>
|
|
<UButton
|
|
icon="i-heroicons-arrow-path"
|
|
color="neutral"
|
|
variant="soft"
|
|
size="sm"
|
|
:loading="syncing"
|
|
:disabled="!selectedAccountId"
|
|
@click="syncAccount"
|
|
>
|
|
Aktualisieren
|
|
</UButton>
|
|
</template>
|
|
</UDashboardToolbar>
|
|
|
|
<div class="flex h-[calc(100vh-150px)] overflow-hidden">
|
|
<aside class="w-72 shrink-0 border-r border-(--ui-border) bg-(--ui-bg) overflow-y-auto">
|
|
<div class="border-b border-(--ui-border) p-3">
|
|
<USkeleton v-if="loadingAccounts" class="h-9" />
|
|
<USelectMenu
|
|
v-else-if="accounts.length"
|
|
v-model="selectedAccountId"
|
|
:items="accounts"
|
|
label-key="email"
|
|
value-key="id"
|
|
class="w-full"
|
|
/>
|
|
<UButton
|
|
v-else
|
|
icon="i-heroicons-plus"
|
|
block
|
|
@click="navigateTo('/settings/emailaccounts/create')"
|
|
>
|
|
E-Mail Konto
|
|
</UButton>
|
|
</div>
|
|
|
|
<div v-if="loadingMailboxes" class="space-y-2 p-3">
|
|
<USkeleton v-for="i in 6" :key="i" class="h-9" />
|
|
</div>
|
|
|
|
<div v-else-if="!accounts.length" class="p-4 text-sm text-dimmed">
|
|
Lege zuerst ein E-Mail Konto an.
|
|
</div>
|
|
|
|
<nav v-else class="p-2">
|
|
<button
|
|
v-for="row in mailboxRows"
|
|
:key="row.mailbox.id"
|
|
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
|
:class="selectedMailboxPath === row.mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)'"
|
|
:style="{ paddingLeft: `${12 + row.depth * 18}px` }"
|
|
@click="selectMailbox(row.mailbox)"
|
|
>
|
|
<UIcon
|
|
v-if="row.hasChildren"
|
|
:name="isMailboxExpanded(row.mailbox) ? 'i-heroicons-chevron-down' : 'i-heroicons-chevron-right'"
|
|
class="size-3 shrink-0 text-dimmed"
|
|
@click.stop="toggleMailboxExpanded(row.mailbox)"
|
|
/>
|
|
<span
|
|
v-else
|
|
class="size-3 shrink-0"
|
|
/>
|
|
<UIcon :name="mailboxIcon(row.mailbox)" class="size-4 shrink-0" />
|
|
<span class="min-w-0 flex-1 truncate">{{ mailboxLabel(row.mailbox) }}</span>
|
|
<UBadge
|
|
v-if="row.mailbox.unseen"
|
|
size="xs"
|
|
color="primary"
|
|
variant="soft"
|
|
>
|
|
{{ row.mailbox.unseen }}
|
|
</UBadge>
|
|
</button>
|
|
</nav>
|
|
</aside>
|
|
|
|
<section class="w-[390px] shrink-0 border-r border-(--ui-border) bg-(--ui-bg) overflow-y-auto">
|
|
<div class="flex h-12 items-center justify-between border-b border-(--ui-border) px-4">
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-medium">
|
|
{{ selectedMailbox ? mailboxLabel(selectedMailbox) : 'Postfach' }}
|
|
</p>
|
|
<p class="text-xs text-dimmed">
|
|
{{ filteredMessages.length }} Nachrichten
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loadingMessages" class="space-y-2 p-3">
|
|
<USkeleton v-for="i in 8" :key="i" class="h-20" />
|
|
</div>
|
|
|
|
<TableEmptyState
|
|
v-else-if="!filteredMessages.length"
|
|
label="Keine E-Mails anzuzeigen"
|
|
/>
|
|
|
|
<div v-else class="divide-y divide-(--ui-border)">
|
|
<button
|
|
v-for="message in filteredMessages"
|
|
:key="message.id"
|
|
class="block w-full border-l-2 px-4 py-3 text-left transition-colors"
|
|
:class="selectedMessage?.id === message.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-(--ui-bg-muted)'"
|
|
@click="selectMessage(message)"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<p class="min-w-0 truncate text-sm" :class="message.seen ? 'font-medium' : 'font-semibold'">
|
|
{{ formatAddress(message.from?.[0]) }}
|
|
</p>
|
|
<span class="shrink-0 text-xs text-dimmed">
|
|
{{ formatMessageDate(message.receivedAt || message.sentAt) }}
|
|
</span>
|
|
</div>
|
|
<p class="mt-1 truncate text-sm" :class="message.seen ? 'text-highlighted' : 'font-semibold text-highlighted'">
|
|
{{ message.subject || '(kein Betreff)' }}
|
|
</p>
|
|
<div class="mt-1 flex items-center gap-2 text-xs text-dimmed">
|
|
<UIcon
|
|
v-if="message.hasAttachments"
|
|
name="i-heroicons-paper-clip"
|
|
class="size-3.5"
|
|
/>
|
|
<p class="min-w-0 flex-1 truncate">
|
|
{{ message.preview || 'Keine Vorschau' }}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
<main class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)">
|
|
<div v-if="loadingMessage" class="space-y-4 p-6">
|
|
<USkeleton class="h-8 w-2/3" />
|
|
<USkeleton class="h-5 w-1/3" />
|
|
<USkeleton class="h-80" />
|
|
</div>
|
|
|
|
<div v-else-if="selectedMessage" class="flex min-h-full flex-col">
|
|
<div class="border-b border-(--ui-border) px-6 py-4">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="min-w-0">
|
|
<h1 class="truncate text-xl font-semibold text-highlighted">
|
|
{{ selectedMessage.subject || '(kein Betreff)' }}
|
|
</h1>
|
|
<p class="mt-1 text-sm text-dimmed">
|
|
{{ formatDetailDate(selectedMessage.receivedAt || selectedMessage.sentAt) }}
|
|
</p>
|
|
</div>
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<UButton
|
|
:icon="selectedMessage.seen ? 'i-heroicons-envelope' : 'i-heroicons-envelope-open'"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="setMessageSeen(selectedMessage.id, !selectedMessage.seen)"
|
|
>
|
|
{{ selectedMessage.seen ? 'Ungelesen' : 'Gelesen' }}
|
|
</UButton>
|
|
<UButton
|
|
icon="i-heroicons-arrow-uturn-left"
|
|
color="neutral"
|
|
variant="soft"
|
|
size="sm"
|
|
@click="navigateTo(`/email/new?to=${encodeURIComponent(formatAddressList(selectedMessage.from))}&subject=${encodeURIComponent(`Re: ${selectedMessage.subject || ''}`)}`)"
|
|
>
|
|
Antworten
|
|
</UButton>
|
|
<UButton
|
|
icon="i-heroicons-arrow-uturn-right"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
@click="navigateTo(`/email/new?subject=${encodeURIComponent(`Fw: ${selectedMessage.subject || ''}`)}`)"
|
|
>
|
|
Weiterleiten
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-4 grid gap-1 text-sm">
|
|
<p>
|
|
<span class="text-dimmed">Von:</span>
|
|
<span class="ml-2">{{ formatAddressList(selectedMessage.from) || 'Unbekannt' }}</span>
|
|
</p>
|
|
<p>
|
|
<span class="text-dimmed">An:</span>
|
|
<span class="ml-2">{{ formatAddressList(selectedMessage.to) || selectedAccount?.email }}</span>
|
|
</p>
|
|
<p v-if="selectedMessage.cc?.length">
|
|
<span class="text-dimmed">Kopie:</span>
|
|
<span class="ml-2">{{ formatAddressList(selectedMessage.cc) }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="selectedMessage.attachments?.length" class="mt-4 flex flex-wrap gap-2">
|
|
<div
|
|
v-for="attachment in selectedMessage.attachments"
|
|
:key="attachment.id"
|
|
class="flex items-center gap-2 rounded-md border border-(--ui-border) px-3 py-2 text-sm"
|
|
>
|
|
<UIcon name="i-heroicons-paper-clip" class="size-4 text-dimmed" />
|
|
<span>{{ attachment.filename || 'Anhang' }}</span>
|
|
<span class="text-xs text-dimmed">{{ formatAttachmentSize(attachment.size) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 p-6">
|
|
<iframe
|
|
title="E-Mail Inhalt"
|
|
class="h-[calc(100vh-360px)] min-h-[420px] w-full"
|
|
sandbox="allow-popups allow-popups-to-escape-sandbox"
|
|
:srcdoc="iframeContent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="flex h-full items-center justify-center p-6 text-center">
|
|
<div>
|
|
<UIcon name="i-heroicons-envelope-open" class="mx-auto mb-3 size-10 text-dimmed" />
|
|
<p class="font-medium">Keine E-Mail ausgewählt</p>
|
|
<p class="mt-1 text-sm text-dimmed">Wähle links eine Nachricht aus oder synchronisiere dein Postfach.</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
</UPage>
|
|
</template>
|