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

This commit is contained in:
2026-09-08 10:18:00 +02:00
parent a60570e0eb
commit 1c39b69513
2 changed files with 227 additions and 164 deletions

View File

@@ -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
selectedMessage.value = message
moveTargetMailboxPath.value = ""
loadingMessage.value = true loadingMessage.value = true
if (!message.seen && !bulkAction.value) void setMessageSeen(message.id, true)
try { try {
selectedMessage.value = await $api(`/api/email/messages/${message.id}`) const detail = await $api(`/api/email/messages/${message.id}`)
moveTargetMailboxPath.value = "" if (request !== messageRequest || selectedMessage.value?.id !== message.id) return
if (!message.seen) { selectedMessage.value = { ...detail, seen: messages.value.find((item) => item.id === message.id)?.seen ?? detail.seen }
await setMessageSeen(message.id, true) } 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 = "") {
selectedMessage.value = null 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) { 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"

View 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)
})