Ein Ablagefenster für Mehrfachauswahl + Sitzungs-/Retry-Fixes

Mehrfachauswahl öffnet jetzt EIN Ablagefenster (?messageIds=1,2,3) statt eines
Fensters je Nachricht. Der dafür schon vorhandene, bisher unerreichbare Multi-Pfad
in dialog.js hatte drei Fehler, die dabei aufgeschlagen wären:
- Der Anhang-Haken wurde aus der ERSTEN Mail abgeleitet: hatte die keine Anhänge,
  fielen die Anhänge aller übrigen Mails still weg.
- Die Anhangsliste zeigte die Anhänge von Mail 1 mit wirkungslosen Haken.
- Der „bereits abgelegt"-Banner galt nur für Mail 1, sah aber nach einer Aussage
  über die ganze Auswahl aus.

Weiter:
- Server-/Zugangsdaten-Wechsel verwirft jetzt auch die serverspezifischen Caches.
  auth:logout leert zusätzlich ARCHIVED_CACHE; mit scope:"server" fliegen die
  gecachte Schrankliste sowie defaultCabinetId/dialogByCabinet raus, weil deren
  GUIDs nur auf dem alten Server existieren.
- Retry nach einem Teilfehler lud bereits übertragene Teile erneut hoch und
  erzeugte Dubletten. Jeder Teil (eml/pdf/Anhang) wird nun je Ziel einmal
  hochgeladen; dieselbe Mail lässt sich dadurch auch bewusst in ein zweites
  Archiv ablegen.
- ARCHIVED_CACHE wächst nicht mehr unbegrenzt (500 Einträge, ältester fliegt).
- HTTP 401/403 liefern statt des nackten Statuscodes eine verständliche Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sylyx 2026-08-20 16:16:35 +02:00
parent d02228e4c9
commit e201e6c654
4 changed files with 204 additions and 58 deletions

View File

