E-Mail Oberfläche im Outlook-Stil ergänzen

KI-AGENT: Ergänzt eine dreispaltige E-Mail Arbeitsansicht mit Konto- und Ordnerliste, Nachrichtenliste, Lesebereich, Suche, Aktualisierung und Composer-Verknüpfung. Die Navigation zeigt nun auf die neue E-Mail Übersicht.
This commit is contained in:
2026-05-23 20:07:54 +02:00
parent 21e2bc2755
commit 347319aee3
2 changed files with 524 additions and 2 deletions

View File

@@ -0,0 +1,523 @@
<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
specialUse?: string | null
unseen?: number
}
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 selectedAccount = computed(() =>
accounts.value.find((account) => account.id === selectedAccountId.value) || null
)
const selectedMailbox = computed(() =>
mailboxes.value.find((mailbox) => mailbox.path === selectedMailboxPath.value) || null
)
const sortedMailboxes = computed(() => {
const priority = (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
}
return [...mailboxes.value].sort((first, second) =>
priority(first) - priority(second) || first.name.localeCompare(second.name)
)
})
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 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
loadingMailboxes.value = true
selectedMessage.value = null
try {
mailboxes.value = await $api(`/api/email/accounts/${selectedAccountId.value}/mailboxes`)
const inbox = mailboxes.value.find((mailbox) => mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX")
selectedMailboxPath.value = inbox?.path || mailboxes.value[0]?.path || "INBOX"
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
await loadMessages()
}
async function selectMessage(message: EmailMessage) {
loadingMessage.value = true
try {
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
} finally {
loadingMessage.value = false
}
}
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="mailbox in sortedMailboxes"
:key="mailbox.id"
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors"
:class="selectedMailboxPath === mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)'"
@click="selectMailbox(mailbox)"
>
<UIcon :name="mailboxIcon(mailbox)" class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ mailboxLabel(mailbox) }}</span>
<UBadge
v-if="mailbox.unseen"
size="xs"
color="primary"
variant="soft"
>
{{ 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="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>