feat(compose): wire the send-options chevron to scheduled send

The chevron beside Send was a type="button" with no data-action at all —
named for the accessibility audit, inert to the click. It now opens a menu:
Tomorrow morning / Tomorrow afternoon / Monday morning, a separator, and
"Pick date & time" revealing an inline <input type="datetime-local">. Same
ui--dropdown shape as the integration picker, so outside-click and Escape
come free.

Message::$submissionSendAt and MessageSendService already honoured a hold,
and JMAP has implemented delayed send end to end for a while. The web
composer was the only surface that never set it.

POST /compose/schedule[/{id}] runs the same path as send() — recipient
validation, firstUnsendableAddress(), DraftPersister::save() — then records
the hold and dispatches SendMessageMessage with a DelayStamp for its
distance rather than the fixed ten seconds that IS the undo window. Each
menu item is a real submit button with `formaction`, so a schedule goes
through the form exactly as a send does, guards and CSRF included, rather
than down a second quieter path.

Timezone: the menu computes wall clocks against the user's *configured*
zone (ClockGlobal::getTimezone, resolved exactly as TwigTimezoneSubscriber
resolves it) and posts "2026-08-13T09:00"; ScheduledSendResolver turns that
into an instant in PHP, where the tz database knows what to do with a
spring-forward Sunday. No offset arithmetic in JavaScript at all. The
browser's own zone is deliberately not consulted — a laptop still on New
York time must not move a Berlin user's morning.

Ceiling: ScheduledSendResolver::MAX_SECONDS imports
SubmissionEnvelope::MAX_HOLD_SECONDS, the value the session advertises as
maxDelayedSend, so web and JMAP cannot disagree about what a client was
told. A floor of one minute as well: below it "schedule" is "send" with a
worse undo.

Cancel reuses the undo endpoint — `cancelled = true`, which
SendMessageHandler reads and clears — and now also clears submissionSendAt,
because that column with sentAt null is exactly what EmailSubmission/get
reports as `pending`; leaving it made a cancel invisible on every other
device, and permanent.

Surfaced in the Drafts list and on a draft's row inside a thread, since the
toast has faded by the time anyone goes looking. The schedule menu also
carries "Scheduled to send … / Cancel scheduled send" on a draft already
held, which is the affordance that is still there tomorrow.

While in ComposeController: adopt RendersTurboStreams and delete the
byte-for-byte private turboStream() copy.

Tests: ScheduledSendResolverTest (zone, spring-forward pair, floor,
ceiling, delay), ScheduledSendTest (hold recorded in the user's zone and
demonstrably not the server's, delay, cancel, past/over-ceiling/unsendable
refused), DraftRowRenderTest (badge), and compose-scheduled-send.spec.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-12 17:39:35 +02:00
1 parent 1b105f3eb5
commit 81fa3d6aa0
17 files changed
+2015 -29

No files matched your search

