From 376cf4ae72f4eddbe8331a95141870e8e8f552a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Mon, 14 Sep 2026 21:51:58 +0200 Subject: [PATCH] Add Paperless-ngx as an attachment source and a save destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect it with an address and an API token and it appears beside the other services; nothing else in the UI had to change, because the picker, the destination chooser and the connect dialog are all driven by the provider taxonomy and the driver interfaces. TAGS ARE THE CONTAINER LAYER. A document archive has no directory tree — Paperless replaced folders with tags and expects the rest to be found by searching — so the picker's "descend into a container, pick a file" has nothing to descend into. The root lists every tag as a folder with the newest documents beneath it, and opening a tag lists that tag's documents. Correspondents and document types would serve equally well and are deliberately not offered: three sideways slices of one archive is a navigation problem, and search already crosses all three. Search is Paperless's own full-text index, so it reads the OCR layer as well as the metadata and a scanned invoice is findable by a line item nobody typed. That is the feature: there is no longer a reason to open Paperless, find a document and download it before writing the mail. Upload is asynchronous — post_document answers with a task id, not a document id — so upload() returns it prefixed. Ids are opaque outside the driver, which is what makes the prefix safe, and download() refuses a task id with a sentence a person can act on rather than letting it 404 later. ShareLink is not declared. Minting a Paperless share link is a permissioned feature with its own expiry semantics, not something to do silently. The taxonomy test that assumed only photo services lack share links had a false premise; it now states the real rule rather than being relaxed. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 30 + README.md | 2 +- docs/README.md | 2 +- docs/de/README.md | 4 +- docs/de/features/integrations.md | 63 +- docs/de/install/configuration.md | 4 +- docs/de/internals/security-model.md | 5 +- docs/features/integrations.md | 52 +- docs/install/configuration.md | 3 +- docs/internals/security-model.md | 6 +- src/Domain/Enum/Integration/Provider.php | 46 +- .../Integration/Driver/PaperlessDriver.php | 879 ++++++++++++++++++ .../tutorial/_paperless.html.twig | 23 + .../Domain/Enum/Integration/ProviderTest.php | 46 +- .../Integration/PaperlessDriverTest.php | 533 +++++++++++ tests/e2e/integrations.spec.ts | 5 +- translations/messages.de.yaml | 10 + translations/messages.en.yaml | 10 + translations/messages.en_PI.yaml | 10 + 19 files changed, 1682 insertions(+), 51 deletions(-) create mode 100644 src/Service/Integration/Driver/PaperlessDriver.php create mode 100644 templates/admin/integrations/tutorial/_paperless.html.twig create mode 100644 tests/Service/Integration/PaperlessDriverTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 02579e84..b8212937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,36 @@ The published image tags: `latest` follows the most recent release below, ## Unreleased +### Added + +- **Paperless-ngx, as somewhere to file an attachment and somewhere to attach from.** Connect it + under Settings → Connections with its address and an API token, and it appears beside Nextcloud + and the rest. + + Attaching is the half worth describing. Paperless replaced folders with tags and expects + everything else to be found by searching, so the picker uses tags as its containers — the root + lists them with the newest documents beneath, and opening one lists that tag's documents. The + search box is Paperless's own full-text index, which reads the OCR layer as well as the metadata, + so a scanned invoice is findable by a line item nobody ever typed. The point is that there is no + longer any reason to open Paperless, find a document and download it before writing the mail. + + Saving out files the attachment under a tag you pick, or none, which is Paperless's inbox. + Uploads there are asynchronous — Paperless answers with a task and consumes the file afterwards — + so what comes back is marked as a task rather than a document, and trying to attach it before it + has been filed says so instead of failing obscurely. + + No share links: minting one in Paperless is a permissioned feature with its own expiry, not + something to do silently on a user's behalf, so the picker offers "attach a copy" only. + +### Fixed + +- **The account menu is no longer painted over by the calendar.** Its `z-[200]` read as "above + everything" and was nothing of the sort — it ordered the menu inside the header's own stacking + context and said nothing about the header against the rest of the page, so it stayed on top only + while nothing outside the header made a context of its own. The calendar keeps making them, + especially once a background image turns the panes' glass on. It goes to the browser's top layer + now, which is above every stacking context there is, so the question cannot be asked again. + ## v0.2.25 — 2026-09-14 ### Added diff --git a/README.md b/README.md index 4bf1797a..f2834aa5 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ provider doesn't. - **A calendar beside the mail** — two-way sync with Google, Microsoft and CalDAV, drag-to-move week grid, invitations answered in place, share and booking links. - **Files where you keep them** — attach from and save to Drive, Photos, OneDrive, Dropbox, - Nextcloud and Immich. + Nextcloud, Immich and Paperless-ngx. - **A language model, if you want one** — point plMail at an Ollama box on your own network and it can search by meaning, draft replies, summarise a thread and sort mail into tabs. A long thread is summarised from as much of it as fits, and says so; ask for the whole conversation and it runs as a diff --git a/docs/README.md b/docs/README.md index 0a1b1067..766f44a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,7 +34,7 @@ mechanism behind it. | [Reminders](features/calendar-alerts.md) | Setting alerts, how they are delivered, what a fresh install needs before they arrive | | [Connected calendars](features/calendar-sync.md) | Subscribing to Google, Microsoft and CalDAV calendars, two-way sync, ICS import, export and feed subscriptions, duplicate meetings | | [Sharing and booking](features/calendar-sharing.md) | Share links and what each reveals, appointment pages, how a booking arrives | -| [Files and integrations](features/integrations.md) | Attaching from and saving to Drive, Photos, OneDrive, Dropbox, Nextcloud and Immich | +| [Files and integrations](features/integrations.md) | Attaching from and saving to Drive, Photos, OneDrive, Dropbox, Nextcloud, Immich and Paperless-ngx | | [Security](features/security.md) | Changing your password, two-factor authentication, recovery codes, remembered devices, app passwords, sessions | | [Other clients](features/clients.md) | Connecting a JMAP client, per-app passwords, the PWA and browser notifications | | [Appearance](features/appearance.md) | Themes, custom colours and background, the live preview, what a list row shows, typeface and text size, per-pane density, import and export, language | diff --git a/docs/de/README.md b/docs/de/README.md index c3ff5e33..5c422631 100644 --- a/docs/de/README.md +++ b/docs/de/README.md @@ -1,4 +1,4 @@ - + # plMail-Dokumentation @@ -36,7 +36,7 @@ wo der Mechanismus dahinter steht. | [Erinnerungen](features/calendar-alerts.md) | Erinnerungen setzen, wie sie zugestellt werden, was eine frische Installation braucht, damit sie ankommen | | [Verbundene Kalender](features/calendar-sync.md) | Google-, Microsoft- und CalDAV-Kalender abonnieren, Abgleich in beide Richtungen, ICS-Import, -Export und Feed-Abonnements, doppelte Termine | | [Teilen und Buchen](features/calendar-sharing.md) | Freigabelinks und was jeder preisgibt, Buchungsseiten, wie eine Buchung ankommt | -| [Dateien und Integrationen](features/integrations.md) | Anhängen aus und Speichern in Drive, Photos, OneDrive, Dropbox, Nextcloud und Immich | +| [Dateien und Integrationen](features/integrations.md) | Anhängen aus und Speichern in Drive, Photos, OneDrive, Dropbox, Nextcloud, Immich und Paperless-ngx | | [Sicherheit](features/security.md) | Dein Passwort ändern, Zwei-Faktor-Authentifizierung, Wiederherstellungscodes, gemerkte Geräte, App-Passwörter, Sitzungen | | [Andere Clients](features/clients.md) | Einen JMAP-Client verbinden, Passwörter pro App, die PWA und Browser-Benachrichtigungen | | [Darstellung](features/appearance.md) | Themes, eigene Farben und Hintergrund, die Live-Vorschau, was eine Listenzeile zeigt, Schriftart und Textgröße, Dichte je Bereich, Import und Export, Sprache | diff --git a/docs/de/features/integrations.md b/docs/de/features/integrations.md index 7de180f0..e8da9bbe 100644 --- a/docs/de/features/integrations.md +++ b/docs/de/features/integrations.md @@ -1,11 +1,11 @@ - + # Dateien und Integrationen plMail kann eine Datei aus einem Dienst holen, den du ohnehin benutzt, und sie an eine Nachricht hängen — und einen Anhang in die andere Richtung in diesen Dienst schieben. -Unterstützt werden sechs Dienste: **Nextcloud**, **Immich**, **Google Drive**, **Google -Photos**, **OneDrive** und **Dropbox**. +Unterstützt werden sieben Dienste: **Nextcloud**, **Immich**, **Paperless-ngx**, **Google +Drive**, **Google Photos**, **OneDrive** und **Dropbox**. Verbindungen gehören einer Person und lassen sich einzeln widerrufen. Welche Dienste überhaupt angeboten werden, entscheidet die Administration — siehe [Administration](admin.md). @@ -24,6 +24,7 @@ Wie du dich verbindest, hängt vom Dienst ab: |---|---|---| | Nextcloud | App-Passwort | Serveradresse, Benutzername, App-Passwort | | Immich | API-Schlüssel | Serveradresse, API-Schlüssel | +| Paperless-ngx | API-Token | Serveradresse, API-Token | | Google Drive | Anmeldung | Nichts — die Zustimmungsseite | | Google Photos | Anmeldung | Nichts — die Zustimmungsseite | | OneDrive | Anmeldung | Nichts — die Zustimmungsseite | @@ -38,11 +39,12 @@ Hat deine Administration für einen selbst gehosteten Dienst eine Serveradresse fehlt das Adressfeld ganz, statt deaktiviert zu sein, und ein trotzdem abgeschickter Wert wird ignoriert. -Lege bei Nextcloud und Immich Zugangsdaten auf dem Dienst an, statt dein Anmeldepasswort zu -verwenden. Nextcloud führt App-Passwörter unter **Einstellungen → Sicherheit → Geräte & -Sitzungen**; Immich führt API-Schlüssel unter **Account Settings → API Keys**. Ein App-Passwort -lässt sich einzeln widerrufen und funktioniert neben der Zwei-Faktor-Authentifizierung; ein -Anmeldepasswort kann beides nicht. +Lege bei den selbst gehosteten Diensten Zugangsdaten auf dem Dienst an, statt dein +Anmeldepasswort zu verwenden. Nextcloud führt App-Passwörter unter **Einstellungen → Sicherheit +→ Geräte & Sitzungen**; Immich führt API-Schlüssel unter **Account Settings → API Keys**; +Paperless-ngx zeigt ein API-Token unter **My Profile → API Auth Token**. Ein App-Passwort oder +Token lässt sich einzeln widerrufen und funktioniert neben der Zwei-Faktor-Authentifizierung; +ein Anmeldepasswort kann beides nicht. Beim Speichern wird die Verbindung immer sofort geprüft. Eine Verbindung, die sauber abgelegt wird und keinen Ordner auflisten kann, ist schlimmer als ein sichtbarer Fehler, denn du fändest @@ -77,6 +79,7 @@ also an genau einer Stelle. | Google Drive | ja | ja | ja | ja | ja | ja | — | | OneDrive | ja | ja | ja | ja | ja | ja | — | | Dropbox | ja | ja | ja | ja | ja | ja | — | +| Paperless-ngx | ja | ja | ja | — | ja | ja | — | | Immich | ja | ja | ja | — | ja | ja | ja | | Google Photos | ja | ja | ja | — | ja | — | — | @@ -88,6 +91,27 @@ einzige, der seine Bibliothek als Daten zusammenfassen kann, und genau das ist s Google Photos hat keine Textsuche, weil seine Library-API keine anbietet — nur Album- und Datumsfilter. +Paperless-ngx hat aus einem anderen Grund keinen Freigabelink: Es kann ein Dokument teilen, aber +nur über eine eigene, rechtegesteuerte Funktion mit eigenem Ablaufdatum, und ein abgelegtes +Dokument zu veröffentlichen ist ein viel größerer Schritt, als es an eine Nachricht zu hängen. + +## Paperless-ngx, das keine Ordner hat + +Paperless hat Ordner bewusst durch Schlagwörter ersetzt, also benutzt plMail seine +**Schlagwörter** als Ordnerebene. Die Dateiauswahl öffnet mit allen Schlagwörtern als Ordnern und +den neuesten Dokumenten darunter; ein Schlagwort zu öffnen listet dessen Dokumente. **Speichern +in** legt einen Anhang unter einem Schlagwort ab, und ein leeres Ziel legt ihn unter keinem ab — +im Posteingang von Paperless selbst, wo ein unsortiertes Dokument hingehört. + +Die Suche läuft über den Volltextindex von Paperless, der auch die OCR-Ebene liest, nicht nur +Titel und Metadaten. Eine gescannte Quittung ist damit über eine Zeile auffindbar, die nie jemand +getippt hat, und genau darum geht es: Es gibt keinen Grund, erst Paperless zu öffnen, ein +Dokument zu suchen und herunterzuladen, bevor du es anhängst. Eine Suche aus einem Schlagwort +heraus bleibt in diesem Schlagwort. + +Angehängt wird die **archivierte** Fassung — das durchsuchbare PDF, das Paperless beim Ablegen +erzeugt hat — und ersatzweise das Original, wo keine erzeugt werden konnte. + ## Aus einem Dienst anhängen Im Schreibfenster öffnet **Aus einem Dienst anhängen** die Dateiauswahl in einem Dialog. Sie @@ -142,19 +166,28 @@ Erlaubnisliste steht.** Selbst gehostete Dienste liegen auf Adressen, auf die ei Person plMails ausgehenden HTTP-Client sonst richten könnte — darunter `localhost:5432` und der Cloud-Metadaten-Endpunkt unter `169.254.169.254`. Loopback, Link-Local, RFC1918 und die Bereiche für Carrier-Grade NAT sind alle gesperrt, solange der Host nicht in -`INTEGRATIONS_ALLOWED_HOSTS` steht. In einem Heimnetz ist das die Einstellung, die Nextcloud und -Immich überhaupt erreichbar macht. +`INTEGRATIONS_ALLOWED_HOSTS` steht. In einem Heimnetz ist das die Einstellung, die Nextcloud, +Immich und Paperless-ngx überhaupt erreichbar macht. **`http://` wird abgelehnt, solange `INTEGRATIONS_ALLOW_HTTP` nicht an ist.** Selbst hosten im LAN ohne TLS ist eine ganz gewöhnliche Lage, diese Option wird also oft gesetzt sein — der Punkt ist, dass Zugangsdaten im Klartext zu verschicken eine bewusste Entscheidung wird und keine stille Voreinstellung. -**Ein Foto über der Anhangsgrenze lässt sich aus Immich oder Google Photos überhaupt nicht -anhängen.** Keiner von beiden kann für ein einzelnes Objekt eine öffentliche URL erzeugen, ohne -ein geteiltes Album anzulegen, und das ist eine schwerere Nebenwirkung, als das Anhängen einer -Datei haben sollte. Es gibt deshalb keinen Rückfall auf einen Link: über 25 MB lautet die -Antwort nein. +**Eine Datei über der Anhangsgrenze lässt sich aus Immich, Google Photos oder Paperless-ngx +überhaupt nicht anhängen.** Die Fotobibliotheken können für ein einzelnes Objekt keine +öffentliche URL erzeugen, ohne ein geteiltes Album anzulegen; Paperless kann ein Dokument teilen, +aber nur über eine rechtegesteuerte Funktion mit eigenem Ablaufdatum, und ein abgelegtes Dokument +zu veröffentlichen ist eine schwerere Nebenwirkung, als das Anhängen einer Datei haben sollte. Es +gibt deshalb bei keinem der drei einen Rückfall auf einen Link: über 25 MB lautet die Antwort +nein. + +**Das Speichern nach Paperless-ngx meldet die Übergabe, nicht das abgelegte Dokument.** Der +Upload-Endpunkt arbeitet asynchron: Er übergibt die Datei an den Paperless-Consumer und antwortet +mit einer Task-ID, und das Dokument erscheint Sekunden später, sobald der Consumer es eingelesen +hat — oder gar nicht, wenn Paperless es als Dublette ablehnt. plMail meldet Erfolg, sobald +Paperless die Datei angenommen hat, und das ist der letzte Moment, von dem plMail etwas weiß. +Schau in Paperless nach, wenn ein Dokument nicht auftaucht. **Eine frisch registrierte Google-Photos-Anwendung kann eine bestehende Bibliothek meist nicht durchsehen.** Google hat die Photos-Lesescopes im März 2025 eingeschränkt; eine neue App bekommt diff --git a/docs/de/install/configuration.md b/docs/de/install/configuration.md index 679a8a68..bf191fc4 100644 --- a/docs/de/install/configuration.md +++ b/docs/de/install/configuration.md @@ -1,4 +1,4 @@ - + # Konfigurationsreferenz Jede Umgebungsvariable, die plMail liest, was sie bewirkt, welchen Vorgabewert sie hat und was @@ -163,7 +163,7 @@ eine Hälfte nicht lesen, was die andere schreibt. Zwei Variablen lockern eine Prüfung, und es lohnt sich, genau zu sagen, wofür diese Prüfung da ist. -Selbst gehostete Integrationen — Nextcloud, Immich — lassen eine angemeldete Person ihre eigene +Selbst gehostete Integrationen — Nextcloud, Immich, Paperless-ngx — lassen eine angemeldete Person ihre eigene Serveradresse eintippen. Das richtet plMails ausgehenden HTTP-Client dorthin, wohin diese Person will, und zwar aus einem Containernetz heraus, in dem auch Postgres und der Mercure-Hub liegen. `App\Service\Integration\IntegrationUrlValidator` weist jede Adresse ab, die nach `127.0.0.0/8`, diff --git a/docs/de/internals/security-model.md b/docs/de/internals/security-model.md index a8529482..ed5878f0 100644 --- a/docs/de/internals/security-model.md +++ b/docs/de/internals/security-model.md @@ -1,4 +1,4 @@ - + # Sicherheitsmodell Verschlüsselung ruhender Daten und die Prüfung, die den Start ohne brauchbaren Schlüssel @@ -342,7 +342,8 @@ den Workern. festgenagelt ist, sodass auch eine alte Zeile von vor dem Festnageln nicht weiter anderswohin greifen kann. 2. **`http://` wird abgelehnt, sofern `INTEGRATIONS_ALLOW_HTTP` nicht an ist.** Selbsthosting im - LAN ist bei Nextcloud und Immich der Normalfall, dieser Schalter wird also oft gesetzt sein — + LAN ist bei Nextcloud, Immich und Paperless-ngx der Normalfall, dieser Schalter wird also oft + gesetzt sein — der Punkt ist, dass Klartext-Anmeldedaten auf der Leitung damit zu einer bewussten Admin-Entscheidung werden statt zu einer stillen Voreinstellung. 3. **Loopback-, Link-Local- und private Bereiche werden abgelehnt**, sofern der Host nicht in diff --git a/docs/features/integrations.md b/docs/features/integrations.md index 26ed736d..44801b84 100644 --- a/docs/features/integrations.md +++ b/docs/features/integrations.md @@ -1,8 +1,8 @@ # Files and integrations plMail can pull a file out of a service you already use and attach it to a message, and push an -attachment the other way into that service. Six services are supported: **Nextcloud**, **Immich**, -**Google Drive**, **Google Photos**, **OneDrive** and **Dropbox**. +attachment the other way into that service. Seven services are supported: **Nextcloud**, **Immich**, +**Paperless-ngx**, **Google Drive**, **Google Photos**, **OneDrive** and **Dropbox**. Connections belong to one user and are revocable one at a time. Which services are offered at all is an administrator's decision — see [Administration](admin.md). @@ -20,6 +20,7 @@ How you connect depends on the service: |---|---|---| | Nextcloud | App password | Server address, username, app password | | Immich | API key | Server address, API key | +| Paperless-ngx | API token | Server address, API token | | Google Drive | Sign-in | Nothing — the consent screen | | Google Photos | Sign-in | Nothing — the consent screen | | OneDrive | Sign-in | Nothing — the consent screen | @@ -32,10 +33,11 @@ home and work, and the button stays available after the first connection for exa If your administrator has pinned a server address for a self-hosted service, the address field is absent rather than disabled, and a submitted value is ignored regardless. -For Nextcloud and Immich, create a credential on the service rather than using your login password. -Nextcloud puts app passwords under **Settings → Security → Devices & sessions**; Immich puts API -keys under **Account Settings → API Keys**. An app password is individually revocable and works -alongside two-factor authentication; a login password is neither. +For the self-hosted services, create a credential on the service rather than using your login +password. Nextcloud puts app passwords under **Settings → Security → Devices & sessions**; Immich +puts API keys under **Account Settings → API Keys**; Paperless-ngx shows an API token under **My +Profile → API Auth Token**. An app password or token is individually revocable and works alongside +two-factor authentication; a login password is neither. Saving always probes the connection there and then. A connection that stores cleanly but cannot list a folder is worse than a visible failure, because you would only find out halfway through @@ -64,6 +66,7 @@ service name — so a service that gains or loses an ability changes in exactly | Google Drive | yes | yes | yes | yes | yes | yes | — | | OneDrive | yes | yes | yes | yes | yes | yes | — | | Dropbox | yes | yes | yes | yes | yes | yes | — | +| Paperless-ngx | yes | yes | yes | — | yes | yes | — | | Immich | yes | yes | yes | — | yes | yes | yes | | Google Photos | yes | yes | yes | — | yes | — | — | @@ -74,6 +77,26 @@ summarise its library as dates, which is what its scrubber is. Google Photos has no text search because its Library API offers none — only album and date filters. +Paperless-ngx has no share link for a different reason: it can share a document, but only through a +separate permissioned feature with its own expiry, and publishing a filed document is a much bigger +act than attaching one to a message. + +## Paperless-ngx, which has no folders + +Paperless replaced folders with tags on purpose, so plMail browses its **tags** as the folder layer. +The picker opens on every tag, as folders, with the newest documents listed under them; opening a tag +lists that tag's documents. **Save to** files an attachment under a tag, and leaving the destination +empty files it under none — Paperless's own inbox, which is where an unsorted document is meant to +land. + +Search goes through Paperless's full-text index, which reads the OCR layer as well as the title and +metadata. A scanned receipt is findable by a line item nobody ever typed, which is the point: there +is no reason to open Paperless, find a document and download it before attaching it. Searching from +inside a tag stays inside that tag. + +Attaching a document fetches the **archived** copy — the searchable PDF Paperless made when it filed +the document — falling back to the original where it could not make one. + ## Attaching from a service In the compose window, **Attach from a service** opens the picker in a modal. It offers every @@ -123,16 +146,23 @@ A filter can do the same thing automatically with the **Save attachments to** ac addresses an authenticated user could otherwise aim plMail's outbound HTTP client at — including `localhost:5432` and the cloud metadata endpoint at `169.254.169.254`. Loopback, link-local, RFC1918 and carrier-grade NAT ranges are all blocked unless the host appears in `INTEGRATIONS_ALLOWED_HOSTS`. -On a home LAN this is the setting that makes Nextcloud and Immich reachable at all. +On a home LAN this is the setting that makes Nextcloud, Immich and Paperless-ngx reachable at all. **`http://` is refused unless `INTEGRATIONS_ALLOW_HTTP` is on.** Self-hosting on a LAN without TLS is an ordinary situation, so this flag will often be set — the point is that sending credentials in plaintext becomes a deliberate decision rather than a silent default. -**A photo above the attachment ceiling cannot be attached from Immich or Google Photos at all.** -Neither can mint a public URL for a single item without creating a shared album, which is a heavier -side effect than attaching a file should have. So there is no link fallback: over 25 MB, the answer -is no. +**A file above the attachment ceiling cannot be attached from Immich, Google Photos or Paperless-ngx +at all.** The photo libraries cannot mint a public URL for a single item without creating a shared +album; Paperless can share a document, but only through a permissioned feature with its own expiry, +and publishing a filed document is a heavier side effect than attaching a file should have. So there +is no link fallback on any of the three: over 25 MB, the answer is no. + +**Saving to Paperless-ngx reports the handover, not the filed document.** The upload endpoint is +asynchronous: it hands the file to the Paperless consumer and answers with a task id, and the +document appears seconds later once the consumer has ingested it — or never, if Paperless rejects it +as a duplicate. plMail says the save succeeded when Paperless accepted the file, which is the last +moment it knows anything. Check Paperless itself if a document does not turn up. **A newly registered Google Photos application usually cannot browse an existing library.** Google restricted the Photos read scopes in March 2025; a new app is generally granted only diff --git a/docs/install/configuration.md b/docs/install/configuration.md index 81d8ccff..b9a3e9f7 100644 --- a/docs/install/configuration.md +++ b/docs/install/configuration.md @@ -156,7 +156,8 @@ writes. Two variables relax one check, and it is worth being exact about what the check is for. -Self-hosted integrations — Nextcloud, Immich — let a signed-in user type their own server address. +Self-hosted integrations — Nextcloud, Immich, Paperless-ngx — let a signed-in user type their own +server address. That aims plMail's outbound HTTP client wherever the user likes, from inside a container network that also holds Postgres and the Mercure hub. `App\Service\Integration\IntegrationUrlValidator` refuses any address resolving into `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, diff --git a/docs/internals/security-model.md b/docs/internals/security-model.md index 9ec6df39..cff1eb11 100644 --- a/docs/internals/security-model.md +++ b/docs/internals/security-model.md @@ -311,9 +311,9 @@ runs in a container network alongside Postgres, Mercure and the workers. `resolve()` ignores the user's value when one is pinned, so a stale row from before the pin cannot keep reaching elsewhere. 2. **`http://` is refused unless `INTEGRATIONS_ALLOW_HTTP` is on.** Self-hosting on a LAN is - the normal case for Nextcloud and Immich, so this flag will often be set — the point is that - plaintext credentials over the wire become a deliberate admin decision rather than a silent - default. + the normal case for Nextcloud, Immich and Paperless-ngx, so this flag will often be set — the + point is that plaintext credentials over the wire become a deliberate admin decision rather + than a silent default. 3. **Loopback, link-local and private ranges are refused** unless the host appears in `INTEGRATIONS_ALLOWED_HOSTS`. `BLOCKED_RANGES` covers `127.0.0.0/8`, the three RFC1918 ranges, `169.254.0.0/16` (link-local, including the cloud metadata endpoint at diff --git a/src/Domain/Enum/Integration/Provider.php b/src/Domain/Enum/Integration/Provider.php index 7acf595b..c9a58d22 100644 --- a/src/Domain/Enum/Integration/Provider.php +++ b/src/Domain/Enum/Integration/Provider.php @@ -27,6 +27,20 @@ enum Provider: string { case Nextcloud = 'nextcloud'; case Immich = 'immich'; + + /** + * Paperless-ngx, the self-hosted document archive. + * + * The one file service with no folders at all — it replaced them with tags + * on purpose — so PaperlessDriver browses its tags as the container layer. + * That is a decision about this service and lives in the driver; nothing + * here needs to know, which is the point of ids being opaque. + * + * Named for the product rather than for "documents": Paperless-ngx is the + * fork everybody actually runs, and a case called Paperless that turned out + * to mean the abandoned original would be a support ticket a month. + */ + case Paperless = 'paperless'; case GoogleDrive = 'googleDrive'; case GooglePhotos = 'googlePhotos'; case OneDrive = 'oneDrive'; @@ -71,6 +85,7 @@ enum Provider: string return match ($this) { self::Nextcloud => 'Nextcloud', self::Immich => 'Immich', + self::Paperless => 'Paperless-ngx', self::GoogleDrive => 'Google Drive', self::GooglePhotos => 'Google Photos', self::OneDrive => 'OneDrive', @@ -86,6 +101,12 @@ enum Provider: string return match ($this) { self::Nextcloud => 'fa-solid fa-cloud', self::Immich => 'fa-solid fa-images', + // An archive box rather than a document glyph: the settings list + // puts every service in one column, and a single sheet of paper + // beside a cloud and a photo stack says "a file", which is what all + // three hold. What distinguishes this one is that it is the filed, + // sorted, searchable pile. + self::Paperless => 'fa-solid fa-box-archive', self::GoogleDrive => 'fa-brands fa-google-drive', self::GooglePhotos => 'fa-solid fa-photo-film', self::OneDrive => 'fa-brands fa-microsoft', @@ -114,6 +135,7 @@ enum Provider: string return match ($this) { self::Nextcloud, self::Immich, + self::Paperless, self::GoogleDrive, self::GooglePhotos, self::OneDrive, @@ -134,6 +156,10 @@ enum Provider: string return match ($this) { self::Nextcloud, self::Immich, + // Paperless mints per-user API tokens under the user's own profile, + // the same shape as an Immich key — and it is self-hosted, which is + // the half of this answer needsBaseUrl() reads. + self::Paperless, // Every CalDAV server worth connecting to takes an app-specific // password (iCloud and Fastmail require one; Nextcloud and Baïkal // offer one), and the protocol's own answer is HTTP Basic. OAuth @@ -165,9 +191,12 @@ enum Provider: string * Declared for every provider, including the unimplemented ones, so the * admin tutorial can state honestly what connecting will buy. * - * Immich and Google Photos are absent from ShareLink: neither exposes a - * per-asset public URL without creating a shared album, which is a heavier - * side effect than attaching a file should have. + * Immich, Google Photos and Paperless are absent from ShareLink, for two + * different reasons that happen to end in the same place. The photo + * libraries cannot expose a per-asset public URL without creating a shared + * album; Paperless can share a document, but only through a permissioned + * feature with its own expiry and its own audit trail. Either way, minting + * one is a heavier side effect than attaching a file should have. * * Exhaustive on purpose. The `default` arm this used to carry handed the * full file capability set to any case that had not been thought about — @@ -189,6 +218,16 @@ enum Provider: string Capability::Search, Capability::Timeline, ], + // Everything a file store does except a share link. Paperless's own + // sharing is a separate permissioned feature, and "attach this" must + // not quietly publish a document — so the picker offers a copy only. + self::Paperless => [ + Capability::Browse, + Capability::Download, + Capability::Upload, + Capability::Thumbnail, + Capability::Search, + ], // Google Photos is the one without search: its Library API offers // no text search over media, only album and date filters. self::GooglePhotos => [ @@ -255,6 +294,7 @@ enum Provider: string // order an admin meets them in. self::Nextcloud, self::Immich, + self::Paperless, self::CalDav, self::Ics, self::GoogleDrive, diff --git a/src/Service/Integration/Driver/PaperlessDriver.php b/src/Service/Integration/Driver/PaperlessDriver.php new file mode 100644 index 00000000..1387dea5 --- /dev/null +++ b/src/Service/Integration/Driver/PaperlessDriver.php @@ -0,0 +1,879 @@ +get($integration, '/documents/', ['page_size' => 1]); + } + + public function list(Integration $integration, ?string $folderId = null, ?string $cursor = null): Listing + { + $tagId = $this->tagFrom($folderId); + + if (null === $tagId) { + // The root is the tag list followed by the newest documents, so the + // archive is browsable and its containers visible in one view. The + // tags ride on the first page only: they are the same set on every + // page, and repeating them mid-scroll would read as duplicates + // because the picker appends pages rather than replacing them. + return $this->documents( + $integration, + null, + null, + $cursor, + [$this->root()], + 1 === $this->page($cursor), + ); + } + + return $this->documents($integration, $tagId, null, $cursor, [ + $this->root(), + Entry::folder($tagId, $this->tagName($integration, $tagId)), + ], false); + } + + /** + * Paperless's full-text search. + * + * `query` runs against the Whoosh index, which holds the OCR text as well as + * the title, correspondent and tags — so a scanned receipt is findable by + * something printed on it. Relevance order comes from the index, so no + * explicit ordering is sent: asking for newest-first would throw away the + * ranking that makes the search worth having. + * + * A query launched from inside a tag stays inside it. That is the same + * decision SearchableDriverInterface describes for Immich's people view — + * the box means "search here" when the user is somewhere, and "search + * everything" when they are not. + */ + public function search( + Integration $integration, + string $query, + ?string $folderId = null, + ?string $cursor = null, + ): Listing { + $query = trim($query); + + // Clearing the box is how a user leaves a search, so an empty one goes + // back to where they were rather than searching for nothing. + if ('' === $query) { + return $this->list($integration, $folderId); + } + + $tagId = $this->tagFrom($folderId); + $breadcrumb = [$this->root()]; + + if (null !== $tagId) { + $breadcrumb[] = Entry::folder($tagId, $this->tagName($integration, $tagId)); + } + + // The last crumb names the search rather than a place, since hits cross + // everything the scope still allows. + $breadcrumb[] = Entry::folder('', sprintf('“%s”', $query)); + + return $this->documents($integration, $tagId, $query, $cursor, $breadcrumb, false); + } + + public function download(Integration $integration, string $fileId): RemoteFile + { + $id = $this->documentFrom($fileId); + + // No ?original=true: /download/ answers with the archived PDF where + // Paperless made one and falls back to the original by itself where it + // could not. That is the copy the user saw when they filed the document + // — searchable, with the OCR layer baked in — and asking for the + // original would attach the unreadable scan instead. + $response = $this->request($integration, 'GET', $this->url($integration, '/documents/'.$id.'/download/')); + + try { + $contents = $response->getContent(); + $headers = $response->getHeaders(); + } catch (HttpExceptionInterface $e) { + throw $this->translate($e); + } + + return new RemoteFile( + // Paperless sends the filename in Content-Disposition; the metadata + // lookup behind it costs a round trip and only runs when a server or + // a proxy has stripped the header. An attachment called "42" is a + // bug report, so the fallback is worth the call it almost never + // makes. + filename: $this->filenameFromHeaders($headers) ?? $this->documentName($integration, $id), + mime: trim(explode(';', $headers['content-type'][0] ?? 'application/octet-stream')[0]), + contents: $contents, + ); + } + + /** + * Hand a file to Paperless's consumer, and return the *task* id it answers + * with — prefixed, because it is not a document id. See TASK_PREFIX. + */ + public function upload( + Integration $integration, + string $absolutePath, + string $filename, + string $mime, + ?string $folderId = null, + ): string { + if (false === is_readable($absolutePath)) { + throw new IntegrationException(sprintf('Cannot read %s to upload.', $filename)); + } + + // A list of single-entry arrays rather than one map, because that is the + // only shape FormDataPart turns into repeated fields of the same name: + // a nested array under a string key would be serialised as "tags[0]", + // which Paperless reads as a field it does not have rather than as a tag. + $fields = [ + ['document' => new DataPart(new File($absolutePath), $filename, $mime)], + ['title' => $this->titleFor($filename)], + ]; + + $tagId = $this->tagFrom($folderId); + + if (null !== $tagId) { + $fields[] = ['tags' => $tagId]; + } + + $form = new FormDataPart($fields); + + $response = $this->request($integration, 'POST', $this->url($integration, '/documents/post_document/'), [ + 'headers' => $form->getPreparedHeaders()->toArray(), + 'body' => $form->bodyToIterable(), + ]); + + return self::TASK_PREFIX.$this->taskId($response); + } + + // ── Destinations ──────────────────────────────────────────────────────────── + + /** + * An archive with no folders files a save under a tag, so the containers a + * save may target are the tag list — flat, and without the documents list() + * mixes in beside them. $folderId and $cursor are ignored for the same + * reason Immich ignores them: there is nothing to descend into and one page + * holds the lot. + */ + public function destinations(Integration $integration, ?string $folderId = null, ?string $cursor = null): Listing + { + return new Listing($this->tags($integration), [$this->root()]); + } + + /** + * A tag id arrives in a request, so it is confirmed against this account's + * own tags before a byte is uploaded. + * + * Two things are being refused, and they are different. Anything that is not + * a plain integer never reaches a URL at all — the id is interpolated into + * one, so a traversal attempt is stopped by shape before it is stopped by + * lookup. And an integer that Paperless does not answer for is refused as + * well: the token is this user's, so a foreign tag could never be written to + * in any case, but "save under this tag" that silently files under none is a + * lie to the user, and worth one round trip to avoid telling. + * + * The empty string is "no tag" — Paperless's own inbox, which is where an + * unfiled document is supposed to land — and is always valid. + */ + public function assertDestination(Integration $integration, string $destination): void + { + if ('' === $destination) { + return; + } + + $id = $this->tagFrom($destination); + + if (null === $id) { + throw new IntegrationException('That tag is not in this Paperless account.'); + } + + try { + $this->get($integration, '/tags/'.$id.'/'); + } catch (IntegrationException $e) { + // Paperless answers 404 both for a tag that never existed and for + // one this token may not see, which is the same answer for our + // purposes. Anything else — a dead server, a revoked token — is a + // different problem and keeps its own message. + if (404 === $e->getStatus()) { + throw new IntegrationException('That tag is not in this Paperless account.', $e->getStatus(), $e); + } + + throw $e; + } + } + + /** Create a tag and return its id. A flat list, so $parent is ignored. */ + public function createDestination(Integration $integration, ?string $parent, string $name): string + { + $name = trim($name); + + if ('' === $name) { + throw new IntegrationException('A tag needs a name.'); + } + + $payload = $this->json($integration, 'POST', $this->url($integration, '/tags/'), [ + 'json' => ['name' => $name], + ]); + + $id = $this->intOrNull($payload['id'] ?? null); + + if (null === $id) { + throw new IntegrationException('Paperless created the tag but did not return its id.'); + } + + return (string) $id; + } + + /** + * Always null — see the class docblock. Provider::Paperless does not declare + * ShareLink, so nothing should be calling this. + */ + public function shareLink(Integration $integration, string $fileId): ?string + { + return null; + } + + public function thumbnail(Integration $integration, string $fileId): ?RemoteFile + { + $id = $this->intOrNull($fileId); + + // A task id, or anything else that is not a document, has no preview — + // and "no preview" is a null here rather than an exception, which is + // what the interface asks for and what the picker renders a placeholder + // from. + if (null === $id) { + return null; + } + + $response = $this->request($integration, 'GET', $this->url($integration, '/documents/'.$id.'/thumb/'), [], false); + + try { + if (200 !== $response->getStatusCode()) { + return null; + } + + $contents = $response->getContent(false); + $mime = $response->getHeaders(false)['content-type'][0] ?? 'image/png'; + } catch (HttpExceptionInterface) { + return null; + } + + if ('' === $contents) { + return null; + } + + return new RemoteFile((string) $id, trim(explode(';', $mime)[0]), $contents); + } + + // ── Listings ────────────────────────────────────────────────────────────── + + /** + * One page of documents, optionally scoped to a tag and optionally filtered + * by a full-text query, with the tag list in front of it at the root. + * + * The one place documents are turned into entries, so browsing a tag, + * reading the root and searching all agree about what a row looks like. + * + * @param list $breadcrumb + * @param bool $withTags put this account's tags in front of the + * documents, as containers to descend into + */ + private function documents( + Integration $integration, + ?string $tagId, + ?string $query, + ?string $cursor, + array $breadcrumb, + bool $withTags, + ): Listing { + $page = $this->page($cursor); + + $parameters = [ + 'page' => $page, + 'page_size' => self::PAGE_SIZE, + ]; + + if (null !== $tagId) { + $parameters['tags__id__all'] = $tagId; + } + + if (null === $query) { + $parameters['ordering'] = self::ORDERING; + } else { + // No ordering alongside a query: the index ranks the hits, and + // re-sorting them by date would discard the ranking. + $parameters['query'] = $query; + } + + // Tags first, because they are what the listing puts first; the reading + // order of the two calls and the reading order of the rows agree. + $entries = true === $withTags ? $this->tags($integration) : []; + + $payload = $this->get($integration, '/documents/', $parameters); + $results = $payload['results'] ?? []; + + if (true === is_array($results)) { + foreach ($results as $document) { + if (false === is_array($document)) { + continue; + } + + $entry = $this->document($document); + + if (null !== $entry) { + $entries[] = $entry; + } + } + } + + // `next` is a full URL Paperless built from its own notion of where it + // lives, which is not necessarily where we are allowed to talk to it — + // behind a reverse proxy it is routinely the internal address. So the + // cursor is the page number and the URL is rebuilt through + // IntegrationUrlValidator like every other request; `next` is read only + // for whether there IS another page. + $next = $payload['next'] ?? null; + + return new Listing( + $entries, + $breadcrumb, + true === is_string($next) && '' !== $next ? (string) ($page + 1) : null, + ); + } + + /** + * Every tag, as folders to descend into. + * + * Sorted here rather than by asking Paperless for an ordering: the sort is + * the same natural, case-insensitive one the other file drivers use for + * their folders, so a tag list and a folder list read alike, and it holds + * whatever a given Paperless version decides to do with an `ordering` + * parameter it does not recognise. + * + * @return list + */ + private function tags(Integration $integration): array + { + $payload = $this->get($integration, '/tags/', ['page_size' => self::TAG_LIMIT]); + $results = $payload['results'] ?? []; + $tags = []; + + if (true === is_array($results)) { + foreach ($results as $tag) { + if (false === is_array($tag)) { + continue; + } + + $id = $this->intOrNull($tag['id'] ?? null); + + if (null === $id) { + continue; + } + + $tags[] = Entry::folder( + (string) $id, + $this->stringOrNull($tag['name'] ?? null) ?? sprintf('Tag %d', $id), + ); + } + } + + usort($tags, static fn (Entry $a, Entry $b): int => strnatcasecmp($a->name, $b->name)); + + return $tags; + } + + /** + * One document as a picker entry, or null if it has no usable id. + * + * The name is the **title**, because that is what Paperless shows and what a + * person recognises — the stored filename is generated from the title and a + * checksum on most installs, so showing it would be showing the same thing + * worse. The real filename still reaches the draft: download() takes it from + * Content-Disposition, where Paperless puts the name it would serve. + * + * @param array $document + */ + private function document(array $document): ?Entry + { + $id = $this->intOrNull($document['id'] ?? null); + + if (null === $id) { + return null; + } + + $filename = $this->stringOrNull($document['archived_file_name'] ?? null) + ?? $this->stringOrNull($document['original_file_name'] ?? null); + + return new Entry( + id: (string) $id, + name: $this->stringOrNull($document['title'] ?? null) + ?? $filename + ?? sprintf('Document %d', $id), + isFolder: false, + // Paperless reports no size on a document listing at all. Null is + // "not known", which the picker treats as attachable and the attach + // endpoint re-checks once it holds the bytes — the same reading + // Immich's unprocessed assets get, and for the same reason: calling + // unknown "oversize" would make every row unselectable. + size: null, + // mime_type only appeared in recent versions, so an older server + // leaves the row's icon to be derived from the filename. A wrong + // icon is cosmetic; an absent one on every row looks broken. + mime: $this->stringOrNull($document['mime_type'] ?? null) ?? $this->mimeFor($filename), + // `created` is the date on the document itself — the invoice date, + // not the scan date — which is the one a person is looking for. + // `added` is when Paperless saw it, and stands in when a version or + // a document has no `created`. + modifiedAt: $this->parseDate($document['created'] ?? $document['added'] ?? null), + ); + } + + /** + * A tag's name. Only for the breadcrumb, so a failure is the generic word + * rather than an exception — the listing beside it succeeded, and taking + * the whole view down for a label would be trading the feature for its + * caption. + */ + private function tagName(Integration $integration, string $tagId): string + { + try { + $tag = $this->get($integration, '/tags/'.$tagId.'/'); + } catch (IntegrationException) { + return 'Tag'; + } + + return $this->stringOrNull($tag['name'] ?? null) ?? 'Tag'; + } + + /** + * The filename Paperless would serve for a document, for the rare download + * whose response carried no Content-Disposition. + */ + private function documentName(Integration $integration, string $id): string + { + try { + $document = $this->get($integration, '/documents/'.$id.'/'); + } catch (IntegrationException) { + return 'document-'.$id; + } + + return $this->stringOrNull($document['archived_file_name'] ?? null) + ?? $this->stringOrNull($document['original_file_name'] ?? null) + ?? $this->stringOrNull($document['title'] ?? null) + ?? 'document-'.$id; + } + + /** The crumb every trail starts from, and the id that means "the archive". */ + private function root(): Entry + { + return Entry::folder('', 'Paperless'); + } + + // ── Ids and names ───────────────────────────────────────────────────────── + + /** + * The tag a folder id names, or null for the root. + * + * Anything that is not a plain integer is the root rather than an error. + * Folder ids reach here straight off a query string, so a hand-edited one is + * a URL somebody typed rather than a fault worth recording against the + * connection — and the root is the one answer that is always safe to give. + */ + private function tagFrom(?string $folderId): ?string + { + if (null === $folderId || '' === $folderId) { + return null; + } + + $id = $this->intOrNull($folderId); + + return null === $id ? null : (string) $id; + } + + /** + * The document a file id names. + * + * Refuses rather than falling back, because there is no safe default for + * "fetch this file": every alternative would attach some *other* document. + * The task-id case gets its own sentence — it is the one failure a user can + * actually do something about, by waiting and picking the document once + * Paperless has filed it. + */ + private function documentFrom(string $fileId): string + { + if (true === str_starts_with($fileId, self::TASK_PREFIX)) { + throw new IntegrationException('Paperless is still filing that document, so it cannot be attached yet.'); + } + + $id = $this->intOrNull($fileId); + + if (null === $id) { + throw new IntegrationException('That is not a Paperless document.'); + } + + return (string) $id; + } + + /** + * Page number from a cursor. Anything unparseable starts at the beginning + * rather than failing — the cursor is in a URL a user can edit, and a 500 + * is a poor answer to a typo. + */ + private function page(?string $cursor): int + { + if (null === $cursor || false === ctype_digit($cursor)) { + return 1; + } + + return max(1, (int) $cursor); + } + + /** + * The title a newly uploaded document gets. + * + * The filename without its extension. Paperless falls back to the filename + * itself when no title is sent, so "invoice.pdf" would become a document + * literally titled "invoice.pdf" — the extension is noise in a list of + * titles, and stripping it is the whole difference. + */ + private function titleFor(string $filename): string + { + $stem = pathinfo($filename, PATHINFO_FILENAME); + + return '' === $stem ? $filename : $stem; + } + + /** + * The consumer task id from a post_document/ response. + * + * Paperless answers with a bare JSON string — a quoted UUID, not an object — + * so this reads the body rather than using toArray(), which would throw on + * a payload that is perfectly valid and simply is not a map. Recent versions + * wrap it, hence the second shape. + */ + private function taskId(ResponseInterface $response): string + { + try { + $body = $response->getContent(); + } catch (HttpExceptionInterface $e) { + throw $this->translate($e); + } + + $decoded = json_decode($body, true); + + $id = true === is_array($decoded) + ? $decoded['task_id'] ?? null + : $decoded; + + if (false === is_string($id) || '' === $id) { + throw new IntegrationException('Paperless accepted the upload but did not say what it did with it.'); + } + + return $id; + } + + // ── HTTP ────────────────────────────────────────────────────────────────── + + /** + * Any request whose response is a JSON object. Tag creation is a POST, so + * get() alone would not cover it. + * + * @param array $options + * + * @return array + * + * @throws IntegrationException + */ + private function json(Integration $integration, string $method, string $url, array $options = []): array + { + $response = $this->request($integration, $method, $url, $options); + + try { + return $response->toArray(); + } catch (HttpExceptionInterface $e) { + throw $this->translate($e); + } + } + + /** + * @param array $query + * + * @return array + */ + private function get(Integration $integration, string $path, array $query = []): array + { + $response = $this->request($integration, 'GET', $this->url($integration, $path), [ + 'query' => $query, + ]); + + try { + return $response->toArray(); + } catch (HttpExceptionInterface $e) { + throw $this->translate($e); + } + } + + /** + * @param array $options + */ + private function request( + Integration $integration, + string $method, + string $url, + array $options = [], + bool $throwOnError = true, + ): ResponseInterface { + if (null === $integration->secret || '' === $integration->secret) { + throw new IntegrationException('This Paperless connection is missing its API token.'); + } + + $options['headers'] = array_merge( + // "Token", not "Bearer": Paperless uses DRF's own token + // authentication, which refuses a Bearer scheme outright. + ['Authorization' => 'Token '.$integration->secret, 'Accept' => 'application/json'], + $options['headers'] ?? [], + ); + + try { + $response = $this->httpClient->request($method, $url, $options); + + if (true === $throwOnError) { + $status = $response->getStatusCode(); + + if ($status >= 400) { + throw new IntegrationException($this->messageForStatus($status), $status); + } + } + + return $response; + } catch (IntegrationException $e) { + throw $e; + } catch (HttpExceptionInterface $e) { + throw $this->translate($e); + } + } + + private function translate(HttpExceptionInterface $e): IntegrationException + { + $status = method_exists($e, 'getResponse') ? $e->getResponse()->getStatusCode() : 0; + + return new IntegrationException($this->messageForStatus($status), $status, $e); + } + + private function messageForStatus(int $status): string + { + return match (true) { + 401 === $status || 403 === $status => 'Paperless rejected the API token.', + 404 === $status => 'Paperless could not find that tag or document.', + // The consumer refuses a file it cannot parse, and says so with a + // 400 on the upload endpoint. Worth its own sentence: it is the one + // status here a user can fix, by sending a different file. + 400 === $status => 'Paperless would not accept that file.', + 413 === $status => 'That file is too large for the Paperless server.', + $status >= 500 => 'The Paperless server reported an error.', + 0 === $status => 'Could not reach the Paperless server. Check the address.', + default => sprintf('Paperless returned an unexpected response (%d).', $status), + }; + } + + private function url(Integration $integration, string $path): string + { + $base = $this->urlValidator->resolve( + $integration, + $this->configRepository->findOneByProvider(Provider::Paperless), + ); + + return $base.self::API.$path; + } + + // ── Parsing ─────────────────────────────────────────────────────────────── + + /** + * @param array> $headers + */ + private function filenameFromHeaders(array $headers): ?string + { + $disposition = $headers['content-disposition'][0] ?? null; + + if (null === $disposition) { + return null; + } + + if (1 === preg_match('/filename\*?=(?:UTF-8\'\')?"?([^";]+)"?/i', $disposition, $matches)) { + return rawurldecode($matches[1]); + } + + return null; + } + + /** + * The type an extension implies, for the servers that do not report one. + * + * Through Symfony's own table rather than a hand-written map here: the + * picker only needs enough to choose a row icon, and a second copy of + * "pdf means application/pdf" is a copy that can drift. + */ + private function mimeFor(?string $filename): ?string + { + if (null === $filename) { + return null; + } + + $extension = pathinfo($filename, PATHINFO_EXTENSION); + + if ('' === $extension) { + return null; + } + + return MimeTypes::getDefault()->getMimeTypes($extension)[0] ?? null; + } + + private function parseDate(mixed $raw): ?DateTimeImmutable + { + if (false === is_string($raw) || '' === $raw) { + return null; + } + + try { + return new DateTimeImmutable($raw); + } catch (\Exception) { + return null; + } + } + + private function intOrNull(mixed $value): ?int + { + return is_int($value) || (is_string($value) && ctype_digit($value)) ? (int) $value : null; + } + + private function stringOrNull(mixed $value): ?string + { + return is_string($value) && '' !== $value ? $value : null; + } +} diff --git a/templates/admin/integrations/tutorial/_paperless.html.twig b/templates/admin/integrations/tutorial/_paperless.html.twig new file mode 100644 index 00000000..f5ec24e9 --- /dev/null +++ b/templates/admin/integrations/tutorial/_paperless.html.twig @@ -0,0 +1,23 @@ +{# +Paperless-ngx is API-token based, so like Nextcloud and Immich there is nothing +for an admin to register. The one thing worth saying up front is that a save +lands asynchronously, because an admin who tests the integration will otherwise +watch the document fail to appear and conclude the connection is broken. +#} +

