Files
FEDEO/frontend/pages/email/index.vue
florianfederspiel d45cefbc20 E-Mail Anhang-Download über API-Client laden
KI-AGENT: Der Attachment-Download nutzt jetzt den zentralen API-Client mit apiBase, damit keine Frontend-HTML-Seite mehr als PDF gespeichert wird.
2026-05-23 20:54:29 +02:00

878 lines
29 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 syncedMailboxPaths = ref<string[]>([])
const actionLoading = ref("")
const moveTargetMailboxPath = ref("")
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 moveMailboxOptions = computed(() =>
mailboxes.value
.filter((mailbox) => mailbox.path !== selectedMessage.value?.mailboxPath)
.map((mailbox) => ({
label: mailboxLabel(mailbox),
value: mailbox.path,
}))
)
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
}
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
const previousMailboxPath = selectedMailboxPath.value
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")
const previousMailbox = mailboxes.value.find((mailbox) => mailbox.path === previousMailboxPath)
selectedMailboxPath.value = previousMailbox?.path || inbox?.path || mailboxes.value[0]?.path || "INBOX"
expandMailboxAncestors(selectedMailboxPath.value)
await loadMessages()
} finally {
loadingMailboxes.value = false
}
}
async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
if (!selectedAccountId.value || !selectedMailboxPath.value) return
loadingMessages.value = true
selectedMessage.value = null
try {
messages.value = await fetchMessages(selectedMailboxPath.value)
if (!messages.value.length && options.syncIfEmpty && !syncedMailboxPaths.value.includes(selectedMailboxPath.value)) {
await syncSelectedMailbox({ silent: true, reloadMailboxes: false })
syncedMailboxPaths.value = [...syncedMailboxPaths.value, selectedMailboxPath.value]
messages.value = await fetchMessages(selectedMailboxPath.value)
}
if (messages.value.length) {
await selectMessage(messages.value[0])
}
} finally {
loadingMessages.value = false
}
}
async function fetchMessages(mailboxPath: string) {
return await $api(`/api/email/accounts/${selectedAccountId.value}/messages`, {
query: {
mailbox: mailboxPath,
limit: 100,
},
})
}
async function selectMailbox(mailbox: EmailMailbox) {
selectedMailboxPath.value = mailbox.path
expandMailboxAncestors(mailbox.path)
await loadMessages({ syncIfEmpty: true })
}
async function selectMessage(message: EmailMessage) {
loadingMessage.value = true
try {
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
moveTargetMailboxPath.value = ""
if (!message.seen) {
await setMessageSeen(message.id, true)
}
} finally {
loadingMessage.value = false
}
}
function removeMessageFromCurrentList(messageId: string) {
const currentIndex = messages.value.findIndex((message) => message.id === messageId)
messages.value = messages.value.filter((message) => message.id !== messageId)
const nextMessage = messages.value[currentIndex] || messages.value[currentIndex - 1] || null
selectedMessage.value = null
if (nextMessage) {
selectMessage(nextMessage)
}
}
async function archiveSelectedMessage() {
if (!selectedMessage.value) return
const messageId = selectedMessage.value.id
actionLoading.value = "archive"
try {
await $api(`/api/email/messages/${messageId}/archive`, { method: "POST" })
removeMessageFromCurrentList(messageId)
toast.add({ title: "E-Mail archiviert", color: "success" })
} catch (err: any) {
toast.add({
title: "Archivieren fehlgeschlagen",
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht archiviert werden.",
color: "error",
})
} finally {
actionLoading.value = ""
}
}
async function deleteSelectedMessage() {
if (!selectedMessage.value) return
const messageId = selectedMessage.value.id
actionLoading.value = "delete"
try {
await $api(`/api/email/messages/${messageId}`, { method: "DELETE" })
removeMessageFromCurrentList(messageId)
toast.add({ title: "E-Mail gelöscht", color: "success" })
} catch (err: any) {
toast.add({
title: "Löschen fehlgeschlagen",
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht gelöscht werden.",
color: "error",
})
} finally {
actionLoading.value = ""
}
}
async function moveSelectedMessage() {
if (!selectedMessage.value || !moveTargetMailboxPath.value) return
const messageId = selectedMessage.value.id
actionLoading.value = "move"
try {
await $api(`/api/email/messages/${messageId}/move`, {
method: "POST",
body: { mailbox: moveTargetMailboxPath.value },
})
removeMessageFromCurrentList(messageId)
toast.add({ title: "E-Mail verschoben", color: "success" })
moveTargetMailboxPath.value = ""
} catch (err: any) {
toast.add({
title: "Verschieben fehlgeschlagen",
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht verschoben werden.",
color: "error",
})
} finally {
actionLoading.value = ""
}
}
async function downloadAttachment(attachment: NonNullable<EmailMessage["attachments"]>[number]) {
actionLoading.value = `attachment-${attachment.id}`
try {
const response = await $api.raw(`/api/email/attachments/${attachment.id}/download`, {
responseType: "arrayBuffer",
})
const contentType = response.headers.get("content-type") || attachment.contentType || "application/octet-stream"
const blob = new Blob([response._data as ArrayBuffer], { type: contentType })
const disposition = response.headers.get("content-disposition") || ""
const dispositionFilename = disposition.match(/filename="([^"]+)"/)?.[1]
const filename = dispositionFilename || attachment.filename || "anhang"
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (err: any) {
toast.add({
title: "Download fehlgeschlagen",
description: err?.data?.error || err?.message || "Der Anhang konnte nicht geladen werden.",
color: "error",
})
} finally {
actionLoading.value = ""
}
}
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
await syncSelectedMailbox({ silent: false, reloadMailboxes: true })
}
async function syncSelectedMailbox(options: { silent: boolean; reloadMailboxes: boolean }) {
syncing.value = true
try {
const res = await $api(`/api/email/accounts/${selectedAccountId.value}/sync`, {
method: "POST",
body: {
mailbox: selectedMailboxPath.value,
limit: 100,
},
})
if (!options.silent) {
toast.add({
title: "E-Mails synchronisiert",
description: `${res.synced?.[0]?.fetched || 0} neue Nachrichten geladen`,
color: "success",
})
}
if (options.reloadMailboxes) {
await loadMailboxes()
}
} catch (err: any) {
if (!options.silent) {
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">
<USelectMenu
v-model="moveTargetMailboxPath"
:items="moveMailboxOptions"
value-key="value"
label-key="label"
size="sm"
class="w-48"
placeholder="Verschieben nach"
/>
<UButton
icon="i-heroicons-folder-arrow-down"
color="neutral"
variant="soft"
size="sm"
:loading="actionLoading === 'move'"
:disabled="!moveTargetMailboxPath"
@click="moveSelectedMessage"
/>
<UButton
icon="i-heroicons-archive-box-arrow-down"
color="neutral"
variant="ghost"
size="sm"
:loading="actionLoading === 'archive'"
@click="archiveSelectedMessage"
/>
<UButton
icon="i-heroicons-trash"
color="error"
variant="ghost"
size="sm"
:loading="actionLoading === 'delete'"
@click="deleteSelectedMessage"
/>
<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">
<button
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-left text-sm hover:bg-(--ui-bg-muted)"
@click="downloadAttachment(attachment)"
>
<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>
<UIcon
:name="actionLoading === `attachment-${attachment.id}` ? 'i-heroicons-arrow-path' : 'i-heroicons-arrow-down-tray'"
class="size-4 text-dimmed"
:class="actionLoading === `attachment-${attachment.id}` ? 'animate-spin' : ''"
/>
</button>
</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>