refactor(mail): compose gives up the parts that were never about HTTP

1,130 lines and 93 methods, with sixteen section headers the author had already
drawn — decomposed in the head, not on disk. Four pieces come out.

ComposeAttachmentController takes the three upload actions, and they are the
one genuine seam in that class. Everything else — new, reply, forward, draft,
send, schedule, undo — is a single pipeline building the same ComposeType form
from the same request context and answering with the same window, so pulling
"sending" away from "composing" would only have moved nine shared private
helpers into a shared parent and called the result two classes. These three
share none of it. MAX_ATTACHMENT_BYTES goes with them, which is where the file
picker and the window now read it.

ComposeContext replaces `array{inline, frame, thread, replyTo, urlParams}`, a
shape written out in EIGHT docblocks because every method touching it had to
redeclare it for PHPStan — so a key added to it meant eight edits, and a key
misspelled at a read site meant null rather than an error. `urlParams` was also
a STORED key derived from the other four, so the array could carry a value that
disagreed with the fields it came from; it is a method now and cannot. A value
resolver builds it, so actions type-hint it and four of them stop taking a
Request they no longer need.

ComposeWindow and ComposeFormFactory take the rest: the default From account,
the signature seed, the picker's services, the unsendable-address check, the
discard's row arithmetic, and the one place the form is built. All of it was
reachable only through a route before, so "which account is the default From"
could not be asked without building a request.

Two template assumptions fell out of it. `ctx|merge({thread: …})` was the last
thing standing between this shape and being a type — merge is an array filter —
and is `ctx.withThread()` now. And _window.html.twig defaulted every field it
read, with fallbacks that had DRIFTED from the controller's: it defaulted
`frame` to 'compose_dock', so a window could render as dock while the server
thought it was inline. A guaranteed type needs no fallbacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 801639173c4c5017bfa0852791eb97306ee9db39)
(cherry picked from commit 2e1d713897de293c5e7623f95e661286dd245951)
This commit is contained in:
Paul Lützner committed 2026-08-17 11:25:39 +02:00
1 parent 69fbd8b077
commit 85cc9e7166
10 files changed
+695 -464

No files matched your search

@@ -32,10 +32,13 @@ Variables: form, message, ctx (the compose context), threadEntity (MessageThread
the empty frame the reply zone just rendered swaps the editor back in. #}
<turbo-stream action="replace" target="{{ ctx.frame }}">
<template>
{# ctx.withThread(), not ctx|merge({thread: …}): ctx is a
ComposeContext now, and merge is an array filter. The object answers
the same question — see App\Domain\DTO\Mail\ComposeContext. #}
{% include 'compose/_window.html.twig' with {
form: form,
message: message,
ctx: ctx|merge({ thread: threadEntity is not null ? threadEntity.id : null }),
ctx: ctx.withThread(threadEntity is not null ? threadEntity.id : null),
pickerIntegrations: pickerIntegrations,
} only %}
</template>
+13 -7
View File
@@ -12,15 +12,21 @@ name travel back to the server on every POST via the `frame`/`thread` query
params baked into draftUrl/sendUrl, which is what keeps the named inline form
("compose_inline_*" ids) from colliding with the dock's.
Variables: form, message, ctx (inline, frame, thread, urlParams)
Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
#}
{% set ctx = ctx|default({}) %}
{% set inline = ctx.inline|default(false) %}
{% set frame = ctx.frame|default('compose_dock') %}
{% set thread = ctx.thread|default(null) %}
{# No |default() on any of these any more. ctx used to be an array that might
arrive half-built, so every read had to name its own fallback — and the
fallbacks had drifted from the ones the controller used, which is how a
window could render against 'compose_dock' while the server thought it was
inline. It is a value object now: all three renders of this template
(renderWindow, _undo_toast, _inline_undo) construct one, so the fields are
there by type. #}
{% set inline = ctx.inline %}
{% set frame = ctx.frame %}
{% set thread = ctx.thread %}
{# Query params the server needs back on every POST: which frame rendered us,
which conversation we're in, and which message we're answering. #}
{% set params = ctx.urlParams|default({}) %}
{% set params = ctx.urlParams %}
{# Window-chrome button. Larger below md so it clears the 44px touch target. #}
{% set hc = "w-9 h-9 md:w-7 md:h-7 flex items-center justify-center rounded-lg
@@ -75,7 +81,7 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
data-compose--compose-signatures-value="{{ signature_map()|json_encode|e('html_attr') }}"
data-compose--compose-inline-value="{{ inline ? 'true' : 'false' }}"
data-compose--compose-min-chars-value="{{ min_chars|default(5) }}"
data-compose--compose-max-attachment-bytes-value="{{ constant('App\\Controller\\Mail\\ComposeController::MAX_ATTACHMENT_BYTES') }}"
data-compose--compose-max-attachment-bytes-value="{{ constant('App\\Controller\\Mail\\ComposeAttachmentController::MAX_ATTACHMENT_BYTES') }}"
{% if thread is not null %}data-compose--compose-thread-value="{{ thread }}"{% endif %}
{# The schedule URL rides along with the other two because all three go
stale together. A window opens with no id, the first autosave mints