Save to Paperless-ngx: tags are choices, and the upload goes through (#78)

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 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 11:38:06 +02:00
1 parent fdbe79c231
commit 3d7a25c1e6
12 files changed
+390 -21

No files matched your search

+10
View File
@@ -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
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Domain\Enum\Integration;
/**
* What a "Save to…" lands in, which decides how the destination chooser is drawn
* and what it calls the things it lists.
*
* Separate from Provider::isMediaLibrary() because the two questions only
* coincided until Paperless arrived: that flag says "this holds photos", and the
* chooser had been reading it as "this has no folder tree". Paperless holds
* documents and has no tree either, so it was drawn as a tree its driver could
* not walk.
*/
enum DestinationKind: string
{
/** A tree: a folder can hold folders, so rows navigate and the footer saves "here". */
case Folder = 'folder';
/** A flat list of albums in a photo library. Each row is itself the choice. */
case Album = 'album';
/** A flat list of tags in a document archive. Each row is itself the choice. */
case Tag = 'tag';
/**
* Whether there is nothing to descend into, so every listed container is a
* choice rather than a place to browse to.
*/
public function isFlat(): bool
{
return self::Folder !== $this;
}
}
+16
View File
@@ -472,6 +472,22 @@ enum Provider: string
};
}
/**
* What a save is filed into — a folder, an album or a tag.
*
* Not derived from isMediaLibrary(): that is about what the service holds,
* this is about the shape of the containers, and a document archive has
* the second without the first.
*/
public function destinationKind(): DestinationKind
{
return match ($this) {
self::Immich, self::GooglePhotos => DestinationKind::Album,
self::Paperless => DestinationKind::Tag,
default => DestinationKind::Folder,
};
}
/**
* Whether a "Save to…" of this content type could possibly land.
*
@@ -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);
+29 -20
View File
@@ -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 %}
</p>
@@ -138,7 +147,7 @@ drift into looking like different features.
{% else %}
{# ── Breadcrumb — a folder tree only ── #}
{% if not isMediaLibrary %}
{% if not isFlat %}
<nav class="flex items-center gap-1 flex-wrap px-6 pb-3 shrink-0 text-xs text-ink-muted">
{% for crumb in listing.breadcrumb %}
{% if not loop.last %}
@@ -155,18 +164,24 @@ drift into looking like different features.
{# ── The containers ── #}
<div class="flex-1 min-h-[14rem] min-w-0 px-6 overflow-y-auto overscroll-contain">
<div class="py-2">
{% if isMediaLibrary %}
{% if isFlat %}
{# What the two flat kinds differ in: the row that files
under no container at all, and the icons. #}
{% set flat = {
album: { rootLabel: 'integration.destination.library', rootIcon: 'fa-photo-film', icon: 'fa-images' },
tag: { rootLabel: 'integration.destination.no_tag', rootIcon: 'fa-box-archive', icon: 'fa-tag' },
}[kind.value] %}
<ul class="space-y-0.5">
{# Always available: the library itself, no album. #}
{{ dest.chooseRow(integration, part, isPick, '', 'integration.destination.library'|trans, 'fa-photo-film') }}
{# Always available: no album, no tag. #}
{{ dest.chooseRow(integration, part, isPick, '', flat.rootLabel|trans, flat.rootIcon) }}
{% for entry in listing.folders %}
{{ dest.chooseRow(integration, part, isPick, entry.id, entry.name, 'fa-images') }}
{{ dest.chooseRow(integration, part, isPick, entry.id, entry.name, flat.icon) }}
{% endfor %}
</ul>
{% if listing.folders is empty %}
<p class="py-6 text-center text-sm text-ink-faint italic">
{{ 'integration.destination.no_albums'|trans }}
{{ ('integration.destination.no_' ~ kind.value ~ 's')|trans }}
</p>
{% endif %}
@@ -226,9 +241,7 @@ drift into looking like different features.
text-xs font-medium text-ink-soft hover:bg-hover transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<i class="fa-solid fa-plus text-[10px]" aria-hidden="true"></i>
{{ (isMediaLibrary
? 'integration.destination.new_album'
: 'integration.destination.new_folder')|trans }}
{{ ('integration.destination.new_' ~ kind.value)|trans }}
</summary>
<form method="post" data-ui--modal-keep-open
action="{{ path('app_integration_create_destination', { id: integration.id, part: part.id }) }}"
@@ -236,14 +249,10 @@ drift into looking like different features.
<input type="hidden" name="_token" value="{{ csrf_token('integration-destination-' ~ part.id) }}">
<input type="hidden" name="parent" value="{{ folderId }}">
<label class="sr-only" for="new-destination-name">
{{ (isMediaLibrary
? 'integration.destination.album_name'
: 'integration.destination.folder_name')|trans }}
{{ ('integration.destination.' ~ kind.value ~ '_name')|trans }}
</label>
<input type="text" id="new-destination-name" name="name" required
placeholder="{{ (isMediaLibrary
? 'integration.destination.album_name'
: 'integration.destination.folder_name')|trans }}"
placeholder="{{ ('integration.destination.' ~ kind.value ~ '_name')|trans }}"
class="flex-1 min-w-0 h-9 px-3 rounded-lg text-sm bg-field border border-field text-ink
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<button type="submit"
@@ -273,7 +282,7 @@ drift into looking like different features.
Offered even when the listing failed: the top level is always
a valid target, so a user is never left unable to save at all
because a subfolder would not load. #}
{% if not isMediaLibrary %}
{% if not isFlat %}
{# The readable half of the answer. Folder ids are opaque for
most services — a Drive id is a random string — so the
trail the driver built is what a person is shown once the
@@ -6,6 +6,7 @@ namespace App\Tests\Domain\Enum\Integration;
use App\Domain\Enum\Integration\AuthKind;
use App\Domain\Enum\Integration\Capability;
use App\Domain\Enum\Integration\DestinationKind;
use App\Domain\Enum\Integration\Provider;
use App\Domain\Enum\Integration\ServiceKind;
use App\Domain\Interface\CalendarSyncDriverInterface;
@@ -351,6 +352,42 @@ final class ProviderTest extends TestCase
}
}
/**
* What a "Save to…" lands in, which decides whether the destination chooser
* draws a tree to walk or a flat list to pick from.
*
* Paperless is the case this exists for: it is not a photo library, but it
* has no tree either — a save files under a tag, and nothing is beneath a
* tag. Deriving "flat" from isMediaLibrary() drew its tags as folders to
* navigate into, and clicking one went nowhere.
*/
#[DataProvider('destinationKinds')]
public function testEachFileProviderSaysWhatItsDestinationsAre(Provider $provider, DestinationKind $expected): void
{
self::assertSame($expected, $provider->destinationKind());
}
public function testOnlyAFolderTreeIsNavigable(): void
{
self::assertFalse(DestinationKind::Folder->isFlat());
self::assertTrue(DestinationKind::Album->isFlat());
self::assertTrue(DestinationKind::Tag->isFlat());
}
/**
* @return iterable<string,array{Provider,DestinationKind}>
*/
public static function destinationKinds(): iterable
{
yield 'nextcloud' => [Provider::Nextcloud, DestinationKind::Folder];
yield 'googleDrive' => [Provider::GoogleDrive, DestinationKind::Folder];
yield 'oneDrive' => [Provider::OneDrive, DestinationKind::Folder];
yield 'dropbox' => [Provider::Dropbox, DestinationKind::Folder];
yield 'immich' => [Provider::Immich, DestinationKind::Album];
yield 'googlePhotos' => [Provider::GooglePhotos, DestinationKind::Album];
yield 'paperless' => [Provider::Paperless, DestinationKind::Tag];
}
public function testEveryProviderHasALabelAndAnIcon(): void
{
foreach (Provider::cases() as $provider) {
@@ -345,6 +345,38 @@ final class PaperlessDriverTest extends TestCase
}
}
/**
* A streamed multipart body leaves as `Transfer-Encoding: chunked` with no
* Content-Length, and Paperless's server then reads it as an empty form: it
* answers 400 "No file was submitted." to a perfectly good PDF that curl gets
* a 200 for. So the body must be a whole string, which the client sends with
* a Content-Length. Verified against a real paperless-ngx 2026-10-11.
*/
public function testUploadSendsTheFileWithAContentLengthRatherThanChunked(): void
{
$path = $this->scratchFile('%PDF-1.4 scan-bytes');
try {
$driver = $this->driver([new MockResponse('"3f2a1b8c-0000-4000-8000-000000000003"')]);
$driver->upload($this->integration(), $path, 'bill.pdf', 'application/pdf');
$headers = $this->requests[0]['options']['normalized_headers'];
// Presence: the file really is in the body, so the two absences
// below are not an empty form passing for correct behaviour.
self::assertStringContainsString('%PDF-1.4 scan-bytes', $this->requests[0]['body']);
self::assertStringContainsString('filename="bill.pdf"', $this->requests[0]['body']);
self::assertSame(
['Content-Length: '.strlen($this->requests[0]['body'])],
$headers['content-length'] ?? [],
);
self::assertArrayNotHasKey('transfer-encoding', $headers);
} finally {
unlink($path);
}
}
public function testNothingCanMistakeATaskIdForADocument(): void
{
$driver = $this->driver([]);
+205
View File
@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace App\Tests\Twig;
use App\Domain\DTO\Integration\Entry;
use App\Domain\DTO\Integration\Listing;
use App\Domain\Enum\Integration\Provider;
use App\Entity\Integration\Integration;
use App\Entity\Mail\MessagePart;
use App\Entity\User\User;
use ReflectionProperty;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Loader\ChainLoader;
/**
* What the destination chooser draws for each kind of container.
*
* Paperless has no folders — a save files under a TAG, and the driver offers the
* tags as a flat list with nothing beneath any of them. The chooser used to
* decide "flat or tree" by asking whether the provider was a photo library, so
* Paperless fell into the tree branch: every tag was drawn as a link that
* navigates INTO it, the driver answered with the very same flat list, and the
* dialog appeared to do nothing when a tag was clicked. Nothing in the markup
* could save anywhere except the footer's "Save here", which files under no tag.
*
* Asserted on the rendered HTML rather than on the driver, because the driver
* was right all along; the fault was in what the template did with a flat list.
*
* The page layout is replaced by an empty shell: it needs a whole request's
* worth of appearance and session state that is nothing to do with this
* template, and the chooser's own markup is all that is under test.
*/
final class DestinationPickerRenderTest extends KernelTestCase
{
private Environment $twig;
private TranslatorInterface $translator;
private User $user;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->twig = $container->get(Environment::class);
$this->translator = $container->get(TranslatorInterface::class);
// The save controls carry CSRF tokens, and the token manager reads the
// session off the request stack — empty outside a real request.
$request = new Request();
$request->setSession(new Session(new MockArraySessionStorage()));
$container->get('request_stack')->push($request);
$this->twig->setLoader(new ChainLoader([
new ArrayLoader(['_layout/app.html.twig' => '{% block body %}{% endblock %}']),
$this->twig->getLoader(),
]));
$this->user = new User();
}
/**
* The regression itself. A tag is a destination: clicking it must be the
* choice, not a trip to a list that looks identical.
*/
public function testAPaperlessTagIsAChoiceRatherThanALinkIntoItself(): void
{
$html = $this->render(Provider::Paperless, [
Entry::folder('7', 'Taxes'),
Entry::folder('9', 'Invoices'),
]);
self::assertStringContainsString('data-destination-folder="7"', $html);
self::assertStringContainsString('data-destination-label="Taxes"', $html);
self::assertStringContainsString('data-destination-folder="9"', $html);
// The failing shape: a link that browses to the tag and re-renders the
// same flat list. Neither id may appear in a browse URL.
self::assertStringNotContainsString('folder=7', $html);
self::assertStringNotContainsString('folder=9', $html);
}
/**
* Presence companion to the test above. "No link into a tag" would also
* hold for an empty chooser, so this pins that the other half — filing
* under no tag at all — is still on offer, and is the only thing at the
* root that is.
*/
public function testPaperlessOffersNoTagAsTheRootChoiceAndNoFooterToSaveHere(): void
{
$html = $this->render(Provider::Paperless, [Entry::folder('7', 'Taxes')]);
self::assertStringContainsString('data-destination-folder=""', $html);
self::assertStringContainsString($this->translator->trans('integration.destination.no_tag'), $html);
// A flat list has no "here" to browse to; the footer's save-here would
// be a second, unlabelled way to file under no tag.
self::assertStringNotContainsString(
$this->translator->trans('integration.destination.choose_here'),
$html,
);
}
/**
* In the save flow — the one the reading pane starts — a tag row is a form
* that posts the upload with the tag as its destination, the way an Immich
* album does. Pick mode shares the row's markup and differs only in the
* wrapper, so this is the half of it that actually moves a file.
*/
public function testAPaperlessTagPostsASaveWhenThereIsAnAttachmentInHand(): void
{
$html = $this->render(Provider::Paperless, [Entry::folder('7', 'Taxes')], withPart: true);
self::assertStringContainsString('name="destination" value="7"', $html);
self::assertStringContainsString('/save-attachment/', $html);
self::assertStringNotContainsString('folder=7', $html);
}
/** Called tags, not albums: Paperless has neither folders nor albums. */
public function testPaperlessSpeaksOfTagsNotAlbums(): void
{
$html = $this->render(Provider::Paperless, [], withPart: true);
self::assertStringContainsString(
$this->translator->trans('integration.destination.no_tags'),
$html,
);
self::assertStringContainsString(
$this->translator->trans('integration.destination.choose_tag', ['%file%' => 'invoice.pdf']),
$html,
);
self::assertStringNotContainsString(
$this->translator->trans('integration.destination.no_albums'),
$html,
);
}
/**
* The two kinds this change must not move. A file store still navigates
* into a folder, and a photo library's album is still a choice — if the
* flat/tree decision were inverted for everyone, the Paperless tests above
* would pass for the wrong reason.
*/
public function testAFileStoreStillNavigatesIntoAFolder(): void
{
$html = $this->render(Provider::Nextcloud, [Entry::folder('Mail/Invoices', 'Invoices')]);
self::assertStringContainsString('browse?folder=Mail/Invoices&amp;mode=destination', $html);
self::assertStringNotContainsString('data-destination-label="Invoices"', $html);
self::assertStringContainsString(
$this->translator->trans('integration.destination.choose_here'),
$html,
);
}
public function testAPhotoLibraryAlbumIsStillAChoice(): void
{
$html = $this->render(Provider::Immich, [Entry::folder('a1', 'Holiday')]);
self::assertStringContainsString('data-destination-folder="a1"', $html);
self::assertStringNotContainsString('folder=a1', $html);
self::assertStringContainsString($this->translator->trans('integration.destination.library'), $html);
}
// ── Fixtures ─────────────────────────────────────────────────────────────
/**
* @param list<Entry> $folders
*/
private function render(Provider $provider, array $folders, bool $withPart = false): string
{
$integration = new Integration($this->user, $provider, 'Test connection');
$this->setId($integration, 5);
$part = null;
if (true === $withPart) {
$part = new MessagePart();
$part->filename = 'invoice.pdf';
$this->setId($part, 42);
}
return $this->twig->render('integration/_destination.html.twig', [
'integration' => $integration,
'part' => $part,
'listing' => new Listing($folders, [Entry::folder('', $provider->label())]),
'error' => null,
'createError' => null,
'folderId' => null,
'canCreate' => false,
]);
}
private function setId(object $entity, int $id): void
{
(new ReflectionProperty($entity::class, 'id'))->setValue($entity, $id);
}
}
+5
View File
@@ -2177,16 +2177,21 @@ integration:
# den er später speichert — für Mail, die noch gar nicht da ist.
choose_for_filter: 'Wähle, wohin dieser Filter Anhänge speichert.'
choose_here: 'Diesen Ordner wählen'
choose_tag: 'Tag für %file% wählen'
choose_this: Wählen
create: Erstellen
folder_name: 'Ordnername'
library: 'Mediathek (kein Album)'
new_album: 'Neues Album'
new_folder: 'Neuer Ordner'
new_tag: 'Neuer Tag'
no_albums: 'Noch keine Alben.'
no_subfolders: 'Keine Ordner hier.'
no_tag: 'Kein Tag'
no_tags: 'Noch keine Tags.'
save_here: 'Hier speichern'
start_over: 'Von vorn beginnen'
tag_name: 'Tag-Name'
picker:
attach: Anhängen
attach_from: 'Aus %name% anhängen'
+5
View File
@@ -2176,16 +2176,21 @@ integration:
# folder it will save to, for mail that has not arrived yet.
choose_for_filter: 'Choose where this filter saves attachments.'
choose_here: 'Choose this folder'
choose_tag: 'Choose a tag for %file%'
choose_this: Choose
create: Create
folder_name: 'Folder name'
library: 'Library (no album)'
new_album: 'New album'
new_folder: 'New folder'
new_tag: 'New tag'
no_albums: 'No albums yet.'
no_subfolders: 'No folders here.'
no_tag: 'No tag'
no_tags: 'No tags yet.'
save_here: 'Save here'
start_over: 'Start over'
tag_name: 'Tag name'
picker:
attach: Attach
attach_from: 'Attach from %name%'
+5
View File
@@ -2056,16 +2056,21 @@ integration:
choose_folder: 'Pick a hold fer %file%'
choose_for_filter: "Pick where this sieve stows its cargo."
choose_here: 'Take this hold'
choose_tag: 'Pick a tag fer %file%'
choose_this: Take it
create: Make
folder_name: 'Hold name'
library: 'Whole library (no album)'
new_album: 'New album'
new_folder: 'New hold'
new_tag: 'New tag'
no_albums: 'No albums yet, matey.'
no_subfolders: 'No holds here.'
no_tag: 'No tag'
no_tags: 'No tags yet, matey.'
save_here: 'Stow here'
start_over: 'Start afresh'
tag_name: 'Tag name'
picker:
attach: 'Haul aboard'
attach_from: 'Haul from %name%'
+5
View File
@@ -2187,16 +2187,21 @@ integration:
# folder it will save to, for mail that has not arrived yet.
choose_for_filter: 'Choisissez où ce filtre enregistre les pièces jointes.'
choose_here: 'Choisir ce dossier'
choose_tag: 'Choisir une étiquette pour %file%'
choose_this: Choisir
create: Créer
folder_name: 'Nom du dossier'
library: 'Bibliothèque (sans album)'
new_album: 'Nouvel album'
new_folder: 'Nouveau dossier'
new_tag: 'Nouvelle étiquette'
no_albums: 'Aucun album pour l’instant.'
no_subfolders: 'Aucun dossier ici.'
no_tag: 'Sans étiquette'
no_tags: 'Aucune étiquette pour l’instant.'
save_here: 'Enregistrer ici'
start_over: 'Recommencer'
tag_name: 'Nom de l’étiquette'
picker:
attach: Joindre
attach_from: 'Joindre depuis %name%'