Files
FEDEO/frontend/pages/email/index.vue
flfeders 1c39b69513
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 46s
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 21s
Build and Push Docker Images / build-docs (push) Successful in 1m21s
perf(email): update message actions optimistically
2026-09-08 10:18:00 +02:00

1117 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
displayName?: string
credentialsReadable?: boolean
imapHost?: string | null
hasPassword?: boolean
}
type EmailMailbox = {
id: string
path: string
name: string
delimiter?: string | null
specialUse?: string | null
exists?: number
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
}>
entityLinks?: Array<{
id: string
entityType: string
entityId: number
entityName: string
entityTypeLabel: string
}>
}
const { $api } = useNuxtApp()
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
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 checkedMessageIds = ref<string[]>([])
const bulkAction = ref("")
const bulkProgress = ref(0)
const bulkTotal = ref(0)
const pendingReadIds = ref<string[]>([])
let messageRequest = 0
const bulkMoveTarget = ref("")
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 draggedMessageId = ref<string | null>(null)
const dragOverMailboxPath = ref<string | null>(null)
let deepLinkApplied = 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 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 checkedMessages = computed(() =>
filteredMessages.value.filter((message) => checkedMessageIds.value.includes(message.id))
)
const allMessagesChecked = computed(() =>
checkedMessages.value.length > 0 && checkedMessages.value.length === filteredMessages.value.length
)
const bulkDisabled = computed(() => Boolean(bulkAction.value || actionLoading.value || pendingReadIds.value.length || loadingMessages.value || loadingMessage.value || loadingMailboxes.value || syncing.value))
const bulkMoveOptions = computed(() =>
mailboxes.value.filter((mailbox) => mailbox.path !== selectedMailboxPath.value)
.map((mailbox) => ({ label: mailboxLabel(mailbox), value: mailbox.path }))
)
function toggleMessageChecked(messageId: string, checked: boolean) {
if (bulkDisabled.value) return
checkedMessageIds.value = checked
? [...new Set([...checkedMessageIds.value, messageId])]
: checkedMessageIds.value.filter((id) => id !== messageId)
}
function toggleAllMessages(checked: boolean) {
if (bulkDisabled.value) return
checkedMessageIds.value = checked ? filteredMessages.value.map((message) => message.id) : []
}
watch(search, () => {
checkedMessageIds.value = checkedMessageIds.value.filter((id) => filteredMessages.value.some((message) => message.id === id))
})
async function runBulkAction(action: "read" | "unread" | "archive" | "delete" | "move") {
if (bulkDisabled.value || !checkedMessages.value.length) return
const target = bulkMoveTarget.value
if (action === "move" && !target) return
const batch = [...checkedMessages.value]
if (action === "delete" && !window.confirm(`${batch.length} E-Mails endgültig löschen? Diese Aktion kann nicht rückgängig gemacht werden.`)) return
bulkAction.value = action
bulkProgress.value = 0
bulkTotal.value = batch.length
const failedIds: string[] = []
let lastError = ""
// Apply the entire selection before starting the first server request.
const operations = batch.map((message) => prepareMessageAction(message, action, target))
checkedMessageIds.value = []
try {
for (const [index, operation] of operations.entries()) {
try {
await operation()
} catch (err: any) {
failedIds.push(batch[index].id)
lastError = err?.data?.error || err?.message || "Unbekannter Fehler"
}
bulkProgress.value++
}
checkedMessageIds.value = failedIds
const labels = { read: "als gelesen markiert", unread: "als ungelesen markiert", archive: "archiviert", delete: "gelöscht", move: "verschoben" }
toast.add({
title: `${batch.length - failedIds.length} von ${batch.length} E-Mails ${labels[action]}`,
description: failedIds.length ? `${failedIds.length} fehlgeschlagen und weiterhin ausgewählt. ${lastError}` : undefined,
color: failedIds.length ? "error" : "success",
})
if (!failedIds.length) bulkMoveTarget.value = ""
} finally {
bulkAction.value = ""
}
}
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")
const requestedAccountId = String(route.query.account || "")
selectedAccountId.value = accounts.value.some((account) => account.id === requestedAccountId)
? requestedAccountId
: 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)
const requestedMailboxPath = String(route.query.mailbox || "")
const requestedMailbox = mailboxes.value.find((mailbox) => mailbox.path === requestedMailboxPath)
selectedMailboxPath.value = requestedMailbox?.path || 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
checkedMessageIds.value = []
bulkMoveTarget.value = ""
selectedMessage.value = null
++messageRequest
loadingMessage.value = false
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) {
const requestedMessageId = deepLinkApplied ? "" : String(route.query.message || "")
const requestedMessage = messages.value.find((message) => message.id === requestedMessageId)
void selectMessage(requestedMessage || messages.value[0])
deepLinkApplied = true
}
} 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) {
if (bulkDisabled.value) return
selectedMailboxPath.value = mailbox.path
expandMailboxAncestors(mailbox.path)
await loadMessages({ syncIfEmpty: true })
}
async function selectMessage(message: EmailMessage) {
const request = ++messageRequest
selectedMessage.value = message
moveTargetMailboxPath.value = ""
loadingMessage.value = true
if (!message.seen && !bulkAction.value) void setMessageSeen(message.id, true)
try {
const detail = await $api(`/api/email/messages/${message.id}`)
if (request !== messageRequest || selectedMessage.value?.id !== message.id) return
selectedMessage.value = { ...detail, seen: messages.value.find((item) => item.id === message.id)?.seen ?? detail.seen }
} catch (err: any) {
if (request === messageRequest) toast.add({ title: "E-Mail konnte nicht geladen werden", description: err?.data?.error || err?.message, color: "error" })
} finally {
if (request === messageRequest) loadingMessage.value = false
}
}
function updateSelectedMessageEntityLinks(entityLinks: NonNullable<EmailMessage["entityLinks"]>) {
if (!selectedMessage.value) return
selectedMessage.value = { ...selectedMessage.value, entityLinks }
}
function openComposer(mode: "reply" | "replyAll" | "forward") {
if (!selectedMessage.value) return
navigateTo({
path: "/email/new",
query: {
mode,
source: selectedMessage.value.id,
},
})
}
type MessageAction = "read" | "unread" | "archive" | "delete" | "move"
function updateLocalSeen(messageId: string, seen: boolean) {
messages.value = messages.value.map((message) => message.id === messageId ? { ...message, seen } : message)
if (selectedMessage.value?.id === messageId) selectedMessage.value = { ...selectedMessage.value, seen }
}
function prepareMessageAction(message: EmailMessage, action: MessageAction, target = "") {
const order = messages.value.map((item) => item.id)
const detail = selectedMessage.value?.id === message.id ? selectedMessage.value : null
const accountId = selectedAccountId.value
const folder = selectedMailboxPath.value
const reading = action === "read" || action === "unread"
const seen = action === "read"
const archive = mailboxes.value.find((mailbox) => mailbox.specialUse === "\\Archive")
|| mailboxes.value.find((mailbox) => ["archive", "archiv"].includes(mailbox.name.toLowerCase()))
|| mailboxes.value.find((mailbox) => ["archive", "archiv"].includes(mailbox.path.toLowerCase()))
const destination = action === "archive" ? archive?.path : target
const removing = !reading && (action === "delete" || (Boolean(destination) && destination !== message.mailboxPath))
const changes: Array<{ path: string; exists: number; unseen: number }> = []
mailboxes.value = mailboxes.value.map((mailbox) => {
const delta = removing ? (mailbox.path === message.mailboxPath ? -1 : mailbox.path === destination ? 1 : 0) : 0
const readDelta = reading && mailbox.path === message.mailboxPath && Boolean(message.seen) !== seen ? (seen ? -1 : 1) : 0
const exists = Math.max(0, Number(mailbox.exists || 0) + delta)
const unseen = Math.max(0, Number(mailbox.unseen || 0) + (message.seen ? 0 : delta) + readDelta)
changes.push({ path: mailbox.path, exists: exists - Number(mailbox.exists || 0), unseen: unseen - Number(mailbox.unseen || 0) })
return { ...mailbox, exists, unseen }
})
if (reading) updateLocalSeen(message.id, seen)
if (removing) {
messages.value = messages.value.filter((item) => item.id !== message.id)
checkedMessageIds.value = checkedMessageIds.value.filter((id) => id !== message.id)
if (detail) {
++messageRequest
loadingMessage.value = false
selectedMessage.value = null
}
}
return async () => {
try {
await $api(`/api/email/messages/${message.id}${action === "delete" ? "" : `/${reading ? "read" : action}`}`, {
method: action === "delete" ? "DELETE" : "POST",
...(reading ? { body: { seen } } : action === "move" ? { body: { mailbox: target } } : {}),
})
} catch (error) {
// Undo only this message's changes, preserving successful sibling operations.
if (selectedAccountId.value === accountId) {
mailboxes.value = mailboxes.value.map((mailbox) => {
const change = changes.find((item) => item.path === mailbox.path)
return change ? { ...mailbox, exists: Math.max(0, Number(mailbox.exists || 0) - change.exists), unseen: Math.max(0, Number(mailbox.unseen || 0) - change.unseen) } : mailbox
})
if (selectedMailboxPath.value === folder) {
if (reading) updateLocalSeen(message.id, Boolean(message.seen))
if (removing && !messages.value.some((item) => item.id === message.id)) {
const nextId = order.slice(order.indexOf(message.id) + 1).find((id) => messages.value.some((item) => item.id === id))
const restored = [...messages.value]
restored.splice(nextId ? restored.findIndex((item) => item.id === nextId) : restored.length, 0, message)
messages.value = restored
if (detail && !selectedMessage.value) selectedMessage.value = detail
}
}
}
throw error
}
}
}
async function runSingleMessageAction(message: EmailMessage, action: "archive" | "delete" | "move", target = "") {
if (bulkDisabled.value) return
actionLoading.value = action
const operation = prepareMessageAction(message, action, target)
try {
await operation()
toast.add({ title: action === "archive" ? "E-Mail archiviert" : action === "delete" ? "E-Mail gelöscht" : "E-Mail verschoben", color: "success" })
} catch (err: any) {
toast.add({ title: "Aktion fehlgeschlagen Änderung zurückgenommen", description: err?.data?.error || err?.message, color: "error" })
} finally {
actionLoading.value = ""
}
}
async function archiveSelectedMessage() {
if (selectedMessage.value) await runSingleMessageAction(selectedMessage.value, "archive")
}
async function deleteSelectedMessage() {
if (selectedMessage.value) await runSingleMessageAction(selectedMessage.value, "delete")
}
async function moveSelectedMessage() {
if (!selectedMessage.value || !moveTargetMailboxPath.value) return
const target = moveTargetMailboxPath.value
moveTargetMailboxPath.value = ""
await moveMessageToMailbox(selectedMessage.value.id, target)
}
async function moveMessageToMailbox(messageId: string, mailboxPath: string) {
const message = messages.value.find((item) => item.id === messageId)
if (!message || message.mailboxPath === mailboxPath) return
await runSingleMessageAction(message, "move", mailboxPath)
}
function startMessageDrag(event: DragEvent, message: EmailMessage) {
if (!event.dataTransfer || actionLoading.value || bulkAction.value) {
event.preventDefault()
return
}
draggedMessageId.value = message.id
event.dataTransfer.effectAllowed = "move"
event.dataTransfer.setData("application/x-fedeo-email-id", message.id)
event.dataTransfer.setData("text/plain", message.id)
}
function finishMessageDrag() {
draggedMessageId.value = null
dragOverMailboxPath.value = null
}
function dragMessageOverMailbox(event: DragEvent, mailbox: EmailMailbox) {
const message = messages.value.find((item) => item.id === draggedMessageId.value)
if (!message || message.mailboxPath === mailbox.path) {
if (event.dataTransfer) event.dataTransfer.dropEffect = "none"
return
}
event.preventDefault()
dragOverMailboxPath.value = mailbox.path
if (event.dataTransfer) event.dataTransfer.dropEffect = "move"
}
function leaveMailboxDropTarget(event: DragEvent, mailbox: EmailMailbox) {
const currentTarget = event.currentTarget as HTMLElement | null
const relatedTarget = event.relatedTarget as Node | null
if (currentTarget && relatedTarget && currentTarget.contains(relatedTarget)) return
if (dragOverMailboxPath.value === mailbox.path) dragOverMailboxPath.value = null
}
async function dropMessageOnMailbox(event: DragEvent, mailbox: EmailMailbox) {
event.preventDefault()
const messageId = draggedMessageId.value
|| event.dataTransfer?.getData("application/x-fedeo-email-id")
|| ""
finishMessageDrag()
if (!messageId) return
await moveMessageToMailbox(messageId, mailbox.path)
}
async function downloadAttachment(attachment: NonNullable<EmailMessage["attachments"]>[number]) {
actionLoading.value = `attachment-${attachment.id}`
try {
const apiBase = String(runtimeConfig.public.apiBase || "").replace(/\/$/, "")
const path = `/api/email/attachments/${attachment.id}/download`
const downloadUrl = new URL(apiBase ? `${apiBase}${path}` : path, window.location.origin)
const token = useCookie<string | null>("token", { path: "/" }).value
if (token) {
downloadUrl.searchParams.set("downloadToken", token)
}
window.location.assign(downloadUrl.toString())
} catch (err: any) {
toast.add({
title: "Download fehlgeschlagen",
description: err?.data?.error || err?.message || "Der Anhang konnte nicht geladen werden.",
color: "error",
})
} finally {
window.setTimeout(() => {
actionLoading.value = ""
}, 750)
}
}
async function setMessageSeen(messageId: string, seen: boolean) {
if (pendingReadIds.value.includes(messageId) || bulkAction.value) return
const message = messages.value.find((item) => item.id === messageId)
if (!message || Boolean(message.seen) === seen) return
pendingReadIds.value = [...pendingReadIds.value, messageId]
const operation = prepareMessageAction(message, seen ? "read" : "unread")
try {
await operation()
} catch (err: any) {
toast.add({ title: "Lesestatus konnte nicht gespeichert werden", description: err?.data?.error || err?.message, color: "error" })
} finally {
pendingReadIds.value = pendingReadIds.value.filter((id) => id !== messageId)
}
}
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 || bulkDisabled"
@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"
:disabled="bulkDisabled"
:items="accounts"
label-key="displayName"
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">
<div
v-if="draggedMessageId"
class="mx-1 mb-2 flex items-center gap-2 rounded-md bg-primary/10 px-3 py-2 text-xs text-primary"
>
<UIcon name="i-heroicons-folder-arrow-down" class="size-4" />
E-Mail in einem Ordner ablegen
</div>
<button
v-for="row in mailboxRows"
:key="row.mailbox.id"
:disabled="bulkDisabled"
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)',
dragOverMailboxPath === row.mailbox.path ? 'bg-primary/15 text-primary ring-2 ring-primary' : '',
draggedMessageId && selectedMailboxPath === row.mailbox.path ? 'cursor-not-allowed opacity-60' : '',
]"
:style="{ paddingLeft: `${12 + row.depth * 18}px` }"
@click="selectMailbox(row.mailbox)"
@dragover="dragMessageOverMailbox($event, row.mailbox)"
@dragleave="leaveMailboxDropTarget($event, row.mailbox)"
@drop="dropMessageOnMailbox($event, 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="filteredMessages.length || bulkAction || actionLoading" class="sticky top-0 z-10 space-y-3 border-b border-(--ui-border) bg-(--ui-bg) p-3">
<div class="flex items-center justify-between gap-2">
<UCheckbox
:model-value="allMessagesChecked ? true : checkedMessages.length ? 'indeterminate' : false"
:disabled="bulkDisabled"
label="Alle angezeigten auswählen"
@update:model-value="toggleAllMessages($event === true)"
/>
<span class="text-xs text-dimmed" aria-live="polite">{{ checkedMessages.length }} ausgewählt</span>
</div>
<template v-if="checkedMessages.length">
<div class="flex flex-wrap gap-2">
<UButton size="xs" color="neutral" variant="soft" :disabled="bulkDisabled" @click="runBulkAction('read')">Gelesen</UButton>
<UButton size="xs" color="neutral" variant="soft" :disabled="bulkDisabled" @click="runBulkAction('unread')">Ungelesen</UButton>
<UButton size="xs" color="neutral" variant="soft" :disabled="bulkDisabled" @click="runBulkAction('archive')">Archivieren</UButton>
<UButton size="xs" color="error" variant="soft" :disabled="bulkDisabled" @click="runBulkAction('delete')">Löschen</UButton>
<UButton size="xs" color="neutral" variant="ghost" :disabled="bulkDisabled" @click="toggleAllMessages(false)">Auswahl aufheben</UButton>
</div>
<div class="flex gap-2">
<USelectMenu v-model="bulkMoveTarget" :items="bulkMoveOptions" value-key="value" label-key="label" placeholder="Verschieben nach" size="sm" class="min-w-0 flex-1" :disabled="bulkDisabled" />
<UButton size="sm" color="neutral" variant="soft" :disabled="bulkDisabled || !bulkMoveTarget" @click="runBulkAction('move')">Verschieben</UButton>
</div>
</template>
<p v-if="bulkAction" role="status" class="text-xs text-dimmed">{{ bulkProgress }} von {{ bulkTotal }} E-Mails gespeichert </p>
<p v-else-if="actionLoading || pendingReadIds.length" role="status" class="text-xs text-dimmed">Änderungen werden gespeichert </p>
</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)">
<div v-for="message in filteredMessages" :key="message.id" class="flex items-start" :class="checkedMessageIds.includes(message.id) ? 'bg-primary/5' : ''">
<UCheckbox class="ml-3 mt-4 shrink-0" :model-value="checkedMessageIds.includes(message.id)" :disabled="bulkDisabled" :aria-label="`E-Mail auswählen: ${message.subject || '(kein Betreff)'}`" @update:model-value="toggleMessageChecked(message.id, $event === true)" />
<button
class="block min-w-0 flex-1 cursor-grab border-l-2 px-4 py-3 text-left transition-colors active:cursor-grabbing"
:class="[
selectedMessage?.id === message.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-(--ui-bg-muted)',
draggedMessageId === message.id ? 'opacity-50' : '',
]"
draggable="true"
@click="selectMessage(message)"
@dragstart="startMessageDrag($event, message)"
@dragend="finishMessageDrag"
>
<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>
</div>
</section>
<main class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)">
<div v-if="loadingMessage && !selectedMessage" 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 :inert="bulkDisabled" 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="openComposer('reply')"
>
Antworten
</UButton>
<UButton
v-if="selectedMessage.cc?.length || (selectedMessage.to?.length || 0) > 1"
icon="i-heroicons-users"
color="neutral"
variant="ghost"
size="sm"
@click="openComposer('replyAll')"
>
Allen antworten
</UButton>
<UButton
icon="i-heroicons-arrow-uturn-right"
color="neutral"
variant="ghost"
size="sm"
@click="openComposer('forward')"
>
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>
<EmailEntityLinks
:key="selectedMessage.id"
:message-id="selectedMessage.id"
:entity-links="selectedMessage.entityLinks"
@updated="updateSelectedMessageEntityLinks"
/>
<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">
<USkeleton v-if="loadingMessage" class="h-80" />
<iframe
v-else
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>