/** * background.js - Ereignisseite der Erweiterung. * * Hier liegen die Menüeinträge, die Schaltflächen und die Vermittlung * zwischen den Dialogfenstern und der Schnittstelle. Die Dialoge selbst * sind gewöhnliche HTML-Seiten; sie rufen über runtime.sendMessage hier * an, statt selbst mit Paperless zu sprechen. * * Der Grund für diese Aufteilung: Die Berechtigung für die Zieladresse * wird zur Laufzeit angefordert und hängt am Hintergrundkontext. Ein * Dialogfenster müsste sie sonst erneut erbitten. */ import { PaperlessClient, PaperlessError } from "./lib/client.js"; import { ladeEinstellungen, vollstaendig, sichererName, versionslabel, titelAusBetreff, t } from "./lib/settings.js"; /** * Ergebnis einer Nachrichten-Abfrage vereinheitlichen. * * Je nach Version liefert Thunderbird ein Feld, ein MessageList-Objekt mit * der Eigenschaft "messages" oder eine einzelne Nachricht. Ohne diese * Vereinheitlichung bricht die Erweiterung bei jedem Versionswechsel. */ function alsListe(wert) { if (!wert) return []; if (Array.isArray(wert)) return wert; if (Array.isArray(wert.messages)) return wert.messages; if (wert.id !== undefined) return [wert]; return []; } /** * Angezeigte Nachricht(en) eines Fensters holen. * * getDisplayedMessage (Einzahl) ist in neueren Ständen entfallen; dort * gibt es nur noch getDisplayedMessages (Mehrzahl). Beide werden * probiert, dazu als letzter Ausweg die Auswahl im Nachrichtenbereich. */ async function angezeigteNachrichten(tabId) { const md = browser.messageDisplay; const versuche = []; // OHNE Reiterkennung zuerst. Beim Kontextmenü eines Anhangs übergibt // Thunderbird einen Reiter mit type null - das ist nicht der // Nachrichtenbereich, und jeder Aufruf mit dieser Kennung geht ins // Leere. Ohne Argument nimmt Thunderbird den aktiven Reiter, und das // ist der richtige. if (md?.getDisplayedMessages) { versuche.push(["getDisplayedMessages()", () => md.getDisplayedMessages()]); if (tabId !== undefined && tabId !== null) { versuche.push(["getDisplayedMessages(tabId)", () => md.getDisplayedMessages(tabId)]); } } // Einzahl-Fassung für ältere Stände. if (md?.getDisplayedMessage) { versuche.push(["getDisplayedMessage()", () => md.getDisplayedMessage()]); } // Auswahl in der Nachrichtenliste, wenn kein Nachrichtenbereich offen ist. if (browser.mailTabs?.getSelectedMessages) { versuche.push(["mailTabs.getSelectedMessages()", () => browser.mailTabs.getSelectedMessages()]); } for (const [name, fn] of versuche) { try { const r = alsListe(await fn()); if (r.length) return r; } catch (err) { console.debug("[paperless]", name, "ohne Erfolg:", err?.message); } } return []; } /** * Datum einer Nachricht als JJJJ-MM-TT. * * Je nach Version ist date ein Date-Objekt oder eine Zeichenkette. */ function alsDatum(wert) { if (!wert) return undefined; const d = wert instanceof Date ? wert : new Date(wert); return Number.isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10); } // Offene Dialoge, damit sie ihre Aufgabe abholen können. const auftraege = new Map(); let naechsteAuftragsnummer = 1; // ---------------------------------------------------------------- Menüs browser.runtime.onInstalled.addListener(() => { browser.menus.create({ id: "paperless-anhang", title: t("menuStoreAttachment"), contexts: ["message_attachments"] }); browser.menus.create({ id: "paperless-nachricht", title: t("menuStoreMessage"), contexts: ["message_list"] }); }); browser.menus.onClicked.addListener(async (info, tab) => { try { console.debug("[paperless] Menü", info.menuItemId, "Anhänge:", alsListe(info.attachments).length); if (info.menuItemId === "paperless-anhang") { await anhangAblegen(info, tab); } else if (info.menuItemId === "paperless-nachricht") { await nachrichtAblegen(info, tab); } } catch (err) { await melde(err); } }); browser.messageDisplayAction.onClicked.addListener(async (tab) => { try { await nachrichtAblegen({}, tab); } catch (err) { await melde(err); } }); browser.composeAction.onClicked.addListener(async (tab) => { try { await dialogOeffnen("einfuegen", { tabId: tab.id }); } catch (err) { await melde(err); } }); // -------------------------------------------------------------- Ablegen async function anhangAblegen(info, tab) { const nachricht = alsListe(info.displayedMessages)[0] || alsListe(info.selectedMessages)[0] || (await angezeigteNachrichten(tab?.id))[0]; if (!nachricht) throw new PaperlessError(t("errNoMessage")); const alle = await browser.messages.listAttachments(nachricht.id); // Beim Klick auf einen einzelnen Anhang liefert das Menü nur diesen. // Über die Sammelfunktion "Alle Anhänge" kommen mehrere. const gewaehlt = alsListe(info.attachments).length ? alsListe(info.attachments).map((a) => a.partName) : alle.map((a) => a.partName); // Nur die Beschreibung sammeln. Die eigentlichen Daten werden erst beim // Hochladen geholt - sonst läge ein grosser Anhang unnötig im Speicher, // während der Anwender noch im Dialog überlegt. const dateien = alle .filter((teil) => gewaehlt.includes(teil.partName)) .map((teil) => ({ name: sichererName(teil.name), size: teil.size, contentType: teil.contentType, partName: teil.partName, messageId: nachricht.id })); await dialogOeffnen("ablegen", { quelle: "anhaenge", messageId: nachricht.id, betreff: nachricht.subject, absender: nachricht.author, datum: alsDatum(nachricht.date), dateien }); } async function nachrichtAblegen(info, tab) { let nachrichten = alsListe(info.selectedMessages); if (!nachrichten.length) nachrichten = alsListe(info.displayedMessages); if (!nachrichten.length) { nachrichten = await angezeigteNachrichten(tab?.id); } if (!nachrichten.length) throw new PaperlessError(t("errNoMessage")); const nachricht = nachrichten[0]; const anhaenge = await browser.messages.listAttachments(nachricht.id); await dialogOeffnen("ablegen", { quelle: "nachricht", messageId: nachricht.id, betreff: nachricht.subject, absender: nachricht.author, datum: alsDatum(nachricht.date), dateien: anhaenge.map((a) => ({ name: sichererName(a.name), size: a.size, contentType: a.contentType, partName: a.partName, messageId: nachricht.id })) }); } // -------------------------------------------------------------- Dialoge async function dialogOeffnen(art, daten) { const einstellungen = await ladeEinstellungen(); if (!vollstaendig(einstellungen)) { await browser.runtime.openOptionsPage(); return; } const nummer = naechsteAuftragsnummer++; auftraege.set(nummer, daten); // Nach einer halben Stunde verwerfen. Ein Auftrag enthält Betreff, // Absender und Dateinamen; das muss nicht bis zum Neustart im Speicher // liegen, nur weil jemand den Dialog offen stehen liess. setTimeout(() => auftraege.delete(nummer), 30 * 60 * 1000); const seite = art === "einfuegen" ? "ui/insert.html" : "ui/store.html"; // Die Adresse MUSS über runtime.getURL aufgelöst werden. Ein relativer // Pfad wird in Manifest V3 nicht gegen das Erweiterungsverzeichnis // aufgelöst - das Fenster öffnet dann stillschweigend nicht. const url = browser.runtime.getURL(`${seite}?auftrag=${nummer}`); try { await browser.windows.create({ url, type: "popup", width: art === "einfuegen" ? 760 : 820, height: art === "einfuegen" ? 640 : 720 }); } catch (err) { // Manche Stände lehnen type "popup" ab. Dann ein gewöhnliches Fenster. console.warn("[paperless] popup abgelehnt, versuche normales Fenster", err); await browser.windows.create({ url }); } } // -------------------------------------------- Vermittlung für die Dialoge browser.runtime.onMessage.addListener(async (nachricht, absender) => { // Nur Nachrichten aus dieser Erweiterung annehmen. Thunderbird lässt // fremde Absender hier zwar ohnehin nicht durch, solange kein // externally_connectable gesetzt ist - die Prüfung kostet nichts und // hält die Zusicherung explizit. if (absender?.id && absender.id !== browser.runtime.id) { return { ok: false, fehler: "Fremder Absender" }; } try { switch (nachricht?.befehl) { case "auftrag": return { ok: true, daten: auftraege.get(nachricht.nummer) || null }; case "einstellungen": return { ok: true, daten: await ladeEinstellungen() }; case "berechtigungPruefen": return { ok: true, daten: await berechtigungPruefen() }; case "suchen": { const c = await client(); return { ok: true, daten: await c.search(nachricht.query) }; } case "dokument": { const c = await client(); return { ok: true, daten: await c.document(nachricht.id) }; } case "versionen": { const c = await client(); const doc = await c.document(nachricht.id); return { ok: true, daten: await c.versions(doc) }; } case "stammdaten": { const c = await client(); const [tags, korr, typen] = await Promise.all( [c.tags(), c.correspondents(), c.documentTypes()]); return { ok: true, daten: { tags, korr, typen } }; } case "ablegen": return { ok: true, daten: await ablegen(nachricht) }; case "klassifizieren": { const c = await client(); const payload = { ...nachricht.payload }; payload.correspondent = await aufloesen( c, "correspondents/", nachricht.korrName); payload.document_type = await aufloesen( c, "document_types/", nachricht.typName); await c.patchDocument(nachricht.id, payload); return { ok: true }; } case "anhaengen": return { ok: true, daten: await anhaengenAnEntwurf(nachricht) }; case "freigabelink": return { ok: true, daten: await freigabelink(nachricht) }; default: return { ok: false, fehler: `Unbekannter Befehl: ${nachricht?.befehl}` }; } } catch (err) { return { ok: false, fehler: String(err?.message || err) }; } }); async function client() { const s = await ladeEinstellungen(); if (!vollstaendig(s)) throw new PaperlessError(t("errNoSettings")); // Ohne Zugriffsrecht für den Ursprung schlägt fetch mit einem nackten // "NetworkError" fehl. Die Prüfung vorweg erspart eine irreführende // Fehlersuche beim Netzwerk. let origin; try { origin = new URL(s.baseUrl).origin + "/*"; } catch { throw new PaperlessError(t("errBadUrl")); } const erteilt = await browser.permissions.contains({ origins: [origin] }); if (!erteilt) { throw new PaperlessError( `${t("errNoPermission")}\n\n${origin}\n\n` + `In den Einstellungen erneut auf Speichern klicken - die Abfrage ` + `kann nur aus einer Benutzeraktion heraus erscheinen.`); } return new PaperlessClient(s.baseUrl, s.token); } /** * Zugriffsrecht für die Zieladresse. * * Es wird bewusst nicht pauschal im Manifest verlangt, sondern erst zur * Laufzeit für genau die eingetragene Adresse angefordert. Eine * Erweiterung, die von Haus aus jede Adresse erreichen darf, ist eine * unnötig große Angriffsfläche. */ async function berechtigungPruefen() { const s = await ladeEinstellungen(); if (!vollstaendig(s)) return { noetig: false, erteilt: false }; const muster = new URL(s.baseUrl).origin + "/*"; const erteilt = await browser.permissions.contains({ origins: [muster] }); return { noetig: true, erteilt, muster }; } // -------------------------------------------------------------- Vorgänge async function datenHolen(auftrag) { if (auftrag.quelle === "nachricht") { const roh = await browser.messages.getRaw(auftrag.messageId); const blob = new Blob([roh], { type: "message/rfc822" }); const name = sichererName( `${titelAusBetreff(auftrag.betreff)}.eml`); return [{ blob, name }]; } const out = []; for (const d of auftrag.dateien || []) { if (!d.ausgewaehlt) continue; const datei = await browser.messages.getAttachmentFile( d.messageId, d.partName); out.push({ blob: datei, name: sichererName(d.name) }); } return out; } async function ablegen(nachricht) { const c = await client(); const s = await ladeEinstellungen(); const dateien = await datenHolen(nachricht.auftrag); if (!dateien.length) throw new PaperlessError(t("errNothingSelected")); const ergebnisse = []; for (const datei of dateien) { if (nachricht.ziel === "vorhanden") { const antwort = await c.updateVersion( nachricht.zielId, datei.blob, datei.name, versionslabel(s.versionLabel)); const id = await c.waitForTask(antwort); ergebnisse.push({ id, name: datei.name, art: "version", zielId: nachricht.zielId }); } else { const felder = { title: nachricht.titel || datei.name }; if (nachricht.erstellt) felder.created = nachricht.erstellt; const antwort = await c.postDocument(datei.blob, datei.name, felder); const id = await c.waitForTask(antwort); ergebnisse.push({ id, name: datei.name, art: "neu" }); } } return ergebnisse; } async function aufloesen(c, pfad, name) { const gesucht = String(name || "").trim(); if (!gesucht) return null; const liste = pfad.startsWith("correspondents") ? await c.correspondents() : await c.documentTypes(); const treffer = liste.find( (x) => x.name.trim().toLowerCase() === gesucht.toLowerCase()); if (treffer) return treffer.id; const neu = await c.create(pfad, gesucht); return neu.id; } async function anhaengenAnEntwurf(nachricht) { const c = await client(); const { blob, name } = await c.download(nachricht.versionId, true); const datei = new File([blob], sichererName(name || `paperless-${nachricht.versionId}`)); await browser.compose.addAttachment(nachricht.tabId, { file: datei }); return { name: datei.name }; } async function freigabelink(nachricht) { const c = await client(); const url = await c.shareLink(nachricht.id, nachricht.tage, nachricht.variante); if (!url) throw new PaperlessError(t("errNoLink")); // Kennzeichnung setzen. Fehler werden NICHT verschluckt: Der Tag ist der // Grund, warum sich geteilte Dokumente später wiederfinden lassen - ein // stiller Fehlschlag wäre schlimmer als eine Meldung. let tagStatus = null; if (nachricht.tagName) { try { const name = String(nachricht.tagName).trim(); const tags = await c.tags(); let treffer = tags.find( (x) => String(x.name).trim().toLowerCase() === name.toLowerCase()); if (!treffer) treffer = await c.create("tags/", name); if (!treffer || treffer.id === undefined) { throw new PaperlessError( `Tag "${name}" ohne Kennung: ${JSON.stringify(treffer)}`); } const doc = await c.document(nachricht.id); const vorhanden = (doc.tags || []).map(Number); if (!vorhanden.includes(Number(treffer.id))) { await c.patchDocument( nachricht.id, { tags: [...vorhanden, Number(treffer.id)] }); } tagStatus = { ok: true, name }; } catch (err) { tagStatus = { ok: false, fehler: String(err?.message || err) }; console.error("[paperless] Tag konnte nicht gesetzt werden", err); } } // Ablaufdatum selbst rechnen. Es steht auch in der Antwort, aber nicht // in jeder Version unter demselben Namen - die eigene Rechnung stimmt // mit dem überein, was im Dialog eingestellt wurde. const gueltigBis = nachricht.tage ? new Date(Date.now() + Number(nachricht.tage) * 86400000) : null; if (nachricht.tabId !== undefined && nachricht.einfuegen) { await linkEinfuegen(nachricht.tabId, url, nachricht.linktext, gueltigBis); } return { url, tagStatus, gueltigBis: gueltigBis ? gueltigBis.toISOString() : null }; } /** * Datum als TT.MM.JJJJ. * * Bewusst kein toLocaleDateString: Die Nachricht geht an einen Empfänger * mit unbekannter Spracheinstellung. 03.09. und 09.03. sehen gleich aus * und bedeuten Verschiedenes - bei einem Ablaufdatum ist das der falsche * Ort für Mehrdeutigkeit. */ function alsDeutschesDatum(d) { const z = (n) => String(n).padStart(2, "0"); return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()}`; } /** * Freigabelink in einen Entwurf einfügen. * * ACHTUNG: Die Compose-Schnittstelle kennt kein Einfügen an der * Schreibmarke. Der Körper muss vollständig gelesen und zurückgeschrieben * werden. Der Eintrag landet deshalb am Ende, bei HTML vor dem * schliessenden . * * Name UND Adresse stehen sichtbar. Ein Link, dessen Ziel man nicht sieht, * ist beim Empfänger schwer einzuschätzen - erst recht bei einer Adresse, * die ohne Anmeldung Zugriff gewährt. */ async function linkEinfuegen(tabId, url, linktext, gueltigBis) { const details = await browser.compose.getComposeDetails(tabId); const name = String(linktext || "").trim(); const hinweis = gueltigBis ? t("linkValidUntil", [alsDeutschesDatum(gueltigBis)]) : t("linkValidUnlimited"); if (details.isPlainText) { const koerper = details.plainTextBody || ""; const zeilen = [name, url, hinweis].filter(Boolean); await browser.compose.setComposeDetails(tabId, { plainTextBody: `${koerper}\n\n${zeilen.join("\n")}\n` }); return; } const koerper = details.body || ""; const sicherUrl = escapeHtml(url); const kopf = name ? `${escapeHtml(name)}
` : ""; const block = `

${kopf}` + `${sicherUrl}
` + `${escapeHtml(hinweis)}` + `

`; const neuerKoerper = koerper.includes("") ? koerper.replace("", `${block}`) : koerper + block; await browser.compose.setComposeDetails(tabId, { body: neuerKoerper }); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // ------------------------------------------------------------- Meldungen async function melde(err) { const text = String(err?.message || err); // Immer auch auf die Konsole. Benachrichtigungen können abgeschaltet // sein, und dann bliebe ein Fehler völlig unsichtbar. console.error("[paperless]", err); try { await browser.notifications.create({ type: "basic", iconUrl: browser.runtime.getURL("icons/paperless-logo_64.png"), title: "Paperless", message: text.slice(0, 300) }); } catch { console.error("[paperless]", err); } }