diff --git a/frontend/pages/email/index.vue b/frontend/pages/email/index.vue index 46ab9fa..8d28f61 100644 --- a/frontend/pages/email/index.vue +++ b/frontend/pages/email/index.vue @@ -78,6 +78,9 @@ const selectedMessage = ref(null) const checkedMessageIds = ref([]) const bulkAction = ref("") const bulkProgress = ref(0) +const bulkTotal = ref(0) +const pendingReadIds = ref([]) +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 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)