Compare commits
2 Commits
41e4cad929
...
1c39b69513
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c39b69513 | |||
| a60570e0eb |
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
97
frontend/tests/email-actions.test.cjs
Normal file
97
frontend/tests/email-actions.test.cjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user