mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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>
59 lines
2.7 KiB
Twig
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>
|