From 41e4cad9299571fbec2d855ad0638717de891579 Mon Sep 17 00:00:00 2001 From: flfeders Date: Tue, 8 Sep 2026 09:56:38 +0200 Subject: [PATCH] feat(email): add selection and bulk message actions --- frontend/pages/email/index.vue | 140 +++++++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 8 deletions(-) diff --git a/frontend/pages/email/index.vue b/frontend/pages/email/index.vue index f38406f..46ab9fa 100644 --- a/frontend/pages/email/index.vue +++ b/frontend/pages/email/index.vue @@ -75,6 +75,10 @@ const messages = ref([]) const selectedAccountId = ref("") const selectedMailboxPath = ref("INBOX") const selectedMessage = ref(null) +const checkedMessageIds = ref([]) +const bulkAction = ref("") +const bulkProgress = ref(0) +const bulkMoveTarget = ref("") const search = ref("") const loadingAccounts = ref(true) const loadingMailboxes = ref(false) @@ -188,6 +192,89 @@ const filteredMessages = computed(() => { ].some((value) => String(value || "").toLowerCase().includes(needle))) }) +const checkedMessages = computed(() => + filteredMessages.value.filter((message) => checkedMessageIds.value.includes(message.id)) +) +const allMessagesChecked = computed(() => + checkedMessages.value.length > 0 && checkedMessages.value.length === filteredMessages.value.length +) +const bulkDisabled = computed(() => Boolean(bulkAction.value || actionLoading.value || loadingMessages.value || loadingMessage.value || loadingMailboxes.value || syncing.value)) +const bulkMoveOptions = computed(() => + mailboxes.value.filter((mailbox) => mailbox.path !== selectedMailboxPath.value) + .map((mailbox) => ({ label: mailboxLabel(mailbox), value: mailbox.path })) +) + +function toggleMessageChecked(messageId: string, checked: boolean) { + if (bulkDisabled.value) return + checkedMessageIds.value = checked + ? [...new Set([...checkedMessageIds.value, messageId])] + : checkedMessageIds.value.filter((id) => id !== messageId) +} + +function toggleAllMessages(checked: boolean) { + if (bulkDisabled.value) return + checkedMessageIds.value = checked ? filteredMessages.value.map((message) => message.id) : [] +} + +watch(search, () => { + checkedMessageIds.value = checkedMessageIds.value.filter((id) => filteredMessages.value.some((message) => message.id === id)) +}) + +async function runBulkAction(action: "read" | "unread" | "archive" | "delete" | "move") { + if (bulkDisabled.value || !checkedMessages.value.length) return + const target = bulkMoveTarget.value + if (action === "move" && !target) return + const batch = [...checkedMessages.value] + if (action === "delete" && !window.confirm(`${batch.length} E-Mails endgültig löschen? Diese Aktion kann nicht rückgängig gemacht werden.`)) return + + bulkAction.value = action + bulkProgress.value = 0 + const failedIds: string[] = [] + let lastError = "" + try { + for (const message of batch) { + 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 + } + } + } catch (err: any) { + failedIds.push(message.id) + lastError = err?.data?.error || err?.message || "Unbekannter Fehler" + } + bulkProgress.value++ + } + checkedMessageIds.value = failedIds + const labels = { read: "als gelesen markiert", unread: "als ungelesen markiert", archive: "archiviert", delete: "gelöscht", move: "verschoben" } + toast.add({ + title: `${batch.length - failedIds.length} von ${batch.length} E-Mails ${labels[action]}`, + description: failedIds.length ? `${failedIds.length} fehlgeschlagen und weiterhin ausgewählt. ${lastError}` : undefined, + color: failedIds.length ? "error" : "success", + }) + if (!failedIds.length) bulkMoveTarget.value = "" + } finally { + bulkAction.value = "" + } +} + const moveMailboxOptions = computed(() => mailboxes.value .filter((mailbox) => mailbox.path !== selectedMessage.value?.mailboxPath) @@ -347,6 +434,8 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) { if (!selectedAccountId.value || !selectedMailboxPath.value) return loadingMessages.value = true + checkedMessageIds.value = [] + bulkMoveTarget.value = "" selectedMessage.value = null try { @@ -379,12 +468,14 @@ async function fetchMessages(mailboxPath: string) { } async function selectMailbox(mailbox: EmailMailbox) { + if (bulkAction.value) return selectedMailboxPath.value = mailbox.path expandMailboxAncestors(mailbox.path) await loadMessages({ syncIfEmpty: true }) } async function selectMessage(message: EmailMessage) { + if (bulkAction.value) return loadingMessage.value = true try { selectedMessage.value = await $api(`/api/email/messages/${message.id}`) @@ -416,6 +507,7 @@ 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) if (!wasSelected) return @@ -478,6 +570,7 @@ async function moveSelectedMessage() { } async function moveMessageToMailbox(messageId: string, mailboxPath: string, loadingKey = `move-${messageId}`) { + if (bulkAction.value) return const message = messages.value.find((item) => item.id === messageId) if (!message || message.mailboxPath === mailboxPath) return @@ -524,7 +617,7 @@ async function moveMessageToMailbox(messageId: string, mailboxPath: string, load } function startMessageDrag(event: DragEvent, message: EmailMessage) { - if (!event.dataTransfer || actionLoading.value) { + if (!event.dataTransfer || actionLoading.value || bulkAction.value) { event.preventDefault() return } @@ -703,6 +796,7 @@ onMounted(loadAccounts)