From d032e3b19c975874d2fe9464043bc5acda687313 Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Mon, 7 Sep 2026 22:33:55 +0200 Subject: [PATCH] =?UTF-8?q?=C3=9Cbergabe=20von=20E-Mails=20als=20PDF=20mit?= =?UTF-8?q?=20eingebetteten=20Anlagen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 66 +++++- SICHERHEIT.md | 55 ++++- _locales/de/messages.json | 45 ++++ _locales/en/messages.json | 45 ++++ _locales/fr/messages.json | 45 ++++ background.js | 287 ++++++++++++++++++++++- build.sh | 2 +- lib/mailpdf.js | 213 +++++++++++++++++ lib/pdf.js | 470 ++++++++++++++++++++++++++++++++++++++ lib/settings.js | 29 +++ manifest.json | 3 +- ui/gemeinsam.css | 7 +- ui/options.html | 70 +++++- ui/options.js | 24 +- ui/store.html | 6 + ui/store.js | 24 +- 16 files changed, 1363 insertions(+), 28 deletions(-) create mode 100644 lib/mailpdf.js create mode 100644 lib/pdf.js diff --git a/README.md b/README.md index ca957f5..ba4c90d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ WebExtensions, LibreOffice UNO. | Ort | Befehl | Wirkung | |---|---|---| | Kontextmenü am Anhang | Anhang in Paperless ablegen | ausgewählte Anhänge als Dokument | -| Kontextmenü in der Nachrichtenliste | E-Mail in Paperless ablegen | vollständige Nachricht als `.eml` | +| Kontextmenü in der Nachrichtenliste | E-Mail in Paperless ablegen | als PDF mit eingebettetem Original, wahlweise als `.eml` | | Schaltfläche im Nachrichtenfenster | dasselbe für die angezeigte Nachricht | | | Schaltfläche im Verfassenfenster | Aus Paperless einfügen | als Anhang oder als Freigabelink | @@ -35,9 +35,18 @@ abgewählt — sie gehören zur Darstellung der Nachricht, nicht zum Inhalt. Wer sie alle mit archiviert, hat nach einem Jahr hunderte Logos im Bestand. Die Schwelle liegt bei 8 KB und ist einstellbar. -**Die ganze Nachricht als `.eml`.** Anhänge bleiben darin enthalten; -Paperless legt sie nicht als eigene Dokumente an, aber die Datei ist -vollständig und lässt sich jederzeit wieder in einem Mailprogramm öffnen. +**Die ganze Nachricht als PDF.** Betreff als Überschrift, darunter ein +grau hinterlegter Kopfblock mit Konto, Absender, Empfängern, Datum und +Anlagen, dann der Nachrichtentext. Zitierte Abschnitte stehen grau und +eingerückt — bei langen Verläufen sieht man so auf einen Blick, was neu +ist. + +Eingebettet werden die vollständige Originalnachricht als `.eml` sowie +alle Anhänge im Original. Es geht also nichts verloren, auch wenn die +Darstellung nicht originalgetreu ist. + +Wer lieber die Rohfassung will, stellt das in den Einstellungen um oder +nimmt das Häkchen im Ablegen-Dialog heraus. **Titel aus dem Betreff.** Präfixe wie `AW:` oder `Fwd:` werden entfernt — sie tragen im Archiv nichts bei und stören beim Sortieren. @@ -45,6 +54,52 @@ sie tragen im Archiv nichts bei und stören beim Sortieren. **Klassifizierung direkt danach.** Sonst trägt ein neues Dokument nur den Titel, und nachträglich macht man es erfahrungsgemäß nicht mehr. +### Warum kein originalgetreues Abbild + +HTML-Nachrichten werden als Text gesetzt. Eine WebExtension hat keine +Darstellungsmaschine, die HTML nach PDF überträgt, und **Thunderbird +bietet Erweiterungen keine Druckfunktion an** — die tabs-Schnittstelle +kennt in Manifest V3 kein `saveAsPDF`, anders als in Firefox. Geprüft an +der API-Dokumentation für Thunderbird 156. + +Für die Umwandlung von HTML nach Text übernimmt +`messengerUtilities.convertToPlainText()` die Arbeit. Das ist dieselbe +Funktion, die beim Antworten in Nur-Text greift, und liefert deutlich +bessere Ergebnisse als eine selbstgebaute Entschlackung. + +### Der PDF-Erzeuger + +Selbst geschrieben, in `lib/pdf.js`. Der Grund ist Vorsatz: Eine +Erweiterung soll nichts nachladen, und eine mitgelieferte Bibliothek wie +`pdf-lib` wäre rund ein Megabyte für einen Bruchteil ihrer Fähigkeiten. +Gebraucht werden hier nur Text und eingebettete Dateien. + +Erzeugt wird PDF 1.7 mit den eingebauten Schriften Helvetica und +Helvetica-Bold. Die sind in jedem Betrachter vorhanden und müssen nicht +eingebettet werden. + +**Ausdrücklich kein PDF/A.** Dafür wären eingebettete Schriftschnitte, ein +Farbprofil und XMP-Metadaten nötig — und vor allem eine Prüfung mit +veraPDF. Ein Dokument als PDF/A zu bezeichnen, ohne es geprüft zu haben, +wäre eine leere Behauptung. + +Einstellbar sind Papierformat (A4, A5, Letter, Legal), die vier Ränder in +Millimetern und die Grundschriftgröße (Vorgabe 11 Punkt). + +### Das Konto im Kopfblock + +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. + +Ermittelt wird sie auf zwei Wegen: über die accounts-Schnittstelle, die +allerdings die Berechtigung `accountsRead` braucht, und ersatzweise über +die Zustellkopfzeilen `Delivered-To`, `X-Original-To` und Verwandte. + +Letztere setzt der zustellende Server, und sie sind nicht immer vorhanden +— Postfix und Dovecot setzen sie zuverlässig, Microsoft-Umgebungen oft +nicht. Findet sich nichts, fehlt die Zeile im PDF ganz. + --- ## Einfügen in Entwürfe @@ -233,3 +288,6 @@ schließt jede Gewährleistung aus. Vor dem ersten produktiven Einsatz: eine Testnachricht ablegen und in Paperless nachsehen, ob Titel, Datum und Anhänge stimmen. + +Es wird weder eine Gewähr noch eine Haftung übernommen. Für entstandene +Schäden haftet der jeweilige Anwender. Die Nutzun erfolgt auf eigenes Risiko. diff --git a/SICHERHEIT.md b/SICHERHEIT.md index 9cbaaf0..111a72e 100644 --- a/SICHERHEIT.md +++ b/SICHERHEIT.md @@ -1,7 +1,7 @@ # Sicherheitsbetrachtung Was die Erweiterung tut, welche Angriffswege sich daraus ergeben und was -dagegen unternommen wird. Stand: Version 1.5.0. +dagegen unternommen wird. Stand: Version 1.11.3. --- @@ -10,7 +10,11 @@ dagegen unternommen wird. Stand: Version 1.5.0. Die Lage ist deutlich entspannter als bei der LibreOffice-Fassung. Dort war der kritischste Schritt das **Öffnen** eines heruntergeladenen Dokuments in einer Anwendung mit Makrofähigkeit. Das entfällt hier -vollständig — die Erweiterung öffnet nichts, sie überträgt nur. +vollständig — die Erweiterung öffnet nichts. + +Seit Version 1.9 erzeugt sie allerdings PDF-Dateien aus E-Mails. Das ist +sicherheitstechnisch unauffällig, gehört aber der Vollständigkeit halber +in diese Aufstellung — siehe Abschnitt 5. | Handlung | Vertrauensgrenze | |---|---| @@ -18,6 +22,7 @@ vollständig — die Erweiterung öffnet nichts, sie überträgt nur. | Anhänge und Nachrichten hochladen | Inhalt vom Absender, nicht vertrauenswürdig | | Dokumente als Anhang in Entwürfe | Inhalt aus dem Archiv | | Freigabelink in den Nachrichtentext | erzeugte Zeichenkette | +| PDF aus einer Nachricht erzeugen | Inhalt vom Absender | | Token dauerhaft speichern | Geheimnis | --- @@ -30,7 +35,8 @@ Bewusst knapp gehalten: |---|---|---| | `storage` | Einstellungen | | | `menus` | Kontextmenüs | | -| `messagesRead` | Anhänge und `.eml` lesen | nur auf Anforderung genutzt | +| `messagesRead` | Anhänge, Text und `.eml` lesen | nur auf Anforderung genutzt | +| `accountsRead` | Konto der Nachricht ermitteln | ohne sie greift der Weg über die Zustellkopfzeilen | | `compose` | Anhang und Link einfügen | | | `notifications` | Fehlermeldungen | | @@ -101,7 +107,38 @@ Speicher liegen, nur weil jemand einen Dialog offen stehen liess. --- -## 5. Speicherverhalten +## 5. Das erzeugte PDF + +Seit Version 1.9 werden ganze Nachrichten als PDF aufbereitet. Der +Erzeuger ist selbst geschrieben (`lib/pdf.js`), ohne Fremdbibliothek. + +**Das PDF enthält keinen ausführbaren Inhalt.** Kein JavaScript, keine +OpenAction, keine Verweise nach außen — nur Textoperatoren, ein +Rechteck und eingebettete Dateien. Ein PDF-Betrachter hat darin nichts +zu tun, was über das Darstellen hinausgeht. + +**Eingebettet werden Originalnachricht und Anhänge.** Deren Inhalt +stammt vom Absender und ist nicht vertrauenswürdig. Die Erweiterung +öffnet sie nicht, sie legt sie nur ab — was ein Betrachter tut, wenn +jemand später auf einen Anhang doppelklickt, liegt außerhalb ihres +Einflusses. Das entspricht der Lage bei jedem anderen Archivdokument. + +**Dateinamen werden bereinigt**, bevor sie in die Anhangstabelle des PDF +geschrieben werden. Sie stammen vom Absender. + +**Texte werden für die PDF-Syntax abgesichert.** Klammern und +Rückstriche haben dort besondere Bedeutung; ohne Maskierung könnte ein +Betreff mit einer schliessenden Klammer die Struktur der Datei stören. +Zeichen ausserhalb von WinAnsi werden durch ein Fragezeichen ersetzt. + +**Ränder und Schriftgröße werden begrenzt** — 0 bis 60 mm, 7 bis 16 +Punkt, und zusätzlich auf höchstens ein Drittel der Seitenbreite. Eine +Zahl aus einem Eingabefeld kann alles sein, und ein Dokument mit +negativer Textbreite wäre schwer zu deuten. + +--- + +## 6. Speicherverhalten Anhangdaten werden **erst beim Hochladen** geladen, nicht beim Öffnen des Dialogs. Bei einer Nachricht mit mehreren großen Anhängen läge sonst alles @@ -109,7 +146,7 @@ im Speicher, während der Anwender noch überlegt. --- -## 6. Transport +## 7. Transport **Adressprüfung.** Nur `http` und `https`. Ohne diese Prüfung liessen sich andere Schemata hinterlegen, die `fetch` teilweise bedient. @@ -125,7 +162,7 @@ ohnehin nicht umgehen, und das ist richtig so. --- -## 7. Der Token +## 8. Der Token Er liegt im Klartext in `browser.storage.local`, also im Profil von Thunderbird. Das entspricht dem, was Erweiterungen üblicherweise tun, ist @@ -145,11 +182,12 @@ Erweiterung kann dann nur, was dieser Benutzer darf. --- -## 8. Was die Erweiterung NICHT tut +## 9. Was die Erweiterung NICHT tut - kein `eval`, kein `innerHTML` mit fremden Inhalten — alle Listen werden über `textContent` und `createElement` aufgebaut - kein Öffnen heruntergeladener Dateien +- kein ausführbarer Inhalt im erzeugten PDF - kein Schreiben ins Dateisystem - kein Versenden von Nachrichten - keine Übertragung an Dritte; einzige Gegenstelle ist die konfigurierte @@ -157,7 +195,7 @@ Erweiterung kann dann nur, was dieser Benutzer darf. --- -## 9. Bekannte Grenzen +## 10. Bekannte Grenzen | Punkt | Bewertung | |---|---| @@ -166,6 +204,7 @@ Erweiterung kann dann nur, was dieser Benutzer darf. | Kompromittierte Paperless-Instanz | könnte manipulierte Dokumente als Anhang liefern — sie werden aber nicht geöffnet, nur angehängt | | Kein Schutz gegen Zugriff auf das eigene Benutzerkonto | systembedingt | | Der Nachrichtentext wird beim Einfügen vollständig neu geschrieben | Vorgabe der Compose-Schnittstelle, siehe README | +| Das erzeugte PDF ist **kein PDF/A** | dafür fehlen Schrifteinbettung, Farbprofil und eine Prüfung mit veraPDF | --- diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 49fc7de..c7b8c9f 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -263,5 +263,50 @@ }, "tagsNoMatch": { "message": "Keine Übereinstimmung." + }, + "fieldMailFormat": { + "message": "Ganze E-Mails ablegen als" + }, + "mailFormatPdf": { + "message": "PDF mit eingebettetem Original" + }, + "mailFormatEml": { + "message": "Rohformat (.eml)" + }, + "fieldPdfAttachments": { + "message": "Anhänge zusätzlich einzeln ins PDF einbetten" + }, + "optionsHintMailFormat": { + "message": "Das PDF ist lesbar und durchsuchbar; HTML-Nachrichten werden dabei als Text gesetzt. Die Originalnachricht steckt als Datei im PDF, es geht also nichts verloren." + }, + "storeWholePdf": { + "message": "Die Nachricht als PDF, mit eingebettetem Original" + }, + "storeAsPdf": { + "message": "Als PDF aufbereiten, Original und Anhänge einbetten" + }, + "fieldPdfFontSize": { + "message": "Schriftgröße" + }, + "optionsHintPdfFontSize": { + "message": "7 bis 16 Punkt" + }, + "fieldPdfFormat": { + "message": "Papierformat" + }, + "fieldPdfMargins": { + "message": "Ränder in mm" + }, + "marginLeft": { + "message": "links" + }, + "marginRight": { + "message": "rechts" + }, + "marginTop": { + "message": "oben" + }, + "marginBottom": { + "message": "unten" } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9fe90fb..7f63074 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -263,5 +263,50 @@ }, "tagsNoMatch": { "message": "No match." + }, + "fieldMailFormat": { + "message": "Store whole e-mails as" + }, + "mailFormatPdf": { + "message": "PDF with embedded original" + }, + "mailFormatEml": { + "message": "Raw format (.eml)" + }, + "fieldPdfAttachments": { + "message": "Also embed attachments individually in the PDF" + }, + "optionsHintMailFormat": { + "message": "The PDF is readable and searchable; HTML messages are rendered as text. The original message is embedded in the PDF, so nothing is lost." + }, + "storeWholePdf": { + "message": "The message as PDF, with the original embedded" + }, + "storeAsPdf": { + "message": "Prepare as PDF, embedding original and attachments" + }, + "fieldPdfFontSize": { + "message": "Font size" + }, + "optionsHintPdfFontSize": { + "message": "7 to 16 points" + }, + "fieldPdfFormat": { + "message": "Paper size" + }, + "fieldPdfMargins": { + "message": "Margins in mm" + }, + "marginLeft": { + "message": "left" + }, + "marginRight": { + "message": "right" + }, + "marginTop": { + "message": "top" + }, + "marginBottom": { + "message": "bottom" } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 42ff803..011b29c 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -263,5 +263,50 @@ }, "tagsNoMatch": { "message": "Aucune correspondance." + }, + "fieldMailFormat": { + "message": "Enregistrer les courriels entiers comme" + }, + "mailFormatPdf": { + "message": "PDF avec l'original intégré" + }, + "mailFormatEml": { + "message": "Format brut (.eml)" + }, + "fieldPdfAttachments": { + "message": "Intégrer aussi les pièces jointes séparément dans le PDF" + }, + "optionsHintMailFormat": { + "message": "Le PDF est lisible et consultable ; les messages HTML y sont rendus sous forme de texte. Le message d'origine est intégré au PDF, rien n'est perdu." + }, + "storeWholePdf": { + "message": "Le message en PDF, avec l'original intégré" + }, + "storeAsPdf": { + "message": "Préparer en PDF, avec l'original et les pièces jointes intégrés" + }, + "fieldPdfFontSize": { + "message": "Taille de police" + }, + "optionsHintPdfFontSize": { + "message": "7 à 16 points" + }, + "fieldPdfFormat": { + "message": "Format de papier" + }, + "fieldPdfMargins": { + "message": "Marges en mm" + }, + "marginLeft": { + "message": "gauche" + }, + "marginRight": { + "message": "droite" + }, + "marginTop": { + "message": "haut" + }, + "marginBottom": { + "message": "bas" } } diff --git a/background.js b/background.js index 70621ae..3d87b39 100644 --- a/background.js +++ b/background.js @@ -16,6 +16,7 @@ import { ladeEinstellungen, vollstaendig, sichererName, versionslabel, titelAusBetreff, zugriffsmuster, t } from "./lib/settings.js"; +import { baueMailPdf, alsEmpfaenger } from "./lib/mailpdf.js"; /** * Ergebnis einer Nachrichten-Abfrage vereinheitlichen. @@ -90,6 +91,16 @@ function alsDatum(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". @@ -196,6 +207,7 @@ async function anhangAblegen(info, tab) { betreff: nachricht.subject, absender: nachricht.author, datum: alsDatum(nachricht.date), + zeitpunkt: alsZeitpunkt(nachricht.date), dateien }); } @@ -217,6 +229,7 @@ async function nachrichtAblegen(info, tab) { 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, @@ -400,14 +413,272 @@ async function berechtigungPruefen() { // -------------------------------------------------------------- Vorgänge -async function datenHolen(auftrag) { - if (auftrag.quelle === "nachricht") { - const roh = await browser.messages.getRaw(auftrag.messageId); - const blob = new Blob([roh], { type: "message/rfc822" }); - const name = sichererName( - `${titelAusBetreff(auftrag.betreff)}.eml`); - return [{ blob, name }]; +/** + * 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; @@ -421,7 +692,7 @@ async function datenHolen(auftrag) { async function ablegen(nachricht) { const c = await client(); const s = await ladeEinstellungen(); - const dateien = await datenHolen(nachricht.auftrag); + const dateien = await datenHolen(nachricht.auftrag, s); if (!dateien.length) throw new PaperlessError(t("errNothingSelected")); const ergebnisse = []; diff --git a/build.sh b/build.sh index e2885b8..1bf9b84 100644 --- a/build.sh +++ b/build.sh @@ -15,7 +15,7 @@ OUT="${NAME}-${VERSION}.xpi" # ohne diese Prüfung entstünde ein Paket, das sich nicht laden lässt. FEHLT="" for f in manifest.json background.js \ - lib/client.js lib/settings.js \ + lib/client.js lib/settings.js lib/pdf.js lib/mailpdf.js \ ui/store.html ui/store.js ui/classify.html ui/classify.js \ ui/insert.html ui/insert.js ui/options.html ui/options.js \ ui/gemeinsam.js ui/gemeinsam.css \ diff --git a/lib/mailpdf.js b/lib/mailpdf.js new file mode 100644 index 0000000..005225b --- /dev/null +++ b/lib/mailpdf.js @@ -0,0 +1,213 @@ +/** + * mailpdf.js - E-Mail als lesbares PDF aufbereiten. + * + * Aufbau: Betreff als Überschrift, darunter ein grau hinterlegter + * Kopfblock mit Konto, Absender, Empfängern, Datum und Anlagen, dann der + * Nachrichtentext. + * + * Eingebettet werden die vollständige Originalnachricht als .eml sowie + * auf Wunsch alle Anhänge im Original. + * + * BEWUSSTE EINSCHRÄNKUNG: HTML-Nachrichten werden als Text gesetzt, nicht + * originalgetreu dargestellt. Eine Erweiterung hat keine + * Darstellungsmaschine für HTML, und Thunderbird bietet Erweiterungen + * keine Druckfunktion an - die tabs-Schnittstelle kennt in Manifest V3 + * kein saveAsPDF. + * + * Deshalb steckt das Original mit im PDF. Wer die gestaltete Fassung + * braucht, öffnet den Anhang. + */ + +import { PdfDokument, textbreite, umbrechen, mm, FORMATE } + from "./pdf.js"; + +const GRAU_TEXT = [0.35, 0.35, 0.35]; +const GRAU_FLAECHE = 0.95; // etwa 5 Prozent +const INNEN = 8; // Polster im Kopfblock + +/** + * @param {object} daten + * betreff, konto, von, an, kopie, datum Zeichenketten + * text Nachrichtentext als Text + * anhaenge [{name, groesse}] für die Aufstellung + * dateien [{name, daten, mime, beschreibung}] wird eingebettet + * @param {object} opt + * schrift Grundschriftgröße in Punkten, Vorgabe 11 + * format Papierformat: a4, a5, letter, legal + * rand {links, rechts, oben, unten} in Millimetern + * @returns {Uint8Array} + */ +export function baueMailPdf(daten, opt = {}) { + const basis = begrenzen(Number(opt.schrift) || 11, 7, 16); + // Die Überschrift wächst schwächer mit als der Fließtext. Sonst nimmt + // sie bei größerer Grundschrift zu viel Raum und läuft über zwei + // Zeilen, bevor der Inhalt überhaupt beginnt. + const ueberschrift = basis + 2; + const klein = Math.max(7, basis - 1.5); + + const papier = FORMATE[String(opt.format || "a4").toLowerCase()] + || FORMATE.a4; + const r = opt.rand || {}; + + // Die Ränder werden begrenzt, damit kein Dokument mit negativer + // Textbreite entsteht - eine Zahl aus einem Eingabefeld kann alles + // sein, und ein unbrauchbares PDF wäre schwer zu deuten. + const grenze = (wert, vorgabe) => + Math.min(papier.breite / 3, Math.max(0, Number(wert) ?? vorgabe)); + + const d = new PdfDokument({ + titel: daten.betreff || "E-Mail", + autor: daten.von || "", + betreff: daten.betreff || "", + breite: mm(papier.breite), + hoehe: mm(papier.hoehe), + randLinks: mm(grenze(r.links ?? 20, 20)), + randRechts: mm(grenze(r.rechts ?? 10, 10)), + randOben: mm(grenze(r.oben ?? 10, 10)), + randUnten: mm(grenze(r.unten ?? 10, 10)) + }); + d.neueSeite(); + + // -- Überschrift + d.absatz(daten.betreff || "(ohne Betreff)", { + groesse: ueberschrift, fett: true, abstand: 1.25 + }); + d.abstand(6); + + // -- Kopfblock + kopfblock(d, daten, klein); + + d.abstand(12); + + // -- Inhalt + const text = String(daten.text ?? "").replace(/\r\n?/g, "\n").trimEnd(); + if (text) { + for (const zeile of text.split("\n")) { + // Zitierte Abschnitte grau und eingerückt. Bei langen Verläufen + // sieht man so auf einen Blick, was neu ist. + const zitat = /^\s*>/.test(zeile); + d.absatz(zeile || " ", { + groesse: basis, + abstand: 1.35, + farbe: zitat ? GRAU_TEXT : null, + einzug: zitat ? 10 : 0 + }); + } + } else { + d.absatz("(kein Textinhalt - siehe eingebettete Originalnachricht)", + { groesse: basis, farbe: GRAU_TEXT }); + } + + // -- Fussbereich + d.abstand(10); + d.linie(); + d.absatz("Erzeugt aus der Originalnachricht, die diesem PDF als Datei " + + "beiliegt. HTML-Nachrichten sind hier als Text gesetzt; die " + + "gestaltete Fassung enthält der Anhang.", + { groesse: Math.max(6.5, basis - 3), farbe: GRAU_TEXT, + abstand: 1.3 }); + + for (const f of daten.dateien ?? []) { + d.anhang(f.name, f.daten, { + beschreibung: f.beschreibung || "", + mime: f.mime || "application/octet-stream" + }); + } + + return d.bauen(); +} + +/** + * Kopfblock mit ausgerichteten Beschriftungen auf grauem Grund. + * + * Die Höhe muss VOR dem Zeichnen feststehen: Die Fläche wird zuerst + * gesetzt, sonst läge sie über dem Text - im Inhaltsstrom gilt die + * Reihenfolge. Deshalb wird der Umbruch hier vorweg berechnet und danach + * nur noch ausgegeben. + */ +function kopfblock(d, daten, groesse) { + const zeilenhoehe = groesse * 1.45; + + const felder = []; + const nimm = (name, wert) => { + if (wert && String(wert).trim()) { + felder.push([name, [String(wert).trim()]]); + } + }; + nimm("Konto", daten.konto); + nimm("Von", daten.von); + nimm("An", daten.an); + nimm("Kopie", daten.kopie); + nimm("Datum", daten.datum); + + if (daten.anhaenge?.length) { + felder.push(["Anlagen", daten.anhaenge.map((a) => { + const g = a.groesse ? ` (${lesbareGroesse(a.groesse)})` : ""; + return `- ${a.name}${g}`; + })]); + } + if (!felder.length) return; + + // Spaltenbreite aus der breitesten Beschriftung. So beginnen alle Werte + // in derselben Spalte, ohne dass eine feste Breite geraten werden muss. + let spalte = 0; + for (const [name] of felder) { + spalte = Math.max(spalte, textbreite(name, groesse, true)); + } + spalte += 14; + + const wertBreite = d.innenbreite - 2 * INNEN - spalte; + + // Umbruch vorweg berechnen + const ausgabe = []; + for (const [name, werte] of felder) { + const zeilen = []; + for (const w of werte) { + for (const z of umbrechen(w, wertBreite, groesse)) zeilen.push(z); + } + ausgabe.push([name, zeilen]); + } + const anzahl = ausgabe.reduce((n, [, z]) => n + z.length, 0); + const hoehe = anzahl * zeilenhoehe + 2 * INNEN; + + d.platzPruefen(hoehe); + + const oben = d.y; + d.rechteck(d.rand, oben - hoehe, d.innenbreite, hoehe, GRAU_FLAECHE); + + let y = oben - INNEN; + for (const [name, zeilen] of ausgabe) { + for (const [i, zeile] of zeilen.entries()) { + const grundlinie = y - groesse; + if (i === 0) { + d.textAn(d.rand + INNEN, grundlinie, name, + { groesse, fett: true, farbe: GRAU_TEXT }); + } + d.textAn(d.rand + INNEN + spalte, grundlinie, zeile, { groesse }); + y -= zeilenhoehe; + } + } + d.y = oben - hoehe; +} + +function begrenzen(wert, min, max) { + return Math.min(max, Math.max(min, wert)); +} + +export function lesbareGroesse(bytes) { + const b = Number(bytes) || 0; + if (b < 1024) return `${b} B`; + if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`; + return `${(b / 1024 / 1024).toFixed(1)} MB`; +} + +/** + * Empfängerliste in eine Zeile überführen. + * + * Thunderbird liefert je nach Version ein Feld oder eine Zeichenkette. + */ +export function alsEmpfaenger(wert) { + if (!wert) return ""; + if (Array.isArray(wert)) return wert.filter(Boolean).join(", "); + return String(wert); +} diff --git a/lib/pdf.js b/lib/pdf.js new file mode 100644 index 0000000..8c80b62 --- /dev/null +++ b/lib/pdf.js @@ -0,0 +1,470 @@ +/** + * pdf.js - Schlanker PDF-Erzeuger für Textseiten mit eingebetteten Dateien. + * + * Warum selbst geschrieben statt einer fertigen Bibliothek: Eine + * Erweiterung soll nichts nachladen, und ein mitgeliefertes pdf-lib wäre + * rund ein Megabyte für einen Bruchteil seiner Fähigkeiten. Hier werden + * nur Text und Anhänge gebraucht. + * + * Erzeugt wird PDF 1.7 mit den eingebauten Schriften Helvetica und + * Helvetica-Bold. Die sind in jedem Betrachter vorhanden und müssen nicht + * eingebettet werden. + * + * AUSDRÜCKLICH KEIN PDF/A. Dafür wären eingebettete Schriftschnitte, ein + * Farbprofil und XMP-Metadaten nötig - und vor allem eine Prüfung mit + * veraPDF, die hier niemand durchführen kann. Ein Dokument als PDF/A zu + * bezeichnen, ohne es geprüft zu haben, wäre eine leere Behauptung. + */ + +/** Millimeter in Punkte. Ein Punkt ist 1/72 Zoll. */ +export function mm(wert) { + return (Number(wert) || 0) * 72 / 25.4; +} + +/** + * Papierformate in Millimetern. + * + * A4 ist ausserhalb Nordamerikas der Regelfall, Letter und Legal dort. + * A5 ist selten, aber bei kurzen Nachrichten sparsam im Ausdruck. + */ +export const FORMATE = { + a4: { name: "A4", breite: 210, hoehe: 297 }, + a5: { name: "A5", breite: 148, hoehe: 210 }, + letter: { name: "Letter", breite: 215.9, hoehe: 279.4 }, + legal: { name: "Legal", breite: 215.9, hoehe: 355.6 } +}; + +// Rückwärtskompatible Vorgaben in Punkten +export const SEITE_BREITE = mm(FORMATE.a4.breite); +export const SEITE_HOEHE = mm(FORMATE.a4.hoehe); + +// Zeichenbreiten der Standardschriften, in Tausendstel der Schriftgröße. +// Aus den AFM-Dateien von Adobe. Sie werden nur zum Umbrechen gebraucht - +// eine kleine Abweichung verschiebt den Umbruch, verfälscht aber nichts. +const BREITE_HELVETICA = { + " ": 278, "!": 278, '"': 355, "#": 556, $: 556, "%": 889, "&": 667, + "'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333, + ".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584, + "?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, _: 556, + "`": 333, "{": 334, "|": 260, "}": 334, "~": 584, + A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278, + J: 500, K: 667, L: 556, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722, + S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611, + a: 556, b: 556, c: 500, d: 556, e: 556, f: 278, g: 556, h: 556, i: 222, + j: 222, k: 500, l: 222, m: 833, n: 556, o: 556, p: 556, q: 556, r: 333, + s: 500, t: 278, u: 556, v: 500, w: 722, x: 500, y: 500, z: 500, + "0": 556, "1": 556, "2": 556, "3": 556, "4": 556, "5": 556, "6": 556, + "7": 556, "8": 556, "9": 556, + "ä": 556, "ö": 556, "ü": 556, "Ä": 667, "Ö": 778, "Ü": 722, "ß": 556, + "é": 556, "è": 556, "à": 556, "ç": 500, "–": 556, "—": 1000, "€": 556 +}; + +const BREITE_FETT = { + ...BREITE_HELVETICA, + A: 722, B: 722, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278, + J: 556, K: 722, L: 611, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722, + S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611, + a: 556, b: 611, c: 556, d: 611, e: 556, f: 333, g: 611, h: 611, i: 278, + j: 278, k: 556, l: 278, m: 889, n: 611, o: 611, p: 611, q: 611, r: 389, + s: 556, t: 333, u: 611, v: 556, w: 778, x: 556, y: 556, z: 500, + "'": 238, "-": 333, ":": 333, ";": 333, "(": 333, ")": 333 +}; + +const BREITE_ERSATZ = 556; + +/** + * Zeichen, die in WinAnsiEncoding an anderer Stelle stehen als in + * Latin-1. PDF-Betrachter erwarten diese Belegung; ohne die Umsetzung + * erschiene etwa das Eurozeichen als Steuerzeichen. + */ +const WINANSI_SONDER = { + "\u20AC": 0x80, "\u201A": 0x82, "\u0192": 0x83, "\u201E": 0x84, + "\u2026": 0x85, "\u2020": 0x86, "\u2021": 0x87, "\u02C6": 0x88, + "\u2030": 0x89, "\u0160": 0x8A, "\u2039": 0x8B, "\u0152": 0x8C, + "\u017D": 0x8E, "\u2018": 0x91, "\u2019": 0x92, "\u201C": 0x93, + "\u201D": 0x94, "\u2022": 0x95, "\u2013": 0x96, "\u2014": 0x97, + "\u02DC": 0x98, "\u2122": 0x99, "\u0161": 0x9A, "\u203A": 0x9B, + "\u0153": 0x9C, "\u017E": 0x9E, "\u0178": 0x9F +}; + +/** Breite einer Zeichenkette in Punkten. */ +export function textbreite(text, groesse, fett = false) { + const tabelle = fett ? BREITE_FETT : BREITE_HELVETICA; + let summe = 0; + for (const z of String(text)) { + summe += tabelle[z] ?? BREITE_ERSATZ; + } + return (summe * groesse) / 1000; +} + +/** + * Zeichenkette in eine PDF-Literalzeichenkette überführen. + * + * Zeichen ausserhalb von WinAnsi werden durch ein Fragezeichen ersetzt. + * Das betrifft in der Praxis fast nur Emojis - dafür wäre eine + * eingebettete Schrift nötig. + */ +function pdfText(text) { + const bytes = []; + for (const z of String(text)) { + let code; + if (WINANSI_SONDER[z] !== undefined) { + code = WINANSI_SONDER[z]; + } else { + const cp = z.codePointAt(0); + code = cp <= 0xFF ? cp : 0x3F; // Fragezeichen + } + if (code === 0x28 || code === 0x29 || code === 0x5C) { + bytes.push(0x5C); // Rückstrich voranstellen + } + bytes.push(code); + } + return bytes; +} + +/** Text in Zeilen umbrechen, die in die angegebene Breite passen. */ +export function umbrechen(text, breite, groesse, fett = false) { + const zeilen = []; + for (const absatz of String(text ?? "").split(/\r?\n/)) { + if (!absatz.trim()) { + zeilen.push(""); + continue; + } + let zeile = ""; + for (const wort of absatz.split(/ +/)) { + const versuch = zeile ? `${zeile} ${wort}` : wort; + if (textbreite(versuch, groesse, fett) <= breite || !zeile) { + // Ein einzelnes zu langes Wort wird hart getrennt - sonst liefe + // es über den Rand hinaus. + if (!zeile && textbreite(wort, groesse, fett) > breite) { + let rest = wort; + while (textbreite(rest, groesse, fett) > breite) { + let n = 1; + while (n < rest.length + && textbreite(rest.slice(0, n + 1), groesse, fett) <= breite) { + n += 1; + } + zeilen.push(rest.slice(0, n)); + rest = rest.slice(n); + } + zeile = rest; + } else { + zeile = versuch; + } + } else { + zeilen.push(zeile); + zeile = wort; + } + } + zeilen.push(zeile); + } + return zeilen; +} + +// ---------------------------------------------------------------- Aufbau + +export class PdfDokument { + /** + * Seitengröße und Ränder werden in PUNKTEN erwartet. Für die Umrechnung + * aus Millimetern gibt es mm(). + */ + constructor({ titel = "", autor = "", betreff = "", + breite = SEITE_BREITE, hoehe = SEITE_HOEHE, + randLinks = mm(20), randRechts = mm(10), + randOben = mm(10), randUnten = mm(10) } = {}) { + this.titel = titel; + this.autor = autor; + this.betreff = betreff; + this.breite = breite; + this.hoehe = hoehe; + this.randLinks = randLinks; + this.randRechts = randRechts; + this.randOben = randOben; + this.randUnten = randUnten; + this.seiten = []; // je Seite ein Feld von Byte-Stücken + this.anhaenge = []; // {name, daten, beschreibung, mime} + this.aktuell = null; + this.y = 0; + } + + /** Linker Rand. Der Name bleibt kurz, weil er oft gebraucht wird. */ + get rand() { + return this.randLinks; + } + + get innenbreite() { + return this.breite - this.randLinks - this.randRechts; + } + + neueSeite() { + this.aktuell = []; + this.seiten.push(this.aktuell); + this.y = this.hoehe - this.randOben; + return this.aktuell; + } + + /** Platz für die nächsten Zeilen sichern, notfalls umblättern. */ + platzPruefen(hoehe) { + if (!this.aktuell) this.neueSeite(); + if (this.y - hoehe < this.randUnten) this.neueSeite(); + } + + /** + * Eine Textzeile setzen. Gibt die neue Schreibhöhe zurück. + */ + zeile(text, { groesse = 10, fett = false, einzug = 0, + abstand = 1.35, farbe = null } = {}) { + const hoehe = groesse * abstand; + this.platzPruefen(hoehe); + const x = this.rand + einzug; + const teile = []; + if (farbe) { + teile.push(`${farbe[0]} ${farbe[1]} ${farbe[2]} rg\n`); + } + teile.push("BT\n"); + teile.push(`/${fett ? "F2" : "F1"} ${groesse} Tf\n`); + teile.push(`1 0 0 1 ${x.toFixed(2)} ${(this.y - groesse).toFixed(2)} Tm\n`); + this.aktuell.push(...teile.map((t) => new TextEncoder().encode(t))); + this.aktuell.push(new Uint8Array([0x28])); // ( + this.aktuell.push(new Uint8Array(pdfText(text))); + this.aktuell.push(new TextEncoder().encode(") Tj\nET\n")); + if (farbe) { + this.aktuell.push(new TextEncoder().encode("0 0 0 rg\n")); + } + this.y -= hoehe; + return this.y; + } + + /** Mehrere Zeilen mit automatischem Umbruch. */ + absatz(text, opt = {}) { + const groesse = opt.groesse ?? 10; + const einzug = opt.einzug ?? 0; + const breite = this.innenbreite - einzug; + for (const z of umbrechen(text, breite, groesse, opt.fett)) { + this.zeile(z, opt); + } + } + + /** + * Text an einer festen Stelle setzen, ohne die Schreibhöhe zu ändern. + * + * Wird für Spaltensatz gebraucht: Beschriftung und Wert stehen auf + * derselben Grundlinie, aber an verschiedenen Stellen. + */ + textAn(x, grundlinie, text, { groesse = 10, fett = false, + farbe = null } = {}) { + if (!this.aktuell) this.neueSeite(); + const teile = []; + if (farbe) teile.push(`${farbe[0]} ${farbe[1]} ${farbe[2]} rg\n`); + teile.push("BT\n"); + teile.push(`/${fett ? "F2" : "F1"} ${groesse} Tf\n`); + teile.push(`1 0 0 1 ${x.toFixed(2)} ${grundlinie.toFixed(2)} Tm\n`); + this.aktuell.push(...teile.map((t) => new TextEncoder().encode(t))); + this.aktuell.push(new Uint8Array([0x28])); + this.aktuell.push(new Uint8Array(pdfText(text))); + this.aktuell.push(new TextEncoder().encode(") Tj\nET\n")); + if (farbe) this.aktuell.push(new TextEncoder().encode("0 0 0 rg\n")); + } + + /** + * Gefülltes Rechteck. + * + * Muss VOR dem Text gezeichnet werden - im Inhaltsstrom gilt die + * Reihenfolge, Späteres liegt oben. + */ + rechteck(x, y, breite, hoehe, grau = 0.95) { + if (!this.aktuell) this.neueSeite(); + const s = `${grau} g ${x.toFixed(2)} ${y.toFixed(2)} ` + + `${breite.toFixed(2)} ${hoehe.toFixed(2)} re f 0 g\n`; + this.aktuell.push(new TextEncoder().encode(s)); + } + + /** Waagerechte Linie. */ + linie(dicke = 0.6, grau = 0.75) { + this.platzPruefen(8); + this.y -= 4; + const s = `${grau} G ${dicke} w ${this.randLinks.toFixed(2)} ` + + `${this.y.toFixed(2)} m ` + + `${(this.breite - this.randRechts).toFixed(2)} ` + + `${this.y.toFixed(2)} l S\n`; + this.aktuell.push(new TextEncoder().encode(s)); + this.y -= 8; + } + + abstand(punkte) { + this.platzPruefen(punkte); + this.y -= punkte; + } + + /** + * Datei an das Dokument anhängen. + * + * daten muss ein Uint8Array sein. Der Name wird im Anhangsbereich des + * Betrachters angezeigt. + */ + anhang(name, daten, { beschreibung = "", mime = "application/octet-stream" } = {}) { + this.anhaenge.push({ + name: String(name), + daten: daten instanceof Uint8Array ? daten : new Uint8Array(daten), + beschreibung: String(beschreibung), + mime: String(mime) + }); + } + + // ------------------------------------------------------------ Ausgabe + + bauen() { + if (!this.seiten.length) this.neueSeite(); + + const enc = (s) => new TextEncoder().encode(s); + const objekte = []; // 1-basiert, Index 0 bleibt leer + const neu = (inhalt) => { objekte.push(inhalt); return objekte.length; }; + + // Reihenfolge festlegen, damit Verweise bekannt sind + const katalogNr = 1; + const seitenbaumNr = 2; + const infoNr = 3; + const f1Nr = 4; + const f2Nr = 5; + objekte.push(null, null, null, null, null); // Plätze 1..5 + + // Seiteninhalte und Seitenobjekte + const seitenNr = []; + for (const stuecke of this.seiten) { + const inhalt = verbinden(stuecke); + const stromNr = neu({ + kopf: `<< /Length ${inhalt.length} >>`, + strom: inhalt + }); + const seiteNr = neu({ + kopf: `<< /Type /Page /Parent ${seitenbaumNr} 0 R ` + + `/MediaBox [0 0 ${this.breite.toFixed(2)} ` + + `${this.hoehe.toFixed(2)}] ` + + `/Resources << /Font << /F1 ${f1Nr} 0 R /F2 ${f2Nr} 0 R >> >> ` + + `/Contents ${stromNr} 0 R >>` + }); + seitenNr.push(seiteNr); + } + + // Anhänge + const namensPaare = []; + for (const a of this.anhaenge) { + const stromNr = neu({ + kopf: `<< /Type /EmbeddedFile /Subtype ${nameEscape(a.mime)} ` + + `/Length ${a.daten.length} /Params << /Size ${a.daten.length} >> >>`, + strom: a.daten + }); + const specNr = neu({ + kopf: `<< /Type /Filespec /F (${roh(a.name)}) /UF (${roh(a.name)}) ` + + (a.beschreibung ? `/Desc (${roh(a.beschreibung)}) ` : "") + + `/AFRelationship /Source /EF << /F ${stromNr} 0 R >> >>` + }); + namensPaare.push(`(${roh(a.name)}) ${specNr} 0 R`); + } + + let namenNr = 0; + if (namensPaare.length) { + namenNr = neu({ + kopf: `<< /EmbeddedFiles << /Names [ ${namensPaare.join(" ")} ] >> >>` + }); + } + + // Feste Objekte nachtragen + objekte[katalogNr - 1] = { + kopf: `<< /Type /Catalog /Pages ${seitenbaumNr} 0 R` + + (namenNr ? ` /Names ${namenNr} 0 R /PageMode /UseAttachments` : "") + + " >>" + }; + objekte[seitenbaumNr - 1] = { + kopf: `<< /Type /Pages /Count ${seitenNr.length} ` + + `/Kids [ ${seitenNr.map((n) => `${n} 0 R`).join(" ")} ] >>` + }; + objekte[infoNr - 1] = { + kopf: "<< " + [ + this.titel ? `/Title (${roh(this.titel)})` : "", + this.autor ? `/Author (${roh(this.autor)})` : "", + this.betreff ? `/Subject (${roh(this.betreff)})` : "", + "/Producer (Paperless-Connector)", + `/CreationDate (${pdfDatum(new Date())})` + ].filter(Boolean).join(" ") + " >>" + }; + objekte[f1Nr - 1] = { + kopf: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica " + + "/Encoding /WinAnsiEncoding >>" + }; + objekte[f2Nr - 1] = { + kopf: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold " + + "/Encoding /WinAnsiEncoding >>" + }; + + // Zusammensetzen + const teile = [enc("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")]; + let pos = teile[0].length; + const versatz = [0]; + + for (let i = 0; i < objekte.length; i += 1) { + const o = objekte[i]; + versatz.push(pos); + const kopf = enc(`${i + 1} 0 obj\n${o.kopf}\n`); + teile.push(kopf); + pos += kopf.length; + if (o.strom) { + const a = enc("stream\n"); + teile.push(a, o.strom, enc("\nendstream\n")); + pos += a.length + o.strom.length + 11; + } + const ende = enc("endobj\n"); + teile.push(ende); + pos += ende.length; + } + + const xrefPos = pos; + let xref = `xref\n0 ${objekte.length + 1}\n0000000000 65535 f \n`; + for (let i = 1; i <= objekte.length; i += 1) { + xref += `${String(versatz[i]).padStart(10, "0")} 00000 n \n`; + } + xref += `trailer\n<< /Size ${objekte.length + 1} ` + + `/Root ${katalogNr} 0 R /Info ${infoNr} 0 R >>\n` + + `startxref\n${xrefPos}\n%%EOF\n`; + teile.push(enc(xref)); + + return verbinden(teile); + } +} + +// ------------------------------------------------------------ Hilfsmittel + +function verbinden(stuecke) { + let laenge = 0; + for (const s of stuecke) laenge += s.length; + const aus = new Uint8Array(laenge); + let p = 0; + for (const s of stuecke) { aus.set(s, p); p += s.length; } + return aus; +} + +/** Zeichenkette für eine PDF-Literalzeichenkette absichern. */ +function roh(text) { + return String(text) + .replace(/\\/g, "\\\\") + .replace(/\(/g, "\\(") + .replace(/\)/g, "\\)") + // Zeichen ausserhalb von ASCII vermeiden - in Namen und Titeln reicht + // das, und es erspart die Umsetzung nach WinAnsi an dieser Stelle. + .replace(/[^\x20-\x7E]/g, (z) => ({ + "ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", + "ß": "ss" + }[z] ?? "?")); +} + +/** Namen im PDF-Syntax absichern, etwa für Medientypen. */ +function nameEscape(text) { + return "/" + String(text).replace(/[^A-Za-z0-9._-]/g, + (z) => "#" + z.charCodeAt(0).toString(16).padStart(2, "0")); +} + +function pdfDatum(d) { + const z = (n) => String(n).padStart(2, "0"); + return `D:${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}` + + `${z(d.getHours())}${z(d.getMinutes())}${z(d.getSeconds())}`; +} diff --git a/lib/settings.js b/lib/settings.js index d5079ba..cfb9a8c 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -21,6 +21,35 @@ const VORGABEN = { // Leer = baseUrl verwenden. shareBaseUrl: "", versionLabel: "Thunderbird {datum}", + + // Wie eine ganze Nachricht abgelegt wird. + // + // "pdf" lesbar aufbereitet, Original und Anhänge im PDF eingebettet + // "eml" Rohformat, verlustfrei und wieder in ein Mailprogramm + // einlesbar, aber im Archiv unhandlich + mailFormat: "pdf", + + // Anhänge zusätzlich IN das PDF einbetten. Ohne das enthält das PDF + // nur die Originalnachricht - die Anhänge stecken dann zwar in der + // .eml, sind aber nicht einzeln greifbar. + pdfAnhaengeEinbetten: true, + + // Grundschriftgröße des erzeugten PDF in Punkten. 11 ist gut lesbar + // und entspricht etwa dem, was Textverarbeitungen als Vorgabe nutzen. + // Die Überschrift wächst schwächer mit, damit sie nicht über zwei + // Zeilen läuft. + pdfSchriftgroesse: 11, + + // Papierformat und Ränder. Die Ränder in Millimetern. + // + // Links mehr als rechts, damit beim Abheften nichts im Ordner + // verschwindet - bei einem Archivdokument der wahrscheinlichere Fall + // als eine symmetrische Gestaltung. + pdfFormat: "a4", + pdfRandLinks: 20, + pdfRandRechts: 10, + pdfRandOben: 10, + pdfRandUnten: 10, shareDays: 7, shareTag: "Sharelink", // Beschriftung der eingefügten Linkblöcke. Leer lassen für keine diff --git a/manifest.json b/manifest.json index 17a24e4..006c631 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_extName__", "description": "__MSG_extDescription__", - "version": "1.8.1", + "version": "1.11.3", "author": "Name des Herausgebers", "homepage_url": "https://example.org/paperless-thunderbird", "default_locale": "de", @@ -16,6 +16,7 @@ "storage", "menus", "messagesRead", + "accountsRead", "compose", "notifications" ], diff --git a/ui/gemeinsam.css b/ui/gemeinsam.css index 0454308..9ae33ab 100644 --- a/ui/gemeinsam.css +++ b/ui/gemeinsam.css @@ -35,7 +35,12 @@ input[type=text], input[type=password], input[type=number], select { background: Field; color: FieldText; } -.zeile { display: flex; gap: 8px; align-items: center; } +.zeile { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +/* Kompakte Zahlenfelder mit vorangestellter Beschriftung. */ +.masse { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +.mass { display: flex; gap: 5px; align-items: center; } +.mass label { margin: 0; white-space: nowrap; } +.mass input { width: 68px; } .zeile > input[type=text] { flex: 1; } .wahl { display: flex; gap: 6px; align-items: center; margin: 3px 0; } .wahl input { width: auto; margin: 0; } diff --git a/ui/options.html b/ui/options.html index 860043d..53bc430 100644 --- a/ui/options.html +++ b/ui/options.html @@ -46,7 +46,75 @@
Verhalten -