From 3d7a25c1e64a047ba3d78b84208a1e2c6c5e3e1f Mon Sep 17 00:00:00 2001
From: Walid Mani
Date: Thu, 15 Oct 2026 11:38:06 +0200
Subject: [PATCH] Save to Paperless-ngx: tags are choices, and the upload goes
through (#78)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Save to… chooser drew Paperless tags as folders to open, and clicking one did nothing. A DestinationKind (folder, album, tag) now decides how the chooser is drawn: a tag is the choice itself, with a No tag row. The upload is sent as a string with a Content-Length instead of streamed, which Paperless read as an empty form ("No file was submitted", #79).
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 10 +
.../Enum/Integration/DestinationKind.php | 36 +++
src/Domain/Enum/Integration/Provider.php | 16 ++
.../Integration/Driver/PaperlessDriver.php | 6 +-
templates/integration/_destination.html.twig | 49 +++--
.../Domain/Enum/Integration/ProviderTest.php | 37 ++++
.../Integration/PaperlessDriverTest.php | 32 +++
tests/Twig/DestinationPickerRenderTest.php | 205 ++++++++++++++++++
translations/messages.de.yaml | 5 +
translations/messages.en.yaml | 5 +
translations/messages.en_PI.yaml | 5 +
translations/messages.fr.yaml | 5 +
12 files changed, 390 insertions(+), 21 deletions(-)
create mode 100644 src/Domain/Enum/Integration/DestinationKind.php
create mode 100644 tests/Twig/DestinationPickerRenderTest.php
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f112c0e..f86adbd9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,16 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
+**Saving an attachment to Paperless-ngx works.** The "Save to…" chooser listed your Paperless tags
+as folders to open, but a tag has nothing inside it, so clicking one only reloaded the same list and
+the dialog seemed to do nothing. A tag is now the choice itself: click it and the attachment is filed
+under it, or pick **No tag** to file it under none. The same goes for the folder a filter saves to.
+The upload itself was also refused ("No file was submitted") because it was sent in a form
+Paperless could not read; it now goes through. Paperless still turns down file types it cannot
+consume, a ZIP for one, and says so. Nextcloud, Google Drive, OneDrive, Dropbox, Immich and Google Photos choose exactly as before. No
+migration.
+[#78](https://github.com/karatektus/pl_mail/pull/78), by @Flouss.
+
**A message's header no longer breaks into pieces in a narrow pane.** On a phone, or with the
reading pane on the right and dragged narrow, the sender's address was cut mid-word across three
lines while the date and the buttons took half the width. Below 36rem of pane width the date, the
diff --git a/src/Domain/Enum/Integration/DestinationKind.php b/src/Domain/Enum/Integration/DestinationKind.php
new file mode 100644
index 00000000..3bf06866
--- /dev/null
+++ b/src/Domain/Enum/Integration/DestinationKind.php
@@ -0,0 +1,36 @@
+ DestinationKind::Album,
+ self::Paperless => DestinationKind::Tag,
+ default => DestinationKind::Folder,
+ };
+ }
+
/**
* Whether a "Save to…" of this content type could possibly land.
*
diff --git a/src/Service/Integration/Driver/PaperlessDriver.php b/src/Service/Integration/Driver/PaperlessDriver.php
index 1387dea5..bb80c533 100644
--- a/src/Service/Integration/Driver/PaperlessDriver.php
+++ b/src/Service/Integration/Driver/PaperlessDriver.php
@@ -266,7 +266,11 @@ final readonly class PaperlessDriver implements IntegrationDriverInterface, Sear
$response = $this->request($integration, 'POST', $this->url($integration, '/documents/post_document/'), [
'headers' => $form->getPreparedHeaders()->toArray(),
- 'body' => $form->bodyToIterable(),
+ // A string, not bodyToIterable(): a streamed body leaves chunked with
+ // no Content-Length, and Paperless's server then reads the form as
+ // empty and answers 400 "No file was submitted." to a valid PDF. The
+ // whole file is held in memory for it; attachments are mail-sized.
+ 'body' => $form->toString(),
]);
return self::TASK_PREFIX.$this->taskId($response);
diff --git a/templates/integration/_destination.html.twig b/templates/integration/_destination.html.twig
index 9cf9e861..154c4b1c 100644
--- a/templates/integration/_destination.html.twig
+++ b/templates/integration/_destination.html.twig
@@ -16,6 +16,13 @@ Two shapes, decided by the taxonomy rather than by provider name:
tree to descend, so each album is itself the button, and a "Library" row
saves with no album at all.
+ A document archive (Paperless) gets its tags the same way, flat: a tag is a
+ label a document is filed under, not a place that holds anything. Each tag is
+ itself the button, and a "No tag" row files under none. What decides between
+ this and a tree is Provider::destinationKind(), not whether the service holds
+ photos — a flat list drawn as a tree is a list of links that all lead back to
+ the same list.
+
And two MODES, decided by whether there is a part:
With a part, a choice is a save: the row or the footer posts the upload and
@@ -36,7 +43,11 @@ a successful save is exactly when the modal should close.
#}
{% extends '_layout/app.html.twig' %}
-{% set isMediaLibrary = integration.provider.isMediaLibrary %}
+{# Folder, album or tag: the shape of the containers, and so what they are
+ called. `isFlat` is what changes the markup; `kind.value` completes the
+ translation keys, so a fourth kind is one enum case and its strings. #}
+{% set kind = integration.provider.destinationKind %}
+{% set isFlat = kind.isFlat %}
{# Pick mode is the absence of something to upload, not a separate flag — see
FilePickerController::renderDestination(). #}
@@ -107,9 +118,7 @@ drift into looking like different features.
the folder a rule will use for whatever arrives. #}
{{ 'integration.destination.choose_for_filter'|trans }}
{% else %}
- {{ (isMediaLibrary
- ? 'integration.destination.choose_album'
- : 'integration.destination.choose_folder')|trans({ '%file%': part.filename ?: 'attachment' }) }}
+ {{ ('integration.destination.choose_' ~ kind.value)|trans({ '%file%': part.filename ?: 'attachment' }) }}
{% endif %}
@@ -138,7 +147,7 @@ drift into looking like different features.
{% else %}
{# ── Breadcrumb — a folder tree only ── #}
- {% if not isMediaLibrary %}
+ {% if not isFlat %}