Files
thunderbird-paperless-conne…/background.js

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(/&nbsp;/gi, " ").replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<").replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"').replace(/&#39;/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) => ({
"&": "&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);
}
}