perf(email): update message actions optimistically
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
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
This commit is contained in:
@@ -78,6 +78,9 @@ const selectedMessage = ref<EmailMessage | null>(null)
|
|||||||
const checkedMessageIds = ref<string[]>([])
|
const checkedMessageIds = ref<string[]>([])
|
||||||
const bulkAction = ref("")
|
const bulkAction = ref("")
|
||||||
const bulkProgress = ref(0)
|
const bulkProgress = ref(0)
|
||||||
|
const bulkTotal = ref(0)
|
||||||
|
const pendingReadIds = ref<string[]>([])
|
||||||
|
let messageRequest = 0
|
||||||
const bulkMoveTarget = ref("")
|
const bulkMoveTarget = ref("")
|
||||||
const search = ref("")
|
const search = ref("")
|
||||||
const loadingAccounts = ref(true)
|
const loadingAccounts = ref(true)
|
||||||
@@ -198,7 +201,7 @@ const checkedMessages = computed(() =>
|
|||||||
const allMessagesChecked = computed(() =>
|
const allMessagesChecked = computed(() =>
|
||||||
checkedMessages.value.length > 0 && checkedMessages.value.length === filteredMessages.value.length
|
checkedMessages.value.length > 0 && checkedMessages.value.length === filteredMessages.value.length
|
||||||
)
|
)
|
||||||
const bulkDisabled = computed(() => Boolean(bulkAction.value || actionLoading.value || loadingMessages.value || loadingMessage.value || loadingMailboxes.value || syncing.value))
|
const bulkDisabled = computed(() => Boolean(bulkAction.value || actionLoading.value || pendingReadIds.value.length || loadingMessages.value || loadingMessage.value || loadingMailboxes.value || syncing.value))
|
||||||
const bulkMoveOptions = computed(() =>
|
const bulkMoveOptions = computed(() =>
|
||||||
mailboxes.value.filter((mailbox) => mailbox.path !== selectedMailboxPath.value)
|
mailboxes.value.filter((mailbox) => mailbox.path !== selectedMailboxPath.value)
|
||||||
.map((mailbox) => ({ label: mailboxLabel(mailbox), value: mailbox.path }))
|
.map((mailbox) => ({ label: mailboxLabel(mailbox), value: mailbox.path }))
|
||||||
@@ -229,35 +232,18 @@ async function runBulkAction(action: "read" | "unread" | "archive" | "delete" |
|
|||||||
|
|
||||||
bulkAction.value = action
|
bulkAction.value = action
|
||||||
bulkProgress.value = 0
|
bulkProgress.value = 0
|
||||||
|
bulkTotal.value = batch.length
|
||||||
const failedIds: string[] = []
|
const failedIds: string[] = []
|
||||||
let lastError = ""
|
let lastError = ""
|
||||||
|
// Apply the entire selection before starting the first server request.
|
||||||
|
const operations = batch.map((message) => prepareMessageAction(message, action, target))
|
||||||
|
checkedMessageIds.value = []
|
||||||
try {
|
try {
|
||||||
for (const message of batch) {
|
for (const [index, operation] of operations.entries()) {
|
||||||
try {
|
try {
|
||||||
if (action === "read" || action === "unread") {
|
await operation()
|
||||||
await setMessageSeen(message.id, action === "read")
|
|
||||||
} else {
|
|
||||||
const result = await $api(`/api/email/messages/${message.id}${action === "delete" ? "" : `/${action}`}`, {
|
|
||||||
method: action === "delete" ? "DELETE" : "POST",
|
|
||||||
...(action === "move" ? { body: { mailbox: target } } : {}),
|
|
||||||
})
|
|
||||||
// Moving to the current folder is a no-op (for example, archiving archived mail).
|
|
||||||
if (action === "delete" || (result.destinationMailboxPath || result.mailboxPath) !== message.mailboxPath) {
|
|
||||||
mailboxes.value = mailboxes.value.map((mailbox) => {
|
|
||||||
const delta = mailbox.path === message.mailboxPath ? -1
|
|
||||||
: mailbox.path === result.destinationMailboxPath ? 1 : 0
|
|
||||||
return delta ? {
|
|
||||||
...mailbox,
|
|
||||||
exists: Math.max(0, Number(mailbox.exists || 0) + delta),
|
|
||||||
unseen: Math.max(0, Number(mailbox.unseen || 0) + (message.seen ? 0 : delta)),
|
|
||||||
} : mailbox
|
|
||||||
})
|
|
||||||
messages.value = messages.value.filter((item) => item.id !== message.id)
|
|
||||||
if (selectedMessage.value?.id === message.id) selectedMessage.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
failedIds.push(message.id)
|
failedIds.push(batch[index].id)
|
||||||
lastError = err?.data?.error || err?.message || "Unbekannter Fehler"
|
lastError = err?.data?.error || err?.message || "Unbekannter Fehler"
|
||||||
}
|
}
|
||||||
bulkProgress.value++
|
bulkProgress.value++
|
||||||
@@ -437,6 +423,8 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
|
|||||||
checkedMessageIds.value = []
|
checkedMessageIds.value = []
|
||||||
bulkMoveTarget.value = ""
|
bulkMoveTarget.value = ""
|
||||||
selectedMessage.value = null
|
selectedMessage.value = null
|
||||||
|
++messageRequest
|
||||||
|
loadingMessage.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
messages.value = await fetchMessages(selectedMailboxPath.value)
|
messages.value = await fetchMessages(selectedMailboxPath.value)
|
||||||
@@ -450,7 +438,7 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
|
|||||||
if (messages.value.length) {
|
if (messages.value.length) {
|
||||||
const requestedMessageId = deepLinkApplied ? "" : String(route.query.message || "")
|
const requestedMessageId = deepLinkApplied ? "" : String(route.query.message || "")
|
||||||
const requestedMessage = messages.value.find((message) => message.id === requestedMessageId)
|
const requestedMessage = messages.value.find((message) => message.id === requestedMessageId)
|
||||||
await selectMessage(requestedMessage || messages.value[0])
|
void selectMessage(requestedMessage || messages.value[0])
|
||||||
deepLinkApplied = true
|
deepLinkApplied = true
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -468,23 +456,26 @@ async function fetchMessages(mailboxPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectMailbox(mailbox: EmailMailbox) {
|
async function selectMailbox(mailbox: EmailMailbox) {
|
||||||
if (bulkAction.value) return
|
if (bulkDisabled.value) return
|
||||||
selectedMailboxPath.value = mailbox.path
|
selectedMailboxPath.value = mailbox.path
|
||||||
expandMailboxAncestors(mailbox.path)
|
expandMailboxAncestors(mailbox.path)
|
||||||
await loadMessages({ syncIfEmpty: true })
|
await loadMessages({ syncIfEmpty: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectMessage(message: EmailMessage) {
|
async function selectMessage(message: EmailMessage) {
|
||||||
if (bulkAction.value) return
|
const request = ++messageRequest
|
||||||
loadingMessage.value = true
|
selectedMessage.value = message
|
||||||
try {
|
|
||||||
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
|
||||||
moveTargetMailboxPath.value = ""
|
moveTargetMailboxPath.value = ""
|
||||||
if (!message.seen) {
|
loadingMessage.value = true
|
||||||
await setMessageSeen(message.id, 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 {
|
} finally {
|
||||||
loadingMessage.value = false
|
if (request === messageRequest) loadingMessage.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,116 +495,107 @@ function openComposer(mode: "reply" | "replyAll" | "forward") {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMessageFromCurrentList(messageId: string) {
|
type MessageAction = "read" | "unread" | "archive" | "delete" | "move"
|
||||||
const currentIndex = messages.value.findIndex((message) => message.id === messageId)
|
|
||||||
const wasSelected = selectedMessage.value?.id === messageId
|
|
||||||
checkedMessageIds.value = checkedMessageIds.value.filter((id) => id !== messageId)
|
|
||||||
messages.value = messages.value.filter((message) => message.id !== messageId)
|
|
||||||
|
|
||||||
if (!wasSelected) return
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
const nextMessage = messages.value[currentIndex] || messages.value[currentIndex - 1] || null
|
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
|
selectedMessage.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (nextMessage) {
|
return async () => {
|
||||||
selectMessage(nextMessage)
|
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() {
|
async function archiveSelectedMessage() {
|
||||||
if (!selectedMessage.value) return
|
if (selectedMessage.value) await runSingleMessageAction(selectedMessage.value, "archive")
|
||||||
|
|
||||||
const messageId = selectedMessage.value.id
|
|
||||||
actionLoading.value = "archive"
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $api(`/api/email/messages/${messageId}/archive`, { method: "POST" })
|
|
||||||
removeMessageFromCurrentList(messageId)
|
|
||||||
toast.add({ title: "E-Mail archiviert", color: "success" })
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.add({
|
|
||||||
title: "Archivieren fehlgeschlagen",
|
|
||||||
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht archiviert werden.",
|
|
||||||
color: "error",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
actionLoading.value = ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelectedMessage() {
|
async function deleteSelectedMessage() {
|
||||||
if (!selectedMessage.value) return
|
if (selectedMessage.value) await runSingleMessageAction(selectedMessage.value, "delete")
|
||||||
|
|
||||||
const messageId = selectedMessage.value.id
|
|
||||||
actionLoading.value = "delete"
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $api(`/api/email/messages/${messageId}`, { method: "DELETE" })
|
|
||||||
removeMessageFromCurrentList(messageId)
|
|
||||||
toast.add({ title: "E-Mail gelöscht", color: "success" })
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.add({
|
|
||||||
title: "Löschen fehlgeschlagen",
|
|
||||||
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht gelöscht werden.",
|
|
||||||
color: "error",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
actionLoading.value = ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveSelectedMessage() {
|
async function moveSelectedMessage() {
|
||||||
if (!selectedMessage.value || !moveTargetMailboxPath.value) return
|
if (!selectedMessage.value || !moveTargetMailboxPath.value) return
|
||||||
|
const target = moveTargetMailboxPath.value
|
||||||
await moveMessageToMailbox(selectedMessage.value.id, moveTargetMailboxPath.value, "move")
|
|
||||||
moveTargetMailboxPath.value = ""
|
moveTargetMailboxPath.value = ""
|
||||||
|
await moveMessageToMailbox(selectedMessage.value.id, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveMessageToMailbox(messageId: string, mailboxPath: string, loadingKey = `move-${messageId}`) {
|
async function moveMessageToMailbox(messageId: string, mailboxPath: string) {
|
||||||
if (bulkAction.value) return
|
|
||||||
const message = messages.value.find((item) => item.id === messageId)
|
const message = messages.value.find((item) => item.id === messageId)
|
||||||
if (!message || message.mailboxPath === mailboxPath) return
|
if (!message || message.mailboxPath === mailboxPath) return
|
||||||
|
await runSingleMessageAction(message, "move", mailboxPath)
|
||||||
actionLoading.value = loadingKey
|
|
||||||
|
|
||||||
try {
|
|
||||||
await $api(`/api/email/messages/${messageId}/move`, {
|
|
||||||
method: "POST",
|
|
||||||
body: { mailbox: mailboxPath },
|
|
||||||
})
|
|
||||||
mailboxes.value = mailboxes.value.map((mailbox) => {
|
|
||||||
if (mailbox.path === message.mailboxPath) {
|
|
||||||
return {
|
|
||||||
...mailbox,
|
|
||||||
exists: Math.max(0, Number(mailbox.exists || 0) - 1),
|
|
||||||
unseen: Math.max(0, Number(mailbox.unseen || 0) - (message.seen ? 0 : 1)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mailbox.path === mailboxPath) {
|
|
||||||
return {
|
|
||||||
...mailbox,
|
|
||||||
exists: Number(mailbox.exists || 0) + 1,
|
|
||||||
unseen: Number(mailbox.unseen || 0) + (message.seen ? 0 : 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mailbox
|
|
||||||
})
|
|
||||||
removeMessageFromCurrentList(messageId)
|
|
||||||
const targetMailbox = mailboxes.value.find((mailbox) => mailbox.path === mailboxPath)
|
|
||||||
toast.add({
|
|
||||||
title: "E-Mail verschoben",
|
|
||||||
description: targetMailbox ? `Abgelegt in „${mailboxLabel(targetMailbox)}“` : undefined,
|
|
||||||
color: "success",
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.add({
|
|
||||||
title: "Verschieben fehlgeschlagen",
|
|
||||||
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht verschoben werden.",
|
|
||||||
color: "error",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
actionLoading.value = ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startMessageDrag(event: DragEvent, message: EmailMessage) {
|
function startMessageDrag(event: DragEvent, message: EmailMessage) {
|
||||||
@@ -690,34 +672,17 @@ async function downloadAttachment(attachment: NonNullable<EmailMessage["attachme
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setMessageSeen(messageId: string, seen: boolean) {
|
async function setMessageSeen(messageId: string, seen: boolean) {
|
||||||
const previousMessage = messages.value.find((message) => message.id === messageId)
|
if (pendingReadIds.value.includes(messageId) || bulkAction.value) return
|
||||||
|
const message = messages.value.find((item) => item.id === messageId)
|
||||||
const res = await $api(`/api/email/messages/${messageId}/read`, {
|
if (!message || Boolean(message.seen) === seen) return
|
||||||
method: "POST",
|
pendingReadIds.value = [...pendingReadIds.value, messageId]
|
||||||
body: { seen },
|
const operation = prepareMessageAction(message, seen ? "read" : "unread")
|
||||||
})
|
try {
|
||||||
|
await operation()
|
||||||
messages.value = messages.value.map((message) =>
|
} catch (err: any) {
|
||||||
message.id === messageId ? { ...message, seen } : message
|
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)
|
||||||
if (selectedMessage.value?.id === messageId) {
|
|
||||||
selectedMessage.value = {
|
|
||||||
...selectedMessage.value,
|
|
||||||
...(res.message || {}),
|
|
||||||
seen,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousMessage && previousMessage.seen !== seen) {
|
|
||||||
mailboxes.value = mailboxes.value.map((mailbox) => {
|
|
||||||
if (mailbox.path !== previousMessage.mailboxPath) return mailbox
|
|
||||||
const delta = seen ? -1 : 1
|
|
||||||
return {
|
|
||||||
...mailbox,
|
|
||||||
unseen: Math.max(0, Number(mailbox.unseen || 0) + delta),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,7 +761,6 @@ onMounted(loadAccounts)
|
|||||||
<template #left>
|
<template #left>
|
||||||
<UInput
|
<UInput
|
||||||
v-model="search"
|
v-model="search"
|
||||||
:disabled="!!bulkAction"
|
|
||||||
icon="i-heroicons-magnifying-glass"
|
icon="i-heroicons-magnifying-glass"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-72"
|
class="w-72"
|
||||||
@@ -818,7 +782,7 @@ onMounted(loadAccounts)
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
size="sm"
|
size="sm"
|
||||||
:loading="syncing"
|
:loading="syncing"
|
||||||
:disabled="!selectedAccountId || !!bulkAction"
|
:disabled="!selectedAccountId || bulkDisabled"
|
||||||
@click="syncAccount"
|
@click="syncAccount"
|
||||||
>
|
>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
@@ -833,7 +797,7 @@ onMounted(loadAccounts)
|
|||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-else-if="accounts.length"
|
v-else-if="accounts.length"
|
||||||
v-model="selectedAccountId"
|
v-model="selectedAccountId"
|
||||||
:disabled="!!bulkAction"
|
:disabled="bulkDisabled"
|
||||||
:items="accounts"
|
:items="accounts"
|
||||||
label-key="displayName"
|
label-key="displayName"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
@@ -868,7 +832,7 @@ onMounted(loadAccounts)
|
|||||||
<button
|
<button
|
||||||
v-for="row in mailboxRows"
|
v-for="row in mailboxRows"
|
||||||
:key="row.mailbox.id"
|
:key="row.mailbox.id"
|
||||||
:disabled="!!bulkAction"
|
:disabled="bulkDisabled"
|
||||||
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
selectedMailboxPath === row.mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)',
|
selectedMailboxPath === row.mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)',
|
||||||
@@ -917,7 +881,7 @@ onMounted(loadAccounts)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="filteredMessages.length || bulkAction" class="sticky top-0 z-10 space-y-3 border-b border-(--ui-border) bg-(--ui-bg) p-3">
|
<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">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<UCheckbox
|
<UCheckbox
|
||||||
:model-value="allMessagesChecked ? true : checkedMessages.length ? 'indeterminate' : false"
|
:model-value="allMessagesChecked ? true : checkedMessages.length ? 'indeterminate' : false"
|
||||||
@@ -925,7 +889,7 @@ onMounted(loadAccounts)
|
|||||||
label="Alle angezeigten auswählen"
|
label="Alle angezeigten auswählen"
|
||||||
@update:model-value="toggleAllMessages($event === true)"
|
@update:model-value="toggleAllMessages($event === true)"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-dimmed" aria-live="polite">{{ bulkAction ? checkedMessageIds.length : checkedMessages.length }} ausgewählt</span>
|
<span class="text-xs text-dimmed" aria-live="polite">{{ checkedMessages.length }} ausgewählt</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="checkedMessages.length">
|
<template v-if="checkedMessages.length">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
@@ -940,7 +904,8 @@ onMounted(loadAccounts)
|
|||||||
<UButton size="sm" color="neutral" variant="soft" :disabled="bulkDisabled || !bulkMoveTarget" @click="runBulkAction('move')">Verschieben</UButton>
|
<UButton size="sm" color="neutral" variant="soft" :disabled="bulkDisabled || !bulkMoveTarget" @click="runBulkAction('move')">Verschieben</UButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="bulkAction" role="status" class="text-xs text-dimmed">{{ bulkProgress }} von {{ checkedMessageIds.length }} E-Mails bearbeitet …</p>
|
<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>
|
||||||
|
|
||||||
<div v-if="loadingMessages" class="space-y-2 p-3">
|
<div v-if="loadingMessages" class="space-y-2 p-3">
|
||||||
@@ -956,7 +921,6 @@ onMounted(loadAccounts)
|
|||||||
<div v-for="message in filteredMessages" :key="message.id" class="flex items-start" :class="checkedMessageIds.includes(message.id) ? 'bg-primary/5' : ''">
|
<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)" />
|
<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
|
<button
|
||||||
:disabled="!!bulkAction"
|
|
||||||
class="block min-w-0 flex-1 cursor-grab border-l-2 px-4 py-3 text-left transition-colors active:cursor-grabbing"
|
class="block min-w-0 flex-1 cursor-grab border-l-2 px-4 py-3 text-left transition-colors active:cursor-grabbing"
|
||||||
:class="[
|
:class="[
|
||||||
selectedMessage?.id === message.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-(--ui-bg-muted)',
|
selectedMessage?.id === message.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-(--ui-bg-muted)',
|
||||||
@@ -993,8 +957,8 @@ onMounted(loadAccounts)
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main :inert="!!bulkAction" class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)">
|
<main class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)">
|
||||||
<div v-if="loadingMessage" class="space-y-4 p-6">
|
<div v-if="loadingMessage && !selectedMessage" class="space-y-4 p-6">
|
||||||
<USkeleton class="h-8 w-2/3" />
|
<USkeleton class="h-8 w-2/3" />
|
||||||
<USkeleton class="h-5 w-1/3" />
|
<USkeleton class="h-5 w-1/3" />
|
||||||
<USkeleton class="h-80" />
|
<USkeleton class="h-80" />
|
||||||
@@ -1011,7 +975,7 @@ onMounted(loadAccounts)
|
|||||||
{{ formatDetailDate(selectedMessage.receivedAt || selectedMessage.sentAt) }}
|
{{ formatDetailDate(selectedMessage.receivedAt || selectedMessage.sentAt) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-2">
|
<div :inert="bulkDisabled" class="flex shrink-0 items-center gap-2">
|
||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-model="moveTargetMailboxPath"
|
v-model="moveTargetMailboxPath"
|
||||||
:items="moveMailboxOptions"
|
:items="moveMailboxOptions"
|
||||||
@@ -1128,7 +1092,9 @@ onMounted(loadAccounts)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 p-6">
|
<div class="flex-1 p-6">
|
||||||
|
<USkeleton v-if="loadingMessage" class="h-80" />
|
||||||
<iframe
|
<iframe
|
||||||
|
v-else
|
||||||
title="E-Mail Inhalt"
|
title="E-Mail Inhalt"
|
||||||
class="h-[calc(100vh-360px)] min-h-[420px] w-full"
|
class="h-[calc(100vh-360px)] min-h-[420px] w-full"
|
||||||
sandbox="allow-popups allow-popups-to-escape-sandbox"
|
sandbox="allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
|||||||
97
frontend/tests/email-actions.test.cjs
Normal file
97
frontend/tests/email-actions.test.cjs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
// Exercise the page's real handlers with delayed API responses and Vue ref stubs.
|
||||||
|
function setup() {
|
||||||
|
const requests = []
|
||||||
|
const notices = []
|
||||||
|
const context = vm.createContext({
|
||||||
|
ref: value => ({ value }),
|
||||||
|
computed: getter => ({ get value() { return getter() } }),
|
||||||
|
watch() {}, onMounted() {},
|
||||||
|
useRoute: () => ({ query: {} }), useRuntimeConfig: () => ({}),
|
||||||
|
useToast: () => ({ add: notice => notices.push(notice) }),
|
||||||
|
window: { confirm: () => true },
|
||||||
|
useNuxtApp: () => ({ $api: (url, options) => new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })) }),
|
||||||
|
})
|
||||||
|
const source = fs.readFileSync(`${__dirname}/../pages/email/index.vue`, 'utf8')
|
||||||
|
.split('<script setup lang="ts">')[1].split('</script>')[0].replace(/^import .*$/gm, '')
|
||||||
|
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }), context)
|
||||||
|
const run = code => vm.runInContext(code, context)
|
||||||
|
run(`selectedAccountId.value='account'; messages.value=[{id:'a',mailboxPath:'INBOX',seen:false},{id:'b',mailboxPath:'INBOX',seen:false}]; mailboxes.value=[{path:'INBOX',name:'INBOX',exists:2,unseen:2},{path:'Archive',name:'Archive',exists:0,unseen:0}];`)
|
||||||
|
return { run, requests, notices }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('whole bulk selection disappears before response; partial failure restores only failed mail and counters', async () => {
|
||||||
|
const { run, requests } = setup()
|
||||||
|
run(`toggleAllMessages(true); bulkMoveTarget.value='Archive'`)
|
||||||
|
const pending = run(`runBulkAction('move')`)
|
||||||
|
assert.equal(run('messages.value.length'), 0)
|
||||||
|
assert.equal(run('mailboxes.value[1].exists'), 2)
|
||||||
|
assert.equal(requests.length, 1)
|
||||||
|
requests[0].resolve({ destinationMailboxPath: 'Archive' })
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
requests[1].reject(Error('offline'))
|
||||||
|
await pending
|
||||||
|
assert.equal(run('messages.value.map(m=>m.id).join()'), 'b')
|
||||||
|
assert.equal(run('checkedMessageIds.value.join()'), 'b')
|
||||||
|
assert.equal(run('mailboxes.value[0].unseen'), 1)
|
||||||
|
assert.equal(run('mailboxes.value[1].exists'), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bulk read updates every mail immediately and rolls back individual failures', async () => {
|
||||||
|
const { run, requests } = setup()
|
||||||
|
run('toggleAllMessages(true)')
|
||||||
|
const pending = run(`runBulkAction('read')`)
|
||||||
|
assert.equal(run('messages.value.every(m=>m.seen)'), true)
|
||||||
|
assert.equal(run('mailboxes.value[0].unseen'), 0)
|
||||||
|
requests[0].reject(Error('offline'))
|
||||||
|
await new Promise(setImmediate)
|
||||||
|
requests[1].resolve({})
|
||||||
|
await pending
|
||||||
|
assert.equal(run('messages.value[0].seen'), false)
|
||||||
|
assert.equal(run('messages.value[1].seen'), true)
|
||||||
|
assert.equal(run('mailboxes.value[0].unseen'), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('single deletion is immediate and restores details on failure', async () => {
|
||||||
|
const { run, requests } = setup()
|
||||||
|
run(`selectedMessage.value={...messages.value[0],body:{text:'Body'}}`)
|
||||||
|
const pending = run('deleteSelectedMessage()')
|
||||||
|
assert.equal(run('messages.value.length'), 1)
|
||||||
|
assert.equal(run('selectedMessage.value'), null)
|
||||||
|
requests[0].reject(Error('offline'))
|
||||||
|
await pending
|
||||||
|
assert.equal(run('messages.value.map(m=>m.id).join()'), 'a,b')
|
||||||
|
assert.equal(run('selectedMessage.value.body.text'), 'Body')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late message response cannot replace newer selection; header and read state are immediate', async () => {
|
||||||
|
const { run, requests } = setup()
|
||||||
|
const first = run('selectMessage(messages.value[0])')
|
||||||
|
assert.equal(run('selectedMessage.value.id'), 'a')
|
||||||
|
assert.equal(run('messages.value[0].seen'), true)
|
||||||
|
const second = run('selectMessage(messages.value[1])')
|
||||||
|
const detailRequests = requests.filter(request => !request.options)
|
||||||
|
detailRequests[1].resolve({ id: 'b', body: { text: 'B' }, seen: false })
|
||||||
|
await second
|
||||||
|
detailRequests[0].resolve({ id: 'a', body: { text: 'A' }, seen: false })
|
||||||
|
await first
|
||||||
|
assert.equal(run('selectedMessage.value.id'), 'b')
|
||||||
|
assert.equal(run('selectedMessage.value.seen'), true)
|
||||||
|
assert.equal(run('loadingMessage.value'), false)
|
||||||
|
requests.filter(request => request.options).forEach(request => request.resolve({}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('archiving in archive leaves the message in place', async () => {
|
||||||
|
const { run, requests } = setup()
|
||||||
|
run(`messages.value[0].mailboxPath='Archive';selectedMessage.value=messages.value[0]`)
|
||||||
|
const pending = run('archiveSelectedMessage()')
|
||||||
|
assert.equal(run('messages.value.length'), 2)
|
||||||
|
requests[0].resolve({ mailboxPath: 'Archive' })
|
||||||
|
await pending
|
||||||
|
assert.equal(run('messages.value.length'), 2)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user