@@ -0,0 +1,69 @@
{#
compose/_scheduled.stream.html.twig
─────────────────────────────────────────────────────────────────────────────
Answer to POST /compose/schedule: the window closes, a toast names the time,
and the toast's action cancels the hold.
The cancel goes to the ordinary undo route, which is the same mechanism the
ten-second send guard uses — `cancelled = true` for SendMessageHandler, plus
clearing submissionSendAt — and which reopens the draft in the composer. That
is why the toast is given eight seconds rather than four: it is not a receipt,
it is the last cheap way back out.
The window is taken down differently depending on where it was, because the
three places put it in three different containers:
• the dock — its own frame, emptied;
• the reply box at the foot of a thread — the frame lives inside
#reply_zone_{id}, so the zone is re-rendered whole and comes back as the
Reply / Reply all / Forward buttons;
• a draft being edited in place — the frame IS the draft's row, so it is
replaced by the row again. Still a draft: a scheduled message has not been
sent, and its row belongs where it was.
Variables: message, ctx, sendAt (DateTimeImmutable, UTC), thread, cancelUrl
#}
{# The zone is named rather than left to default, and that is not belt and
braces. `|date` picks it up from the Twig environment (TwigTimezoneSubscriber
puts the user's there), but `|intl_date` keeps whatever zone the value
already carries — and sendAt is UTC, straight off the resolver. Left implicit
the two halves of one timestamp would disagree, and near midnight they would
disagree about the day. #}
{% set when = ('compose.schedule.when'|trans({
'%date%': sendAt|intl_date('dMMM', clock.timezone),
'%time%': sendAt|date(clock.time, clock.timezone),
})) %}
<turbo-stream action="append" target="toast-region">
<template>
{% include '_partials/_toast.html.twig' with {
type: 'success',
message: 'compose.schedule.toast'|trans({ '%when%': when }),
extra: {
undo_url: cancelUrl,
hide_after: 8000,
},
} only %}
</template>
</turbo-stream>
{% if ctx.inline and ctx.frame starts with 'compose_draft_' %}
<turbo-stream action="replace" target="{{ ctx.frame }}">
<template>
{% include 'mail/_thread_message.html.twig' with {
message: message,
expanded: false,
} only %}
</template>
</turbo-stream>
{% elseif ctx.inline and thread is not null %}
<turbo-stream action="replace" target="reply_zone_{{ thread.id }}">
<template>
{% include 'mail/_reply_zone.html.twig' with { thread: thread } only %}
</template>
</turbo-stream>
{% else %}
<turbo-stream action="update" target="compose_dock">
<template></template>
</turbo-stream>
{% endif %}
+174 -14
View File
@@ -498,20 +498,180 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
{# Action bar #}
<div class="flex items-center gap-1">
{# The Send pill is desktop only — below md the header
carries Send, which is always on screen. #}
<div class="hidden md:flex items-center rounded-full overflow-hidden bg-accent mr-2 shrink-0">
<button type="submit"
data-compose--compose-target="sendBtn"
class="text-white text-sm font-semibold px-4 py-1.5 hover:bg-white/10 transition-colors">
{{ 'compose.send'|trans }}
</button>
<button type="button"
title="{{ 'compose.send_options'|trans }}"
aria-label="{{ 'compose.send_options'|trans }}"
class="text-white border-l border-white/30 px-2.5 py-1.5 hover:bg-white/10 transition-colors text-xs">
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
{# ── The Send pill, and behind its chevron, send later ──
Desktop only — below md the header carries Send, which
is always on screen. Which is also why the send-options
menu needs no mobile treatment: there is no chevron on a
phone for it to open off the edge of.
The positioning box is OUTSIDE the pill because the pill
is `overflow-hidden` (that is what rounds the two
buttons into one shape) and a menu absolutely positioned
inside it would be clipped to a 30px strip. Same shape as
the integration picker below: relative wrapper,
ui--dropdown, a `menu` target that starts hidden,
`bottom-full` so it opens upwards out of a window that
already sits at the bottom of the screen.
Every item is a real submit button pointed at the
schedule route with `formaction`, so choosing a time goes
through the same submit path as Send — the same guards in
compose--compose#_handleSubmit, the same CSRF token, the
same form body. See compose--schedule. #}
{% set scheduleUrl = null == message.id
? path('app_compose_mail_schedule', params)
: path('app_compose_mail_schedule_draft', params|merge({'id': message.id})) %}
{# The hold already in place, if any. sentAt guards it: on a
sent message submissionSendAt is a record of when it was
due, not a schedule anyone can still call off. #}
{% set scheduledAt = (message.id and message.sentAt is null)
? message.submissionSendAt : null %}
<div class="relative hidden md:block mr-2 shrink-0"
data-controller="ui--dropdown compose--schedule"
data-action="ui--dropdown:opened->compose--schedule#refresh"
{# The zone every other timestamp in this app is drawn
in — the user's setting, not the browser's guess.
The menu computes "tomorrow morning" against it and
posts a wall clock; ScheduledSendResolver turns that
into an instant with the tz database in hand. #}
data-compose--schedule-timezone-value="{{ clock.timezone }}"
data-compose--schedule-max-days-value="{{ constant('App\\Service\\Mail\\ScheduledSendResolver::MAX_SECONDS') // 86400 }}"
data-compose--schedule-i18n-value="{{ {
scheduleUnreadable: 'compose.schedule.error.unreadable'|trans,
schedulePast: 'compose.schedule.error.past'|trans,
scheduleTooFar: 'compose.schedule.error.too_far'|trans({
'%days%': constant('App\\Service\\Mail\\ScheduledSendResolver::MAX_SECONDS') // 86400,
}),
}|json_encode|e('html_attr') }}">
<div class="flex items-center rounded-full overflow-hidden bg-accent">
<button type="submit"
data-compose--compose-target="sendBtn"
class="text-white text-sm font-semibold px-4 py-1.5 hover:bg-white/10 transition-colors">
{{ 'compose.send'|trans }}
</button>
<button type="button"
data-action="click->ui--dropdown#toggle"
title="{{ 'compose.send_options'|trans }}"
aria-label="{{ 'compose.send_options'|trans }}"
class="text-white border-l border-white/30 px-2.5 py-1.5 hover:bg-white/10 transition-colors text-xs">
<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>
</button>
</div>
{# `right-0` on desktop: the pill sits at the left of a
520px window, and a menu anchored to its right edge
opens back across the window rather than off it.
Flipped to `left-0` below md, exactly as the
integration picker flips its own. The pill is hidden
at that width today, so this costs nothing now — but
right-anchored at 393px the menu's left edge lands at
-140, and the day the pill is ever shown on a phone
the failure would be a menu half off the screen with
no obvious cause. compose-scheduled-send.spec.ts
measures it either way. #}
<div data-ui--dropdown-target="menu" hidden
class="absolute bottom-full mb-1 right-0 max-md:left-0 max-md:right-auto
z-20 w-64 max-w-[calc(100vw-2rem)] popover py-1">
{% if scheduledAt is not null %}
{# A draft that is already on a hold. The menu is
where the schedule was set, so it is where it
can be called off — and unlike the toast, this
is still here tomorrow. #}
<div class="px-3 py-2 border-b border-line">
<p class="text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
{{ 'compose.schedule.scheduled_for'|trans }}
</p>
<p class="text-sm text-ink mt-0.5">
{{ 'compose.schedule.when'|trans({
'%date%': scheduledAt|intl_date('dMMM', clock.timezone),
'%time%': scheduledAt|date(clock.time, clock.timezone),
}) }}
</p>
<button type="button"
data-cancel-url="{{ path('app_compose_mail_undo', params|merge({'id': message.id})) }}"
data-action="click->compose--schedule#cancel click->ui--dropdown#close"
class="mt-1 text-xs text-danger hover:underline cursor-pointer">
{{ 'compose.schedule.cancel'|trans }}
</button>
</div>
{% endif %}
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
{{ 'compose.schedule.heading'|trans }}
</p>
{# Hidden until the controller has a time to put on
it: a preset showing its label and no hour is a
button whose effect you cannot read.
`style="display:none"` as well as the attribute,
and not belt and braces — `[hidden]` lives in the
UA stylesheet, so ANY author rule that sets
display wins over it, and `flex` below is exactly
such a rule. The attribute stays for the
semantics an assistive technology reads; the
inline style is what actually keeps it off the
screen. Kept in step by compose--schedule. #}
{% for key in ['tomorrow_morning', 'tomorrow_afternoon', 'monday_morning'] %}
<button type="submit" hidden style="display:none" formnovalidate
formaction="{{ scheduleUrl }}"
data-compose--schedule-target="option"
data-schedule-key="{{ key }}"
data-action="click->compose--schedule#choose"
class="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-ink-muted
hover:bg-hover hover:text-ink transition-colors text-left">
<span>{{ ('compose.schedule.' ~ key)|trans }}</span>
<span data-schedule-when class="text-xs text-ink-faint tabular-nums"></span>
</button>
{% endfor %}
<div class="my-1 border-t border-line"></div>
<button type="button"
data-compose--schedule-target="toggle"
data-action="click->compose--schedule#showCustom"
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-ink-muted
hover:bg-hover hover:text-ink transition-colors text-left">
<i class="fa-regular fa-calendar w-4 text-center" aria-hidden="true"></i>
<span>{{ 'compose.schedule.custom'|trans }}</span>
</button>
{# Native input, no picker library — the same choice
the calendar event editor makes. #}
<div data-compose--schedule-target="custom" hidden class="px-3 py-2 space-y-2">
<input type="datetime-local"
data-compose--schedule-target="input"
aria-label="{{ 'compose.schedule.custom'|trans }}"
class="w-full rounded-lg border border-line bg-surface px-2 py-1.5 text-sm text-ink">
{# Named, because a bare time is ambiguous the
moment the machine and the setting disagree —
which is exactly when it matters. #}
<p class="text-[11px] text-ink-faint">
{{ 'compose.schedule.timezone_hint'|trans({ '%zone%': clock.timezone }) }}
</p>
<p data-compose--schedule-target="error" hidden
class="text-xs text-danger"></p>
<button type="submit" formnovalidate
formaction="{{ scheduleUrl }}"
data-action="click->compose--schedule#confirm"
class="w-full rounded-full bg-accent text-white text-sm font-semibold
px-3 py-1.5 hover:bg-white/10 transition-colors">
{{ 'compose.schedule.confirm'|trans }}
</button>
</div>
{# The only field of the lot that is submitted. The
datetime-local above is deliberately unnamed —
the value that counts is the one a preset or the
confirm button writes here. #}
<input type="hidden" name="schedule_at" value=""
data-compose--schedule-target="field">
</div>
</div>
{% set ic = "shrink-0 w-9 h-9 md:w-8 md:h-8 flex items-center justify-center rounded-full transition-colors text-ink-muted hover:text-ink hover:bg-hover" %}