@ -30,8 +30,19 @@ browser.runtime.onMessage.addListener((msg) => {
})();
}
// Sitzung verwerfen. Immer: Token + Badge-Cache (der Cache ist serverspezifisch
// und würde sonst ein ✓ aus der alten Verbindung weiterzeigen).
// scope:"server" = die Server-URL hat sich geändert -> zusätzlich die gecachte
// Schrankliste wegwerfen, denn deren GUIDs gehören zum alten Server.
if (msg.type === "auth:logout") {
Auth.reset();
ARCHIVED_CACHE.clear();
if (msg.scope === "server") {
return browser.storage.local
.remove("cabinets")
.catch(() => {})
.then(() => ({ ok: true }));
}
return Promise.resolve({ ok: true });
}
@ -40,7 +51,7 @@ browser.runtime.onMessage.addListener((msg) => {
if (msg.type === "archive:done") {
const mid = DocuWare.normalizeMessageId(msg.rfcMessageId);
if (mid && msg.cabinetId) {
ARCHIVED_CACHE.set(`${mid}|${msg.cabinetId}`, { found: true, docId: msg.docId || null });
cacheArchived(`${mid}|${msg.cabinetId}`, { found: true, docId: msg.docId || null });
}
return Promise.resolve({ ok: true });
}
@ -54,6 +65,16 @@ browser.runtime.onMessage.addListener((msg) => {
// Schrank wird das Ergebnis im Speicher gecacht (eine Suche pro Mail).
const ARCHIVED_CACHE = new Map(); // "messageId|cabinetId" -> { found, docId }
const ARCHIVED_CACHE_MAX = 500; // Obergrenze: Thunderbird-Sitzungen laufen wochenlang
// Schreibt in den Badge-Cache und hält ihn klein (ältester Eintrag fliegt raus –
// Map-Iteration ist Einfügereihenfolge).
function cacheArchived(key, info) {
if (ARCHIVED_CACHE.size >= ARCHIVED_CACHE_MAX && !ARCHIVED_CACHE.has(key)) {
ARCHIVED_CACHE.delete(ARCHIVED_CACHE.keys().next().value);
}
ARCHIVED_CACHE.set(key, info);
}
async function rfcMessageIdOf(messageId) {
try {
@ -95,7 +116,7 @@ async function updateArchivedBadge(tab, message) {
() => null
);
info = { found: !!hit, docId: hit ? hit.id : null };
ARCHIVED_CACHE.set(key, info);
cacheArchived(key, info);
}
setBadge(tabId, info.found);
} catch (_) {
@ -110,19 +131,15 @@ browser.messageDisplay.onMessagesDisplayed.addListener((tab, displayed) => {
});
function openDialog(messageIds) {
const ids = Array.isArray(messageIds) ? messageIds : [messageIds];
// Pro Nachricht ein eigenes Ablagefenster (leicht versetzt gestapelt).
ids.forEach((id, i) => {
const url = browser.runtime.getURL(`${DIALOG_URL}?messageId=${id}`);
browser.windows.create({
url,
type: "popup",
width: 720,
height: 820,
left: 100 + i * 28,
top: 80 + i * 28,
});
});
const ids = (Array.isArray(messageIds) ? messageIds : [messageIds])
.map((id) => parseInt(id, 10))
.filter((n) => !isNaN(n));
if (!ids.length) return;
// EIN Ablagefenster für die gesamte Auswahl – dialog.js arbeitet die Liste
// nacheinander ab (Fortschritt „Mail 3/20"). Früher wurde pro Nachricht ein
// eigenes Fenster geöffnet, was bei größerer Auswahl unbrauchbar war.
const url = browser.runtime.getURL(`${DIALOG_URL}?messageIds=${ids.join(",")}`);
browser.windows.create({ url, type: "popup", width: 720, height: 820, left: 100, top: 80 });
}
// Button in der geöffneten Nachricht

View File

@ -15,7 +15,14 @@ let STATE = {
fields: [], // Felddefinitionen des Store-Dialogs
existingDoc: null, // Treffer der Dedup-Suche im gewählten Schrank (oder null)
cabinetFieldsByCab: {}, // Cache: cabinetId -> [Feldnamen] (für EML_ID-Erkennung)
uploadedIds: new Set(), // bereits erfolgreich abgelegte Nachrichten (für Retry-Schutz)
// Vollständig abgelegte Nachrichten je Ziel ("<cab>|<mid>") – Tag/Badge nur einmal.
// Ziel im Schlüssel, damit dieselbe Mail bewusst in ein zweites Archiv abgelegt
// werden kann, ohne den Dialog neu zu öffnen.
uploadedIds: new Set(),
// Bereits hochgeladene EINZELTEILE, damit ein Retry nach einem Teilfehler nichts
// doppelt hochlädt. Schlüssel inkl. Ziel-Schrank ("<cab>|<mid>|eml"), damit ein
// Wechsel des Ziels nach einem Fehlversuch wieder alles hochlädt.
uploadedParts: new Set(),
};
// Mail-Token -> Funktion, die den Wert aus den Mail-Metadaten liefert.
@ -88,10 +95,12 @@ async function init() {
return;
}
if (STATE.messageIds.length > 1) {
if (isMulti()) {
$("multiNote").textContent =
`${STATE.messageIds.length} E-Mails werden abgelegt. ` +
"Eingetragene Werte gelten für alle; Absender/Betreff/Datum werden je E-Mail einzeln übernommen.";
"Eingetragene Werte gelten für alle; zugeordnete Felder (Absender/Betreff/Datum …) " +
"werden je E-Mail einzeln übernommen. Anhänge werden – falls aktiviert – je E-Mail " +
"vollständig mit abgelegt. Angezeigt sind die Werte der ersten E-Mail.";
}
renderAttachments();
@ -165,10 +174,21 @@ function askPassword(errMsg) {
function applyDefaults() {
// Mail-Format (.eml ODER PDF) aus den Einstellungen.
setMailFormat(STATE.settings.mailFormat === "pdf" ? "pdf" : "eml");
// Anhänge-Haken nur sinnvoll, wenn die Mail überhaupt Anhänge hat.
const hasAtt = STATE.attachments.length > 0;
const att = $("optAtt");
att.checked = hasAtt && STATE.settings.storeAttachments !== false;
const want = STATE.settings.storeAttachments !== false;
if (isMulti()) {
// Mehrfachauswahl: STATE.attachments gehört nur zur ERSTEN Mail. Daraus darf
// nicht geschlossen werden, ob die übrigen Anhänge haben – sonst bliebe der
// Haken deaktiviert und die Anhänge aller anderen Mails fielen still weg.
// Ob es etwas hochzuladen gibt, entscheidet submit() je Mail einzeln.
att.checked = want;
att.disabled = false;
$("attList").style.display = "none";
return;
}
// Einzelmail: Haken nur sinnvoll, wenn die Mail überhaupt Anhänge hat.
const hasAtt = STATE.attachments.length > 0;
att.checked = hasAtt && want;
att.disabled = !hasAtt;
$("attList").style.display = hasAtt && att.checked ? "" : "none";
}
@ -186,6 +206,10 @@ function getMailFormat() {
function renderAttachments() {
const ul = $("attList");
ul.innerHTML = "";
// Die Einzelauswahl der Anhänge gilt nur für EINE Mail. Bei Mehrfachauswahl
// würden hier die Anhänge der ersten Mail stehen, die Haken aber nichts tun
// (submit() legt dort je Mail alle Anhänge ab) -> Liste bleibt leer/verborgen.
if (isMulti()) return;
STATE.attachments.forEach((a, i) => {
// Bewusst per DOM-API (nicht innerHTML): der Anhang-Name stammt aus der Mail
// und ist angreiferkontrolliert – als Text einsetzen, nie als HTML.
@ -304,6 +328,9 @@ async function onCabinetChange() {
async function checkExisting() {
STATE.existingDoc = null;
hideArchivedBanner();
// Bei Mehrfachauswahl nicht prüfen: der Banner bezöge sich nur auf die erste
// Mail, würde aber wie eine Aussage über die ganze Auswahl wirken.
if (isMulti()) return;
if (STATE.isBasket || !STATE.cabinetId || !STATE.meta || !STATE.meta.rfcMessageId) return;
try {
const names = await cabinetFieldNames(STATE.cabinetId);
@ -577,7 +604,7 @@ async function submit() {
// Dubletten-Warnung (nur Einzelmail): liegt die Mail im Ziel schon, warnen –
// „Trotzdem ablegen" bleibt aber möglich.
if (STATE.messageIds.length === 1 && !STATE.isBasket && STATE.meta.rfcMessageId) {
if (!isMulti() && !STATE.isBasket && STATE.meta.rfcMessageId) {
let hit = STATE.existingDoc;
if (!hit) {
const names = await cabinetFieldNames(STATE.cabinetId);
@ -598,16 +625,18 @@ async function submit() {
await saveOptionPrefs(fmt, wantAttScope); // Sticky: Format + Anhänge-Wahl merken
$("submit").disabled = true;
const multi = STATE.messageIds.length > 1;
const multi = isMulti();
let tagFailed = false;
let current = ""; // zuletzt bearbeitete Mail – für eine brauchbare Fehlermeldung
try {
const fieldNames = STATE.isBasket ? [] : await cabinetFieldNames(STATE.cabinetId);
let mi = 0;
for (const mid of STATE.messageIds) {
mi++;
// Retry-Schutz: nach einem Teilfehler nicht erneut hochladen (sonst Dubletten).
if (STATE.uploadedIds.has(mid)) continue;
// Retry-Schutz: in DIESES Ziel bereits vollständig abgelegt -> überspringen.
if (STATE.uploadedIds.has(`${STATE.cabinetId}|${mid}`)) continue;
const prefix = multi ? `Mail ${mi}/${STATE.messageIds.length}: ` : "";
current = multi ? `Mail ${mi}/${STATE.messageIds.length}` : "";
// Meta/Anhänge der jeweiligen Nachricht.
const meta = mid === STATE.messageId ? STATE.meta : await Mail.getMeta(mid);
const atts =
@ -622,14 +651,18 @@ async function submit() {
const wantAtt = wantAttScope && atts.length > 0;
if (wantEml) {
status(`${prefix}E-Mail …`);
const eml = await Mail.getEmlFile(mid, meta.subject);
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, eml, eml.name, fields);
await uploadOnce(`${mid}|eml`, async () => {
status(`${prefix}E-Mail …`);
const eml = await Mail.getEmlFile(mid, meta.subject);
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, eml, eml.name, fields);
});
}
if (wantPdf) {
status(`${prefix}PDF …`);
const pdf = Pdf.fromMail(meta);
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, pdf, pdf.name, fields);
await uploadOnce(`${mid}|pdf`, async () => {
status(`${prefix}PDF …`);
const pdf = Pdf.fromMail(meta);
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, pdf, pdf.name, fields);
});
}
if (wantAtt) {
// Bei Einzelmail: angehakte Anhänge. Bei Mehrfach: alle Anhänge je Mail.
@ -637,10 +670,12 @@ async function submit() {
for (const i of indices) {
const a = atts[i];
if (!a) continue;
status(`${prefix}Anhang ${a.name} …`);
const file = await Mail.getAttachmentFile(mid, a.partName);
const af = STATE.isBasket ? {} : { ...fields, DOC_FILE_NAME: { value: a.name, type: "Text" } };
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, file, a.name, af);
await uploadOnce(`${mid}|att:${a.partName}`, async () => {
status(`${prefix}Anhang ${a.name} …`);
const file = await Mail.getAttachmentFile(mid, a.partName);
const af = STATE.isBasket ? {} : { ...fields, DOC_FILE_NAME: { value: a.name, type: "Text" } };
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, file, a.name, af);
});
}
}
@ -656,19 +691,33 @@ async function submit() {
.catch(() => {});
}
// Diese Nachricht ist vollständig abgelegt -> bei einem späteren Retry überspringen.
STATE.uploadedIds.add(mid);
// In diesem Ziel vollständig abgelegt -> bei einem späteren Retry überspringen.
STATE.uploadedIds.add(`${STATE.cabinetId}|${mid}`);
}
const base = multi ? `${STATE.messageIds.length} E-Mails abgelegt.` : "Erfolgreich abgelegt.";
status(tagFailed ? `${base} (Markierung nicht möglich)` : base, "ok");
setTimeout(() => window.close(), tagFailed ? 2500 : 1200);
} catch (e) {
status(`Fehler beim Ablegen: ${e.message}`, "err");
// Bei Mehrfachauswahl sagen, WO es hakte – und dass ein erneuter Klick die
// bereits übertragenen E-Mails/Teile überspringt (kein Dublettenrisiko).
const where = current ? ` (${current})` : "";
const hint = multi ? " Bereits abgelegtes wird bei erneutem Klick übersprungen." : "";
status(`Fehler beim Ablegen${where}: ${e.message}${hint}`, "err");
$("submit").disabled = false;
}
}
// Lädt einen Teil (eml/pdf/Anhang) genau EINMAL hoch. Schlägt ein späterer Teil
// fehl und der Nutzer klickt erneut auf „Ablegen", werden die bereits erfolgreich
// übertragenen Teile übersprungen – sonst entstünden Dubletten in DocuWare.
async function uploadOnce(part, doUpload) {
const key = `${STATE.cabinetId}|${part}`;
if (STATE.uploadedParts.has(key)) return;
await doUpload();
STATE.uploadedParts.add(key);
}
// Indexfelder für eine konkrete Nachricht: gemappte Felder (EML_*/generisch)
// werden je Mail aus deren Metadaten neu berechnet, manuelle Werte bleiben.
function fieldsForMessage(baseResult, meta, multi) {
@ -720,6 +769,8 @@ async function tagMessage(messageId) {
}
// --- Helfer ---
// Mehrfachauswahl? Steuert Anhang-Liste, Dubletten-Banner und Feldberechnung.
const isMulti = () => STATE.messageIds.length > 1;
const isMemo = (f) => /memo/i.test(f.type) || f.name === "EML_BODY";
// Lokales Datum als YYYY-MM-DD (NICHT toISOString -> das rechnet auf UTC um und
// kippt eine Uhrzeit nahe Mitternacht auf den Vortag).
@ -763,7 +814,7 @@ document.querySelectorAll('input[name="mailFormat"]').forEach((r) =>
r.addEventListener("change", () => saveOptionPrefs(getMailFormat(), $("optAtt").checked))
);
$("optAtt").addEventListener("change", () => {
const show = $("optAtt").checked && STATE.attachments.length > 0;
const show = !isMulti() && $("optAtt").checked && STATE.attachments.length > 0;
$("attList").style.display = show ? "" : "none";
saveOptionPrefs(getMailFormat(), $("optAtt").checked);
});

View File

@ -1,6 +1,8 @@
// DocuWare Platform REST-Client.
// Alle Pfade relativ zu {serverUrl}/DocuWare/Platform.
// Auth läuft per Session-Cookie (siehe auth.js); daher überall credentials:"include".
// Auth primär per Bearer-Token (Identity Service) über Auth.authHeaders(); das
// credentials:"include" überall ist für den Cookie-Fallback alter On-Prem-Server
// nötig (siehe auth.js) und stört im Token-Modus nicht.
const DocuWare = {
async _get(settings, path) {
@ -10,13 +12,32 @@ const DocuWare = {
credentials: "include",
headers: { Accept: "application/json", ...Auth.authHeaders() },
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`GET ${path} -> HTTP ${res.status}. ${t}`.trim());
}
if (!res.ok) throw await this._httpError(res, `GET ${path}`);
return res.json();
},
/**
* Baut aus einer fehlgeschlagenen Antwort einen sprechenden Fehler.
* 401/403 sind der häufigste Fall (Token abgelaufen oder Zugangsdaten/Server
* gewechselt) – der nackte Statuscode hilft dem Anwender dabei nicht weiter.
*/
async _httpError(res, what) {
const body = (await res.text().catch(() => "")).slice(0, 300);
if (res.status === 401) {
return new Error(
"Nicht angemeldet (HTTP 401). Die DocuWare-Sitzung ist abgelaufen oder die " +
"Zugangsdaten stimmen nicht. Bitte das Ablagefenster neu öffnen bzw. in den " +
"Einstellungen „Verbindung testen“."
);
}
if (res.status === 403) {
return new Error(
"Keine Berechtigung (HTTP 403). Der Benutzer darf diese Aktion in DocuWare nicht ausführen."
);
}
return new Error(`${what} -> HTTP ${res.status}. ${body}`.trim());
},
/**
* Liste der Ablageziele: Archive UND Briefkörbe.
* @returns {Promise<Array<{id, name, color, isBasket}>>}
@ -304,10 +325,7 @@ const DocuWare = {
headers: { Accept: "application/json", ...Auth.authHeaders() },
body: form,
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`Upload fehlgeschlagen (HTTP ${res.status}). ${t}`.trim());
}
if (!res.ok) throw await this._httpError(res, "Upload fehlgeschlagen");
const doc = await res.json().catch(() => ({}));
return { id: doc.Id, raw: doc };
},

View File

@ -20,9 +20,54 @@ function insecureWarning(url) {
: "";
}
// Schnappschuss der auth-relevanten Felder (Server/Org/Benutzer) zum Zeitpunkt
// des letzten Speicherns/Ladens. Ändert sich davon etwas, ist ein evtl. noch im
// Broker liegender Token ungültig (er gehört zu den alten Daten) -> verwerfen.
let authSnapshot = { serverUrl: "", organization: "", username: "" };
const AUTH_FIELDS = ["serverUrl", "organization", "username"];
// Server-URLs nur für den Vergleich vereinheitlichen, damit ein rein kosmetischer
// Edit (Schrägstrich am Ende, Groß-/Kleinschreibung) nicht als Wechsel zählt.
const normUrl = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
function snapshotAuth(s) {
authSnapshot = { serverUrl: s.serverUrl || "", organization: s.organization || "", username: s.username || "" };
}
function authChanged(s) {
return AUTH_FIELDS.some((k) => (s[k] || "") !== (authSnapshot[k] || ""));
}
// Nur ein echter Server-Wechsel entwertet die Schrank-GUIDs (Schrankliste,
// Standardziel, gemerkte Ablagedialoge). Org-/Benutzer-Wechsel tut das nicht.
function serverChanged(s) {
return normUrl(s.serverUrl) !== normUrl(authSnapshot.serverUrl);
}
/**
* Nach dem Speichern aufräumen, wenn sich Zugangsdaten geändert haben:
* Token immer verwerfen; bei Server-Wechsel zusätzlich alles wegwerfen, was
* Schrank-GUIDs des alten Servers enthält (sonst zeigt der Ablage-Dialog fremde
* Archive bzw. legt gegen eine nicht mehr existierende GUID ab).
* @returns {Promise<object>} die ggf. bereinigten Einstellungen
*/
async function invalidateSession(s) {
if (!authChanged(s)) { snapshotAuth(s); return s; }
const server = serverChanged(s);
await browser.runtime
.sendMessage({ type: "auth:logout", scope: server ? "server" : "auth" })
.catch(() => {});
let next = s;
if (server) {
next = await Settings.set({ defaultCabinetId: "", dialogByCabinet: {} });
fillCabinets([], ""); // Dropdowns leeren – die alte Liste gilt nicht mehr
}
snapshotAuth(next);
return next;
}
async function load() {
const s = await Settings.get();
FIELDS.forEach((f) => ($(f).value = s[f] || ""));
snapshotAuth(s);
CHECKS.forEach((c) => ($(c).checked = !!s[c]));
setMailFormat(s.mailFormat === "pdf" ? "pdf" : "eml");
$("mappingUrl").value = s.mappingUrl || "";
@ -93,15 +138,23 @@ function fillCabinets(cabinets, selectedId) {
// Besorgt einen Sitzungs-Token: vorhandenen vom Broker nutzen, sonst mit dem
// (transienten) Passwortfeld anmelden. Liefert false, wenn kein Passwort da ist.
async function ensureToken() {
const pw = $("password").value;
// Wurde ein Passwort eingegeben, IMMER frisch anmelden: ein evtl. noch im Broker
// liegender Token könnte zu früheren Zugangsdaten/Server gehören und würde sonst
// zu „GET /FileCabinets -> HTTP 401" führen (bis zum Thunderbird-Neustart).
// Der Broker (background.js) ruft bei auth:logon zuerst Auth.reset() und liest die
// gerade gespeicherten Einstellungen neu.
if (pw) {
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
if (!res || !res.ok) throw new Error(res && res.error ? res.error : "Anmeldung fehlgeschlagen.");
Auth.setToken(res.token);
$("password").value = ""; // Passwort nicht behalten
return true;
}
// Kein Passwort getippt: vorhandenen Sitzungs-Token vom Broker übernehmen.
const st = await browser.runtime.sendMessage({ type: "auth:status" });
if (st && st.hasToken && st.token) { Auth.setToken(st.token); return true; }
const pw = $("password").value;
if (!pw) return false;
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
if (!res || !res.ok) throw new Error(res && res.error ? res.error : "Anmeldung fehlgeschlagen.");
Auth.setToken(res.token);
$("password").value = ""; // Passwort nicht behalten
return true;
return false;
}
// --- Feld-Mapping-Editor ---------------------------------------------------
@ -222,15 +275,22 @@ function collect() {
$("save").addEventListener("click", async () => {
const data = collect();
await Settings.set(data);
const saved = await Settings.set(data);
// Server/Org/Benutzer geändert? Dann gehört ein evtl. noch im Broker liegender
// Token zu den ALTEN Daten -> verwerfen, damit Ablage/Test sich neu anmelden.
const wasServerChange = serverChanged(saved); // vor dem Neu-Schnappschuss prüfen
await invalidateSession(saved);
const warn = insecureWarning(data.serverUrl);
setStatus("Gespeichert." + warn, warn ? "err" : "ok");
const note = wasServerChange ? " Neuer Server – bitte Verbindung testen und Standardziel neu wählen." : "";
setStatus("Gespeichert." + note + warn, warn ? "err" : "ok");
});
$("test").addEventListener("click", async () => {
setStatus("Verbinde …");
try {
const s = await Settings.set(collect()); // erst speichern (ohne Passwort), dann testen
// Erst speichern (ohne Passwort), dann testen. Bei geänderten Zugangsdaten
// vorher den alten Broker-Token verwerfen (sonst HTTP 401).
const s = await invalidateSession(await Settings.set(collect()));
const ok = await ensureToken();
if (!ok) { setStatus("Bitte Passwort eingeben.", "err"); return; }
const cabinets = await DocuWare.listCabinets(s);