/** * 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, alsEmpfaenger } 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 = 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 /** * 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); port.onDisconnect.addListener(() => { verbindungen.delete(port); console.debug("[paperless] Dialog getrennt, offen:", verbindungen.size); }); }); 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 = 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); 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, s); 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, (sek) => { if (sek % 15 === 0) { console.debug("[paperless] warte auf Aufgabe:", sek, "s"); } }); 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, (sek) => { if (sek % 15 === 0) { console.debug("[paperless] warte auf Aufgabe:", sek, "s"); } }); ergebnisse.push({ id, name: datei.name, art: "neu" }); } } return ergebnisse; } async function aufloesen(c, pfad, name) { const gesucht = String(name || "").trim(); if (!gesucht) return null; const liste = pfad.startsWith("correspondents") ? await c.correspondents() : await c.documentTypes(); const treffer = liste.find( (x) => x.name.trim().toLowerCase() === gesucht.toLowerCase()); if (treffer) return treffer.id; const neu = await c.create(pfad, gesucht); return neu.id; } async function anhaengenAnEntwurf(nachricht) { const c = await client(); const { blob, name } = await c.download(nachricht.versionId, true); const datei = new File([blob], sichererName(name || `paperless-${nachricht.versionId}`)); await browser.compose.addAttachment(nachricht.tabId, { file: datei }); return { name: datei.name }; } async function freigabelink(nachricht) { const c = await client(); const url = await c.shareLink(nachricht.id, nachricht.tage, nachricht.variante); if (!url) throw new PaperlessError(t("errNoLink")); // Kennzeichnung setzen. Fehler werden NICHT verschluckt: Der Tag ist der // Grund, warum sich geteilte Dokumente später wiederfinden lassen - ein // stiller Fehlschlag wäre schlimmer als eine Meldung. let tagStatus = null; if (nachricht.tagName) { try { const name = String(nachricht.tagName).trim(); const tags = await c.tags(); let treffer = tags.find( (x) => String(x.name).trim().toLowerCase() === name.toLowerCase()); if (!treffer) treffer = await c.create("tags/", name); if (!treffer || treffer.id === undefined) { throw new PaperlessError( `Tag "${name}" ohne Kennung: ${JSON.stringify(treffer)}`); } const doc = await c.document(nachricht.id); const vorhanden = (doc.tags || []).map(Number); if (!vorhanden.includes(Number(treffer.id))) { await c.patchDocument( nachricht.id, { tags: [...vorhanden, Number(treffer.id)] }); } tagStatus = { ok: true, name }; } catch (err) { tagStatus = { ok: false, fehler: String(err?.message || err) }; console.error("[paperless] Tag konnte nicht gesetzt werden", err); } } // Ablaufdatum selbst rechnen. Es steht auch in der Antwort, aber nicht // in jeder Version unter demselben Namen - die eigene Rechnung stimmt // mit dem überein, was im Dialog eingestellt wurde. const gueltigBis = nachricht.tage ? new Date(Date.now() + Number(nachricht.tage) * 86400000) : null; if (nachricht.tabId !== undefined && nachricht.einfuegen) { await linkEinfuegen(nachricht.tabId, url, nachricht.linktext, gueltigBis); } return { url, tagStatus, gueltigBis: gueltigBis ? gueltigBis.toISOString() : null }; } /** * Datum als TT.MM.JJJJ. * * Bewusst kein toLocaleDateString: Die Nachricht geht an einen Empfänger * mit unbekannter Spracheinstellung. 03.09. und 09.03. sehen gleich aus * und bedeuten Verschiedenes - bei einem Ablaufdatum ist das der falsche * Ort für Mehrdeutigkeit. */ function alsDeutschesDatum(d) { const z = (n) => String(n).padStart(2, "0"); return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()}`; } /** * Freigabelink in einen Entwurf einfügen. * * ACHTUNG: Die Compose-Schnittstelle kennt kein Einfügen an der * Schreibmarke. Der Körper muss vollständig gelesen und zurückgeschrieben * werden. Der Eintrag landet deshalb am Ende, bei HTML vor dem * schliessenden . * * Name UND Adresse stehen sichtbar. Ein Link, dessen Ziel man nicht sieht, * ist beim Empfänger schwer einzuschätzen - erst recht bei einer Adresse, * die ohne Anmeldung Zugriff gewährt. */ async function linkEinfuegen(tabId, url, linktext, gueltigBis) { const details = await browser.compose.getComposeDetails(tabId); const name = String(linktext || "").trim(); const hinweis = gueltigBis ? t("linkValidUntil", [alsDeutschesDatum(gueltigBis)]) : t("linkValidUnlimited"); if (details.isPlainText) { const koerper = details.plainTextBody || ""; const zeilen = [name, url, hinweis].filter(Boolean); await browser.compose.setComposeDetails(tabId, { plainTextBody: `${koerper}\n\n${zeilen.join("\n")}\n` }); return; } const koerper = details.body || ""; const sicherUrl = escapeHtml(url); const kopf = name ? `${escapeHtml(name)}
` : ""; const block = `

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

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