Files
thunderbird-paperless-conne…/background.js
2026-09-04 19:50:29 +02:00

553 lines
19 KiB
JavaScript

/**
* 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 </body>.
*
* 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 ? `<strong>${escapeHtml(name)}</strong><br>` : "";
const block =
`<p style="margin:0 0 .8em 0">${kopf}`
+ `<a href="${sicherUrl}">${sicherUrl}</a><br>`
+ `<span style="font-size:90%;color:#666">${escapeHtml(hinweis)}</span>`
+ `</p>`;
const neuerKoerper = koerper.includes("</body>")
? koerper.replace("</body>", `${block}</body>`)
: koerper + block;
await browser.compose.setComposeDetails(tabId, { body: neuerKoerper });
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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);
}
}