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[]) {
|
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.")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
loadingMessage.value = true
|
selectedMessage.value = message
|
||||||
try {
|
|
||||||
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
|
|
||||||
moveTargetMailboxPath.value = ""
|
moveTargetMailboxPath.value = ""
|
||||||
if (!message.seen) {
|
loadingMessage.value = true
|
||||||
await setMessageSeen(message.id, true)
|
if (!message.seen && !bulkAction.value) void setMessageSeen(message.id, true)
|
||||||
}
|
try {
|
||||||
|
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 {
|
} 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 = "") {
|
||||||
|
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
|
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"
|
||||||
|
|||||||
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