1015 lines
34 KiB
JavaScript
1015 lines
34 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, zugriffsmuster, t
|
|
} from "./lib/settings.js";
|
|
import { baueMailPdf, baueTextPdf, alsEmpfaenger, umwandelbar }
|
|
from "./lib/mailpdf.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);
|
|
}
|
|
|
|
/** Datum mit Uhrzeit für die Anzeige im PDF. */
|
|
function alsZeitpunkt(wert) {
|
|
if (!wert) return "";
|
|
const d = wert instanceof Date ? wert : new Date(wert);
|
|
if (Number.isNaN(d.getTime())) return "";
|
|
const z = (n) => String(n).padStart(2, "0");
|
|
return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()} `
|
|
+ `${z(d.getHours())}:${z(d.getMinutes())} Uhr`;
|
|
}
|
|
|
|
// Beim Laden eine Zeile ausgeben. Fehlt sie in der Konsole, ist das
|
|
// Skript gar nicht erst gestartet - und dann kommt bei jedem Aufruf aus
|
|
// einem Dialog nur "An unexpected error occurred".
|
|
console.info("[paperless] Hintergrundskript geladen",
|
|
browser.runtime.getManifest().version);
|
|
|
|
// Offene Dialoge, damit sie ihre Aufgabe abholen können.
|
|
const auftraege = new Map();
|
|
let naechsteAuftragsnummer = 1;
|
|
|
|
// ---------------------------------------------------------------- Menüs
|
|
|
|
/**
|
|
* Menüeinträge anlegen.
|
|
*
|
|
* NICHT in onInstalled: Eine Ereignisseite wird beendet und bei Bedarf
|
|
* neu geladen, das Skript läuft dabei erneut durch. Die Menüs überleben
|
|
* das aber - ein zweiter menus.create mit derselben Kennung scheitert
|
|
* dann mit "already exists".
|
|
*
|
|
* Deshalb erst removeAll, dann neu anlegen. Das ist bei jedem Aufwachen
|
|
* richtig und kostet nichts.
|
|
*/
|
|
async function menueAufbauen() {
|
|
try {
|
|
await browser.menus.removeAll();
|
|
} catch { /* beim ersten Lauf gibt es nichts zu entfernen */ }
|
|
|
|
browser.menus.create({
|
|
id: "paperless-anhang",
|
|
title: t("menuStoreAttachment"),
|
|
contexts: ["message_attachments"]
|
|
});
|
|
browser.menus.create({
|
|
id: "paperless-nachricht",
|
|
title: t("menuStoreMessage"),
|
|
contexts: ["message_list"]
|
|
});
|
|
}
|
|
|
|
menueAufbauen();
|
|
|
|
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),
|
|
zeitpunkt: alsZeitpunkt(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),
|
|
zeitpunkt: alsZeitpunkt(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 = {
|
|
einfuegen: "ui/insert.html",
|
|
schritt: "ui/schritt.html"
|
|
}[art] || "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",
|
|
// Der Schrittdialog steht zweispaltig. Die Höhe ist auf den Inhalt
|
|
// gerechnet: rund 540 px für die Spalten plus Kopf, Meldung und
|
|
// Fusszeile, dazu etwas Spielraum - die Systemschrift fällt je
|
|
// nach Umgebung anders aus.
|
|
width: { schritt: 980, einfuegen: 760 }[art] || 820,
|
|
height: { schritt: 760, einfuegen: 640 }[art] || 520
|
|
});
|
|
} 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
|
|
|
|
/**
|
|
* Offene Verbindungen der Dialoge.
|
|
*
|
|
* SEHR WICHTIG: Eine Ereignisseite wird beendet, sobald keine Ereignisse
|
|
* mehr anstehen. Eine laufende Wartezeit in waitForTask zählt NICHT als
|
|
* Ereignis - mitten im Ablegen verschwindet sonst der Empfänger, und der
|
|
* Dialog bekommt "Could not establish connection. Receiving end does not
|
|
* exist."
|
|
*
|
|
* Eine offene Verbindung hält die Seite dagegen am Leben. Die Dialoge
|
|
* öffnen deshalb beim Laden eine und halten sie bis zum Schliessen.
|
|
*/
|
|
const verbindungen = new Set();
|
|
|
|
browser.runtime.onConnect.addListener((port) => {
|
|
verbindungen.add(port);
|
|
console.debug("[paperless] Dialog verbunden, offen:", verbindungen.size);
|
|
|
|
// Die Lebenszeichen werden nicht ausgewertet - allein ihr Eintreffen
|
|
// hält die Ereignisseite wach.
|
|
port.onMessage.addListener(() => {});
|
|
|
|
port.onDisconnect.addListener(() => {
|
|
verbindungen.delete(port);
|
|
console.debug("[paperless] Dialog getrennt, offen:", verbindungen.size);
|
|
});
|
|
});
|
|
|
|
/*
|
|
* Befehle aus den Dialogen.
|
|
*
|
|
* Der Listener selbst ist bewusst NICHT async: Ein async-Listener liefert
|
|
* immer ein Promise zurück und beansprucht damit jede Nachricht - auch
|
|
* solche, die gar nicht für ihn bestimmt sind. Stattdessen prüft er
|
|
* synchron, ob die Nachricht aus dieser Erweiterung stammt und einen
|
|
* bekannten Befehl trägt, und gibt nur dann das Promise des Handlers
|
|
* zurück. Alles andere lehnt er mit false ab, sodass andere Empfänger
|
|
* antworten können.
|
|
* Siehe https://webextension-api.thunderbird.net/en/mv3/guides/runtimeMessaging.html
|
|
*/
|
|
const BEFEHLE = {
|
|
auftrag: async (n) => auftraege.get(n.nummer) || null,
|
|
|
|
einstellungen: async () => ladeEinstellungen(),
|
|
|
|
berechtigungPruefen: async () => berechtigungPruefen(),
|
|
|
|
suchen: async (n) => (await client()).search(n.query),
|
|
|
|
dokument: async (n) => (await client()).document(n.id),
|
|
|
|
versionen: async (n) => {
|
|
const c = await client();
|
|
return c.versions(await c.document(n.id));
|
|
},
|
|
|
|
stammdaten: async () => {
|
|
const c = await client();
|
|
const [tags, korr, typen] = await Promise.all(
|
|
[c.tags(), c.correspondents(), c.documentTypes()]);
|
|
return { tags, korr, typen };
|
|
},
|
|
|
|
schrittOeffnen: async (n) => {
|
|
await dialogOeffnen("schritt", n.auftrag);
|
|
return undefined;
|
|
},
|
|
|
|
dateiListe: async (n) => dateiListe(n.auftrag),
|
|
|
|
ablegenEine: async (n) => ablegenEine(n),
|
|
|
|
klassifizieren: async (n) => {
|
|
const c = await client();
|
|
const payload = { ...n.payload };
|
|
payload.correspondent = await aufloesen(c, "correspondents/", n.korrName);
|
|
payload.document_type = await aufloesen(c, "document_types/", n.typName);
|
|
await c.patchDocument(n.id, payload);
|
|
return undefined;
|
|
},
|
|
|
|
anhaengen: async (n) => anhaengenAnEntwurf(n),
|
|
|
|
freigabelink: async (n) => freigabelink(n),
|
|
};
|
|
|
|
async function befehlAusfuehren(handler, nachricht) {
|
|
try {
|
|
const daten = await handler(nachricht);
|
|
return daten === undefined ? { ok: true } : { ok: true, daten };
|
|
} catch (err) {
|
|
return { ok: false, fehler: String(err?.message || err) };
|
|
}
|
|
}
|
|
|
|
browser.runtime.onMessage.addListener((nachricht, absender) => {
|
|
// Nur Nachrichten aus dieser Erweiterung mit bekanntem Befehl annehmen.
|
|
if (absender?.id !== browser.runtime.id) return false;
|
|
const befehl = nachricht?.befehl;
|
|
if (typeof befehl !== "string" || !Object.hasOwn(BEFEHLE, befehl)) {
|
|
return false;
|
|
}
|
|
return befehlAusfuehren(BEFEHLE[befehl], nachricht);
|
|
});
|
|
|
|
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 = zugriffsmuster(s.baseUrl);
|
|
} 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, s.shareBaseUrl);
|
|
}
|
|
|
|
/**
|
|
* 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 = zugriffsmuster(s.baseUrl);
|
|
const erteilt = await browser.permissions.contains({ origins: [muster] });
|
|
return { noetig: true, erteilt, muster };
|
|
}
|
|
|
|
// -------------------------------------------------------------- Vorgänge
|
|
|
|
/**
|
|
* Nachrichtentext als reinen Text beschaffen.
|
|
*
|
|
* Bevorzugt wird der text/plain-Teil. Gibt es nur HTML, übernimmt
|
|
* Thunderbird die Umwandlung - convertToPlainText ist dieselbe Funktion,
|
|
* die beim Antworten in Nur-Text greift, und liefert deutlich bessere
|
|
* Ergebnisse als eine selbstgebaute Entschlackung.
|
|
*/
|
|
async function nachrichtentext(messageId) {
|
|
let teile = [];
|
|
if (browser.messages.listInlineTextParts) {
|
|
try {
|
|
teile = await browser.messages.listInlineTextParts(messageId) || [];
|
|
} catch { /* ältere Stände kennen die Funktion nicht */ }
|
|
}
|
|
|
|
// Rückfall: die Baumstruktur aus getFull durchwandern
|
|
if (!teile.length) {
|
|
try {
|
|
const voll = await browser.messages.getFull(messageId);
|
|
const sammeln = (teil) => {
|
|
if (teil?.body && /^text\/(plain|html)$/.test(teil.contentType || "")) {
|
|
teile.push({ contentType: teil.contentType, content: teil.body });
|
|
}
|
|
for (const k of teil?.parts || []) sammeln(k);
|
|
};
|
|
sammeln(voll);
|
|
} catch (err) {
|
|
console.warn("[paperless] getFull fehlgeschlagen", err);
|
|
}
|
|
}
|
|
|
|
const klartext = teile.find((x) => (x.contentType || "") === "text/plain");
|
|
if (klartext?.content?.trim()) return klartext.content;
|
|
|
|
const html = teile.find((x) => (x.contentType || "") === "text/html");
|
|
if (html?.content) {
|
|
if (browser.messengerUtilities?.convertToPlainText) {
|
|
try {
|
|
return await browser.messengerUtilities.convertToPlainText(
|
|
html.content);
|
|
} catch { /* dann der grobe Weg */ }
|
|
}
|
|
// Notbehelf ohne die Thunderbird-Funktion. Bewusst schlicht: Blöcke
|
|
// werden zu Zeilenumbrüchen, Auszeichnung fällt weg.
|
|
return html.content
|
|
.replace(/<\s*(br|\/p|\/div|\/tr|\/li|\/h[1-6])[^>]*>/gi, "\n")
|
|
.replace(/<[^>]+>/g, "")
|
|
.replace(/ /gi, " ").replace(/&/gi, "&")
|
|
.replace(/</gi, "<").replace(/>/gi, ">")
|
|
.replace(/"/gi, '"').replace(/'/gi, "'")
|
|
.replace(/\n{3,}/g, "\n\n");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Nachricht als PDF aufbereiten.
|
|
*
|
|
* Das Original und alle Anhänge werden im PDF eingebettet - es geht also
|
|
* nichts verloren, auch wenn die Darstellung nicht originalgetreu ist.
|
|
*/
|
|
/**
|
|
* Adresse des Kontos, in dem die Nachricht liegt.
|
|
*
|
|
* Bei Nachrichten an Blindkopie-Empfänger geht aus den Kopfzeilen nicht
|
|
* hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
|
|
* entscheidende Angabe.
|
|
*/
|
|
/**
|
|
* Adresse des Kontos, in dem die Nachricht liegt.
|
|
*
|
|
* Bei Nachrichten an Blindkopie-Empfänger geht aus An und Kopie nicht
|
|
* hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
|
|
* entscheidende Angabe.
|
|
*
|
|
* Zwei Wege, weil der erste eine Berechtigung braucht:
|
|
*
|
|
* 1. accounts-Schnittstelle. Genau, aber accountsRead ist nötig - und
|
|
* dieselbe Berechtigung entscheidet auch darüber, ob messages.get()
|
|
* überhaupt eine Ordnerangabe liefert.
|
|
* 2. Zustellkopfzeilen. Delivered-To und Verwandte tragen die Adresse,
|
|
* an die tatsächlich zugestellt wurde. Sie sind nicht immer
|
|
* vorhanden und stammen vom zustellenden Server, reichen aber ohne
|
|
* zusätzliche Berechtigung aus.
|
|
*/
|
|
async function kontoAdresse(kopf, messageId) {
|
|
const ueberAccounts = await kontoUeberAccounts(kopf);
|
|
if (ueberAccounts) return ueberAccounts;
|
|
return kontoUeberKopfzeilen(messageId);
|
|
}
|
|
|
|
async function kontoUeberAccounts(kopf) {
|
|
if (!browser.accounts?.get) {
|
|
console.debug("[paperless] accounts-Schnittstelle nicht verfügbar - "
|
|
+ "Berechtigung accountsRead fehlt. Ausweichweg über "
|
|
+ "die Zustellkopfzeilen.");
|
|
return "";
|
|
}
|
|
|
|
// Die Kontokennung steckt je nach Version an verschiedenen Stellen.
|
|
const kandidaten = [
|
|
kopf?.folder?.accountId,
|
|
kopf?.folder?.id,
|
|
typeof kopf?.folder === "string" ? kopf.folder : null,
|
|
kopf?.accountId
|
|
].filter(Boolean);
|
|
|
|
const kennungen = [];
|
|
for (const k of kandidaten) {
|
|
kennungen.push(String(k));
|
|
const m = /^([^:/]+)/.exec(String(k));
|
|
if (m && m[1] !== String(k)) kennungen.push(m[1]);
|
|
}
|
|
|
|
for (const kennung of kennungen) {
|
|
try {
|
|
const konto = await browser.accounts.get(kennung);
|
|
const adresse = adresseAusKonto(konto);
|
|
if (adresse) return adresse;
|
|
} catch { /* nächster Kandidat */ }
|
|
}
|
|
|
|
// Gibt es nur ein Konto, ist die Zuordnung ohnehin eindeutig.
|
|
try {
|
|
const alle = await browser.accounts.list(false);
|
|
if (alle?.length === 1) return adresseAusKonto(alle[0]);
|
|
} catch { /* dann eben nicht */ }
|
|
|
|
return "";
|
|
}
|
|
|
|
function adresseAusKonto(konto) {
|
|
if (!konto) return "";
|
|
const ident = konto.identities || [];
|
|
const erste = ident.find((i) => i.email) || ident[0];
|
|
if (erste?.email) {
|
|
return erste.name ? `${erste.name} <${erste.email}>` : erste.email;
|
|
}
|
|
return konto.name || "";
|
|
}
|
|
|
|
/**
|
|
* Empfangsadresse aus den Zustellkopfzeilen.
|
|
*
|
|
* Diese Kopfzeilen setzt der zustellende Server. Delivered-To ist die
|
|
* verbreitetste; X-Original-To und Envelope-To kommen bei anderen
|
|
* Servern vor.
|
|
*/
|
|
async function kontoUeberKopfzeilen(messageId) {
|
|
if (!messageId || !browser.messages?.getFull) return "";
|
|
try {
|
|
const voll = await browser.messages.getFull(messageId);
|
|
const h = voll?.headers || {};
|
|
for (const name of ["delivered-to", "x-original-to", "envelope-to",
|
|
"x-envelope-to", "x-rcpt-to"]) {
|
|
const wert = h[name];
|
|
if (Array.isArray(wert) && wert.length && String(wert[0]).trim()) {
|
|
return String(wert[0]).trim();
|
|
}
|
|
if (typeof wert === "string" && wert.trim()) return wert.trim();
|
|
}
|
|
console.debug("[paperless] keine Zustellkopfzeile gefunden. Vorhanden:",
|
|
Object.keys(h).join(", "));
|
|
} catch (err) {
|
|
console.debug("[paperless] Kopfzeilen nicht lesbar:", err?.message);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function nachrichtAlsPdf(auftrag, s) {
|
|
const id = auftrag.messageId;
|
|
const kopf = await browser.messages.get(id).catch(() => ({}));
|
|
const text = await nachrichtentext(id);
|
|
const konto = await kontoAdresse(kopf, id);
|
|
|
|
const dateien = [];
|
|
const aufstellung = [];
|
|
|
|
// Das Original zuerst - es ist der wichtigste Anhang.
|
|
try {
|
|
const roh = await browser.messages.getRaw(id);
|
|
dateien.push({
|
|
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.eml`),
|
|
daten: new TextEncoder().encode(roh),
|
|
mime: "message/rfc822",
|
|
beschreibung: "Originalnachricht"
|
|
});
|
|
} catch (err) {
|
|
console.warn("[paperless] getRaw fehlgeschlagen", err);
|
|
}
|
|
|
|
// Dann die Anhänge, sofern gewünscht.
|
|
if (s.pdfAnhaengeEinbetten !== false) {
|
|
for (const a of auftrag.dateien || []) {
|
|
aufstellung.push({ name: a.name, groesse: a.size });
|
|
try {
|
|
const datei = await browser.messages.getAttachmentFile(
|
|
a.messageId, a.partName);
|
|
dateien.push({
|
|
name: sichererName(a.name),
|
|
daten: new Uint8Array(await datei.arrayBuffer()),
|
|
mime: a.contentType || "application/octet-stream",
|
|
beschreibung: "Anhang der Nachricht"
|
|
});
|
|
} catch (err) {
|
|
console.warn("[paperless] Anhang nicht lesbar:", a.name, err);
|
|
}
|
|
}
|
|
} else {
|
|
for (const a of auftrag.dateien || []) {
|
|
aufstellung.push({ name: a.name, groesse: a.size });
|
|
}
|
|
}
|
|
|
|
const pdf = baueMailPdf({
|
|
betreff: auftrag.betreff || kopf.subject || "",
|
|
konto,
|
|
von: kopf.author || auftrag.absender || "",
|
|
an: alsEmpfaenger(kopf.recipients),
|
|
kopie: alsEmpfaenger(kopf.ccList),
|
|
datum: auftrag.zeitpunkt || auftrag.datum || "",
|
|
text,
|
|
anhaenge: aufstellung,
|
|
dateien
|
|
}, {
|
|
schrift: Number(s.pdfSchriftgroesse) || 11,
|
|
format: s.pdfFormat || "a4",
|
|
rand: {
|
|
links: Number(s.pdfRandLinks ?? 20),
|
|
rechts: Number(s.pdfRandRechts ?? 10),
|
|
oben: Number(s.pdfRandOben ?? 10),
|
|
unten: Number(s.pdfRandUnten ?? 10)
|
|
}
|
|
});
|
|
|
|
return {
|
|
blob: new Blob([pdf], { type: "application/pdf" }),
|
|
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.pdf`)
|
|
};
|
|
}
|
|
|
|
async function datenHolen(auftrag, s) {
|
|
if (auftrag.quelle === "nachricht") {
|
|
// Als PDF aufbereiten oder im Rohformat ablegen - beides hat seine
|
|
// Berechtigung. Das Rohformat ist verlustfrei und wieder in ein
|
|
// Mailprogramm einlesbar, das PDF dafür lesbar und durchsuchbar.
|
|
// Die Auswahl aus dem Dialog hat Vorrang vor der Voreinstellung.
|
|
const alsPdf = auftrag.alsPdf !== undefined
|
|
? Boolean(auftrag.alsPdf)
|
|
: (s.mailFormat || "pdf") === "pdf";
|
|
if (alsPdf) {
|
|
try {
|
|
return [await nachrichtAlsPdf(auftrag, s)];
|
|
} catch (err) {
|
|
console.error("[paperless] PDF-Aufbereitung gescheitert", err);
|
|
// Nicht abbrechen: lieber die Rohfassung ablegen als gar nichts.
|
|
}
|
|
}
|
|
const roh = await browser.messages.getRaw(auftrag.messageId);
|
|
return [{
|
|
blob: new Blob([roh], { type: "message/rfc822" }),
|
|
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.eml`)
|
|
}];
|
|
}
|
|
|
|
const out = [];
|
|
for (const d of auftrag.dateien || []) {
|
|
if (!d.ausgewaehlt) continue;
|
|
const datei = await browser.messages.getAttachmentFile(
|
|
d.messageId, d.partName);
|
|
|
|
// Aufbereiten nur, wenn der Anwender es gewählt hat UND das Format es
|
|
// hergibt. Eine Rechnung als PDF ist bereits das Dokument; sie durch
|
|
// eine Textfassung zu ersetzen wäre Unfug.
|
|
if (d.alsPdf && umwandelbar(d.contentType, d.name)) {
|
|
try {
|
|
out.push(await anhangAlsPdf(datei, d, s));
|
|
continue;
|
|
} catch (err) {
|
|
console.error("[paperless] Anhang konnte nicht aufbereitet werden",
|
|
d.name, err);
|
|
// Nicht abbrechen: lieber das Original ablegen als gar nichts.
|
|
}
|
|
}
|
|
out.push({ blob: datei, name: sichererName(d.name) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Einen Textanhang als PDF aufbereiten, mit der Ursprungsdatei darin.
|
|
*
|
|
* Sinnvoll etwa bei einem HTML-Anhang, den im Archiv sonst niemand
|
|
* ansieht. Für Formate ohne Textinhalt greift diese Funktion nicht -
|
|
* das prüft umwandelbar() vorher.
|
|
*/
|
|
async function anhangAlsPdf(datei, beschreibung, s) {
|
|
const roh = new Uint8Array(await datei.arrayBuffer());
|
|
let text = new TextDecoder("utf-8", { fatal: false }).decode(roh);
|
|
|
|
const istHtml = /^text\/html$/i.test(beschreibung.contentType || "")
|
|
|| /\.(html?|xhtml)$/i.test(beschreibung.name || "")
|
|
|| /<\s*html[\s>]/i.test(text.slice(0, 2000));
|
|
|
|
if (istHtml && browser.messengerUtilities?.convertToPlainText) {
|
|
try {
|
|
text = await browser.messengerUtilities.convertToPlainText(text);
|
|
} catch { /* dann bleibt der Rohtext */ }
|
|
}
|
|
|
|
const basis = String(beschreibung.name || "Dokument")
|
|
.replace(/\.[^.]{1,8}$/, "");
|
|
|
|
const pdf = baueTextPdf({
|
|
titel: basis,
|
|
text,
|
|
dateien: [{
|
|
name: sichererName(beschreibung.name || "original"),
|
|
daten: roh,
|
|
mime: beschreibung.contentType || "application/octet-stream",
|
|
beschreibung: "Originaldatei"
|
|
}]
|
|
}, {
|
|
schrift: Number(s.pdfSchriftgroesse) || 11,
|
|
format: s.pdfFormat || "a4",
|
|
rand: {
|
|
links: Number(s.pdfRandLinks ?? 20),
|
|
rechts: Number(s.pdfRandRechts ?? 10),
|
|
oben: Number(s.pdfRandOben ?? 10),
|
|
unten: Number(s.pdfRandUnten ?? 10)
|
|
}
|
|
});
|
|
|
|
return {
|
|
blob: new Blob([pdf], { type: "application/pdf" }),
|
|
name: sichererName(`${basis}.pdf`)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Die Dateien eines Auftrags aufzählen, ohne sie zu laden.
|
|
*
|
|
* Der Dialog fragt damit ab, wie viele Schritte anstehen, und ruft dann
|
|
* jeden einzeln auf.
|
|
*/
|
|
function dateiListe(auftrag) {
|
|
if (auftrag.quelle === "nachricht") {
|
|
return [{ art: "nachricht" }];
|
|
}
|
|
return (auftrag.dateien || [])
|
|
.map((d, i) => ({ art: "anhang", index: i, name: d.name }))
|
|
.filter((_x, i) => auftrag.dateien[i].ausgewaehlt);
|
|
}
|
|
|
|
/**
|
|
* EINE Datei ablegen.
|
|
*
|
|
* Bewusst je Datei ein eigener Aufruf statt einer Schleife über alle:
|
|
*
|
|
* * Die Ereignisseite wird in Manifest V3 beendet, sobald sie untätig
|
|
* scheint. Ein Aufruf über zehn Anhänge lief regelmäßig in
|
|
* "Receiving end does not exist".
|
|
* * Der Dialog kann den Fortschritt anzeigen.
|
|
* * Scheitert eine Datei, sind die übrigen trotzdem abgelegt.
|
|
*/
|
|
async function ablegenEine(nachricht) {
|
|
const c = await client();
|
|
const s = await ladeEinstellungen();
|
|
const auftrag = nachricht.auftrag;
|
|
const eintrag = nachricht.eintrag;
|
|
|
|
let datei;
|
|
if (eintrag.art === "nachricht") {
|
|
const alle = await datenHolen(auftrag, s);
|
|
datei = alle[0];
|
|
} else {
|
|
const beschreibung = auftrag.dateien[eintrag.index];
|
|
const roh = await browser.messages.getAttachmentFile(
|
|
beschreibung.messageId, beschreibung.partName);
|
|
|
|
if (beschreibung.alsPdf
|
|
&& umwandelbar(beschreibung.contentType, beschreibung.name)) {
|
|
try {
|
|
datei = await anhangAlsPdf(roh, beschreibung, s);
|
|
} catch (err) {
|
|
console.error("[paperless] Aufbereitung gescheitert",
|
|
beschreibung.name, err);
|
|
datei = { blob: roh, name: sichererName(beschreibung.name) };
|
|
}
|
|
} else {
|
|
datei = { blob: roh, name: sichererName(beschreibung.name) };
|
|
}
|
|
}
|
|
|
|
if (!datei) throw new PaperlessError(t("errNothingSelected"));
|
|
|
|
const melden = (sek) => {
|
|
if (sek % 15 === 0) {
|
|
console.debug("[paperless] warte auf Aufgabe:", sek, "s");
|
|
}
|
|
};
|
|
|
|
if (nachricht.ziel === "vorhanden") {
|
|
const antwort = await c.updateVersion(
|
|
nachricht.zielId, datei.blob, datei.name,
|
|
versionslabel(s.versionLabel));
|
|
const id = await c.waitForTask(antwort, melden);
|
|
return { id, name: datei.name, art: "version", zielId: nachricht.zielId };
|
|
}
|
|
|
|
// Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt -
|
|
// sonst hiessen alle gleich und wären im Archiv nicht auseinander-
|
|
// zuhalten.
|
|
const grundtitel = nachricht.titel || datei.name;
|
|
const titel = nachricht.mehrere
|
|
? `${grundtitel} - ${datei.name.replace(/\.[^.]{1,8}$/, "")}`
|
|
: grundtitel;
|
|
|
|
const felder = { title: titel.slice(0, 127) };
|
|
if (nachricht.erstellt) felder.created = nachricht.erstellt;
|
|
|
|
const antwort = await c.postDocument(datei.blob, datei.name, felder);
|
|
const id = await c.waitForTask(antwort, melden);
|
|
return { id, name: datei.name, art: "neu" };
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|