Send the compose context whole instead of unpacking it four times

The undo action listed seven template variables, five of which were the same
context every other render already had, and urlParams was recomputed at three
call sites from the same input. Now composeContext carries urlParams and the
renders pass ctx.

The templates read ctx.frame and ctx.inline rather than taking them apart
again. _window keeps its defaults, so it still renders standalone.

One trap in doing it: _window already had a local named ctx meaning the query
params, and four route calls used it. Renaming the incoming variable to ctx
without noticing would have put inline, replyTo and a nested array into every
draft and send URL -- a break with no error, since path() takes extra
parameters as a query string. The local is params now.

Also fixes the From alias, which never survived a save. The composer wrote the
address the user picked onto the message and markAsDraft replaced it with the
account's own one line later, so every mail sent from an alias went out as the
primary address. The sender is a parameter now, defaulting to the account.

That one is worth reading with the Android notes open: SERVER_REQUESTS.md files
the identity gap as JMAP-only, reasoning that "the web composer does honour the
choice, through ComposeController::resolveFromAddress()". That method does not
exist anywhere in src/. Both surfaces ignored the alias, so the ask is wider
than it is filed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-03 13:22:13 +02:00
1 parent 1030a9881a
commit 9bc3cf0097
7 files changed
+88 -63

No files matched your search

+2 -2
View File
@@ -10,7 +10,7 @@ from — the conversation row when reading a thread, the list row in Drafts —
and a turbo-stream remove aimed at an absent id is a no-op, so each view gets
exactly the one that applies to it.
Variables: messageId, frame, inline, threadId (null unless the thread is now
Variables: messageId, ctx (the compose context), threadId (null unless the thread is now
empty)
#}
<turbo-stream action="remove" target="thread_message_{{ messageId }}">
@@ -21,6 +21,6 @@ empty)
</turbo-stream>
{% endif %}
<turbo-stream action="update" target="{{ frame }}">
<turbo-stream action="update" target="{{ ctx.frame }}">
<template></template>
</turbo-stream>
@@ -4,7 +4,7 @@ compose/_inline_undo.stream.html.twig
Cancelling an inline send: take the message back out of the conversation and
re-open the draft in the inline editor with its text intact.
Variables: form, message, threadEntity (MessageThread|null), frame, urlParams
Variables: form, message, ctx (the compose context), threadEntity (MessageThread|null)
#}
<turbo-stream action="remove" target="thread_message_{{ message.id }}">
</turbo-stream>
@@ -29,15 +29,12 @@ Variables: form, message, threadEntity (MessageThread|null), frame, urlParams
{# The window's own root is <turbo-frame id="compose_inline">, so replacing
the empty frame the reply zone just rendered swaps the editor back in. #}
<turbo-stream action="replace" target="{{ frame }}">
<turbo-stream action="replace" target="{{ ctx.frame }}">
<template>
{% include 'compose/_window.html.twig' with {
form: form,
message: message,
inline: true,
frame: frame,
thread: threadEntity is not null ? threadEntity.id : null,
urlParams: urlParams,
form: form,
message: message,
ctx: ctx|merge({ thread: threadEntity is not null ? threadEntity.id : null }),
} only %}
</template>
</turbo-stream>
+10 -9
View File
@@ -11,14 +11,15 @@ 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, inline (bool), frame (string), thread (int|null)
Variables: form, message, ctx (inline, frame, thread, urlParams)
#}
{% set inline = inline|default(false) %}
{% set frame = frame|default('compose_dock') %}
{% set thread = thread|default(null) %}
{% set ctx = ctx|default({}) %}
{% set inline = ctx.inline|default(false) %}
{% set frame = ctx.frame|default('compose_dock') %}
{% set thread = ctx.thread|default(null) %}
{# 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 ctx = urlParams|default({}) %}
{% set params = ctx.urlParams|default({}) %}
{# 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
@@ -34,11 +35,11 @@ Variables: form, message, inline (bool), frame (string), thread (int|null)
data-compose--compose-max-attachment-bytes-value="{{ constant('App\\Controller\\Mail\\ComposeController::MAX_ATTACHMENT_BYTES') }}"
{% if thread is not null %}data-compose--compose-thread-value="{{ thread }}"{% endif %}
{% if null == message.id %}
data-compose--compose-draft-url-value="{{ path('app_compose_form_new', ctx) }}"
data-compose--compose-send-url-value="{{ path('app_compose_mail_send', ctx) }}"
data-compose--compose-draft-url-value="{{ path('app_compose_form_new', params) }}"
data-compose--compose-send-url-value="{{ path('app_compose_mail_send', params) }}"
{% else %}
data-compose--compose-draft-url-value="{{ path('app_compose_form_edit', ctx|merge({'id': message.id})) }}"
data-compose--compose-send-url-value="{{ path('app_compose_mail_send_draft', ctx|merge({'id': message.id})) }}"
data-compose--compose-draft-url-value="{{ path('app_compose_form_edit', params|merge({'id': message.id})) }}"
data-compose--compose-send-url-value="{{ path('app_compose_mail_send_draft', params|merge({'id': message.id})) }}"
{% endif %}
class="compose-window flex flex-col
{% if inline %}