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
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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user