diff --git a/background/background.js b/background/background.js index eb2d470..fd47ff4 100644 --- a/background/background.js +++ b/background/background.js @@ -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 diff --git a/dialog/dialog.js b/dialog/dialog.js index eeffc65..391b37e 100644 --- a/dialog/dialog.js +++ b/dialog/dialog.js @@ -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 ("|") – 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 ("||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); }); diff --git a/lib/docuware.js b/lib/docuware.js index ccb822b..53aff50 100644 --- a/lib/docuware.js +++ b/lib/docuware.js @@ -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>} @@ -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 }; }, diff --git a/options/options.js b/options/options.js index 5afd1d9..2130a26 100644 --- a/options/options.js +++ b/options/options.js @@ -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} 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);