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 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)
|
||||
@@ -198,7 +201,7 @@ const checkedMessages = computed(() =>
|
||||
const allMessagesChecked = computed(() =>
|
||||
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(() =>
|
||||
mailboxes.value.filter((mailbox) => mailbox.path !== selectedMailboxPath.value)
|
||||
.map((mailbox) => ({ label: mailboxLabel(mailbox), value: mailbox.path }))
|
||||
@@ -229,35 +232,18 @@ async function runBulkAction(action: "read" | "unread" | "archive" | "delete" |
|
||||
|
||||
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 message of batch) {
|
||||
for (const [index, operation] of operations.entries()) {
|
||||
try {
|
||||
if (action === "read" || action === "unread") {
|
||||
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
|
||||
}
|
||||
}
|
||||
await operation()
|
||||
} catch (err: any) {
|
||||
failedIds.push(message.id)
|
||||
failedIds.push(batch[index].id)
|
||||
lastError = err?.data?.error || err?.message || "Unbekannter Fehler"
|
||||
}
|
||||
bulkProgress.value++
|
||||
@@ -437,6 +423,8 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
|
||||
checkedMessageIds.value = []
|
||||
bulkMoveTarget.value = ""
|
||||
selectedMessage.value = null
|
||||
++messageRequest
|
||||
loadingMessage.value = false
|
||||
|
||||
try {
|
||||
messages.value = await fetchMessages(selectedMailboxPath.value)
|
||||
@@ -450,7 +438,7 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
|
||||
if (messages.value.length) {
|
||||
const requestedMessageId = deepLinkApplied ? "" : String(route.query.message || "")
|
||||
const requestedMessage = messages.value.find((message) => message.id === requestedMessageId)
|
||||
await selectMessage(requestedMessage || messages.value[0])
|
||||
void selectMessage(requestedMessage || messages.value[0])
|
||||
deepLinkApplied = true
|
||||
}
|
||||
} finally {
|
||||
@@ -468,23 +456,26 @@ async function fetchMessages(mailboxPath: string) {
|
||||
}
|
||||
|
||||
async function selectMailbox(mailbox: EmailMailbox) {
|
||||
if (bulkAction.value) return
|
||||
if (bulkDisabled.value) return
|
||||
selectedMailboxPath.value = mailbox.path
|
||||
expandMailboxAncestors(mailbox.path)
|
||||
await loadMessages({ syncIfEmpty: true })
|
||||
}
|
||||
|
||||
async function selectMessage(message: EmailMessage) {
|
||||
if (bulkAction.value) return
|
||||
const request = ++messageRequest
|
||||
selectedMessage.value = message
|
||||
moveTargetMailboxPath.value = ""
|
||||
loadingMessage.value = true
|
||||
if (!message.seen && !bulkAction.value) void setMessageSeen(message.id, true)
|
||||
try {
|
||||
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
||||
moveTargetMailboxPath.value = ""
|
||||
if (!message.seen) {
|
||||
await setMessageSeen(message.id, true)
|
||||
}
|
||||
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 {
|
||||
loadingMessage.value = false
|
||||
if (request === messageRequest) loadingMessage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,116 +495,107 @@ function openComposer(mode: "reply" | "replyAll" | "forward") {
|
||||
})
|
||||
}
|
||||
|
||||
function removeMessageFromCurrentList(messageId: string) {
|
||||
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)
|
||||
type MessageAction = "read" | "unread" | "archive" | "delete" | "move"
|
||||
|
||||
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
|
||||
selectedMessage.value = 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
|
||||
}
|
||||
}
|
||||
|
||||
if (nextMessage) {
|
||||
selectMessage(nextMessage)
|
||||
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) return
|
||||
|
||||
const messageId = selectedMessage.value.id
|
||||
actionLoading.value = "archive"
|
||||
|
||||
try {
|
||||
await $api(`/api/email/messages/${messageId}/archive`, { method: "POST" })
|
||||
removeMessageFromCurrentList(messageId)
|
||||
toast.add({ title: "E-Mail archiviert", color: "success" })
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Archivieren fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht archiviert werden.",
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
actionLoading.value = ""
|
||||
}
|
||||
if (selectedMessage.value) await runSingleMessageAction(selectedMessage.value, "archive")
|
||||
}
|
||||
|
||||
async function deleteSelectedMessage() {
|
||||
if (!selectedMessage.value) return
|
||||
|
||||
const messageId = selectedMessage.value.id
|
||||
actionLoading.value = "delete"
|
||||
|
||||
try {
|
||||
await $api(`/api/email/messages/${messageId}`, { method: "DELETE" })
|
||||
removeMessageFromCurrentList(messageId)
|
||||
toast.add({ title: "E-Mail gelöscht", color: "success" })
|
||||
} catch (err: any) {
|
||||
toast.add({
|
||||
title: "Löschen fehlgeschlagen",
|
||||
description: err?.data?.error || err?.message || "Die E-Mail konnte nicht gelöscht werden.",
|
||||
color: "error",
|
||||
})
|
||||
} finally {
|
||||
actionLoading.value = ""
|
||||
}
|
||||
if (selectedMessage.value) await runSingleMessageAction(selectedMessage.value, "delete")
|
||||
}
|
||||
|
||||
async function moveSelectedMessage() {
|
||||
if (!selectedMessage.value || !moveTargetMailboxPath.value) return
|
||||
|
||||
await moveMessageToMailbox(selectedMessage.value.id, moveTargetMailboxPath.value, "move")
|
||||
const target = moveTargetMailboxPath.value
|
||||
moveTargetMailboxPath.value = ""
|
||||
await moveMessageToMailbox(selectedMessage.value.id, target)
|
||||
}
|
||||
|
||||
async function moveMessageToMailbox(messageId: string, mailboxPath: string, loadingKey = `move-${messageId}`) {
|
||||
if (bulkAction.value) return
|
||||
async function moveMessageToMailbox(messageId: string, mailboxPath: string) {
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message || message.mailboxPath === mailboxPath) return
|
||||
|
||||
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 = ""
|
||||
}
|
||||
await runSingleMessageAction(message, "move", mailboxPath)
|
||||
}
|
||||
|
||||
function startMessageDrag(event: DragEvent, message: EmailMessage) {
|
||||
@@ -690,34 +672,17 @@ async function downloadAttachment(attachment: NonNullable<EmailMessage["attachme
|
||||
}
|
||||
|
||||
async function setMessageSeen(messageId: string, seen: boolean) {
|
||||
const previousMessage = messages.value.find((message) => message.id === messageId)
|
||||
|
||||
const res = await $api(`/api/email/messages/${messageId}/read`, {
|
||||
method: "POST",
|
||||
body: { seen },
|
||||
})
|
||||
|
||||
messages.value = messages.value.map((message) =>
|
||||
message.id === messageId ? { ...message, seen } : message
|
||||
)
|
||||
|
||||
if (selectedMessage.value?.id === messageId) {
|
||||
selectedMessage.value = {
|
||||
...selectedMessage.value,
|
||||
...(res.message || {}),
|
||||
seen,
|
||||
}
|
||||
}
|
||||
|
||||
if (previousMessage && previousMessage.seen !== seen) {
|
||||
mailboxes.value = mailboxes.value.map((mailbox) => {
|
||||
if (mailbox.path !== previousMessage.mailboxPath) return mailbox
|
||||
const delta = seen ? -1 : 1
|
||||
return {
|
||||
...mailbox,
|
||||
unseen: Math.max(0, Number(mailbox.unseen || 0) + delta),
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,7 +761,6 @@ onMounted(loadAccounts)
|
||||
<template #left>
|
||||
<UInput
|
||||
v-model="search"
|
||||
:disabled="!!bulkAction"
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
size="sm"
|
||||
class="w-72"
|
||||
@@ -818,7 +782,7 @@ onMounted(loadAccounts)
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="syncing"
|
||||
:disabled="!selectedAccountId || !!bulkAction"
|
||||
:disabled="!selectedAccountId || bulkDisabled"
|
||||
@click="syncAccount"
|
||||
>
|
||||
Aktualisieren
|
||||
@@ -833,7 +797,7 @@ onMounted(loadAccounts)
|
||||
<USelectMenu
|
||||
v-else-if="accounts.length"
|
||||
v-model="selectedAccountId"
|
||||
:disabled="!!bulkAction"
|
||||
:disabled="bulkDisabled"
|
||||
:items="accounts"
|
||||
label-key="displayName"
|
||||
value-key="id"
|
||||
@@ -868,7 +832,7 @@ onMounted(loadAccounts)
|
||||
<button
|
||||
v-for="row in mailboxRows"
|
||||
: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="[
|
||||
selectedMailboxPath === row.mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)',
|
||||
@@ -917,7 +881,7 @@ onMounted(loadAccounts)
|
||||
</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">
|
||||
<UCheckbox
|
||||
:model-value="allMessagesChecked ? true : checkedMessages.length ? 'indeterminate' : false"
|
||||
@@ -925,7 +889,7 @@ onMounted(loadAccounts)
|
||||
label="Alle angezeigten auswählen"
|
||||
@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>
|
||||
<template v-if="checkedMessages.length">
|
||||
<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>
|
||||
</div>
|
||||
</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 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' : ''">
|
||||
<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
|
||||
: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="[
|
||||
selectedMessage?.id === message.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-(--ui-bg-muted)',
|
||||
@@ -993,8 +957,8 @@ onMounted(loadAccounts)
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main :inert="!!bulkAction" class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)">
|
||||
<div v-if="loadingMessage" class="space-y-4 p-6">
|
||||
<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" />
|
||||
@@ -1011,7 +975,7 @@ onMounted(loadAccounts)
|
||||
{{ formatDetailDate(selectedMessage.receivedAt || selectedMessage.sentAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<div :inert="bulkDisabled" class="flex shrink-0 items-center gap-2">
|
||||
<USelectMenu
|
||||
v-model="moveTargetMailboxPath"
|
||||
:items="moveMailboxOptions"
|
||||
@@ -1128,7 +1092,9 @@ onMounted(loadAccounts)
|
||||
</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"
|
||||
|
||||
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