Compare commits

...

2 Commits

Author SHA1 Message Date
1c39b69513 perf(email): update message actions optimistically
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 46s
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 21s
Build and Push Docker Images / build-docs (push) Successful in 1m21s
2026-09-08 10:18:00 +02:00
a60570e0eb fix(email): support legacy AI gateway models 2026-09-08 10:07:13 +02:00
5 changed files with 286 additions and 179 deletions

View File

@@ -75,17 +75,14 @@ export function buildSuggestionMail(message: any) {
}
}
export async function suggestEmailEntities(message: any, allCandidates: EntityCandidate[]) {
const central = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())
if (!central && !secrets.OPENAI_API_KEY) {
throw Object.assign(new Error("Die KI-Erkennung ist noch nicht konfiguriert."), { statusCode: 503 })
}
const mail = buildSuggestionMail(message)
const candidates = selectCandidates(allCandidates, JSON.stringify(mail))
if (!candidates.length) return []
const request: any = {
model: "gpt-5.6-luna",
reasoning_effort: "none",
export function buildEntitySuggestionRequest(
mail: ReturnType<typeof buildSuggestionMail>,
candidates: EntityCandidate[],
model = "gpt-5.6-luna",
) {
return {
model,
...(model.startsWith("gpt-5") ? { reasoning_effort: "none" } : {}),
store: false,
max_completion_tokens: 1500,
response_format: zodResponseFormat(suggestionFormat as any, "email_entity_suggestions"),
@@ -94,9 +91,35 @@ export async function suggestEmailEntities(message: any, allCandidates: EntityCa
{ role: "user", content: JSON.stringify({ mail, candidates }) },
],
}
const completion = central
? await centralServicesClient.aiChatCompletions(request)
: await new OpenAI({ apiKey: secrets.OPENAI_API_KEY, timeout: 45000, maxRetries: 0 }).chat.completions.create(request)
}
export function shouldRetryWithLegacyAiModel(error: any) {
return [400, 404, 422, 500, 502].includes(Number(error?.status))
}
export async function suggestEmailEntities(message: any, allCandidates: EntityCandidate[]) {
const central = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured())
if (!central && !secrets.OPENAI_API_KEY) {
throw Object.assign(new Error("Die KI-Erkennung ist noch nicht konfiguriert."), { statusCode: 503 })
}
const mail = buildSuggestionMail(message)
const candidates = selectCandidates(allCandidates, JSON.stringify(mail))
if (!candidates.length) return []
const request: any = buildEntitySuggestionRequest(mail, candidates)
let completion: any
if (central) {
try {
completion = await centralServicesClient.aiChatCompletions(request)
} catch (error: any) {
if (!shouldRetryWithLegacyAiModel(error)) throw error
completion = await centralServicesClient.aiChatCompletions(
buildEntitySuggestionRequest(mail, candidates, "gpt-4o-mini"),
)
}
} else {
completion = await new OpenAI({ apiKey: secrets.OPENAI_API_KEY, timeout: 45000, maxRetries: 0 })
.chat.completions.create(request)
}
const choice = completion.choices?.[0]
if (choice?.finish_reason !== "stop" || choice?.message?.refusal || !choice?.message?.content) {
throw new Error("Die KI konnte keine vollständige Analyse liefern. Bitte erneut versuchen.")

View File

@@ -2,8 +2,10 @@ import assert from "node:assert/strict"
import test from "node:test"
import {
buildSuggestionMail,
buildEntitySuggestionRequest,
dismissEntitySuggestion,
selectCandidates,
shouldRetryWithLegacyAiModel,
validateSuggestions,
type EntityCandidate,
} from "../src/modules/email/email.entity-suggestions"
@@ -69,3 +71,22 @@ test("dismisses only the selected entity suggestion", () => {
)
assert.deepEqual(dismissEntitySuggestion(null, "customers", 1), [])
})
test("uses Luna by default and omits reasoning effort for the legacy fallback", () => {
const request = buildEntitySuggestionRequest(buildSuggestionMail({ subject: "Test" }), [customer])
assert.equal(request.model, "gpt-5.6-luna")
assert.equal(request.reasoning_effort, "none")
const fallback = buildEntitySuggestionRequest(buildSuggestionMail({ subject: "Test" }), [customer], "gpt-4o-mini")
assert.equal(fallback.model, "gpt-4o-mini")
assert.equal("reasoning_effort" in fallback, false)
})
test("retries model compatibility errors without retrying auth, rate-limit, or configuration errors", () => {
for (const status of [400, 404, 422, 500, 502]) {
assert.equal(shouldRetryWithLegacyAiModel({ status }), true)
}
for (const status of [401, 403, 429, 503]) {
assert.equal(shouldRetryWithLegacyAiModel({ status }), false)
}
})

View File

@@ -6,7 +6,7 @@ Die Analyse wird pro Mail gespeichert, auch wenn sie keine Treffer findet. Erneu
## Betrieb
Vor dem Einsatz die Backend-Migrationen mit `npm run migrate` im Backend-Verzeichnis ausführen. Migration `0068_email_entity_suggestions` ergänzt den Ergebnisspeicher. Migration `0069_reset_email_entity_suggestions` verwirft bisherige KI-Vorschläge, damit sie beim nächsten Öffnen ohne Empfängerbelege neu berechnet werden. Bestehende Verknüpfungen bleiben erhalten. Die Erkennung nutzt wie die bestehende Rechnungserkennung den konfigurierten zentralen KI-Dienst oder `OPENAI_API_KEY`, mit dem im Erkennungsdienst konfigurierten Modell. Ohne KI-Konfiguration erscheint eine Meldung im Postfach.
Vor dem Einsatz die Backend-Migrationen mit `npm run migrate` im Backend-Verzeichnis ausführen. Migration `0068_email_entity_suggestions` ergänzt den Ergebnisspeicher. Migration `0069_reset_email_entity_suggestions` verwirft bisherige KI-Vorschläge, damit sie beim nächsten Öffnen ohne Empfängerbelege neu berechnet werden. Bestehende Verknüpfungen bleiben erhalten. Die Erkennung nutzt wie die bestehende Rechnungserkennung den konfigurierten zentralen KI-Dienst oder `OPENAI_API_KEY`. Sie fragt standardmäßig `gpt-5.6-luna` an. Lehnt ein älterer zentraler Dienst dieses Modell als inkompatibel ab, wird der Aufruf einmalig mit `gpt-4o-mini` wiederholt. Ohne KI-Konfiguration erscheint eine Meldung im Postfach.
Empfänger- und CC-Kopfdaten werden weder für die Vorauswahl noch als KI-Eingabe verwendet. Empfängerangaben in zitierten Mailköpfen dürfen laut Analyseanweisung keine Zuordnung begründen. Übermittelt werden Betreff, Absender und bis zu 16.000 Zeichen Mailtext (ersatzweise HTML ohne Tags oder Vorschautext). Anhänge werden nicht analysiert. Stammdaten werden auf den aktuellen Mandanten und nicht archivierte Einträge begrenzt; übertragen werden Name, Typ, ID, Nummer und vorhandene E-Mail-Adressen. Bei mehr als 150 Einträgen priorisiert eine lokale Vorauswahl passende Adressen, Nummern und Namen. Dadurch können bei großen Datenbeständen rein semantische Zusammenhänge außerhalb dieser Vorauswahl unentdeckt bleiben.

View File

@@ -78,6 +78,9 @@ const selectedMessage = ref<EmailMessage | null>(null)
const checkedMessageIds = ref<string[]>([])
const bulkAction = ref("")
const bulkProgress = ref(0)
const bulkTotal = ref(0)
const pendingReadIds = ref<string[]>([])
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<EmailMessage["attachme
}
async function setMessageSeen(messageId: string, seen: boolean) {
const previousMessage = messages.value.find((message) => 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)
<template #left>
<UInput
v-model="search"
:disabled="!!bulkAction"
icon="i-heroicons-magnifying-glass"
size="sm"
class="w-72"
@@ -818,7 +782,7 @@ onMounted(loadAccounts)
variant="soft"
size="sm"
:loading="syncing"
:disabled="!selectedAccountId || !!bulkAction"
:disabled="!selectedAccountId || bulkDisabled"
@click="syncAccount"
>
Aktualisieren
@@ -833,7 +797,7 @@ onMounted(loadAccounts)
<USelectMenu
v-else-if="accounts.length"
v-model="selectedAccountId"
:disabled="!!bulkAction"
:disabled="bulkDisabled"
:items="accounts"
label-key="displayName"
value-key="id"
@@ -868,7 +832,7 @@ onMounted(loadAccounts)
<button
v-for="row in mailboxRows"
:key="row.mailbox.id"
:disabled="!!bulkAction"
:disabled="bulkDisabled"
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)',
@@ -917,7 +881,7 @@ 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 v-if="filteredMessages.length || bulkAction || actionLoading" 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"
@@ -925,7 +889,7 @@ onMounted(loadAccounts)
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>
<span class="text-xs text-dimmed" aria-live="polite">{{ checkedMessages.length }} ausgewählt</span>
</div>
<template v-if="checkedMessages.length">
<div class="flex flex-wrap gap-2">
@@ -940,7 +904,8 @@ onMounted(loadAccounts)
<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>
<p v-if="bulkAction" role="status" class="text-xs text-dimmed">{{ bulkProgress }} von {{ bulkTotal }} E-Mails gespeichert </p>
<p v-else-if="actionLoading || pendingReadIds.length" role="status" class="text-xs text-dimmed">Änderungen werden gespeichert </p>
</div>
<div v-if="loadingMessages" class="space-y-2 p-3">
@@ -956,7 +921,6 @@ onMounted(loadAccounts)
<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)',
@@ -993,8 +957,8 @@ onMounted(loadAccounts)
</div>
</section>
<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">
<main class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)">
<div v-if="loadingMessage && !selectedMessage" class="space-y-4 p-6">
<USkeleton class="h-8 w-2/3" />
<USkeleton class="h-5 w-1/3" />
<USkeleton class="h-80" />
@@ -1011,7 +975,7 @@ onMounted(loadAccounts)
{{ formatDetailDate(selectedMessage.receivedAt || selectedMessage.sentAt) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<div :inert="bulkDisabled" class="flex shrink-0 items-center gap-2">
<USelectMenu
v-model="moveTargetMailboxPath"
:items="moveMailboxOptions"
@@ -1128,7 +1092,9 @@ onMounted(loadAccounts)
</div>
<div class="flex-1 p-6">
<USkeleton v-if="loadingMessage" class="h-80" />
<iframe
v-else
title="E-Mail Inhalt"
class="h-[calc(100vh-360px)] min-h-[420px] w-full"
sandbox="allow-popups allow-popups-to-escape-sandbox"

View File

@@ -0,0 +1,97 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
// Exercise the page's real handlers with delayed API responses and Vue ref stubs.
function setup() {
const requests = []
const notices = []
const context = vm.createContext({
ref: value => ({ value }),
computed: getter => ({ get value() { return getter() } }),
watch() {}, onMounted() {},
useRoute: () => ({ query: {} }), useRuntimeConfig: () => ({}),
useToast: () => ({ add: notice => notices.push(notice) }),
window: { confirm: () => true },
useNuxtApp: () => ({ $api: (url, options) => new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })) }),
})
const source = fs.readFileSync(`${__dirname}/../pages/email/index.vue`, 'utf8')
.split('<script setup lang="ts">')[1].split('</script>')[0].replace(/^import .*$/gm, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }), context)
const run = code => vm.runInContext(code, context)
run(`selectedAccountId.value='account'; messages.value=[{id:'a',mailboxPath:'INBOX',seen:false},{id:'b',mailboxPath:'INBOX',seen:false}]; mailboxes.value=[{path:'INBOX',name:'INBOX',exists:2,unseen:2},{path:'Archive',name:'Archive',exists:0,unseen:0}];`)
return { run, requests, notices }
}
test('whole bulk selection disappears before response; partial failure restores only failed mail and counters', async () => {
const { run, requests } = setup()
run(`toggleAllMessages(true); bulkMoveTarget.value='Archive'`)
const pending = run(`runBulkAction('move')`)
assert.equal(run('messages.value.length'), 0)
assert.equal(run('mailboxes.value[1].exists'), 2)
assert.equal(requests.length, 1)
requests[0].resolve({ destinationMailboxPath: 'Archive' })
await new Promise(setImmediate)
requests[1].reject(Error('offline'))
await pending
assert.equal(run('messages.value.map(m=>m.id).join()'), 'b')
assert.equal(run('checkedMessageIds.value.join()'), 'b')
assert.equal(run('mailboxes.value[0].unseen'), 1)
assert.equal(run('mailboxes.value[1].exists'), 1)
})
test('bulk read updates every mail immediately and rolls back individual failures', async () => {
const { run, requests } = setup()
run('toggleAllMessages(true)')
const pending = run(`runBulkAction('read')`)
assert.equal(run('messages.value.every(m=>m.seen)'), true)
assert.equal(run('mailboxes.value[0].unseen'), 0)
requests[0].reject(Error('offline'))
await new Promise(setImmediate)
requests[1].resolve({})
await pending
assert.equal(run('messages.value[0].seen'), false)
assert.equal(run('messages.value[1].seen'), true)
assert.equal(run('mailboxes.value[0].unseen'), 1)
})
test('single deletion is immediate and restores details on failure', async () => {
const { run, requests } = setup()
run(`selectedMessage.value={...messages.value[0],body:{text:'Body'}}`)
const pending = run('deleteSelectedMessage()')
assert.equal(run('messages.value.length'), 1)
assert.equal(run('selectedMessage.value'), null)
requests[0].reject(Error('offline'))
await pending
assert.equal(run('messages.value.map(m=>m.id).join()'), 'a,b')
assert.equal(run('selectedMessage.value.body.text'), 'Body')
})
test('late message response cannot replace newer selection; header and read state are immediate', async () => {
const { run, requests } = setup()
const first = run('selectMessage(messages.value[0])')
assert.equal(run('selectedMessage.value.id'), 'a')
assert.equal(run('messages.value[0].seen'), true)
const second = run('selectMessage(messages.value[1])')
const detailRequests = requests.filter(request => !request.options)
detailRequests[1].resolve({ id: 'b', body: { text: 'B' }, seen: false })
await second
detailRequests[0].resolve({ id: 'a', body: { text: 'A' }, seen: false })
await first
assert.equal(run('selectedMessage.value.id'), 'b')
assert.equal(run('selectedMessage.value.seen'), true)
assert.equal(run('loadingMessage.value'), false)
requests.filter(request => request.options).forEach(request => request.resolve({}))
})
test('archiving in archive leaves the message in place', async () => {
const { run, requests } = setup()
run(`messages.value[0].mailboxPath='Archive';selectedMessage.value=messages.value[0]`)
const pending = run('archiveSelectedMessage()')
assert.equal(run('messages.value.length'), 2)
requests[0].resolve({ mailboxPath: 'Archive' })
await pending
assert.equal(run('messages.value.length'), 2)
})