Files
pl_mail/templates/_partials/_snooze_menu.html.twig
T
Paul LütznerandClaude Opus 5 4656665ffd Add snooze: put a conversation away, and bring it back
Snooze is archive-with-a-timer, expressed in labels like everything else
— Inbox off, Snoozed on — so the "a message carries at least one label"
invariant holds without a second mechanism, and the snoozed pile is
reachable by every path that already works on labels.

The web endpoint was the broken half. POST /status/thread/{id}/snooze
wrote snoozedUntil and nothing else: no labels moved and nothing
propagated, so the conversation stayed in the Inbox both locally and at
the provider while its row vanished from the list — and then the sweep
"woke" a thread that had never left. Both callers now go through
ThreadSnoozeService, so a snooze means the same thing whichever client
set it. ThreadSnoozeEndpointTest asserts on labels rather than on the
column, because the column was always written correctly and a test that
checked it would have passed against the broken implementation.

The UI was missing outright. The row button called an action whose
`until` defaulted to null and which Stimulus calls with the event alone,
so it cleared a snooze instead of setting one; the toolbar hardcoded
"tomorrow 8am" behind a comment asking for a picker. Both now open a
menu. The wake times are computed in the browser, deliberately: the
server has no timezone for the session, so "tomorrow morning" resolved
server-side would mean tomorrow morning wherever the container thinks it
is. "Later today" drops itself once the evening has passed rather than
resolving to a time already gone.

The Snoozed pile also had nowhere to live — no route and no sidebar
entry, so it was unreachable. Both added, gated on there being something
snoozed, on the same reasoning as Archive.

Thread/set is a plMail extension; RFC 8621's Thread is read-only. It
accepts exactly one property and refuses create and destroy outright:
threads come into being by mail arriving, and a client that could conjure
one would be describing something nothing else understands. Unlike the
web endpoint it refuses an unparseable date rather than substituting
"in 1 day" — for an API, a client told its write succeeded would find the
thread back at a time it never chose.

app:mail:wake-snoozed runs every minute, which is the unit people pick a
wake time in, and costs one indexed query when nothing is due.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:14:18 +02:00

59 lines
2.7 KiB
Twig

{#
The snooze menu.
Labels come from here because they are translated; the wake times are filled
in by mail--snooze-menu once the page is running, because only the browser
knows what timezone "tomorrow morning" is in. Each option therefore carries a
data-snooze-key rather than a value, and the controller matches on it.
Parameters:
controller — the Stimulus identifier handling the click, e.g.
"mail--message-row". Used for both the action and the
namespaced url param.
method — the action method on it, e.g. "snooze"
url — optional endpoint for the url param; the toolbar omits it and
derives per-row URLs itself
snoozed — whether the target is currently snoozed, which is the only
thing that puts "Unsnooze" on the menu
#}
{% set snoozed = snoozed|default(false) %}
{% set url = url|default(null) %}
{% set fire = 'click->' ~ controller ~ '#' ~ method ~ ' click->ui--dropdown#close' %}
<div data-ui--dropdown-target="menu" hidden
data-controller="mail--snooze-menu"
data-action="ui--dropdown:opened->mail--snooze-menu#refresh"
class="absolute top-full mt-1 right-0 z-20 min-w-56 popover py-1">
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
{{ 'thread_row.actions.snooze'|trans }}
</p>
{% for key in ['later_today', 'tomorrow', 'this_weekend', 'next_week'] %}
<button type="button"
hidden
data-mail--snooze-menu-target="option"
data-snooze-key="{{ key }}"
{% if url %}data-{{ controller }}-snooze-url-param="{{ url }}"{% endif %}
data-action="{{ fire }}"
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>{{ ('snooze.' ~ key)|trans }}</span>
<span data-snooze-when class="text-xs text-ink-faint tabular-nums"></span>
</button>
{% endfor %}
{% if snoozed %}
<div class="my-1 border-t border-line"></div>
{# No until param at all: its absence is what clears the snooze. #}
<button type="button"
{% if url %}data-{{ controller }}-snooze-url-param="{{ url }}"{% endif %}
data-action="{{ fire }}"
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-solid fa-arrow-rotate-left w-4 text-center" aria-hidden="true"></i>
<span>{{ 'snooze.unsnooze'|trans }}</span>
</button>
{% endif %}
</div>