diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..07d02f7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+# Bauergebnis. Wird von build.sh erzeugt und gehoert nicht ins Repository -
+# Releases werden als Anhang veroeffentlicht, nicht eingecheckt.
+*.xpi
+*.zip
+
+# Editoren und Betriebssysteme
+.vscode/
+.idea/
+*.swp
+*~
+.DS_Store
+Thumbs.db
+desktop.ini
+
+# Symbole werden von make_icons.py erzeugt. Sie sind BEWUSST NICHT
+# ausgenommen: Ohne sie laesst sich das Paket nicht bauen, und wer nur die
+# Erweiterung nutzen will, soll nicht Pillow installieren muessen.
diff --git a/SICHERHEIT.md b/SICHERHEIT.md
new file mode 100644
index 0000000..9cbaaf0
--- /dev/null
+++ b/SICHERHEIT.md
@@ -0,0 +1,178 @@
+# Sicherheitsbetrachtung
+
+Was die Erweiterung tut, welche Angriffswege sich daraus ergeben und was
+dagegen unternommen wird. Stand: Version 1.5.0.
+
+---
+
+## Ausgangslage
+
+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.
+
+| Handlung | Vertrauensgrenze |
+|---|---|
+| HTTPS-Anfragen an die konfigurierte Adresse | Gegenstelle nicht vertrauenswürdig |
+| 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 |
+| Token dauerhaft speichern | Geheimnis |
+
+---
+
+## 1. Berechtigungen
+
+Bewusst knapp gehalten:
+
+| Recht | Wofür | Anmerkung |
+|---|---|---|
+| `storage` | Einstellungen | |
+| `menus` | Kontextmenüs | |
+| `messagesRead` | Anhänge und `.eml` lesen | nur auf Anforderung genutzt |
+| `compose` | Anhang und Link einfügen | |
+| `notifications` | Fehlermeldungen | |
+
+**`compose.send` wurde entfernt.** Es stand im ersten Entwurf, ohne je
+benutzt zu werden — die Erweiterung verschickt nichts. Ein Recht, das
+nicht gebraucht wird, gehört nicht ins Manifest.
+
+**Kein pauschales Zugriffsrecht auf Adressen.** Statt `host_permissions`
+steht dort `optional_host_permissions`, und beim Speichern der
+Einstellungen wird das Recht für genau die eingetragene Adresse erbeten.
+Der Unterschied ist erheblich: Eine Erweiterung mit `` darf jede
+Adresse erreichen. Hier ist es genau ein Ursprung, und der Anwender sieht
+beim Erteilen, welcher.
+
+Die Abfrage muss aus einer Benutzeraktion heraus erfolgen und steht
+deshalb als erste Anweisung im Klickhandler — **vor jedem `await`**. Nach
+dem ersten `await` gilt der Aufruf nicht mehr als aus einer Benutzeraktion
+stammend, und Thunderbird lehnt ihn ab.
+
+---
+
+## 2. Dateinamen aus Anhängen
+
+Der Name eines Anhangs stammt vom **Absender** und ist damit nicht
+vertrauenswürdig. Er wird zwar nur an Paperless weitergereicht und nicht
+ins Dateisystem geschrieben, aber ein Wert wie `../../x` hat in einem
+Dateinamen nichts zu suchen — und was die Gegenstelle damit tut, ist nicht
+Sache dieser Erweiterung.
+
+`sichererName()` entfernt:
+
+- Pfadanteile, **beide** Trennzeichen — unter Linux ist `\` ein gültiges
+ Zeichen im Dateinamen, unter Windows ein Trenner
+- unter Windows verbotene Zeichen und Steuerzeichen
+- reservierte Gerätenamen (`CON`, `AUX`, `NUL`, `COM1`…)
+- Punkte und Leerzeichen am Ende
+
+---
+
+## 3. Freigabelinks im Nachrichtentext
+
+Beim Einfügen in eine HTML-Nachricht werden Adresse und Dokumentname
+maskiert (`escapeHtml`). Ohne das könnte ein Dokumenttitel mit spitzen
+Klammern Auszeichnung in den Entwurf einschleusen.
+
+Der Titel stammt aus Paperless und damit mittelbar aus fremden Quellen —
+wer ein Dokument per Mail ins Archiv einliefern kann, bestimmt dessen
+Titel mit.
+
+---
+
+## 4. Nachrichten zwischen Dialog und Ereignisseite
+
+Die Dialoge sprechen nicht selbst mit Paperless, sondern rufen die
+Ereignisseite. Dort wird der Absender geprüft:
+
+```js
+if (absender?.id && absender.id !== browser.runtime.id) { … }
+```
+
+Thunderbird lässt fremde Absender ohnehin nicht durch, solange kein
+`externally_connectable` gesetzt ist. Die Prüfung kostet nichts und hält
+die Zusicherung explizit.
+
+**Aufträge werden nach 30 Minuten verworfen.** Ein Auftrag enthält
+Betreff, Absender und Dateinamen; das muss nicht bis zum Neustart im
+Speicher liegen, nur weil jemand einen Dialog offen stehen liess.
+
+---
+
+## 5. 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
+im Speicher, während der Anwender noch überlegt.
+
+---
+
+## 6. Transport
+
+**Adressprüfung.** Nur `http` und `https`. Ohne diese Prüfung liessen sich
+andere Schemata hinterlegen, die `fetch` teilweise bedient.
+
+**Keine eigenen Kopfzeileneinträge.** Ein selbst gesetzter Eintrag wie
+`X-Client` ist nicht CORS-sicher und löst eine Vorabanfrage aus. Antwortet
+die Gegenstelle darauf nicht wie erwartet, scheitert schon der erste
+Aufruf mit `NetworkError` — einer Meldung, die über die Ursache nichts
+verrät. Das stand im ersten Entwurf und ist entfernt.
+
+**Zertifikatsprüfung.** Nicht abschaltbar. WebExtensions können das
+ohnehin nicht umgehen, und das ist richtig so.
+
+---
+
+## 7. Der Token
+
+Er liegt im Klartext in `browser.storage.local`, also im Profil von
+Thunderbird. Das entspricht dem, was Erweiterungen üblicherweise tun, ist
+aber **kein Geheimnisspeicher**.
+
+Was sichergestellt ist:
+
+- Er erscheint nie in einer Fehlermeldung — bei HTTP-Fehlern werden
+ Adresse und Antworttext gezeigt, nie das Kopfzeilenfeld.
+- Im Eingabefeld erscheinen Punkte (`type="password"`).
+
+**Empfehlung:** In Paperless einen eigenen Token für diese Erweiterung
+anlegen. Dann lässt er sich einzeln widerrufen, ohne andere Werkzeuge zu
+stören. Wer die Rechte begrenzen will, legt einen Benutzer mit
+eingeschränkten Objektrechten an und erzeugt dessen Token — die
+Erweiterung kann dann nur, was dieser Benutzer darf.
+
+---
+
+## 8. 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 Schreiben ins Dateisystem
+- kein Versenden von Nachrichten
+- keine Übertragung an Dritte; einzige Gegenstelle ist die konfigurierte
+ Adresse
+
+---
+
+## 9. Bekannte Grenzen
+
+| Punkt | Bewertung |
+|---|---|
+| Token im Klartext im Profil | üblich für Erweiterungen |
+| `messagesRead` erlaubt technisch das Lesen aller Nachrichten | für die Funktion nötig; genutzt nur auf Anforderung |
+| 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 |
+
+---
+
+## Empfehlungen für den Betrieb
+
+1. Eigenen Token für die Erweiterung, einzeln widerrufbar
+2. Paperless nur hinter einem Reverse Proxy mit TLS erreichbar machen
+3. Freigabelinks mit Ablauf verwenden, nicht unbegrenzt
+4. Den Tag beim Teilen benutzen — so lässt sich später prüfen, was einmal
+ nach draußen gegeben wurde
diff --git a/_locales/de/messages.json b/_locales/de/messages.json
new file mode 100644
index 0000000..d7d4f51
--- /dev/null
+++ b/_locales/de/messages.json
@@ -0,0 +1,260 @@
+{
+ "extName": {
+ "message": "Paperless-ngx"
+ },
+ "extDescription": {
+ "message": "Anhänge und E-Mails in ein Paperless-ngx-Archiv ablegen, Dokumente als Anhang oder Freigabelink einfügen."
+ },
+ "actionStore": {
+ "message": "In Paperless ablegen"
+ },
+ "actionInsert": {
+ "message": "Aus Paperless einfügen"
+ },
+ "menuStoreAttachment": {
+ "message": "Anhang in Paperless ablegen…"
+ },
+ "menuStoreMessage": {
+ "message": "E-Mail in Paperless ablegen…"
+ },
+ "storeTitle": {
+ "message": "In Paperless ablegen"
+ },
+ "storeWhat": {
+ "message": "Was soll abgelegt werden?"
+ },
+ "storeHintPreselect": {
+ "message": "Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist Teil der Nachricht, keine eigenen Dokumente."
+ },
+ "storeWholeMessage": {
+ "message": "Die vollständige Nachricht als .eml"
+ },
+ "storeAttachmentsInside": {
+ "message": "Die $1 Anhänge bleiben darin enthalten.",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "storeTarget": {
+ "message": "Ziel"
+ },
+ "storeTargetNew": {
+ "message": "Neues Dokument anlegen"
+ },
+ "storeTargetExisting": {
+ "message": "An bestehendes Dokument anhängen"
+ },
+ "storeHintTitleMulti": {
+ "message": "Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt."
+ },
+ "storeClassifyAfter": {
+ "message": "Nach dem Ablegen die Klassifizierung bearbeiten"
+ },
+ "classifyTitle": {
+ "message": "Klassifizierung bearbeiten"
+ },
+ "classifyHintNew": {
+ "message": "Unbekannte Korrespondenten und Dokumenttypen werden angelegt."
+ },
+ "classifyMoreStored": {
+ "message": "$1 Dateien abgelegt",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "insertTitle": {
+ "message": "Aus Paperless einfügen"
+ },
+ "insertHow": {
+ "message": "Einfügen als"
+ },
+ "insertAsAttachment": {
+ "message": "Anhang der Nachricht"
+ },
+ "insertAsLink": {
+ "message": "Freigabelink im Nachrichtentext"
+ },
+ "insertHintLink": {
+ "message": "Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt."
+ },
+ "optionsTitle": {
+ "message": "Paperless — Verbindung"
+ },
+ "optionsConnection": {
+ "message": "Zugang"
+ },
+ "optionsBehaviour": {
+ "message": "Verhalten"
+ },
+ "optionsHintUrl": {
+ "message": "Ohne abschließenden Schrägstrich. Nur http und https."
+ },
+ "optionsHintToken": {
+ "message": "Im Benutzerprofil von Paperless erzeugen. Ein eigener Token für diese Erweiterung lässt sich einzeln widerrufen."
+ },
+ "optionsHintLabel": {
+ "message": "{datum} wird durch das heutige Datum ersetzt."
+ },
+ "optionsHintIgnore": {
+ "message": "Signaturbilder und Logos sind meist kleiner als 8 KB. Sie lassen sich im Dialog jederzeit anhaken."
+ },
+ "fieldBaseUrl": {
+ "message": "Basisadresse"
+ },
+ "fieldToken": {
+ "message": "API-Token"
+ },
+ "fieldTitle": {
+ "message": "Titel"
+ },
+ "fieldCreated": {
+ "message": "Erstellt"
+ },
+ "fieldCorrespondent": {
+ "message": "Korrespondent"
+ },
+ "fieldDocType": {
+ "message": "Dokumenttyp"
+ },
+ "fieldTags": {
+ "message": "Tags"
+ },
+ "fieldVersionLabel": {
+ "message": "Bezeichnung neuer Versionen"
+ },
+ "fieldShareDays": {
+ "message": "Gültigkeit in Tagen"
+ },
+ "fieldShareTag": {
+ "message": "Tag beim Teilen"
+ },
+ "fieldIgnoreSmaller": {
+ "message": "Anhänge vorab abwählen unter … Bytes"
+ },
+ "colAsn": {
+ "message": "ASN"
+ },
+ "colCreated": {
+ "message": "Erstellt"
+ },
+ "colTitle": {
+ "message": "Titel"
+ },
+ "colNo": {
+ "message": "Nr"
+ },
+ "colFormat": {
+ "message": "Format"
+ },
+ "colLabel": {
+ "message": "Bezeichnung"
+ },
+ "colDocuments": {
+ "message": "Dokumente"
+ },
+ "colVersions": {
+ "message": "Versionen"
+ },
+ "btnSearch": {
+ "message": "Suchen"
+ },
+ "btnCancel": {
+ "message": "Abbrechen"
+ },
+ "btnStore": {
+ "message": "Ablegen"
+ },
+ "btnSave": {
+ "message": "Speichern"
+ },
+ "btnSkip": {
+ "message": "Überspringen"
+ },
+ "btnInsert": {
+ "message": "Einfügen"
+ },
+ "btnTest": {
+ "message": "Verbindung prüfen"
+ },
+ "msgNoHits": {
+ "message": "Keine Treffer."
+ },
+ "msgUploading": {
+ "message": "Wird abgelegt …"
+ },
+ "msgStored": {
+ "message": "$1 abgelegt.",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "msgSaving": {
+ "message": "Wird gespeichert …"
+ },
+ "msgSaved": {
+ "message": "Gespeichert."
+ },
+ "msgLoadingVersions": {
+ "message": "Versionen werden geladen …"
+ },
+ "msgCreatingLink": {
+ "message": "Link wird erzeugt …"
+ },
+ "msgAttaching": {
+ "message": "Wird angehängt …"
+ },
+ "msgConnectionOk": {
+ "message": "Verbindung steht ($1 Dokumente sichtbar).",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "errNoMessage": {
+ "message": "Keine Nachricht ausgewählt."
+ },
+ "errNoSettings": {
+ "message": "Bitte zuerst Basisadresse und Token hinterlegen."
+ },
+ "errNothingSelected": {
+ "message": "Nichts ausgewählt."
+ },
+ "errNoLink": {
+ "message": "Der Link konnte nicht ermittelt werden."
+ },
+ "errBadUrl": {
+ "message": "Ungültige Adresse. Erwartet wird https://rechner oder http://rechner."
+ },
+ "errNoPermission": {
+ "message": "Ohne Zugriffsrecht auf die Adresse kann die Erweiterung nicht arbeiten."
+ },
+ "linkLabel": {
+ "message": "Datei"
+ },
+ "linkValidUntil": {
+ "message": "Link gültig bis $1",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "linkValidUnlimited": {
+ "message": "Link ohne zeitliche Begrenzung"
+ },
+ "linkHeading": {
+ "message": "Datei $1",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ }
+}
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
new file mode 100644
index 0000000..a19f45b
--- /dev/null
+++ b/_locales/en/messages.json
@@ -0,0 +1,260 @@
+{
+ "extName": {
+ "message": "Paperless-ngx"
+ },
+ "extDescription": {
+ "message": "Store attachments and e-mails in a Paperless-ngx archive, insert documents as attachment or share link."
+ },
+ "actionStore": {
+ "message": "Store in Paperless"
+ },
+ "actionInsert": {
+ "message": "Insert from Paperless"
+ },
+ "menuStoreAttachment": {
+ "message": "Store attachment in Paperless…"
+ },
+ "menuStoreMessage": {
+ "message": "Store e-mail in Paperless…"
+ },
+ "storeTitle": {
+ "message": "Store in Paperless"
+ },
+ "storeWhat": {
+ "message": "What should be stored?"
+ },
+ "storeHintPreselect": {
+ "message": "Small images and signatures are deselected — they usually belong to the message, not to the content."
+ },
+ "storeWholeMessage": {
+ "message": "The complete message as .eml"
+ },
+ "storeAttachmentsInside": {
+ "message": "The $1 attachments remain inside it.",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "storeTarget": {
+ "message": "Target"
+ },
+ "storeTargetNew": {
+ "message": "Create a new document"
+ },
+ "storeTargetExisting": {
+ "message": "Append to an existing document"
+ },
+ "storeHintTitleMulti": {
+ "message": "With several files the title is extended by the file name."
+ },
+ "storeClassifyAfter": {
+ "message": "Edit the classification after storing"
+ },
+ "classifyTitle": {
+ "message": "Edit classification"
+ },
+ "classifyHintNew": {
+ "message": "Unknown correspondents and document types will be created."
+ },
+ "classifyMoreStored": {
+ "message": "$1 files stored",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "insertTitle": {
+ "message": "Insert from Paperless"
+ },
+ "insertHow": {
+ "message": "Insert as"
+ },
+ "insertAsAttachment": {
+ "message": "Attachment of the message"
+ },
+ "insertAsLink": {
+ "message": "Share link in the message body"
+ },
+ "insertHintLink": {
+ "message": "The link works without signing in. 0 days means unlimited."
+ },
+ "optionsTitle": {
+ "message": "Paperless — Connection"
+ },
+ "optionsConnection": {
+ "message": "Access"
+ },
+ "optionsBehaviour": {
+ "message": "Behaviour"
+ },
+ "optionsHintUrl": {
+ "message": "Without trailing slash. Only http and https."
+ },
+ "optionsHintToken": {
+ "message": "Create it in your Paperless user profile. A dedicated token for this extension can be revoked separately."
+ },
+ "optionsHintLabel": {
+ "message": "{datum} is replaced by today's date."
+ },
+ "optionsHintIgnore": {
+ "message": "Signature images and logos are usually smaller than 8 KB. They can always be ticked in the dialog."
+ },
+ "fieldBaseUrl": {
+ "message": "Base address"
+ },
+ "fieldToken": {
+ "message": "API token"
+ },
+ "fieldTitle": {
+ "message": "Title"
+ },
+ "fieldCreated": {
+ "message": "Created"
+ },
+ "fieldCorrespondent": {
+ "message": "Correspondent"
+ },
+ "fieldDocType": {
+ "message": "Document type"
+ },
+ "fieldTags": {
+ "message": "Tags"
+ },
+ "fieldVersionLabel": {
+ "message": "Label for new versions"
+ },
+ "fieldShareDays": {
+ "message": "Validity in days"
+ },
+ "fieldShareTag": {
+ "message": "Tag when sharing"
+ },
+ "fieldIgnoreSmaller": {
+ "message": "Deselect attachments below … bytes"
+ },
+ "colAsn": {
+ "message": "ASN"
+ },
+ "colCreated": {
+ "message": "Created"
+ },
+ "colTitle": {
+ "message": "Title"
+ },
+ "colNo": {
+ "message": "No"
+ },
+ "colFormat": {
+ "message": "Format"
+ },
+ "colLabel": {
+ "message": "Label"
+ },
+ "colDocuments": {
+ "message": "Documents"
+ },
+ "colVersions": {
+ "message": "Versions"
+ },
+ "btnSearch": {
+ "message": "Search"
+ },
+ "btnCancel": {
+ "message": "Cancel"
+ },
+ "btnStore": {
+ "message": "Store"
+ },
+ "btnSave": {
+ "message": "Save"
+ },
+ "btnSkip": {
+ "message": "Skip"
+ },
+ "btnInsert": {
+ "message": "Insert"
+ },
+ "btnTest": {
+ "message": "Test connection"
+ },
+ "msgNoHits": {
+ "message": "No matches."
+ },
+ "msgUploading": {
+ "message": "Storing …"
+ },
+ "msgStored": {
+ "message": "$1 stored.",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "msgSaving": {
+ "message": "Saving …"
+ },
+ "msgSaved": {
+ "message": "Saved."
+ },
+ "msgLoadingVersions": {
+ "message": "Loading versions …"
+ },
+ "msgCreatingLink": {
+ "message": "Creating link …"
+ },
+ "msgAttaching": {
+ "message": "Attaching …"
+ },
+ "msgConnectionOk": {
+ "message": "Connection works ($1 documents visible).",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "errNoMessage": {
+ "message": "No message selected."
+ },
+ "errNoSettings": {
+ "message": "Please configure base address and token first."
+ },
+ "errNothingSelected": {
+ "message": "Nothing selected."
+ },
+ "errNoLink": {
+ "message": "The link could not be determined."
+ },
+ "errBadUrl": {
+ "message": "Invalid address. Expected https://host or http://host."
+ },
+ "errNoPermission": {
+ "message": "Without access permission for the address the extension cannot work."
+ },
+ "linkLabel": {
+ "message": "File"
+ },
+ "linkValidUntil": {
+ "message": "Link valid until $1",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "linkValidUnlimited": {
+ "message": "Link without time limit"
+ },
+ "linkHeading": {
+ "message": "File $1",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ }
+}
diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json
new file mode 100644
index 0000000..7a2c150
--- /dev/null
+++ b/_locales/fr/messages.json
@@ -0,0 +1,260 @@
+{
+ "extName": {
+ "message": "Paperless-ngx"
+ },
+ "extDescription": {
+ "message": "Enregistrer des pièces jointes et des courriels dans une archive Paperless-ngx, insérer des documents en pièce jointe ou en lien de partage."
+ },
+ "actionStore": {
+ "message": "Enregistrer dans Paperless"
+ },
+ "actionInsert": {
+ "message": "Insérer depuis Paperless"
+ },
+ "menuStoreAttachment": {
+ "message": "Enregistrer la pièce jointe dans Paperless…"
+ },
+ "menuStoreMessage": {
+ "message": "Enregistrer le courriel dans Paperless…"
+ },
+ "storeTitle": {
+ "message": "Enregistrer dans Paperless"
+ },
+ "storeWhat": {
+ "message": "Que faut-il enregistrer ?"
+ },
+ "storeHintPreselect": {
+ "message": "Les petites images et les signatures sont décochées — elles font partie du message, pas du contenu."
+ },
+ "storeWholeMessage": {
+ "message": "Le message complet au format .eml"
+ },
+ "storeAttachmentsInside": {
+ "message": "Les $1 pièces jointes y restent incluses.",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "storeTarget": {
+ "message": "Cible"
+ },
+ "storeTargetNew": {
+ "message": "Créer un nouveau document"
+ },
+ "storeTargetExisting": {
+ "message": "Ajouter à un document existant"
+ },
+ "storeHintTitleMulti": {
+ "message": "Avec plusieurs fichiers, le titre est complété par le nom du fichier."
+ },
+ "storeClassifyAfter": {
+ "message": "Modifier le classement après l'enregistrement"
+ },
+ "classifyTitle": {
+ "message": "Modifier le classement"
+ },
+ "classifyHintNew": {
+ "message": "Les correspondants et types de document inconnus seront créés."
+ },
+ "classifyMoreStored": {
+ "message": "$1 fichiers enregistrés",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "insertTitle": {
+ "message": "Insérer depuis Paperless"
+ },
+ "insertHow": {
+ "message": "Insérer comme"
+ },
+ "insertAsAttachment": {
+ "message": "Pièce jointe du message"
+ },
+ "insertAsLink": {
+ "message": "Lien de partage dans le corps du message"
+ },
+ "insertHintLink": {
+ "message": "Le lien est accessible sans connexion. 0 jour signifie sans limite."
+ },
+ "optionsTitle": {
+ "message": "Paperless — Connexion"
+ },
+ "optionsConnection": {
+ "message": "Accès"
+ },
+ "optionsBehaviour": {
+ "message": "Comportement"
+ },
+ "optionsHintUrl": {
+ "message": "Sans barre oblique finale. Uniquement http et https."
+ },
+ "optionsHintToken": {
+ "message": "À créer dans votre profil utilisateur Paperless. Un jeton dédié à cette extension peut être révoqué séparément."
+ },
+ "optionsHintLabel": {
+ "message": "{datum} est remplacé par la date du jour."
+ },
+ "optionsHintIgnore": {
+ "message": "Les images de signature et les logos font généralement moins de 8 Ko. Ils restent cochables dans la boîte de dialogue."
+ },
+ "fieldBaseUrl": {
+ "message": "Adresse de base"
+ },
+ "fieldToken": {
+ "message": "Jeton d'API"
+ },
+ "fieldTitle": {
+ "message": "Titre"
+ },
+ "fieldCreated": {
+ "message": "Créé le"
+ },
+ "fieldCorrespondent": {
+ "message": "Correspondant"
+ },
+ "fieldDocType": {
+ "message": "Type de document"
+ },
+ "fieldTags": {
+ "message": "Étiquettes"
+ },
+ "fieldVersionLabel": {
+ "message": "Libellé des nouvelles versions"
+ },
+ "fieldShareDays": {
+ "message": "Validité en jours"
+ },
+ "fieldShareTag": {
+ "message": "Étiquette lors du partage"
+ },
+ "fieldIgnoreSmaller": {
+ "message": "Décocher les pièces jointes de moins de … octets"
+ },
+ "colAsn": {
+ "message": "ASN"
+ },
+ "colCreated": {
+ "message": "Créé le"
+ },
+ "colTitle": {
+ "message": "Titre"
+ },
+ "colNo": {
+ "message": "N°"
+ },
+ "colFormat": {
+ "message": "Format"
+ },
+ "colLabel": {
+ "message": "Libellé"
+ },
+ "colDocuments": {
+ "message": "Documents"
+ },
+ "colVersions": {
+ "message": "Versions"
+ },
+ "btnSearch": {
+ "message": "Rechercher"
+ },
+ "btnCancel": {
+ "message": "Annuler"
+ },
+ "btnStore": {
+ "message": "Enregistrer"
+ },
+ "btnSave": {
+ "message": "Enregistrer"
+ },
+ "btnSkip": {
+ "message": "Ignorer"
+ },
+ "btnInsert": {
+ "message": "Insérer"
+ },
+ "btnTest": {
+ "message": "Tester la connexion"
+ },
+ "msgNoHits": {
+ "message": "Aucun résultat."
+ },
+ "msgUploading": {
+ "message": "Enregistrement …"
+ },
+ "msgStored": {
+ "message": "$1 enregistré(s).",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "msgSaving": {
+ "message": "Enregistrement …"
+ },
+ "msgSaved": {
+ "message": "Enregistré."
+ },
+ "msgLoadingVersions": {
+ "message": "Chargement des versions …"
+ },
+ "msgCreatingLink": {
+ "message": "Création du lien …"
+ },
+ "msgAttaching": {
+ "message": "Ajout en pièce jointe …"
+ },
+ "msgConnectionOk": {
+ "message": "La connexion fonctionne ($1 documents visibles).",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "errNoMessage": {
+ "message": "Aucun message sélectionné."
+ },
+ "errNoSettings": {
+ "message": "Veuillez d'abord renseigner l'adresse de base et le jeton."
+ },
+ "errNothingSelected": {
+ "message": "Rien de sélectionné."
+ },
+ "errNoLink": {
+ "message": "Le lien n'a pas pu être déterminé."
+ },
+ "errBadUrl": {
+ "message": "Adresse invalide. Attendu : https://hote ou http://hote."
+ },
+ "errNoPermission": {
+ "message": "Sans droit d'accès à l'adresse, l'extension ne peut pas fonctionner."
+ },
+ "linkLabel": {
+ "message": "Fichier"
+ },
+ "linkValidUntil": {
+ "message": "Lien valable jusqu'au $1",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ },
+ "linkValidUnlimited": {
+ "message": "Lien sans limite de durée"
+ },
+ "linkHeading": {
+ "message": "Fichier $1",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ }
+ }
+ }
+}
diff --git a/background.js b/background.js
new file mode 100644
index 0000000..a10964b
--- /dev/null
+++ b/background.js
@@ -0,0 +1,552 @@
+/**
+ * background.js - Ereignisseite der Erweiterung.
+ *
+ * Hier liegen die Menüeinträge, die Schaltflächen und die Vermittlung
+ * zwischen den Dialogfenstern und der Schnittstelle. Die Dialoge selbst
+ * sind gewöhnliche HTML-Seiten; sie rufen über runtime.sendMessage hier
+ * an, statt selbst mit Paperless zu sprechen.
+ *
+ * Der Grund für diese Aufteilung: Die Berechtigung für die Zieladresse
+ * wird zur Laufzeit angefordert und hängt am Hintergrundkontext. Ein
+ * Dialogfenster müsste sie sonst erneut erbitten.
+ */
+
+import { PaperlessClient, PaperlessError } from "./lib/client.js";
+import {
+ ladeEinstellungen, vollstaendig, sichererName, versionslabel,
+ titelAusBetreff, t
+} from "./lib/settings.js";
+
+/**
+ * Ergebnis einer Nachrichten-Abfrage vereinheitlichen.
+ *
+ * Je nach Version liefert Thunderbird ein Feld, ein MessageList-Objekt mit
+ * der Eigenschaft "messages" oder eine einzelne Nachricht. Ohne diese
+ * Vereinheitlichung bricht die Erweiterung bei jedem Versionswechsel.
+ */
+function alsListe(wert) {
+ if (!wert) return [];
+ if (Array.isArray(wert)) return wert;
+ if (Array.isArray(wert.messages)) return wert.messages;
+ if (wert.id !== undefined) return [wert];
+ return [];
+}
+
+/**
+ * Angezeigte Nachricht(en) eines Fensters holen.
+ *
+ * getDisplayedMessage (Einzahl) ist in neueren Ständen entfallen; dort
+ * gibt es nur noch getDisplayedMessages (Mehrzahl). Beide werden
+ * probiert, dazu als letzter Ausweg die Auswahl im Nachrichtenbereich.
+ */
+async function angezeigteNachrichten(tabId) {
+ const md = browser.messageDisplay;
+ const versuche = [];
+
+ // OHNE Reiterkennung zuerst. Beim Kontextmenü eines Anhangs übergibt
+ // Thunderbird einen Reiter mit type null - das ist nicht der
+ // Nachrichtenbereich, und jeder Aufruf mit dieser Kennung geht ins
+ // Leere. Ohne Argument nimmt Thunderbird den aktiven Reiter, und das
+ // ist der richtige.
+ if (md?.getDisplayedMessages) {
+ versuche.push(["getDisplayedMessages()",
+ () => md.getDisplayedMessages()]);
+ if (tabId !== undefined && tabId !== null) {
+ versuche.push(["getDisplayedMessages(tabId)",
+ () => md.getDisplayedMessages(tabId)]);
+ }
+ }
+ // Einzahl-Fassung für ältere Stände.
+ if (md?.getDisplayedMessage) {
+ versuche.push(["getDisplayedMessage()",
+ () => md.getDisplayedMessage()]);
+ }
+ // Auswahl in der Nachrichtenliste, wenn kein Nachrichtenbereich offen ist.
+ if (browser.mailTabs?.getSelectedMessages) {
+ versuche.push(["mailTabs.getSelectedMessages()",
+ () => browser.mailTabs.getSelectedMessages()]);
+ }
+
+ for (const [name, fn] of versuche) {
+ try {
+ const r = alsListe(await fn());
+ if (r.length) return r;
+ } catch (err) {
+ console.debug("[paperless]", name, "ohne Erfolg:", err?.message);
+ }
+ }
+ return [];
+}
+
+
+/**
+ * Datum einer Nachricht als JJJJ-MM-TT.
+ *
+ * Je nach Version ist date ein Date-Objekt oder eine Zeichenkette.
+ */
+function alsDatum(wert) {
+ if (!wert) return undefined;
+ const d = wert instanceof Date ? wert : new Date(wert);
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10);
+}
+
+// Offene Dialoge, damit sie ihre Aufgabe abholen können.
+const auftraege = new Map();
+let naechsteAuftragsnummer = 1;
+
+// ---------------------------------------------------------------- Menüs
+
+browser.runtime.onInstalled.addListener(() => {
+ browser.menus.create({
+ id: "paperless-anhang",
+ title: t("menuStoreAttachment"),
+ contexts: ["message_attachments"]
+ });
+ browser.menus.create({
+ id: "paperless-nachricht",
+ title: t("menuStoreMessage"),
+ contexts: ["message_list"]
+ });
+});
+
+browser.menus.onClicked.addListener(async (info, tab) => {
+ try {
+ console.debug("[paperless] Menü", info.menuItemId,
+ "Anhänge:", alsListe(info.attachments).length);
+ if (info.menuItemId === "paperless-anhang") {
+ await anhangAblegen(info, tab);
+ } else if (info.menuItemId === "paperless-nachricht") {
+ await nachrichtAblegen(info, tab);
+ }
+ } catch (err) {
+ await melde(err);
+ }
+});
+
+browser.messageDisplayAction.onClicked.addListener(async (tab) => {
+ try {
+ await nachrichtAblegen({}, tab);
+ } catch (err) {
+ await melde(err);
+ }
+});
+
+browser.composeAction.onClicked.addListener(async (tab) => {
+ try {
+ await dialogOeffnen("einfuegen", { tabId: tab.id });
+ } catch (err) {
+ await melde(err);
+ }
+});
+
+// -------------------------------------------------------------- Ablegen
+
+async function anhangAblegen(info, tab) {
+ const nachricht = alsListe(info.displayedMessages)[0]
+ || alsListe(info.selectedMessages)[0]
+ || (await angezeigteNachrichten(tab?.id))[0];
+ if (!nachricht) throw new PaperlessError(t("errNoMessage"));
+
+ const alle = await browser.messages.listAttachments(nachricht.id);
+ // Beim Klick auf einen einzelnen Anhang liefert das Menü nur diesen.
+ // Über die Sammelfunktion "Alle Anhänge" kommen mehrere.
+ const gewaehlt = alsListe(info.attachments).length
+ ? alsListe(info.attachments).map((a) => a.partName)
+ : alle.map((a) => a.partName);
+
+ // Nur die Beschreibung sammeln. Die eigentlichen Daten werden erst beim
+ // Hochladen geholt - sonst läge ein grosser Anhang unnötig im Speicher,
+ // während der Anwender noch im Dialog überlegt.
+ const dateien = alle
+ .filter((teil) => gewaehlt.includes(teil.partName))
+ .map((teil) => ({
+ name: sichererName(teil.name),
+ size: teil.size,
+ contentType: teil.contentType,
+ partName: teil.partName,
+ messageId: nachricht.id
+ }));
+
+ await dialogOeffnen("ablegen", {
+ quelle: "anhaenge",
+ messageId: nachricht.id,
+ betreff: nachricht.subject,
+ absender: nachricht.author,
+ datum: alsDatum(nachricht.date),
+ dateien
+ });
+}
+
+async function nachrichtAblegen(info, tab) {
+ let nachrichten = alsListe(info.selectedMessages);
+ if (!nachrichten.length) nachrichten = alsListe(info.displayedMessages);
+ if (!nachrichten.length) {
+ nachrichten = await angezeigteNachrichten(tab?.id);
+ }
+ if (!nachrichten.length) throw new PaperlessError(t("errNoMessage"));
+
+ const nachricht = nachrichten[0];
+ const anhaenge = await browser.messages.listAttachments(nachricht.id);
+
+ await dialogOeffnen("ablegen", {
+ quelle: "nachricht",
+ messageId: nachricht.id,
+ betreff: nachricht.subject,
+ absender: nachricht.author,
+ datum: alsDatum(nachricht.date),
+ dateien: anhaenge.map((a) => ({
+ name: sichererName(a.name),
+ size: a.size,
+ contentType: a.contentType,
+ partName: a.partName,
+ messageId: nachricht.id
+ }))
+ });
+}
+
+// -------------------------------------------------------------- Dialoge
+
+async function dialogOeffnen(art, daten) {
+ const einstellungen = await ladeEinstellungen();
+ if (!vollstaendig(einstellungen)) {
+ await browser.runtime.openOptionsPage();
+ return;
+ }
+ const nummer = naechsteAuftragsnummer++;
+ auftraege.set(nummer, daten);
+
+ // Nach einer halben Stunde verwerfen. Ein Auftrag enthält Betreff,
+ // Absender und Dateinamen; das muss nicht bis zum Neustart im Speicher
+ // liegen, nur weil jemand den Dialog offen stehen liess.
+ setTimeout(() => auftraege.delete(nummer), 30 * 60 * 1000);
+ const seite = art === "einfuegen" ? "ui/insert.html" : "ui/store.html";
+
+ // Die Adresse MUSS über runtime.getURL aufgelöst werden. Ein relativer
+ // Pfad wird in Manifest V3 nicht gegen das Erweiterungsverzeichnis
+ // aufgelöst - das Fenster öffnet dann stillschweigend nicht.
+ const url = browser.runtime.getURL(`${seite}?auftrag=${nummer}`);
+
+ try {
+ await browser.windows.create({
+ url,
+ type: "popup",
+ width: art === "einfuegen" ? 760 : 820,
+ height: art === "einfuegen" ? 640 : 720
+ });
+ } catch (err) {
+ // Manche Stände lehnen type "popup" ab. Dann ein gewöhnliches Fenster.
+ console.warn("[paperless] popup abgelehnt, versuche normales Fenster",
+ err);
+ await browser.windows.create({ url });
+ }
+}
+
+// -------------------------------------------- Vermittlung für die Dialoge
+
+browser.runtime.onMessage.addListener(async (nachricht, absender) => {
+ // Nur Nachrichten aus dieser Erweiterung annehmen. Thunderbird lässt
+ // fremde Absender hier zwar ohnehin nicht durch, solange kein
+ // externally_connectable gesetzt ist - die Prüfung kostet nichts und
+ // hält die Zusicherung explizit.
+ if (absender?.id && absender.id !== browser.runtime.id) {
+ return { ok: false, fehler: "Fremder Absender" };
+ }
+ try {
+ switch (nachricht?.befehl) {
+ case "auftrag":
+ return { ok: true, daten: auftraege.get(nachricht.nummer) || null };
+
+ case "einstellungen":
+ return { ok: true, daten: await ladeEinstellungen() };
+
+ case "berechtigungPruefen":
+ return { ok: true, daten: await berechtigungPruefen() };
+
+ case "suchen": {
+ const c = await client();
+ return { ok: true, daten: await c.search(nachricht.query) };
+ }
+
+ case "dokument": {
+ const c = await client();
+ return { ok: true, daten: await c.document(nachricht.id) };
+ }
+
+ case "versionen": {
+ const c = await client();
+ const doc = await c.document(nachricht.id);
+ return { ok: true, daten: await c.versions(doc) };
+ }
+
+ case "stammdaten": {
+ const c = await client();
+ const [tags, korr, typen] = await Promise.all(
+ [c.tags(), c.correspondents(), c.documentTypes()]);
+ return { ok: true, daten: { tags, korr, typen } };
+ }
+
+ case "ablegen":
+ return { ok: true, daten: await ablegen(nachricht) };
+
+ case "klassifizieren": {
+ const c = await client();
+ const payload = { ...nachricht.payload };
+ payload.correspondent = await aufloesen(
+ c, "correspondents/", nachricht.korrName);
+ payload.document_type = await aufloesen(
+ c, "document_types/", nachricht.typName);
+ await c.patchDocument(nachricht.id, payload);
+ return { ok: true };
+ }
+
+ case "anhaengen":
+ return { ok: true, daten: await anhaengenAnEntwurf(nachricht) };
+
+ case "freigabelink":
+ return { ok: true, daten: await freigabelink(nachricht) };
+
+ default:
+ return { ok: false, fehler: `Unbekannter Befehl: ${nachricht?.befehl}` };
+ }
+ } catch (err) {
+ return { ok: false, fehler: String(err?.message || err) };
+ }
+});
+
+async function client() {
+ const s = await ladeEinstellungen();
+ if (!vollstaendig(s)) throw new PaperlessError(t("errNoSettings"));
+
+ // Ohne Zugriffsrecht für den Ursprung schlägt fetch mit einem nackten
+ // "NetworkError" fehl. Die Prüfung vorweg erspart eine irreführende
+ // Fehlersuche beim Netzwerk.
+ let origin;
+ try {
+ origin = new URL(s.baseUrl).origin + "/*";
+ } catch {
+ throw new PaperlessError(t("errBadUrl"));
+ }
+ const erteilt = await browser.permissions.contains({ origins: [origin] });
+ if (!erteilt) {
+ throw new PaperlessError(
+ `${t("errNoPermission")}\n\n${origin}\n\n`
+ + `In den Einstellungen erneut auf Speichern klicken - die Abfrage `
+ + `kann nur aus einer Benutzeraktion heraus erscheinen.`);
+ }
+ return new PaperlessClient(s.baseUrl, s.token);
+}
+
+/**
+ * Zugriffsrecht für die Zieladresse.
+ *
+ * Es wird bewusst nicht pauschal im Manifest verlangt, sondern erst zur
+ * Laufzeit für genau die eingetragene Adresse angefordert. Eine
+ * Erweiterung, die von Haus aus jede Adresse erreichen darf, ist eine
+ * unnötig große Angriffsfläche.
+ */
+async function berechtigungPruefen() {
+ const s = await ladeEinstellungen();
+ if (!vollstaendig(s)) return { noetig: false, erteilt: false };
+ const muster = new URL(s.baseUrl).origin + "/*";
+ const erteilt = await browser.permissions.contains({ origins: [muster] });
+ return { noetig: true, erteilt, muster };
+}
+
+// -------------------------------------------------------------- Vorgänge
+
+async function datenHolen(auftrag) {
+ if (auftrag.quelle === "nachricht") {
+ const roh = await browser.messages.getRaw(auftrag.messageId);
+ const blob = new Blob([roh], { type: "message/rfc822" });
+ const name = sichererName(
+ `${titelAusBetreff(auftrag.betreff)}.eml`);
+ return [{ blob, name }];
+ }
+ const out = [];
+ for (const d of auftrag.dateien || []) {
+ if (!d.ausgewaehlt) continue;
+ const datei = await browser.messages.getAttachmentFile(
+ d.messageId, d.partName);
+ out.push({ blob: datei, name: sichererName(d.name) });
+ }
+ return out;
+}
+
+async function ablegen(nachricht) {
+ const c = await client();
+ const s = await ladeEinstellungen();
+ const dateien = await datenHolen(nachricht.auftrag);
+ if (!dateien.length) throw new PaperlessError(t("errNothingSelected"));
+
+ const ergebnisse = [];
+ for (const datei of dateien) {
+ if (nachricht.ziel === "vorhanden") {
+ const antwort = await c.updateVersion(
+ nachricht.zielId, datei.blob, datei.name,
+ versionslabel(s.versionLabel));
+ const id = await c.waitForTask(antwort);
+ ergebnisse.push({ id, name: datei.name, art: "version",
+ zielId: nachricht.zielId });
+ } else {
+ const felder = { title: nachricht.titel || datei.name };
+ if (nachricht.erstellt) felder.created = nachricht.erstellt;
+ const antwort = await c.postDocument(datei.blob, datei.name, felder);
+ const id = await c.waitForTask(antwort);
+ ergebnisse.push({ id, name: datei.name, art: "neu" });
+ }
+ }
+ return ergebnisse;
+}
+
+async function aufloesen(c, pfad, name) {
+ const gesucht = String(name || "").trim();
+ if (!gesucht) return null;
+ const liste = pfad.startsWith("correspondents")
+ ? await c.correspondents() : await c.documentTypes();
+ const treffer = liste.find(
+ (x) => x.name.trim().toLowerCase() === gesucht.toLowerCase());
+ if (treffer) return treffer.id;
+ const neu = await c.create(pfad, gesucht);
+ return neu.id;
+}
+
+async function anhaengenAnEntwurf(nachricht) {
+ const c = await client();
+ const { blob, name } = await c.download(nachricht.versionId, true);
+ const datei = new File([blob], sichererName(name || `paperless-${nachricht.versionId}`));
+ await browser.compose.addAttachment(nachricht.tabId, { file: datei });
+ return { name: datei.name };
+}
+
+async function freigabelink(nachricht) {
+ const c = await client();
+ const url = await c.shareLink(nachricht.id, nachricht.tage,
+ nachricht.variante);
+ if (!url) throw new PaperlessError(t("errNoLink"));
+
+ // Kennzeichnung setzen. Fehler werden NICHT verschluckt: Der Tag ist der
+ // Grund, warum sich geteilte Dokumente später wiederfinden lassen - ein
+ // stiller Fehlschlag wäre schlimmer als eine Meldung.
+ let tagStatus = null;
+ if (nachricht.tagName) {
+ try {
+ const name = String(nachricht.tagName).trim();
+ const tags = await c.tags();
+ let treffer = tags.find(
+ (x) => String(x.name).trim().toLowerCase() === name.toLowerCase());
+ if (!treffer) treffer = await c.create("tags/", name);
+ if (!treffer || treffer.id === undefined) {
+ throw new PaperlessError(
+ `Tag "${name}" ohne Kennung: ${JSON.stringify(treffer)}`);
+ }
+ const doc = await c.document(nachricht.id);
+ const vorhanden = (doc.tags || []).map(Number);
+ if (!vorhanden.includes(Number(treffer.id))) {
+ await c.patchDocument(
+ nachricht.id, { tags: [...vorhanden, Number(treffer.id)] });
+ }
+ tagStatus = { ok: true, name };
+ } catch (err) {
+ tagStatus = { ok: false, fehler: String(err?.message || err) };
+ console.error("[paperless] Tag konnte nicht gesetzt werden", err);
+ }
+ }
+
+ // Ablaufdatum selbst rechnen. Es steht auch in der Antwort, aber nicht
+ // in jeder Version unter demselben Namen - die eigene Rechnung stimmt
+ // mit dem überein, was im Dialog eingestellt wurde.
+ const gueltigBis = nachricht.tage
+ ? new Date(Date.now() + Number(nachricht.tage) * 86400000)
+ : null;
+
+ if (nachricht.tabId !== undefined && nachricht.einfuegen) {
+ await linkEinfuegen(nachricht.tabId, url, nachricht.linktext,
+ gueltigBis);
+ }
+ return {
+ url,
+ tagStatus,
+ gueltigBis: gueltigBis ? gueltigBis.toISOString() : null
+ };
+}
+
+/**
+ * Datum als TT.MM.JJJJ.
+ *
+ * Bewusst kein toLocaleDateString: Die Nachricht geht an einen Empfänger
+ * mit unbekannter Spracheinstellung. 03.09. und 09.03. sehen gleich aus
+ * und bedeuten Verschiedenes - bei einem Ablaufdatum ist das der falsche
+ * Ort für Mehrdeutigkeit.
+ */
+function alsDeutschesDatum(d) {
+ const z = (n) => String(n).padStart(2, "0");
+ return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()}`;
+}
+
+/**
+ * Freigabelink in einen Entwurf einfügen.
+ *
+ * ACHTUNG: Die Compose-Schnittstelle kennt kein Einfügen an der
+ * Schreibmarke. Der Körper muss vollständig gelesen und zurückgeschrieben
+ * werden. Der Eintrag landet deshalb am Ende, bei HTML vor dem
+ * schliessenden
+ Klassifizierung bearbeiten
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unbekannte Korrespondenten und Dokumenttypen werden angelegt.
+
+
+
+
+
+
+
+
+
+
+.
+ *
+ * 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);
+ }
+}
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..81b0c9f
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,57 @@
+#!/bin/sh
+# Baut die Erweiterung zu einer .xpi-Datei.
+#
+# Eine .xpi ist ein ZIP. Wichtig: aus dem Wurzelverzeichnis heraus packen,
+# nicht das Verzeichnis selbst einpacken - sonst findet Thunderbird die
+# manifest.json nicht.
+set -eu
+cd "$(dirname "$0")"
+
+NAME="paperless-thunderbird"
+VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -1)"
+OUT="${NAME}-${VERSION}.xpi"
+
+# Vollständigkeit prüfen. zip überspringt fehlende Pfade stillschweigend -
+# 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 \
+ 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 \
+ _locales/de/messages.json _locales/en/messages.json \
+ _locales/fr/messages.json \
+ icons/paperless-logo_64.png
+do
+ [ -e "$f" ] || FEHLT="$FEHLT $f\n"
+done
+if [ -n "$FEHLT" ]; then
+ printf "ABBRUCH - diese Dateien fehlen:\n%b" "$FEHLT" >&2
+ printf "\nWahrscheinlich wurden beim Kopieren die Unterverzeichnisse\n" >&2
+ printf "ausgelassen. Benoetigt werden lib/, ui/, icons/ und _locales/.\n" >&2
+ exit 1
+fi
+
+rm -f "$OUT"
+
+zip -r -q "$OUT" \
+ manifest.json background.js \
+ lib ui icons _locales \
+ LICENSE \
+ -x "*.oxt" "*.xpi" "build.sh" "README.md" "SICHERHEIT.md" \
+ "make_icons.py" "*/.*"
+
+if ! unzip -l "$OUT" | grep -q 'manifest.json'; then
+ echo "ABBRUCH: manifest.json fehlt im erzeugten Paket." >&2
+ exit 1
+fi
+
+echo "Erstellt: $OUT ($(wc -c < "$OUT") Bytes)"
+echo
+echo "Laden zum Ausprobieren:"
+echo " Extras -> Entwicklerwerkzeuge -> Debuggen von Add-ons"
+echo " -> Temporaeres Add-on laden -> manifest.json waehlen"
+echo
+echo "Dauerhaft installieren:"
+echo " Add-ons-Verwaltung -> Zahnrad -> Add-on aus Datei installieren"
+echo " -> $OUT"
diff --git a/icons/paperless-insert.svg b/icons/paperless-insert.svg
new file mode 100644
index 0000000..a58fab3
--- /dev/null
+++ b/icons/paperless-insert.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/icons/paperless-insert_16.png b/icons/paperless-insert_16.png
new file mode 100644
index 0000000..d8af0b8
Binary files /dev/null and b/icons/paperless-insert_16.png differ
diff --git a/icons/paperless-insert_32.png b/icons/paperless-insert_32.png
new file mode 100644
index 0000000..323ab4b
Binary files /dev/null and b/icons/paperless-insert_32.png differ
diff --git a/icons/paperless-insert_64.png b/icons/paperless-insert_64.png
new file mode 100644
index 0000000..0f58de4
Binary files /dev/null and b/icons/paperless-insert_64.png differ
diff --git a/icons/paperless-logo.svg b/icons/paperless-logo.svg
new file mode 100644
index 0000000..1685cae
--- /dev/null
+++ b/icons/paperless-logo.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/icons/paperless-logo_16.png b/icons/paperless-logo_16.png
new file mode 100644
index 0000000..cca5463
Binary files /dev/null and b/icons/paperless-logo_16.png differ
diff --git a/icons/paperless-logo_32.png b/icons/paperless-logo_32.png
new file mode 100644
index 0000000..4471dfc
Binary files /dev/null and b/icons/paperless-logo_32.png differ
diff --git a/icons/paperless-logo_64.png b/icons/paperless-logo_64.png
new file mode 100644
index 0000000..cd3bd39
Binary files /dev/null and b/icons/paperless-logo_64.png differ
diff --git a/icons/paperless-share.svg b/icons/paperless-share.svg
new file mode 100644
index 0000000..0b18cdd
--- /dev/null
+++ b/icons/paperless-share.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/icons/paperless-share_16.png b/icons/paperless-share_16.png
new file mode 100644
index 0000000..8bb0136
Binary files /dev/null and b/icons/paperless-share_16.png differ
diff --git a/icons/paperless-share_32.png b/icons/paperless-share_32.png
new file mode 100644
index 0000000..959b8f8
Binary files /dev/null and b/icons/paperless-share_32.png differ
diff --git a/icons/paperless-share_64.png b/icons/paperless-share_64.png
new file mode 100644
index 0000000..ee538c5
Binary files /dev/null and b/icons/paperless-share_64.png differ
diff --git a/icons/paperless-store.svg b/icons/paperless-store.svg
new file mode 100644
index 0000000..953713d
--- /dev/null
+++ b/icons/paperless-store.svg
@@ -0,0 +1,14 @@
+
+
diff --git a/icons/paperless-store_16.png b/icons/paperless-store_16.png
new file mode 100644
index 0000000..9f0cfa5
Binary files /dev/null and b/icons/paperless-store_16.png differ
diff --git a/icons/paperless-store_32.png b/icons/paperless-store_32.png
new file mode 100644
index 0000000..6c51b23
Binary files /dev/null and b/icons/paperless-store_32.png differ
diff --git a/icons/paperless-store_64.png b/icons/paperless-store_64.png
new file mode 100644
index 0000000..7b76fbf
Binary files /dev/null and b/icons/paperless-store_64.png differ
diff --git a/lib/client.js b/lib/client.js
new file mode 100644
index 0000000..e87d1dc
--- /dev/null
+++ b/lib/client.js
@@ -0,0 +1,324 @@
+/**
+ * client.js - REST-Client für Paperless-ngx.
+ *
+ * Die Eigenheiten der Schnittstelle sind hier festgehalten, weil sie
+ * nirgends dokumentiert und durch Beobachtung erschlossen sind:
+ *
+ * * update_version nimmt NUR "document" und "version_label" entgegen -
+ * keinen Zeitstempel. Ein "added" je Version lässt sich über die API
+ * nicht setzen.
+ * * Der Status einer Aufgabe kommt kleingeschrieben ("success"), in
+ * älteren Ständen groß. Vergleich deshalb unabhängig von der
+ * Schreibweise.
+ * * Die Dokument-ID steht je nach Stand in related_document_ids,
+ * verschachtelt in result_data.document_id oder direkt in
+ * related_document.
+ * * Der Endpunkt für Freigabelinks heißt je nach Version share_links/
+ * oder sharelinks/.
+ */
+
+const TASK_TIMEOUT = 900_000; // 15 Minuten
+const TASK_POLL = 1500;
+
+// KEIN eigener Kopfzeileneintrag zur Kennzeichnung.
+//
+// Ein selbst gesetzter Eintrag wie "X-Client" ist nicht CORS-sicher und
+// löst eine Vorabanfrage (OPTIONS) aus. Antwortet Paperless darauf nicht
+// wie erwartet, scheitert schon der erste Aufruf mit "NetworkError" -
+// einer Meldung, die nichts über die Ursache verrät.
+//
+// Der Browser sendet ohnehin seine eigene Kennung. Sollte ein Reverse
+// Proxy diese abweisen, gehört die Ausnahme dort konfiguriert, nicht hier
+// umgangen.
+
+export class PaperlessError extends Error {}
+
+export class PaperlessClient {
+ constructor(baseUrl, token) {
+ let parsed;
+ try {
+ parsed = new URL(String(baseUrl || "").trim());
+ } catch {
+ throw new PaperlessError("badUrl");
+ }
+ // Nur http und https. Ohne Prüfung liessen sich hier andere Schemata
+ // hinterlegen, die fetch teilweise bedient.
+ if (!["http:", "https:"].includes(parsed.protocol) || !parsed.host) {
+ throw new PaperlessError("badUrl");
+ }
+ this.base = String(baseUrl).trim().replace(/\/+$/, "");
+ this.token = String(token || "").trim();
+ }
+
+ get origin() {
+ return new URL(this.base).origin + "/*";
+ }
+
+ async #fetch(path, options = {}) {
+ const url = path.startsWith("http") ? path
+ : `${this.base}/api/${String(path).replace(/^\/+/, "")}`;
+ const headers = {
+ "Authorization": `Token ${this.token}`,
+ "Accept": "application/json, */*",
+ ...(options.headers || {})
+ };
+ let response;
+ try {
+ response = await fetch(url, { ...options, headers, redirect: "follow" });
+ } catch (err) {
+ // "NetworkError" ist die Sammelmeldung des Browsers für alles, was
+ // die Anfrage gar nicht erst erreichen liess: fehlendes
+ // Zugriffsrecht, unerreichbarer Rechner, abgewiesenes Zertifikat.
+ // Der Hinweis auf die Berechtigung steht hier, weil das mit Abstand
+ // der häufigste Fall ist.
+ throw new PaperlessError(
+ `Verbindung fehlgeschlagen: ${err.message}\n\n`
+ + `Häufigste Ursachen:\n`
+ + `• Das Zugriffsrecht für ${new URL(this.base).origin} wurde nicht `
+ + `erteilt — in den Einstellungen erneut speichern\n`
+ + `• Der Rechner ist nicht erreichbar\n`
+ + `• Das TLS-Zertifikat wird abgewiesen (bei selbst ausgestellten `
+ + `Zertifikaten in Thunderbird einmal im Browser bestätigen)`);
+ }
+ if (!response.ok) {
+ let body = "";
+ try {
+ body = (await response.text()).slice(0, 400);
+ } catch { /* egal */ }
+ throw new PaperlessError(`HTTP ${response.status}: ${body}`);
+ }
+ return response;
+ }
+
+ async #json(path, options) {
+ const r = await this.#fetch(path, options);
+ const text = await r.text();
+ return text ? JSON.parse(text) : null;
+ }
+
+ // ------------------------------------------------------------ Aufgaben
+
+ static docIdFromTask(task) {
+ const asInt = (v) => {
+ const n = parseInt(v, 10);
+ return Number.isFinite(n) && n > 0 ? n : null;
+ };
+ for (const key of ["related_document_ids", "document_ids"]) {
+ const val = task?.[key];
+ if (Array.isArray(val) && val.length) {
+ const got = asInt(val[0]);
+ if (got) return got;
+ }
+ }
+ const nested = task?.result_data;
+ if (nested && typeof nested === "object") {
+ for (const key of ["document_id", "related_document", "id"]) {
+ const got = asInt(nested[key]);
+ if (got) return got;
+ }
+ }
+ for (const key of ["related_document", "document_id",
+ "related_document_id"]) {
+ const got = asInt(task?.[key]);
+ if (got) return got;
+ }
+ const m = /[Dd]ocument\D+(\d+)/.exec(String(task?.result || ""));
+ return m ? parseInt(m[1], 10) : null;
+ }
+
+ async #findTask(taskId) {
+ for (const query of [`tasks/?task_id=${encodeURIComponent(taskId)}`,
+ "tasks/"]) {
+ let data;
+ try {
+ data = await this.#json(query);
+ } catch {
+ continue;
+ }
+ const rows = Array.isArray(data) ? data : (data?.results || []);
+ const hit = rows.find((t) => String(t.task_id) === String(taskId));
+ if (hit) return hit;
+ }
+ return null;
+ }
+
+ async waitForTask(taskId, onProgress) {
+ const deadline = Date.now() + TASK_TIMEOUT;
+ let waited = 0;
+ while (Date.now() < deadline) {
+ const task = await this.#findTask(taskId);
+ if (task) {
+ const status = String(task.status || "").toUpperCase();
+ if (["SUCCESS", "SUCCEEDED"].includes(status)) {
+ const id = PaperlessClient.docIdFromTask(task);
+ if (id) return id;
+ throw new PaperlessError(
+ `Aufgabe erfolgreich, aber ohne Dokument-ID: ${JSON.stringify(task)}`);
+ }
+ if (["FAILURE", "FAILED", "REVOKED"].includes(status)) {
+ const rd = task.result_data || {};
+ const msg = task.result || rd.error || rd.exc_message
+ || `Aufgabe ${status}`;
+ throw new PaperlessError(String(msg).slice(0, 400));
+ }
+ }
+ await new Promise((r) => setTimeout(r, TASK_POLL));
+ waited += TASK_POLL;
+ onProgress?.(Math.round(waited / 1000));
+ }
+ throw new PaperlessError(`Zeitüberschreitung bei Aufgabe ${taskId}`);
+ }
+
+ // ----------------------------------------------------------- Dokumente
+
+ async ping() {
+ await this.#json("documents/?page_size=1");
+ return true;
+ }
+
+ async search(query, limit = 50) {
+ const params = new URLSearchParams({
+ page_size: String(limit),
+ ordering: "-added",
+ fields: "id,title,created,added,archive_serial_number,mime_type," +
+ "original_file_name"
+ });
+ if (query) params.set("query", query);
+ const data = await this.#json(`documents/?${params}`);
+ return data?.results || [];
+ }
+
+ document(id) {
+ return this.#json(`documents/${Number(id)}/`);
+ }
+
+ async versions(doc) {
+ const list = doc?.versions || [];
+ if (!list.length) {
+ return [{
+ id: doc.id, no: 1, added: doc.added, is_root: true,
+ version_label: null, mime_type: doc.mime_type,
+ filename: doc.original_file_name || ""
+ }];
+ }
+ // Paperless liefert absteigend, neueste zuerst. Die Versionsnummer
+ // ergibt sich aus der Position von unten gezählt.
+ const total = list.length;
+ const out = [];
+ for (const [idx, v] of list.entries()) {
+ const entry = { ...v, no: total - idx };
+ try {
+ const full = await this.document(v.id);
+ entry.mime_type = full.mime_type;
+ entry.filename = full.original_file_name || "";
+ } catch {
+ entry.mime_type = null;
+ entry.filename = "";
+ }
+ out.push(entry);
+ }
+ return out;
+ }
+
+ async download(id, original = true) {
+ const url = `${this.base}/api/documents/${Number(id)}/download/`
+ + (original ? "?original=true" : "");
+ const r = await this.#fetch(url);
+ const disp = r.headers.get("Content-Disposition") || "";
+ let name = null;
+ const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disp);
+ if (m) {
+ try {
+ name = decodeURIComponent(m[1]);
+ } catch {
+ name = m[1];
+ }
+ }
+ return { blob: await r.blob(), name };
+ }
+
+ async postDocument(file, filename, fields = {}) {
+ const form = new FormData();
+ form.append("document", file, filename);
+ for (const [k, v] of Object.entries(fields)) {
+ if (v === null || v === undefined || v === "") continue;
+ if (Array.isArray(v)) {
+ v.forEach((x) => form.append(k, String(x)));
+ } else {
+ form.append(k, String(v));
+ }
+ }
+ return this.#json("documents/post_document/",
+ { method: "POST", body: form });
+ }
+
+ async updateVersion(docId, file, filename, versionLabel) {
+ const form = new FormData();
+ form.append("document", file, filename);
+ if (versionLabel) form.append("version_label", versionLabel);
+ return this.#json(`documents/${Number(docId)}/update_version/`,
+ { method: "POST", body: form });
+ }
+
+ patchDocument(id, payload) {
+ return this.#json(`documents/${Number(id)}/`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+ }
+
+ // ----------------------------------------------------------- Stammdaten
+
+ async #all(path, fields = "id,name") {
+ const out = [];
+ let page = 1;
+ for (;;) {
+ const data = await this.#json(
+ `${path}?page_size=200&page=${page}&fields=${fields}`);
+ if (!data) break;
+ out.push(...(data.results || []));
+ if (!data.next) break;
+ page += 1;
+ }
+ return out;
+ }
+
+ tags() { return this.#all("tags/"); }
+ correspondents() { return this.#all("correspondents/"); }
+ documentTypes() { return this.#all("document_types/"); }
+
+ create(path, name) {
+ return this.#json(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name })
+ });
+ }
+
+ // ------------------------------------------------------------- Freigabe
+
+ async shareLink(docId, days = 7, fileVersion = "original") {
+ const payload = { document: Number(docId), file_version: fileVersion };
+ if (days) {
+ const d = new Date(Date.now() + days * 86400_000);
+ payload.expiration = d.toISOString();
+ }
+ let last = null;
+ // Der Endpunkt heißt je nach Version anders. Beide probieren.
+ for (const path of ["share_links/", "sharelinks/"]) {
+ try {
+ const res = await this.#json(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload)
+ });
+ if (res?.slug) return `${this.base}/share/${res.slug}`;
+ return null;
+ } catch (err) {
+ last = err;
+ }
+ }
+ throw last;
+ }
+}
diff --git a/lib/settings.js b/lib/settings.js
new file mode 100644
index 0000000..a751d86
--- /dev/null
+++ b/lib/settings.js
@@ -0,0 +1,97 @@
+/**
+ * settings.js - Einstellungen und gemeinsam genutzte Hilfsmittel.
+ *
+ * Die Einstellungen liegen in browser.storage.local, also im Profil von
+ * Thunderbird. Der Token steht dort im Klartext - das entspricht dem, was
+ * Erweiterungen üblicherweise tun, ist aber kein Geheimnisspeicher.
+ * Einzelheiten in SICHERHEIT.md.
+ */
+
+const VORGABEN = {
+ baseUrl: "",
+ token: "",
+ versionLabel: "Thunderbird {datum}",
+ shareDays: 7,
+ shareTag: "Sharelink",
+ // Anhänge, die üblicherweise keine Dokumente sind. Sie werden im
+ // Auswahldialog vorab abgewählt, lassen sich aber anhaken.
+ ignorierteTypen: ["image/gif", "image/png", "image/jpeg", "text/vcard",
+ "text/calendar", "application/pkcs7-signature",
+ "application/pgp-signature"],
+ ignorierteKleiner: 8192 // Bytes; darunter meist Signaturbilder
+};
+
+export async function ladeEinstellungen() {
+ const gespeichert = await browser.storage.local.get(Object.keys(VORGABEN));
+ return { ...VORGABEN, ...gespeichert };
+}
+
+export async function speichereEinstellungen(werte) {
+ await browser.storage.local.set(werte);
+}
+
+export function vollstaendig(s) {
+ return Boolean(s?.baseUrl) && Boolean(s?.token);
+}
+
+/**
+ * Beliebige Zeichenkette in einen unbedenklichen Dateinamen überführen.
+ *
+ * Der Name eines E-Mail-Anhangs stammt vom Absender und ist damit nicht
+ * vertrauenswürdig. Er wird zwar nur an Paperless weitergereicht und nicht
+ * ins Dateisystem geschrieben, aber ein Wert wie "../../x" hat in einem
+ * Dateinamen nichts zu suchen.
+ */
+export function sichererName(name, vorgabe = "Dokument", maxlen = 120) {
+ let n = String(name || "");
+ n = n.replace(/\\/g, "/").split("/").pop();
+ n = n.replace(/[<>:"|?*\u0000-\u001f]/g, "").trim();
+ n = n.replace(/\s+/g, " ").replace(/^[. ]+|[. ]+$/g, "");
+ if (!n) return vorgabe;
+ const reserviert = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
+ if (reserviert.test(n)) n = "_" + n;
+ if (n.length > maxlen) {
+ const punkt = n.lastIndexOf(".");
+ const endung = punkt > 0 && n.length - punkt <= 9 ? n.slice(punkt) : "";
+ n = n.slice(0, maxlen - endung.length) + endung;
+ }
+ return n || vorgabe;
+}
+
+export function versionslabel(vorlage) {
+ const heute = new Date().toISOString().slice(0, 10);
+ return String(vorlage || "Thunderbird {datum}").replace("{datum}", heute);
+}
+
+/** Kürzt einen Text für die Anzeige. */
+export function kurz(text, laenge = 70) {
+ const t = String(text || "");
+ return t.length > laenge ? t.slice(0, laenge - 1) + "…" : t;
+}
+
+/** Menschenlesbare Größenangabe. */
+export function groesse(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`;
+}
+
+/**
+ * Betreff einer Nachricht in einen Dokumenttitel überführen.
+ *
+ * Präfixe wie "AW:" oder "Fwd:" tragen im Archiv nichts bei und stören
+ * beim Sortieren.
+ */
+export function titelAusBetreff(betreff) {
+ let t = String(betreff || "").trim();
+ const praefixe = /^((AW|RE|WG|FWD|FW|TR|RÉF|REF)\s*(\[\d+\])?\s*:\s*)+/i;
+ t = t.replace(praefixe, "").trim();
+ return t || "E-Mail ohne Betreff";
+}
+
+/** Übersetzung über die eingebaute Verwaltung von Thunderbird. */
+export function t(schluessel, ersetzungen) {
+ const s = browser.i18n.getMessage(schluessel, ersetzungen);
+ return s || schluessel;
+}
diff --git a/make_icons.py b/make_icons.py
new file mode 100644
index 0000000..f09dc25
--- /dev/null
+++ b/make_icons.py
@@ -0,0 +1,282 @@
+#!/usr/bin/env python3
+"""
+make_icons.py - Erzeugt die Symbole der Erweiterung als SVG und PNG.
+
+Die Geometrie ist EINMAL beschrieben und wird von zwei Ausgaben gelesen.
+So können SVG und PNG nicht auseinanderlaufen.
+
+ SVG für die Symbolleisten, wird von Thunderbird skaliert.
+ PNG in 16, 32 und 64 Pixeln - das Manifest verlangt feste Größen, und
+ für die Erweiterungsverwaltung wird 64 gebraucht.
+
+Alle Symbole teilen dieselbe Grundform - ein Blatt mit umgeknickter Ecke in
+Paperless-Grün - damit die Leiste als zusammengehörige Gruppe erkennbar
+bleibt. Unterschieden wird über eine farbige Scheibe unten rechts.
+
+Aufruf: python3 make_icons.py (benötigt Pillow für die PNG-Ausgabe)
+"""
+
+import math
+import os
+
+OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icons")
+
+# Zeichenfläche in Einheiten. Die Formen reichen bewusst bis dicht an den
+# Rand, damit bei kleiner Darstellung möglichst viel Fläche trägt.
+BOX = 24.0
+PNG_SIZES = (16, 32, 64)
+SCALE = 12 # Überabtastung für die PNG-Ausgabe
+
+GREEN = "#175420"
+GREEN_LIGHT = "#3a8a44"
+PAPER = "#fcfcfa"
+WHITE = "#ffffff"
+BLUE = "#1e60a8" # herunterladen
+ORANGE = "#c86a14" # hinauf
+VIOLET = "#703e98" # klassifizieren
+TEAL = "#117a7a" # teilen
+GREY = "#5c6064" # einstellungen
+
+
+# ----------------------------------------------------------- Grundformen
+
+def poly(points, fill=None, stroke=None, width=0.0):
+ return ("poly", points, fill, stroke, width)
+
+
+def circle(cx, cy, r, fill=None, stroke=None, width=0.0):
+ return ("circle", (cx, cy, r), fill, stroke, width)
+
+
+def line(p1, p2, stroke, width):
+ return ("line", (p1, p2), None, stroke, width)
+
+
+def rrect(x0, y0, x1, y1, r, fill=None, stroke=None, width=0.0):
+ return ("rrect", (x0, y0, x1, y1, r), fill, stroke, width)
+
+
+def sheet(colour=GREEN):
+ """Blatt mit umgeknickter Ecke, füllt die Fläche links oben."""
+ x0, y0, x1, y1 = 0.6, 0.5, 18.4, 23.4
+ fold = 5.4
+ out = [
+ poly([(x0, y0), (x1 - fold, y0), (x1, y0 + fold), (x1, y1), (x0, y1)],
+ fill=PAPER, stroke=colour, width=1.8),
+ poly([(x1 - fold, y0), (x1, y0 + fold), (x1 - fold, y0 + fold)],
+ fill=colour),
+ ]
+ for i, w in enumerate((10.4, 12.6, 8.4)):
+ yy = y0 + 7.4 + i * 3.8
+ out.append(line((x0 + 2.2, yy), (x0 + 2.2 + w, yy), colour, 1.4))
+ return out
+
+
+def badge(colour):
+ """Scheibe unten rechts, mit hellem Ring zur Abgrenzung vom Blatt."""
+ cx, cy, r = 16.9, 17.1, 6.7
+ return cx, cy, r, [
+ circle(cx, cy, r + 1.1, fill=WHITE),
+ circle(cx, cy, r, fill=colour),
+ ]
+
+
+def arrow(cx, cy, up=True, colour=PAPER):
+ w, h = 2.9, 3.5
+ if up:
+ return [poly([(cx, cy - h), (cx - w, cy - 0.3), (cx + w, cy - 0.3)],
+ fill=colour),
+ line((cx, cy - 0.6), (cx, cy + h * 0.85), colour, 2.0)]
+ return [poly([(cx, cy + h), (cx - w, cy + 0.3), (cx + w, cy + 0.3)],
+ fill=colour),
+ line((cx, cy - h * 0.85), (cx, cy + 0.6), colour, 2.0)]
+
+
+# --------------------------------------------------------------- Symbole
+
+def icon_logo():
+ """Markensymbol für den Anfang der Leiste: gefüllte Fläche, weißes Blatt."""
+ out = [rrect(0.4, 0.4, 23.6, 23.6, 4.0, fill=GREEN)]
+ x0, y0, x1, y1 = 5.0, 3.9, 19.0, 20.1
+ fold = 4.4
+ out += [
+ poly([(x0, y0), (x1 - fold, y0), (x1, y0 + fold), (x1, y1), (x0, y1)],
+ fill=PAPER),
+ poly([(x1 - fold, y0), (x1, y0 + fold), (x1 - fold, y0 + fold)],
+ fill=GREEN_LIGHT),
+ ]
+ for i, w in enumerate((7.4, 9.2, 6.0)):
+ yy = y0 + 6.0 + i * 3.4
+ out.append(line((x0 + 2.0, yy), (x0 + 2.0 + w, yy), GREEN, 1.5))
+ return out
+
+
+def icon_open():
+ out = sheet()
+ cx, cy, _r, b = badge(BLUE)
+ return out + b + arrow(cx, cy, up=False)
+
+
+def icon_version():
+ out = sheet()
+ cx, cy, _r, b = badge(ORANGE)
+ out += b + arrow(cx - 1.2, cy, up=True)
+ px, py, a = cx + 3.4, cy - 2.6, 1.7
+ out += [line((px - a, py), (px + a, py), PAPER, 1.4),
+ line((px, py - a), (px, py + a), PAPER, 1.4)]
+ return out
+
+
+def icon_save_as():
+ # Zweites Blatt dahinter: es kann ein eigenständiges Dokument entstehen.
+ out = [poly([(4.4, 0.5), (18.4, 0.5), (18.4, 4.6), (4.4, 4.6)],
+ fill=PAPER, stroke=GREEN_LIGHT, width=1.5)]
+ out += sheet()
+ cx, cy, _r, b = badge(ORANGE)
+ out += b + arrow(cx - 1.2, cy, up=True)
+ sx, sy, a = cx + 3.4, cy - 2.6, 1.9
+ for dx, dy in ((a, 0), (0, a), (a * .7, a * .7), (-a * .7, a * .7)):
+ out.append(line((sx - dx, sy - dy), (sx + dx, sy + dy), PAPER, 1.15))
+ return out
+
+
+def icon_classify():
+ out = sheet()
+ cx, cy, _r, b = badge(VIOLET)
+ out += b + [
+ poly([(cx - 3.6, cy - 0.5), (cx + 0.4, cy - 4.0),
+ (cx + 3.7, cy - 0.6), (cx - 0.2, cy + 3.6)], fill=PAPER),
+ circle(cx + 1.4, cy - 1.5, 0.9, fill=VIOLET),
+ ]
+ return out
+
+
+def icon_share():
+ out = sheet()
+ cx, cy, _r, b = badge(TEAL)
+ a, bb, c = (cx + 2.6, cy - 3.2), (cx + 2.6, cy + 3.2), (cx - 3.2, cy)
+ out += b + [line(c, a, PAPER, 1.3), line(c, bb, PAPER, 1.3)]
+ for p in (a, bb, c):
+ out.append(circle(p[0], p[1], 1.65, fill=PAPER))
+ return out
+
+
+def icon_settings():
+ out = sheet()
+ cx, cy, _r, b = badge(GREY)
+ out += b
+ outer, inner, hole = 5.0, 3.4, 1.6
+ for i in range(8):
+ ang = math.pi * i / 4
+ dx, dy = math.cos(ang), math.sin(ang)
+ out.append(line((cx + dx * inner * .7, cy + dy * inner * .7),
+ (cx + dx * outer, cy + dy * outer), PAPER, 1.7))
+ out += [circle(cx, cy, inner, stroke=PAPER, width=1.5),
+ circle(cx, cy, hole, fill=GREY)]
+ return out
+
+
+# Thunderbird braucht weniger Symbole als die LibreOffice-Fassung: Es gibt
+# nur zwei Schaltflächen (Ablegen im Nachrichtenfenster, Einfügen im
+# Verfassenfenster) und das Symbol der Erweiterung selbst.
+ICONS = {
+ "paperless-logo": icon_logo,
+ "paperless-store": icon_version,
+ "paperless-insert": icon_open,
+ "paperless-share": icon_share,
+}
+
+
+# ----------------------------------------------------------- Ausgabe SVG
+
+def to_svg(shapes):
+ out = ['',
+ '")
+ return "\n".join(out) + "\n"
+
+
+# ----------------------------------------------------------- Ausgabe PNG
+
+def to_png(shapes, size):
+ from PIL import Image, ImageDraw
+
+ def rgb(c):
+ c = c.lstrip("#")
+ return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4)) + (255,)
+
+ s = SCALE
+ img = Image.new("RGBA", (int(BOX * s), int(BOX * s)), (0, 0, 0, 0))
+ d = ImageDraw.Draw(img)
+
+ for kind, data, fill, stroke, width in shapes:
+ f = rgb(fill) if fill else None
+ st = rgb(stroke) if stroke else None
+ w = max(1, int(width * s))
+ if kind == "poly":
+ pts = [(x * s, y * s) for x, y in data]
+ d.polygon(pts, fill=f, outline=st, width=w if st else 0)
+ elif kind == "circle":
+ cx, cy, r = data
+ box = [(cx - r) * s, (cy - r) * s, (cx + r) * s, (cy + r) * s]
+ if f:
+ d.ellipse(box, fill=f)
+ if st:
+ d.ellipse(box, outline=st, width=w)
+ elif kind == "line":
+ (x1, y1), (x2, y2) = data
+ d.line([(x1 * s, y1 * s), (x2 * s, y2 * s)], fill=st, width=w)
+ elif kind == "rrect":
+ x0, y0, x1, y1, r = data
+ d.rounded_rectangle([x0 * s, y0 * s, x1 * s, y1 * s],
+ radius=r * s, fill=f,
+ outline=st, width=w if st else 0)
+
+ return img.resize((size, size), Image.LANCZOS)
+
+
+def main():
+ os.makedirs(OUT, exist_ok=True)
+ for name, fn in ICONS.items():
+ shapes = fn()
+ with open(os.path.join(OUT, name + ".svg"), "w",
+ encoding="utf-8") as fh:
+ fh.write(to_svg(shapes))
+ for size in PNG_SIZES:
+ to_png(shapes, size).save(
+ os.path.join(OUT, "%s_%d.png" % (name, size)),
+ "PNG", optimize=True)
+ print("erzeugt: %s.svg und PNG in %s"
+ % (name, ", ".join("%d" % z for z in PNG_SIZES)))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..2b45633
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,55 @@
+{
+ "manifest_version": 3,
+ "name": "__MSG_extName__",
+ "description": "__MSG_extDescription__",
+ "version": "1.5.1",
+ "author": "Marco Morath",
+ "homepage_url": "https://gitea.cloud.mmorath.de/marco.morath/thunderbird-paperless-connector",
+ "default_locale": "de",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "webmaster@mmorath.de",
+ "strict_min_version": "128.0"
+ }
+ },
+ "permissions": [
+ "storage",
+ "menus",
+ "messagesRead",
+ "compose",
+ "notifications"
+ ],
+ "optional_host_permissions": [
+ "http://*/*",
+ "https://*/*"
+ ],
+ "background": {
+ "scripts": [
+ "background.js"
+ ],
+ "type": "module"
+ },
+ "options_ui": {
+ "page": "ui/options.html",
+ "open_in_tab": true
+ },
+ "message_display_action": {
+ "default_title": "__MSG_actionStore__",
+ "default_icon": {
+ "16": "icons/paperless-store_16.png",
+ "32": "icons/paperless-store_32.png"
+ }
+ },
+ "compose_action": {
+ "default_title": "__MSG_actionInsert__",
+ "default_icon": {
+ "16": "icons/paperless-insert_16.png",
+ "32": "icons/paperless-insert_32.png"
+ }
+ },
+ "icons": {
+ "16": "icons/paperless-logo_16.png",
+ "32": "icons/paperless-logo_32.png",
+ "64": "icons/paperless-logo_64.png"
+ }
+}
diff --git a/ui/classify.html b/ui/classify.html
new file mode 100644
index 0000000..789374b
--- /dev/null
+++ b/ui/classify.html
@@ -0,0 +1,44 @@
+
+
+
+
+
Klassifizierung bearbeiten
+
+
+
+