feat(email): add selection and bulk message actions
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 23s
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
Build and Push Docker Images / build-website (push) Successful in 25s
Build and Push Docker Images / build-central-services-api (push) Successful in 22s
Build and Push Docker Images / build-central-services-admin (push) Successful in 22s
Build and Push Docker Images / build-docs (push) Successful in 21s

This commit is contained in:
2026-09-08 09:56:38 +02:00
parent 1890d1d97d
commit 41e4cad929

View File

@@ -75,6 +75,10 @@ const messages = ref<EmailMessage[]>([])
const selectedAccountId = ref("")
const selectedMailboxPath = ref("INBOX")
const selectedMessage = ref<EmailMessage | null>(null)
const checkedMessageIds = ref<string[]>([])
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)
<template #left>
<UInput
v-model="search"
:disabled="!!bulkAction"
icon="i-heroicons-magnifying-glass"
size="sm"
class="w-72"
@@ -724,7 +818,7 @@ onMounted(loadAccounts)
variant="soft"
size="sm"
:loading="syncing"
:disabled="!selectedAccountId"
:disabled="!selectedAccountId || !!bulkAction"
@click="syncAccount"
>
Aktualisieren
@@ -739,6 +833,7 @@ onMounted(loadAccounts)
<USelectMenu
v-else-if="accounts.length"
v-model="selectedAccountId"
:disabled="!!bulkAction"
:items="accounts"
label-key="displayName"
value-key="id"
@@ -773,6 +868,7 @@ onMounted(loadAccounts)
<button
v-for="row in mailboxRows"
:key="row.mailbox.id"
:disabled="!!bulkAction"
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)',
@@ -821,6 +917,32 @@ 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 class="flex items-center justify-between gap-2">
<UCheckbox
:model-value="allMessagesChecked ? true : checkedMessages.length ? 'indeterminate' : false"
:disabled="bulkDisabled"
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>
</div>
<template v-if="checkedMessages.length">
<div class="flex flex-wrap gap-2">
<UButton size="xs" color="neutral" variant="soft" :disabled="bulkDisabled" @click="runBulkAction('read')">Gelesen</UButton>
<UButton size="xs" color="neutral" variant="soft" :disabled="bulkDisabled" @click="runBulkAction('unread')">Ungelesen</UButton>
<UButton size="xs" color="neutral" variant="soft" :disabled="bulkDisabled" @click="runBulkAction('archive')">Archivieren</UButton>
<UButton size="xs" color="error" variant="soft" :disabled="bulkDisabled" @click="runBulkAction('delete')">Löschen</UButton>
<UButton size="xs" color="neutral" variant="ghost" :disabled="bulkDisabled" @click="toggleAllMessages(false)">Auswahl aufheben</UButton>
</div>
<div class="flex gap-2">
<USelectMenu v-model="bulkMoveTarget" :items="bulkMoveOptions" value-key="value" label-key="label" placeholder="Verschieben nach" size="sm" class="min-w-0 flex-1" :disabled="bulkDisabled" />
<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>
</div>
<div v-if="loadingMessages" class="space-y-2 p-3">
<USkeleton v-for="i in 8" :key="i" class="h-20" />
</div>
@@ -831,10 +953,11 @@ onMounted(loadAccounts)
/>
<div v-else class="divide-y divide-(--ui-border)">
<button
v-for="message in filteredMessages"
:key="message.id"
class="block w-full cursor-grab border-l-2 px-4 py-3 text-left transition-colors active:cursor-grabbing"
<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)',
draggedMessageId === message.id ? 'opacity-50' : '',
@@ -865,11 +988,12 @@ onMounted(loadAccounts)
{{ message.preview || 'Keine Vorschau' }}
</p>
</div>
</button>
</button>
</div>
</div>
</section>
<main class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)">
<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">
<USkeleton class="h-8 w-2/3" />
<USkeleton class="h-5 w-1/3" />