{{ 'admin.integrations.tutorial.paperless.intro'|trans }}

+ +
    +
  1. {{ 'admin.integrations.tutorial.paperless.step_enable'|trans }}
  2. +
  3. {{ 'admin.integrations.tutorial.paperless.step_base_url'|trans }}
  4. +
  5. {{ 'admin.integrations.tutorial.paperless.step_token'|trans }}
  6. +
+ +

+ + {{ 'admin.integrations.tutorial.paperless.asynchronous'|trans }} +

+ +

+ + {{ 'admin.integrations.tutorial.paperless.no_share_links'|trans }} +

diff --git a/tests/Domain/Enum/Integration/ProviderTest.php b/tests/Domain/Enum/Integration/ProviderTest.php index 75a7159b..9ff0355d 100644 --- a/tests/Domain/Enum/Integration/ProviderTest.php +++ b/tests/Domain/Enum/Integration/ProviderTest.php @@ -21,6 +21,7 @@ use App\Service\Integration\Driver\GooglePhotosDriver; use App\Service\Integration\Driver\ImmichDriver; use App\Service\Integration\Driver\NextcloudDriver; use App\Service\Integration\Driver\OneDriveDriver; +use App\Service\Integration\Driver\PaperlessDriver; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -45,7 +46,7 @@ final class ProviderTest extends TestCase public function testEveryProviderIsImplemented(): void { self::assertSame(Provider::cases(), Provider::implemented()); - self::assertCount(8, Provider::cases()); + self::assertCount(9, Provider::cases()); } /** @@ -107,6 +108,7 @@ final class ProviderTest extends TestCase $drivers = [ 'nextcloud' => 'NextcloudDriver', 'immich' => 'ImmichDriver', + 'paperless' => 'PaperlessDriver', 'googleDrive' => 'GoogleDriveDriver', 'googlePhotos' => 'GooglePhotosDriver', 'oneDrive' => 'OneDriveDriver', @@ -127,18 +129,41 @@ final class ProviderTest extends TestCase } } - public function testOnlyPhotoServicesLackShareLink(): void + /** + * Exactly the services that cannot mint a per-file public URL cheaply lack + * ShareLink, and every general file store has it. + * + * This used to be called "only photo services lack share links", and that + * premise is now wrong — Paperless-ngx is a document archive and also lacks + * one. Restated rather than relaxed, because the claim was never really + * about photos: it is that a provider declares ShareLink only when handing + * back a URL costs no more than attaching a copy would. A photo library + * cannot produce one at all without creating a shared album; Paperless can + * share a document, but only through a permissioned feature with its own + * expiry, and publishing a filed document is a much bigger act than + * attaching one to a mail. A provider quietly gaining the capability would + * make the picker offer "insert link" and then hand the user nothing back, + * which is what this exists to catch. + */ + public function testShareLinkIsDeclaredOnlyWhereOneCanBeMintedCheaply(): void { - // Neither can produce a public URL for one asset without creating a - // shared album, which is a bigger side effect than attaching a file - // should have. self::assertFalse(Provider::Immich->supports(Capability::ShareLink)); self::assertFalse(Provider::GooglePhotos->supports(Capability::ShareLink)); + self::assertFalse(Provider::Paperless->supports(Capability::ShareLink)); self::assertTrue(Provider::Nextcloud->supports(Capability::ShareLink)); self::assertTrue(Provider::GoogleDrive->supports(Capability::ShareLink)); self::assertTrue(Provider::OneDrive->supports(Capability::ShareLink)); self::assertTrue(Provider::Dropbox->supports(Capability::ShareLink)); + + // Both directions, so the set stays closed: a provider added later is + // either listed above or fails here, rather than slipping in with + // whatever its capability arm happened to say. + self::assertCount( + 7, + Provider::of(ServiceKind::Files), + 'a file provider was added without deciding whether it can share', + ); } /** @@ -154,6 +179,7 @@ final class ProviderTest extends TestCase $drivers = [ 'nextcloud' => NextcloudDriver::class, 'immich' => ImmichDriver::class, + 'paperless' => PaperlessDriver::class, 'googleDrive' => GoogleDriveDriver::class, 'googlePhotos' => GooglePhotosDriver::class, 'oneDrive' => OneDriveDriver::class, @@ -208,7 +234,7 @@ final class ProviderTest extends TestCase // and AuthKind is what makes needsBaseUrl() true so the address is // validated. See Provider::authKind(), which says why that is not a // third AuthKind case. - foreach ([Provider::Nextcloud, Provider::Immich, Provider::CalDav, Provider::Ics] as $provider) { + foreach ([Provider::Nextcloud, Provider::Immich, Provider::Paperless, Provider::CalDav, Provider::Ics] as $provider) { self::assertSame(AuthKind::AppPassword, $provider->authKind()); self::assertTrue($provider->needsBaseUrl(), 'a self-hosted service has no canonical host'); self::assertSame([], $provider->scopes(), 'an app-password provider asks for no OAuth scopes'); @@ -232,6 +258,7 @@ final class ProviderTest extends TestCase { self::assertSame('nextcloud', Provider::Nextcloud->slug()); self::assertSame('immich', Provider::Immich->slug()); + self::assertSame('paperless', Provider::Paperless->slug()); self::assertSame('google_drive', Provider::GoogleDrive->slug()); self::assertSame('google_photos', Provider::GooglePhotos->slug()); self::assertSame('one_drive', Provider::OneDrive->slug()); @@ -312,8 +339,11 @@ final class ProviderTest extends TestCase yield $library->value.' image-ish' => [$library, 'imagex/foo', false]; } - // File stores — everything, unknown included. - foreach ([Provider::Nextcloud, Provider::GoogleDrive, Provider::OneDrive, Provider::Dropbox] as $store) { + // File stores — everything, unknown included. Paperless belongs here + // rather than with the libraries: it is a document archive, so its + // consumer takes a PDF, a scan or a spreadsheet alike, and a part whose + // type could not be read is exactly the sort of thing it should get. + foreach ([Provider::Nextcloud, Provider::Paperless, Provider::GoogleDrive, Provider::OneDrive, Provider::Dropbox] as $store) { yield $store->value.' pdf' => [$store, 'application/pdf', true]; yield $store->value.' image' => [$store, 'image/jpeg', true]; yield $store->value.' null' => [$store, null, true]; diff --git a/tests/Service/Integration/PaperlessDriverTest.php b/tests/Service/Integration/PaperlessDriverTest.php new file mode 100644 index 00000000..7d89fefe --- /dev/null +++ b/tests/Service/Integration/PaperlessDriverTest.php @@ -0,0 +1,533 @@ +,body:string}> */ + private array $requests = []; + + protected function setUp(): void + { + $this->requests = []; + } + + // ── Credentials ─────────────────────────────────────────────────────────── + + public function testTheTokenTravelsAsADrfTokenHeader(): void + { + $driver = $this->driver([new JsonMockResponse(['count' => 1, 'results' => []])]); + + $driver->verify($this->integration()); + + // "Token", not "Bearer": Paperless uses DRF token auth, which refuses a + // Bearer scheme outright and would 401 a perfectly good token. + self::assertSame( + 'Authorization: Token paperless-token', + $this->requests[0]['options']['normalized_headers']['authorization'][0] ?? null, + ); + // The cheapest call that proves the address and the token at once, and + // one that every Paperless version has — unlike /api/profile/. + self::assertStringContainsString('/api/documents/', $this->requests[0]['url']); + self::assertStringContainsString('page_size=1', $this->requests[0]['url']); + } + + public function testARejectedTokenBecomesAReadableMessage(): void + { + $driver = $this->driver([new MockResponse('', ['http_code' => 401])]); + + $this->expectException(IntegrationException::class); + $this->expectExceptionMessage('Paperless rejected the API token.'); + + $driver->verify($this->integration()); + } + + // ── Browsing ────────────────────────────────────────────────────────────── + + /** + * Paperless replaced folders with tags, so the root offers the tags as + * containers and the newest documents under them. A flat list of every + * document would be a scroll bar rather than a selector. + */ + public function testTheRootListsTagsAsFoldersAheadOfTheNewestDocuments(): void + { + $driver = $this->driver([ + new JsonMockResponse(['results' => [ + ['id' => 4, 'name' => 'Taxes'], + ['id' => 2, 'name' => 'invoices'], + ]]), + new JsonMockResponse(['count' => 1, 'next' => null, 'results' => [ + [ + 'id' => 91, + 'title' => 'Electricity bill', + 'created' => '2026-04-02T00:00:00Z', + 'archived_file_name' => '2026-04-02 Electricity bill.pdf', + ], + ]]), + ]); + + $listing = $driver->list($this->integration()); + + // Sorted naturally and case-insensitively, the way every other driver + // sorts its folders, so a tag list and a folder list read alike. + self::assertSame( + ['invoices', 'Taxes', 'Electricity bill'], + array_map(static fn ($entry) => $entry->name, $listing->entries), + ); + self::assertSame([true, true, false], array_map( + static fn ($entry) => $entry->isFolder, + $listing->entries, + )); + self::assertSame(['2', '4', '91'], array_map( + static fn ($entry) => $entry->id, + $listing->entries, + )); + + // The title is the row's name, because that is what Paperless shows and + // what a person recognises; the mime still comes off the filename so the + // row gets its PDF icon on a server too old to report mime_type. + self::assertSame('application/pdf', $listing->entries[2]->mime); + // No size is reported on a document listing at all. Null means "not + // known", which keeps the row attachable — the attach endpoint re-checks + // once it holds the bytes. + self::assertNull($listing->entries[2]->size); + self::assertSame('2026-04-02', $listing->entries[2]->modifiedAt?->format('Y-m-d')); + + self::assertSame(['Paperless'], array_map(static fn ($crumb) => $crumb->name, $listing->breadcrumb)); + self::assertStringContainsString('/api/tags/', $this->requests[0]['url']); + self::assertStringContainsString('ordering=-created', $this->requests[1]['url']); + } + + /** + * The tags ride on the first page only. They are the same set on every page, + * and the picker appends pages rather than replacing them, so repeating them + * mid-scroll would read as duplicates. + */ + public function testASecondPageOfTheRootCarriesNoTags(): void + { + $driver = $this->driver([ + new JsonMockResponse(['next' => null, 'results' => []]), + ]); + + $listing = $driver->list($this->integration(), null, '2'); + + self::assertSame([], $listing->entries); + self::assertCount(1, $this->requests, 'the tag list is not fetched again'); + self::assertStringContainsString('page=2', $this->requests[0]['url']); + } + + public function testATagListsItsOwnDocumentsUnderATrailBackToTheRoot(): void + { + $driver = $this->driver([ + new JsonMockResponse(['id' => 2, 'name' => 'invoices']), + new JsonMockResponse(['next' => 'https://paperless.example.com/api/documents/?page=2', 'results' => [ + ['id' => 91, 'title' => 'Electricity bill', 'original_file_name' => 'bill.pdf'], + ]]), + ]); + + $listing = $driver->list($this->integration(), '2'); + + self::assertSame(['Electricity bill'], array_map(static fn ($e) => $e->name, $listing->entries)); + self::assertSame(['Paperless', 'invoices'], array_map(static fn ($c) => $c->name, $listing->breadcrumb)); + self::assertSame(['', '2'], array_map(static fn ($c) => $c->id, $listing->breadcrumb)); + self::assertStringContainsString('tags__id__all=2', $this->requests[1]['url']); + + // The cursor is a page number, not Paperless's own `next` URL: behind a + // reverse proxy that URL is routinely the internal address, and every + // request has to go back through IntegrationUrlValidator regardless. + self::assertSame('2', $listing->nextCursor); + } + + public function testAHandEditedFolderIdFallsBackToTheRootInsteadOfFailing(): void + { + $driver = $this->driver([ + new JsonMockResponse(['results' => []]), + new JsonMockResponse(['results' => []]), + ]); + + $driver->list($this->integration(), '../../etc/passwd'); + + // Nothing that is not an integer reaches a URL, and a typed URL is not + // worth a 500 — so it reads as the root, which is always safe to show. + self::assertStringContainsString('/api/tags/', $this->requests[0]['url']); + self::assertStringNotContainsString('passwd', $this->requests[1]['url']); + self::assertStringNotContainsString('tags__id__all', $this->requests[1]['url']); + } + + // ── Search ──────────────────────────────────────────────────────────────── + + /** + * Paperless's full-text index reads the OCR layer, which is the whole reason + * this integration exists: a scanned invoice is findable by a line item + * nobody ever typed, without opening Paperless first. + */ + public function testSearchGoesThroughTheFullTextQueryAndNamesItselfInTheTrail(): void + { + $driver = $this->driver([ + new JsonMockResponse(['next' => null, 'results' => [ + ['id' => 91, 'title' => 'Electricity bill'], + ]]), + ]); + + $listing = $driver->search($this->integration(), ' kilowatt '); + + self::assertSame(['Electricity bill'], array_map(static fn ($e) => $e->name, $listing->entries)); + self::assertStringContainsString('query=kilowatt', $this->requests[0]['url']); + // Relevance comes from the index, so no ordering is imposed on top of it. + self::assertStringNotContainsString('ordering', $this->requests[0]['url']); + self::assertSame(['Paperless', '“kilowatt”'], array_map( + static fn ($c) => $c->name, + $listing->breadcrumb, + )); + } + + public function testASearchLaunchedInsideATagStaysInsideIt(): void + { + $driver = $this->driver([ + new JsonMockResponse(['id' => 2, 'name' => 'invoices']), + new JsonMockResponse(['next' => null, 'results' => []]), + ]); + + $listing = $driver->search($this->integration(), 'kilowatt', '2'); + + self::assertStringContainsString('tags__id__all=2', $this->requests[1]['url']); + // The middle crumb still links back to the tag, because the search is a + // slice of it rather than of the whole archive. + self::assertSame(['Paperless', 'invoices', '“kilowatt”'], array_map( + static fn ($c) => $c->name, + $listing->breadcrumb, + )); + } + + public function testSearchResultsPageThroughTheCursor(): void + { + $driver = $this->driver([ + new JsonMockResponse(['next' => 'https://paperless.example.com/api/documents/?page=4', 'results' => [ + ['id' => 12, 'title' => 'Page three hit'], + ]]), + ]); + + $listing = $driver->search($this->integration(), 'kilowatt', null, '3'); + + self::assertStringContainsString('page=3', $this->requests[0]['url']); + self::assertStringContainsString('query=kilowatt', $this->requests[0]['url']); + self::assertSame('4', $listing->nextCursor, 'the cursor follows the page, not the URL Paperless sent'); + } + + public function testAnEmptyQueryGoesBackToWhereTheUserWas(): void + { + $driver = $this->driver([ + new JsonMockResponse(['results' => []]), + new JsonMockResponse(['results' => []]), + ]); + + $driver->search($this->integration(), ' '); + + // Clearing the box is how a user leaves a search, so it must not go + // looking for the empty string. + self::assertStringContainsString('/api/tags/', $this->requests[0]['url']); + self::assertStringNotContainsString('query=', $this->requests[1]['url']); + } + + // ── Fetching ────────────────────────────────────────────────────────────── + + public function testDownloadTakesTheFilenameFromContentDisposition(): void + { + $driver = $this->driver([ + new MockResponse('%PDF-1.7 ...', ['response_headers' => [ + 'content-type' => 'application/pdf', + 'content-disposition' => 'attachment; filename="2026-04-02 Electricity bill.pdf"', + ]]), + ]); + + $file = $driver->download($this->integration(), '91'); + + self::assertSame('2026-04-02 Electricity bill.pdf', $file->filename); + self::assertSame('application/pdf', $file->mime); + self::assertSame('%PDF-1.7 ...', $file->contents); + // No ?original=true: /download/ serves the archived, OCR'd PDF and falls + // back to the original by itself where Paperless made none. + self::assertStringEndsWith('/api/documents/91/download/', $this->requests[0]['url']); + } + + public function testDownloadAsksForTheNameWhenTheHeaderWasStripped(): void + { + $driver = $this->driver([ + new MockResponse('%PDF-1.7 ...', ['response_headers' => ['content-type' => 'application/pdf']]), + new JsonMockResponse(['id' => 91, 'archived_file_name' => 'bill.pdf', 'title' => 'Electricity bill']), + ]); + + $file = $driver->download($this->integration(), '91'); + + // An attachment called "91" is a bug report, and the extra call only + // happens when a proxy has eaten the header. + self::assertSame('bill.pdf', $file->filename); + self::assertStringEndsWith('/api/documents/91/', $this->requests[1]['url']); + } + + public function testThumbnailReturnsThePreviewAndNullWhereThereIsNone(): void + { + $driver = $this->driver([ + new MockResponse('png-bytes', ['response_headers' => ['content-type' => 'image/png']]), + new MockResponse('', ['http_code' => 404]), + ]); + + $preview = $driver->thumbnail($this->integration(), '91'); + + self::assertNotNull($preview); + self::assertSame('image/png', $preview->mime); + self::assertSame('png-bytes', $preview->contents); + self::assertStringEndsWith('/api/documents/91/thumb/', $this->requests[0]['url']); + + // A missing preview is a null rather than an error: the picker renders a + // placeholder from it, and plenty of things legitimately have none. + self::assertNull($driver->thumbnail($this->integration(), '92')); + } + + // ── Saving out ──────────────────────────────────────────────────────────── + + /** + * post_document/ is asynchronous, so what comes back is a consumer task id + * and not a document id. Returning it bare would hand the caller something + * that looks exactly like a document id and is not one. + */ + public function testUploadReturnsAMarkedTaskIdRatherThanAFakeDocumentId(): void + { + $path = $this->scratchFile('scan-bytes'); + + try { + // A quoted UUID, which is the whole body — not an object. + $driver = $this->driver([new MockResponse('"3f2a1b8c-0000-4000-8000-000000000001"')]); + + $id = $driver->upload($this->integration(), $path, 'bill.pdf', 'application/pdf'); + + self::assertSame('task:3f2a1b8c-0000-4000-8000-000000000001', $id); + self::assertSame('POST', $this->requests[0]['method']); + self::assertStringEndsWith('/api/documents/post_document/', $this->requests[0]['url']); + + // The extension is stripped from the title, because Paperless would + // otherwise file a document literally called "bill.pdf". + self::assertStringContainsString('name="title"', $this->requests[0]['body']); + self::assertStringContainsString('bill', $this->requests[0]['body']); + // No tag chosen, so no tags field at all rather than an empty one. + self::assertStringNotContainsString('name="tags"', $this->requests[0]['body']); + } finally { + unlink($path); + } + } + + public function testNothingCanMistakeATaskIdForADocument(): void + { + $driver = $this->driver([]); + + $this->expectException(IntegrationException::class); + $this->expectExceptionMessage('Paperless is still filing that document, so it cannot be attached yet.'); + + // Refused before any request is made — the point of marking the id is + // that the driver recognises its own. + $driver->download($this->integration(), 'task:3f2a1b8c-0000-4000-8000-000000000001'); + } + + public function testUploadFilesUnderTheChosenTag(): void + { + $path = $this->scratchFile('scan-bytes'); + + try { + $driver = $this->driver([new MockResponse('"3f2a1b8c-0000-4000-8000-000000000002"')]); + + $driver->upload($this->integration(), $path, 'bill.pdf', 'application/pdf', '2'); + + // A repeated form field named "tags", not "tags[0]": Paperless reads + // the latter as a field it does not have and files the document + // under nothing. + self::assertStringContainsString('name="tags"', $this->requests[0]['body']); + self::assertStringNotContainsString('name="tags[0]"', $this->requests[0]['body']); + } finally { + unlink($path); + } + } + + public function testDestinationsAreTheTagsWithNoDocumentsMixedIn(): void + { + $driver = $this->driver([ + new JsonMockResponse(['results' => [ + ['id' => 4, 'name' => 'Taxes'], + ['id' => 2, 'name' => 'invoices'], + ]]), + ]); + + $listing = $driver->destinations($this->integration()); + + self::assertSame(['invoices', 'Taxes'], array_map(static fn ($e) => $e->name, $listing->entries)); + self::assertCount(1, $this->requests, 'a destination pick asks for no documents'); + } + + /** + * A tag id arrives in a request, so it is confirmed against this account's + * own tags before a byte is uploaded. "Save under this tag" that silently + * files under none is a lie to the user. + */ + public function testAssertDestinationRefusesATagThisAccountDoesNotHold(): void + { + $driver = $this->driver([new MockResponse('', ['http_code' => 404])]); + + $this->expectException(IntegrationException::class); + $this->expectExceptionMessage('That tag is not in this Paperless account.'); + + $driver->assertDestination($this->integration(), '9999'); + } + + public function testAssertDestinationRefusesAnythingThatIsNotAnIdAtAll(): void + { + $driver = $this->driver([]); + + try { + $driver->assertDestination($this->integration(), '../../etc'); + self::fail('a traversal attempt should not be accepted as a tag'); + } catch (IntegrationException $e) { + self::assertSame('That tag is not in this Paperless account.', $e->getMessage()); + } + + // Stopped by shape before it is stopped by lookup, so it never reaches + // a URL in the first place. + self::assertSame([], $this->requests); + } + + public function testAssertDestinationAcceptsATagThisAccountHoldsAndTheEmptyDefault(): void + { + $driver = $this->driver([new JsonMockResponse(['id' => 2, 'name' => 'invoices'])]); + + $driver->assertDestination($this->integration(), '2'); + self::assertCount(1, $this->requests); + + // The empty string is "no tag" — Paperless's own inbox — and costs no + // round trip. + $driver->assertDestination($this->integration(), ''); + self::assertCount(1, $this->requests); + } + + public function testCreateDestinationPostsATagAndReturnsItsId(): void + { + $driver = $this->driver([new JsonMockResponse(['id' => 12, 'name' => 'Mail'])]); + + $id = $driver->createDestination($this->integration(), null, 'Mail'); + + self::assertSame('12', $id); + self::assertSame('POST', $this->requests[0]['method']); + self::assertStringEndsWith('/api/tags/', $this->requests[0]['url']); + } + + public function testShareLinkIsAlwaysNullAndIsNotAdvertised(): void + { + // Paperless can share a document, but only through a permissioned + // feature with its own expiry — publishing a filed document is a bigger + // act than attaching one, so the capability is not claimed and the + // picker never offers "insert link". + self::assertFalse(Provider::Paperless->supports(Capability::ShareLink)); + self::assertNull($this->driver([])->shareLink($this->integration(), '91')); + } + + // ── Fixtures ────────────────────────────────────────────────────────────── + + private function scratchFile(string $contents): string + { + $path = tempnam(sys_get_temp_dir(), 'paperless'); + + self::assertIsString($path); + file_put_contents($path, $contents); + + return $path; + } + + /** + * The client normalises an iterable body into a closure yielding chunks + * until it returns ''. + */ + private function drain(mixed $body): string + { + if (true === is_string($body)) { + return $body; + } + + $buffer = ''; + + if ($body instanceof \Closure) { + while ('' !== ($chunk = $body(16372))) { + $buffer .= $chunk; + } + + return $buffer; + } + + if (true === is_iterable($body)) { + foreach ($body as $chunk) { + $buffer .= $chunk; + } + } + + return $buffer; + } + + /** + * @param list $responses + */ + private function driver(array $responses): PaperlessDriver + { + $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$responses): ResponseInterface { + // Drained here rather than at assertion time: the client consumes a + // streamed body on its way out, so reading it later always yields an + // empty string. + $this->requests[] = [ + 'method' => $method, + 'url' => $url, + 'options' => $options, + 'body' => $this->drain($options['body'] ?? ''), + ]; + + return array_shift($responses) ?? new MockResponse('', ['http_code' => 500]); + }); + + $configRepository = $this->createStub(IntegrationProviderConfigRepository::class); + $configRepository->method('findOneByProvider')->willReturn(null); + + return new PaperlessDriver($client, new IntegrationUrlValidator(), $configRepository); + } + + private function integration(): Integration + { + $integration = new Integration(new User(), Provider::Paperless, 'Documents'); + $integration->baseUrl = 'https://paperless.example.com'; + $integration->secret = 'paperless-token'; + + return $integration; + } +} diff --git a/tests/e2e/integrations.spec.ts b/tests/e2e/integrations.spec.ts index 781807b8..4d56c804 100644 --- a/tests/e2e/integrations.spec.ts +++ b/tests/e2e/integrations.spec.ts @@ -7,7 +7,7 @@ import { acceptConfirm } from "./support/confirm"; * configuration state, the setup tutorial is readable inline, and enabling one * persists. * - * All six providers have drivers now, so the thing worth pinning is that an + * Every file provider has a driver now, so the thing worth pinning is that an * unconfigured provider reads as *disabled* — the admin's choice — rather than * as missing. A provider added later without a driver would show as "not * available yet" instead, which is why that string is asserted absent rather @@ -46,6 +46,7 @@ const PROVIDER_IDS: Record = { "Google Photos": "googlePhotos", OneDrive: "oneDrive", Dropbox: "dropbox", + "Paperless-ngx": "paperless", }; function providerRow(page: Page, label: string) { @@ -128,7 +129,7 @@ test.describe("admin integrations", () => { await expect(providerRow(page, label)).toHaveCount(1); } - // All six have drivers now, so nothing is "not available yet" — + // Every one of them has a driver now, so nothing is "not available yet" — // untouched providers read as disabled, which is the admin's choice // rather than a gap in plMail. await expect( diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 4959cd88..cbe3606a 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -582,6 +582,13 @@ admin: step_register: 'Im Azure-Portal unter Microsoft Entra ID → App-Registrierungen eine Anwendung registrieren.' step_secret: 'Unter Zertifikate & Geheimnisse ein Client-Secret erstellen und den Wert sofort kopieren — er wird nur einmal angezeigt.' tenant_note: 'Für private Microsoft-Konten den Tenant „common“ verwenden, oder die eigene Tenant-ID, um den Zugriff auf die Organisation zu beschränken.' + paperless: + asynchronous: 'Beim Speichern übergibt plMail den Anhang an den Paperless-Consumer, der ihn ein paar Sekunden später ablegt. Gemeldet wird die Übergabe, nicht das fertige Dokument — ein Testdokument taucht also kurz nach der Erfolgsmeldung in Paperless auf, und eine Datei, die Paperless als Dublette ablehnt, gar nicht.' + intro: 'Paperless-ngx authentifiziert sich mit einem API-Token pro Benutzer, hier ist also nichts zu registrieren.' + no_share_links: 'plMail legt keine Paperless-Freigabelinks an. Freigaben sind dort eine eigene, rechtegesteuerte Funktion mit eigenem Ablaufdatum, und ein Dokument zu veröffentlichen ist ein größerer Schritt als es anzuhängen — Dokumente über der Anhangsgrenze lassen sich deshalb gar nicht anhängen.' + step_base_url: 'Optional die Serveradresse festlegen, etwa https://paperless.example.com.' + step_enable: 'Oben „Diesen Dienst anbieten“ einschalten.' + step_token: 'Jede Person kopiert ihr API-Token in Paperless unter My Profile → API Auth Token und trägt es in ihren eigenen Integrationseinstellungen ein.' tutorial_heading: 'Einrichtung' unimplemented_notice: 'plMail kann diesen Dienst noch nicht ansprechen. Die Zugangsdaten können jetzt hinterlegt werden — sie greifen, sobald die Unterstützung erscheint.' loading: 'Wird geladen…' @@ -1850,6 +1857,7 @@ integration: immich: 'Ein Foto beschreiben, z. B. Strand bei Sonnenuntergang' nextcloud: 'Nextcloud durchsuchen' oneDrive: 'OneDrive durchsuchen' + paperless: 'Titel und gescannten Text durchsuchen' timeline: 'Zu einem Datum springen' too_big: 'Zu groß zum Anhängen' @@ -3141,6 +3149,7 @@ settings: immich: 'API-Schlüssel' nextcloud: 'App-Passwort' oneDrive: 'Zugriffstoken' + paperless: 'API-Token' secret_help: dropbox: '' googleDrive: '' @@ -3148,6 +3157,7 @@ settings: immich: 'In Immich unter Account Settings → API Keys erzeugen.' nextcloud: 'In Nextcloud unter Einstellungen → Sicherheit → Geräte & Sitzungen erzeugen. Nicht das Anmeldepasswort verwenden.' oneDrive: '' + paperless: 'In Paperless unter My Profile → API Auth Token kopieren.' secret_unchanged: 'Leer lassen, um die gespeicherten Zugangsdaten zu behalten' username: Benutzername heading: Integrationen diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 689e5e8e..25c67a52 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -576,6 +576,13 @@ admin: step_register: 'Open the Azure portal and register an application under Microsoft Entra ID → App registrations.' step_secret: 'Under Certificates & secrets, create a client secret and copy its value immediately — it is shown only once.' tenant_note: 'Use the common tenant for personal Microsoft accounts, or your tenant ID to restrict access to your organisation.' + paperless: + asynchronous: 'Saving an attachment hands it to the Paperless consumer, which files it a few seconds later. plMail reports the handover, not the finished document — so a test save appears in Paperless shortly after it is reported as done, and a file Paperless rejects as a duplicate never appears at all.' + intro: 'Paperless-ngx authenticates with a per-user API token, so there is nothing to register here.' + no_share_links: 'plMail does not create Paperless share links. Sharing there is a separate permissioned feature with its own expiry, and publishing a document is a bigger step than attaching one — so documents above the attachment size limit cannot be attached at all.' + step_base_url: 'Optionally pin the server address, for example https://paperless.example.com.' + step_enable: 'Turn on "Offer this service to users" above.' + step_token: 'Each user copies their API token from Paperless under My Profile → API Auth Token and pastes it into their own integration settings.' tutorial_heading: 'Setup' unimplemented_notice: 'plMail cannot talk to this service yet. You can register the credentials now — they will be used as soon as support ships.' loading: 'Loading…' @@ -1854,6 +1861,7 @@ integration: immich: 'Describe a photo, e.g. beach at sunset' nextcloud: 'Search Nextcloud' oneDrive: 'Search OneDrive' + paperless: 'Search titles and scanned text' timeline: 'Jump to a date' too_big: 'Too large to attach' @@ -3139,6 +3147,7 @@ settings: immich: 'API key' nextcloud: 'App password' oneDrive: 'Access token' + paperless: 'API token' secret_help: dropbox: '' googleDrive: '' @@ -3146,6 +3155,7 @@ settings: immich: 'Create one in Immich under Account Settings → API Keys.' nextcloud: 'Create one in Nextcloud under Settings → Security → Devices & sessions. Do not use your login password.' oneDrive: '' + paperless: 'Copy it from Paperless under My Profile → API Auth Token.' secret_unchanged: 'Leave blank to keep the stored credential' username: Username heading: Integrations diff --git a/translations/messages.en_PI.yaml b/translations/messages.en_PI.yaml index 4458f733..a6185026 100644 --- a/translations/messages.en_PI.yaml +++ b/translations/messages.en_PI.yaml @@ -453,6 +453,13 @@ admin: step_register: 'Open th'' Azure portal and register an application under Microsoft Entra ID → App registrations.' step_secret: 'Under Certificates & secrets, mint a client secret and copy its value at once — it be shown but once.' tenant_note: 'Use th'' common tenant for personal Microsoft accounts, or yer tenant ID to keep it to yer own crew.' + paperless: + asynchronous: 'Stowin'' cargo hands it to th'' Paperless consumer, which files it a few beats later. plMail reports th'' handover, not th'' finished paper — so a test stow shows up in Paperless shortly after it be called done, and a file Paperless casts out as a twin never shows at all.' + intro: 'Paperless-ngx checks papers with a token per hand, so there be nothin'' to register here.' + no_share_links: 'plMail cuts no Paperless share lines. Sharin'' there be a separate, permissioned affair with its own expiry, and publishin'' a paper be a bigger step than haulin'' one aboard — so papers over th'' cargo limit cannot be hauled aboard at all.' + step_base_url: 'Optionally set th'' port address fast, say https://paperless.example.com.' + step_enable: 'Hoist "Offer this port to th'' crew" above.' + step_token: 'Every hand copies their API token from Paperless under My Profile → API Auth Token and stows it in their own port settings.' tutorial_heading: 'Riggin''' unimplemented_notice: 'plMail cannot hail this port yet. Stow th'' papers now — they be used th'' moment passage opens.' loading: 'Haulin'' aboard…' @@ -1544,6 +1551,7 @@ integration: immich: 'Describe a picture, say beach at sundown' nextcloud: 'Search Nextcloud' oneDrive: 'Search OneDrive' + paperless: 'Search titles and scanned text' timeline: 'Jump to a date' too_big: 'Too heavy to haul' @@ -2728,6 +2736,7 @@ settings: immich: 'API key' nextcloud: 'App password' oneDrive: 'Access token' + paperless: 'API token' secret_help: dropbox: '' googleDrive: '' @@ -2735,6 +2744,7 @@ settings: immich: 'Mint one in Immich under Account Settings → API Keys.' nextcloud: 'Mint one in Nextcloud under Settings → Security → Devices & sessions. Never hand over yer login password.' oneDrive: '' + paperless: 'Copy it from Paperless under My Profile → API Auth Token.' secret_unchanged: 'Leave blank to keep th'' stored papers' username: Username heading: 'Friendly ports'