553 lines
19 KiB
JavaScript
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) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
}[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);
|
|
}
|
|
}
|