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
1117 lines
42 KiB
Vue
1117 lines
42 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
|
||
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'")
|
||
}
|
||
|
||
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>
|