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[]) { export function buildEntitySuggestionRequest(
const central = Boolean(secrets.FEDEO_CENTRAL_SERVICES_ENABLED && centralServicesClient.configured()) mail: ReturnType<typeof buildSuggestionMail>,
if (!central && !secrets.OPENAI_API_KEY) { candidates: EntityCandidate[],
throw Object.assign(new Error("Die KI-Erkennung ist noch nicht konfiguriert."), { statusCode: 503 }) model = "gpt-5.6-luna",
} ) {
const mail = buildSuggestionMail(message) return {
const candidates = selectCandidates(allCandidates, JSON.stringify(mail)) model,
if (!candidates.length) return [] ...(model.startsWith("gpt-5") ? { reasoning_effort: "none" } : {}),
const request: any = {
model: "gpt-5.6-luna",
reasoning_effort: "none",
store: false, store: false,
max_completion_tokens: 1500, max_completion_tokens: 1500,
response_format: zodResponseFormat(suggestionFormat as any, "email_entity_suggestions"), 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 }) }, { 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] const choice = completion.choices?.[0]
if (choice?.finish_reason !== "stop" || choice?.message?.refusal || !choice?.message?.content) { 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.") 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 test from "node:test"
import { import {
buildSuggestionMail, buildSuggestionMail,
buildEntitySuggestionRequest,
dismissEntitySuggestion, dismissEntitySuggestion,
selectCandidates, selectCandidates,
shouldRetryWithLegacyAiModel,
validateSuggestions, validateSuggestions,
type EntityCandidate, type EntityCandidate,
} from "../src/modules/email/email.entity-suggestions" } from "../src/modules/email/email.entity-suggestions"
@@ -69,3 +71,22 @@ test("dismisses only the selected entity suggestion", () => {
) )
assert.deepEqual(dismissEntitySuggestion(null, "customers", 1), []) 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 ## 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. 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 checkedMessageIds = ref<string[]>([])
const bulkAction = ref("") const bulkAction = ref("")
const bulkProgress = ref(0) const bulkProgress = ref(0)
const bulkTotal = ref(0)
const pendingReadIds = ref<string[]>([])
let messageRequest = 0
const bulkMoveTarget = ref("") const bulkMoveTarget = ref("")
const search = ref("") const search = ref("")
const loadingAccounts = ref(true) const loadingAccounts = ref(true)
@@ -198,7 +201,7 @@ const checkedMessages = computed(() =>
const allMessagesChecked = computed(() => const allMessagesChecked = computed(() =>
checkedMessages.value.length > 0 && checkedMessages.value.length === filteredMessages.value.length 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(() => const bulkMoveOptions = computed(() =>
mailboxes.value.filter((mailbox) => mailbox.path !== selectedMailboxPath.value) mailboxes.value.filter((mailbox) => mailbox.path !== selectedMailboxPath.value)
.map((mailbox) => ({ label: mailboxLabel(mailbox), value: mailbox.path })) .map((mailbox) => ({ label: mailboxLabel(mailbox), value: mailbox.path }))
@@ -229,35 +232,18 @@ async function runBulkAction(action: "read" | "unread" | "archive" | "delete" |
bulkAction.value = action bulkAction.value = action
bulkProgress.value = 0 bulkProgress.value = 0
bulkTotal.value = batch.length
const failedIds: string[] = [] const failedIds: string[] = []
let lastError = "" let lastError = ""
// Apply the entire selection before starting the first server request.
const operations = batch.map((message) => prepareMessageAction(message, action, target))
checkedMessageIds.value = []
try { try {
for (const message of batch) { for (const [index, operation] of operations.entries()) {
try { try {
if (action === "read" || action === "unread") { await operation()
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) { } catch (err: any) {
failedIds.push(message.id) failedIds.push(batch[index].id)
lastError = err?.data?.error || err?.message || "Unbekannter Fehler" lastError = err?.data?.error || err?.message || "Unbekannter Fehler"
} }
bulkProgress.value++ bulkProgress.value++
@@ -437,6 +423,8 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
checkedMessageIds.value = [] checkedMessageIds.value = []
bulkMoveTarget.value = "" bulkMoveTarget.value = ""
selectedMessage.value = null selectedMessage.value = null
++messageRequest
loadingMessage.value = false
try { try {
messages.value = await fetchMessages(selectedMailboxPath.value) messages.value = await fetchMessages(selectedMailboxPath.value)
@@ -450,7 +438,7 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
if (messages.value.length) { if (messages.value.length) {
const requestedMessageId = deepLinkApplied ? "" : String(route.query.message || "") const requestedMessageId = deepLinkApplied ? "" : String(route.query.message || "")
const requestedMessage = messages.value.find((message) => message.id === requestedMessageId) const requestedMessage = messages.value.find((message) => message.id === requestedMessageId)
await selectMessage(requestedMessage || messages.value[0]) void selectMessage(requestedMessage || messages.value[0])
deepLinkApplied = true deepLinkApplied = true
} }
} finally { } finally {
@@ -468,23 +456,26 @@ async function fetchMessages(mailboxPath: string) {
} }
async function selectMailbox(mailbox: EmailMailbox) { async function selectMailbox(mailbox: EmailMailbox) {
if (bulkAction.value) return if (bulkDisabled.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 const request = ++messageRequest
selectedMessage.value = message
moveTargetMailboxPath.value = ""
loadingMessage.value = true loadingMessage.value = true
if (!message.seen && !bulkAction.value) void setMessageSeen(message.id, true)
try { try {
selectedMessage.value = await $api(`/api/email/messages/${message.id}`) const detail = await $api(`/api/email/messages/${message.id}`)
moveTargetMailboxPath.value = "" if (request !== messageRequest || selectedMessage.value?.id !== message.id) return
if (!message.seen) { selectedMessage.value = { ...detail, seen: messages.value.find((item) => item.id === message.id)?.seen ?? detail.seen }
await setMessageSeen(message.id, true) } catch (err: any) {
} if (request === messageRequest) toast.add({ title: "E-Mail konnte nicht geladen werden", description: err?.data?.error || err?.message, color: "error" })
} finally { } finally {
loadingMessage.value = false if (request === messageRequest) loadingMessage.value = false
} }
} }
@@ -504,116 +495,107 @@ function openComposer(mode: "reply" | "replyAll" | "forward") {
}) })
} }
function removeMessageFromCurrentList(messageId: string) { type MessageAction = "read" | "unread" | "archive" | "delete" | "move"
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 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 function prepareMessageAction(message: EmailMessage, action: MessageAction, target = "") {
selectedMessage.value = null 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) { return async () => {
selectMessage(nextMessage) 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() { async function archiveSelectedMessage() {
if (!selectedMessage.value) return if (selectedMessage.value) await runSingleMessageAction(selectedMessage.value, "archive")
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 = ""
}
} }
async function deleteSelectedMessage() { async function deleteSelectedMessage() {
if (!selectedMessage.value) return if (selectedMessage.value) await runSingleMessageAction(selectedMessage.value, "delete")
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 = ""
}
} }
async function moveSelectedMessage() { async function moveSelectedMessage() {
if (!selectedMessage.value || !moveTargetMailboxPath.value) return if (!selectedMessage.value || !moveTargetMailboxPath.value) return
const target = moveTargetMailboxPath.value
await moveMessageToMailbox(selectedMessage.value.id, moveTargetMailboxPath.value, "move")
moveTargetMailboxPath.value = "" moveTargetMailboxPath.value = ""
await moveMessageToMailbox(selectedMessage.value.id, target)
} }
async function moveMessageToMailbox(messageId: string, mailboxPath: string, loadingKey = `move-${messageId}`) { async function moveMessageToMailbox(messageId: string, mailboxPath: string) {
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
await runSingleMessageAction(message, "move", mailboxPath)
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 = ""
}
} }
function startMessageDrag(event: DragEvent, message: EmailMessage) { function startMessageDrag(event: DragEvent, message: EmailMessage) {
@@ -690,34 +672,17 @@ async function downloadAttachment(attachment: NonNullable<EmailMessage["attachme
} }
async function setMessageSeen(messageId: string, seen: boolean) { async function setMessageSeen(messageId: string, seen: boolean) {
const previousMessage = messages.value.find((message) => message.id === messageId) if (pendingReadIds.value.includes(messageId) || bulkAction.value) return
const message = messages.value.find((item) => item.id === messageId)
const res = await $api(`/api/email/messages/${messageId}/read`, { if (!message || Boolean(message.seen) === seen) return
method: "POST", pendingReadIds.value = [...pendingReadIds.value, messageId]
body: { seen }, const operation = prepareMessageAction(message, seen ? "read" : "unread")
}) try {
await operation()
messages.value = messages.value.map((message) => } catch (err: any) {
message.id === messageId ? { ...message, seen } : message 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)
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),
}
})
} }
} }
@@ -796,7 +761,6 @@ 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"
@@ -818,7 +782,7 @@ onMounted(loadAccounts)
variant="soft" variant="soft"
size="sm" size="sm"
:loading="syncing" :loading="syncing"
:disabled="!selectedAccountId || !!bulkAction" :disabled="!selectedAccountId || bulkDisabled"
@click="syncAccount" @click="syncAccount"
> >
Aktualisieren Aktualisieren
@@ -833,7 +797,7 @@ onMounted(loadAccounts)
<USelectMenu <USelectMenu
v-else-if="accounts.length" v-else-if="accounts.length"
v-model="selectedAccountId" v-model="selectedAccountId"
:disabled="!!bulkAction" :disabled="bulkDisabled"
:items="accounts" :items="accounts"
label-key="displayName" label-key="displayName"
value-key="id" value-key="id"
@@ -868,7 +832,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" :disabled="bulkDisabled"
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)',
@@ -917,7 +881,7 @@ 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 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"> <div class="flex items-center justify-between gap-2">
<UCheckbox <UCheckbox
:model-value="allMessagesChecked ? true : checkedMessages.length ? 'indeterminate' : false" :model-value="allMessagesChecked ? true : checkedMessages.length ? 'indeterminate' : false"
@@ -925,7 +889,7 @@ onMounted(loadAccounts)
label="Alle angezeigten auswählen" label="Alle angezeigten auswählen"
@update:model-value="toggleAllMessages($event === true)" @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> </div>
<template v-if="checkedMessages.length"> <template v-if="checkedMessages.length">
<div class="flex flex-wrap gap-2"> <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> <UButton size="sm" color="neutral" variant="soft" :disabled="bulkDisabled || !bulkMoveTarget" @click="runBulkAction('move')">Verschieben</UButton>
</div> </div>
</template> </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>
<div v-if="loadingMessages" class="space-y-2 p-3"> <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' : ''"> <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)" /> <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 <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="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)',
@@ -993,8 +957,8 @@ onMounted(loadAccounts)
</div> </div>
</section> </section>
<main :inert="!!bulkAction" class="min-w-0 flex-1 overflow-y-auto bg-(--ui-bg)"> <main 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 && !selectedMessage" 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" />
<USkeleton class="h-80" /> <USkeleton class="h-80" />
@@ -1011,7 +975,7 @@ onMounted(loadAccounts)
{{ formatDetailDate(selectedMessage.receivedAt || selectedMessage.sentAt) }} {{ formatDetailDate(selectedMessage.receivedAt || selectedMessage.sentAt) }}
</p> </p>
</div> </div>
<div class="flex shrink-0 items-center gap-2"> <div :inert="bulkDisabled" class="flex shrink-0 items-center gap-2">
<USelectMenu <USelectMenu
v-model="moveTargetMailboxPath" v-model="moveTargetMailboxPath"
:items="moveMailboxOptions" :items="moveMailboxOptions"
@@ -1128,7 +1092,9 @@ onMounted(loadAccounts)
</div> </div>
<div class="flex-1 p-6"> <div class="flex-1 p-6">
<USkeleton v-if="loadingMessage" class="h-80" />
<iframe <iframe
v-else
title="E-Mail Inhalt" title="E-Mail Inhalt"
class="h-[calc(100vh-360px)] min-h-[420px] w-full" class="h-[calc(100vh-360px)] min-h-[420px] w-full"
sandbox="allow-popups allow-popups-to-escape-sandbox" 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)
})