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 selectedAccountId = ref("")
|
||||||
const selectedMailboxPath = ref("INBOX")
|
const selectedMailboxPath = ref("INBOX")
|
||||||
const selectedMessage = ref<EmailMessage | null>(null)
|
const selectedMessage = ref<EmailMessage | null>(null)
|
||||||
|
const checkedMessageIds = ref<string[]>([])
|
||||||
|
const bulkAction = ref("")
|
||||||
|
const bulkProgress = ref(0)
|
||||||
|
const bulkMoveTarget = ref("")
|
||||||
const search = ref("")
|
const search = ref("")
|
||||||
const loadingAccounts = ref(true)
|
const loadingAccounts = ref(true)
|
||||||
const loadingMailboxes = ref(false)
|
const loadingMailboxes = ref(false)
|
||||||
@@ -188,6 +192,89 @@ const filteredMessages = computed(() => {
|
|||||||
].some((value) => String(value || "").toLowerCase().includes(needle)))
|
].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(() =>
|
const moveMailboxOptions = computed(() =>
|
||||||
mailboxes.value
|
mailboxes.value
|
||||||
.filter((mailbox) => mailbox.path !== selectedMessage.value?.mailboxPath)
|
.filter((mailbox) => mailbox.path !== selectedMessage.value?.mailboxPath)
|
||||||
@@ -347,6 +434,8 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
|
|||||||
if (!selectedAccountId.value || !selectedMailboxPath.value) return
|
if (!selectedAccountId.value || !selectedMailboxPath.value) return
|
||||||
|
|
||||||
loadingMessages.value = true
|
loadingMessages.value = true
|
||||||
|
checkedMessageIds.value = []
|
||||||
|
bulkMoveTarget.value = ""
|
||||||
selectedMessage.value = null
|
selectedMessage.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -379,12 +468,14 @@ async function fetchMessages(mailboxPath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectMailbox(mailbox: EmailMailbox) {
|
async function selectMailbox(mailbox: EmailMailbox) {
|
||||||
|
if (bulkAction.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
|
||||||
loadingMessage.value = true
|
loadingMessage.value = true
|
||||||
try {
|
try {
|
||||||
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
||||||
@@ -416,6 +507,7 @@ function openComposer(mode: "reply" | "replyAll" | "forward") {
|
|||||||
function removeMessageFromCurrentList(messageId: string) {
|
function removeMessageFromCurrentList(messageId: string) {
|
||||||
const currentIndex = messages.value.findIndex((message) => message.id === messageId)
|
const currentIndex = messages.value.findIndex((message) => message.id === messageId)
|
||||||
const wasSelected = selectedMessage.value?.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)
|
messages.value = messages.value.filter((message) => message.id !== messageId)
|
||||||
|
|
||||||
if (!wasSelected) return
|
if (!wasSelected) return
|
||||||
@@ -478,6 +570,7 @@ async function moveSelectedMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function moveMessageToMailbox(messageId: string, mailboxPath: string, loadingKey = `move-${messageId}`) {
|
async function moveMessageToMailbox(messageId: string, mailboxPath: string, loadingKey = `move-${messageId}`) {
|
||||||
|
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
|
||||||
|
|
||||||
@@ -524,7 +617,7 @@ async function moveMessageToMailbox(messageId: string, mailboxPath: string, load
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startMessageDrag(event: DragEvent, message: EmailMessage) {
|
function startMessageDrag(event: DragEvent, message: EmailMessage) {
|
||||||
if (!event.dataTransfer || actionLoading.value) {
|
if (!event.dataTransfer || actionLoading.value || bulkAction.value) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -703,6 +796,7 @@ 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"
|
||||||
@@ -724,7 +818,7 @@ onMounted(loadAccounts)
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
size="sm"
|
size="sm"
|
||||||
:loading="syncing"
|
:loading="syncing"
|
||||||
:disabled="!selectedAccountId"
|
:disabled="!selectedAccountId || !!bulkAction"
|
||||||
@click="syncAccount"
|
@click="syncAccount"
|
||||||
>
|
>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
@@ -739,6 +833,7 @@ onMounted(loadAccounts)
|
|||||||
<USelectMenu
|
<USelectMenu
|
||||||
v-else-if="accounts.length"
|
v-else-if="accounts.length"
|
||||||
v-model="selectedAccountId"
|
v-model="selectedAccountId"
|
||||||
|
:disabled="!!bulkAction"
|
||||||
:items="accounts"
|
:items="accounts"
|
||||||
label-key="displayName"
|
label-key="displayName"
|
||||||
value-key="id"
|
value-key="id"
|
||||||
@@ -773,6 +868,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"
|
||||||
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)',
|
||||||
@@ -821,6 +917,32 @@ 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 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">
|
<div v-if="loadingMessages" class="space-y-2 p-3">
|
||||||
<USkeleton v-for="i in 8" :key="i" class="h-20" />
|
<USkeleton v-for="i in 8" :key="i" class="h-20" />
|
||||||
</div>
|
</div>
|
||||||
@@ -831,10 +953,11 @@ onMounted(loadAccounts)
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="divide-y divide-(--ui-border)">
|
<div v-else class="divide-y divide-(--ui-border)">
|
||||||
<button
|
<div v-for="message in filteredMessages" :key="message.id" class="flex items-start" :class="checkedMessageIds.includes(message.id) ? 'bg-primary/5' : ''">
|
||||||
v-for="message in filteredMessages"
|
<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)" />
|
||||||
:key="message.id"
|
<button
|
||||||
class="block w-full cursor-grab border-l-2 px-4 py-3 text-left transition-colors active:cursor-grabbing"
|
: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="[
|
: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)',
|
||||||
draggedMessageId === message.id ? 'opacity-50' : '',
|
draggedMessageId === message.id ? 'opacity-50' : '',
|
||||||
@@ -865,11 +988,12 @@ onMounted(loadAccounts)
|
|||||||
{{ message.preview || 'Keine Vorschau' }}
|
{{ message.preview || 'Keine Vorschau' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div v-if="loadingMessage" 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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user