fix(compose): an autosave the server refused no longer says "Draft saved"

`response.ok` was the wrong question. A form the server REFUSES — a stale CSRF
token, a validation failure — does not come back as an error status: it comes
back as the window re-rendered with the errors on it, HTTP 200. The autosave
read that as success, printed "Draft saved", and cleared the unsaved flag that
the unload guard depends on. So the one path where writing is actually lost was
also the one path that claimed everything was fine.

ComposeController::draft() has passed `saved => true` on the accepted branch
since it was written, and the template has never rendered it. It does now, and
the client requires it. A refusal takes the same road as a network failure:
"Could not save the draft", and `_unsaved` stays standing so leaving the page
still warns.

This is the half of the CSRF bug that made it invisible — the composer was
telling the user their reply was safe while the server discarded it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-18 20:14:52 +02:00
1 parent a5f53a5b47
commit 097f6ca943
2 files changed
+24

No files matched your search

+13
View File
@@ -37,6 +37,19 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
<turbo-frame id="{{ frame }}">
<div data-controller="compose--compose compose--compose-toolbar"
{# The server's statement that this render followed a save that was
actually ACCEPTED. ComposeController::draft() has always passed
`saved` and this template has never rendered it, which left the
client with no way to tell an accepted autosave from a refused one:
a refusal re-renders the window with the errors on it and HTTP 200,
the fetch saw `response.ok`, and the composer printed "Draft saved"
about a draft the server had thrown away.
That is how a session-lost CSRF token (see config/packages/csrf.yaml)
stayed invisible until a browser test caught it. Any future refusal
would have been just as silent, which for an autosave means losing
what somebody wrote. #}
{% if saved|default(false) %}data-compose-saved="1"{% endif %}
{# Every word the controller puts on screen, translated here rather
than spelled in JavaScript — same arrangement as ui--select's
i18n value. A string baked into the controller is a string that