From 4656665ffd7c96e01b7c253ace8ef153f2f61f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Fri, 31 Jul 2026 12:14:18 +0200 Subject: [PATCH] Add snooze: put a conversation away, and bring it back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../mail/list_toolbar_controller.js | 17 +- .../mail/message_row_controller.js | 14 +- .../mail/snooze_menu_controller.js | 73 ++++ assets/controllers/ui/dropdown_controller.js | 5 + assets/snooze_options.js | 70 ++++ .../Maintenance/WakeSnoozedCommand.php | 82 +++++ src/Controller/Mail/MailController.php | 27 ++ .../Mail/ThreadStatusController.php | 29 +- src/Domain/Enum/Mail/LabelRole.php | 15 + .../Scheduler/MaintenanceSchedule.php | 6 + src/Jmap/Mapper/MailboxMapper.php | 5 + src/Jmap/Method/Mail/ThreadGetMethod.php | 24 ++ src/Jmap/Method/Mail/ThreadSetMethod.php | 209 ++++++++++++ .../Mail/MessageThreadRepository.php | 46 +++ src/Service/Mail/ThreadSnoozeService.php | 167 ++++++++++ src/Twig/SidebarCounts.php | 22 ++ templates/_partials/_list_toolbar.html.twig | 11 +- templates/_partials/_sidebar.html.twig | 13 + templates/_partials/_snooze_menu.html.twig | 58 ++++ templates/_partials/_thread_row.html.twig | 27 +- templates/mail/snoozed.html.twig | 20 ++ tests/Command/WakeSnoozedCommandTest.php | 221 +++++++++++++ tests/Controller/ThreadSnoozeEndpointTest.php | 240 ++++++++++++++ tests/Jmap/Method/ThreadSetMethodTest.php | 279 ++++++++++++++++ .../Service/Mail/ThreadSnoozeServiceTest.php | 312 ++++++++++++++++++ translations/messages.de.yaml | 14 + translations/messages.en.yaml | 14 + translations/messages.en_PI.yaml | 14 + 28 files changed, 2011 insertions(+), 23 deletions(-) create mode 100644 assets/controllers/mail/snooze_menu_controller.js create mode 100644 assets/snooze_options.js create mode 100644 src/Command/Maintenance/WakeSnoozedCommand.php create mode 100644 src/Jmap/Method/Mail/ThreadSetMethod.php create mode 100644 src/Service/Mail/ThreadSnoozeService.php create mode 100644 templates/_partials/_snooze_menu.html.twig create mode 100644 templates/mail/snoozed.html.twig create mode 100644 tests/Command/WakeSnoozedCommandTest.php create mode 100644 tests/Controller/ThreadSnoozeEndpointTest.php create mode 100644 tests/Jmap/Method/ThreadSetMethodTest.php create mode 100644 tests/Service/Mail/ThreadSnoozeServiceTest.php diff --git a/assets/controllers/mail/list_toolbar_controller.js b/assets/controllers/mail/list_toolbar_controller.js index 978b0021..a5dd070a 100644 --- a/assets/controllers/mail/list_toolbar_controller.js +++ b/assets/controllers/mail/list_toolbar_controller.js @@ -148,15 +148,18 @@ export default class extends Controller { await this._bulkPost(ids, "read", { read: false }); } - async snoozeSelected() { + /** + * The wake time arrives as a param from the snooze menu (mail--snooze-menu), + * which computes it in the browser — the server has no timezone for the + * session. Absent, this clears the snooze on the selection. + */ + async snoozeSelected(event) { const ids = this._selectedIds(); if (ids.length === 0) { return; } - // Default: snooze until tomorrow morning. - // Replace with a date-picker dispatch if you add a snooze UI. - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(8, 0, 0, 0); - await this._bulkPost(ids, "snooze", { until: tomorrow.toISOString() }); + + const { until = null } = event?.params ?? {}; + + await this._bulkPost(ids, "snooze", { until }); } // ── Private ─────────────────────────────────────────────────────────── diff --git a/assets/controllers/mail/message_row_controller.js b/assets/controllers/mail/message_row_controller.js index 33b9bd5e..53572def 100644 --- a/assets/controllers/mail/message_row_controller.js +++ b/assets/controllers/mail/message_row_controller.js @@ -51,8 +51,18 @@ export default class extends Controller { await this.#post(trashUrl); } - async snooze(event, until = null) { - const { snoozeUrl } = event.params; + /** + * The wake time arrives as a param, set by the snooze menu — see + * mail--snooze-menu, which computes it in the browser because the server + * has no timezone for the session. + * + * Absent, this clears the snooze. That used to be the *only* behaviour: + * the signature took `until = null` as a second argument, and Stimulus + * calls actions with the event alone, so the row's snooze button silently + * unsnoozed instead of snoozing. + */ + async snooze(event) { + const { snoozeUrl, until = null } = event.params; event.stopPropagation(); await this.#post(snoozeUrl, { until }); diff --git a/assets/controllers/mail/snooze_menu_controller.js b/assets/controllers/mail/snooze_menu_controller.js new file mode 100644 index 00000000..ce6f8e06 --- /dev/null +++ b/assets/controllers/mail/snooze_menu_controller.js @@ -0,0 +1,73 @@ +import { Controller } from "@hotwired/stimulus"; +import { snoozeOptions } from "../../snooze_options.js"; + +/** + * Fills a snooze menu with concrete wake times. + * + * The times cannot come from Twig: the server has no timezone for the session, + * so "tomorrow morning" rendered server-side would mean tomorrow morning in + * whatever zone the container thinks it is in. The markup therefore ships the + * labels — which are translated, and must be — and this fills in the instants. + * + * It only sets params; the click itself is handled by mail--message-row#snooze + * or mail--list-toolbar#snoozeSelected, so the posting, the CSRF header and the + * Turbo Stream handling all stay in one place per surface. + * + * Options are recomputed on every open rather than once on connect. A list left + * open across midnight would otherwise offer a "tomorrow" that is today. + */ +export default class extends Controller { + static targets = ["option"]; + + connect() { + this.refresh(); + } + + refresh() { + const options = snoozeOptions(); + const byKey = new Map(options.map((option) => [option.key, option.at])); + + for (const element of this.optionTargets) { + const at = byKey.get(element.dataset.snoozeKey); + + // "Later today" disappears once the evening has passed, so a target + // with no matching option is expected rather than an error. + if (undefined === at) { + element.hidden = true; + continue; + } + + element.hidden = false; + + // setAttribute, not dataset: the identifiers contain a double dash + // ("mail--message-row"), which has no faithful camelCase form and + // so cannot be round-tripped through the dataset API. + // + // Both are set unconditionally. Only one has a controller listening + // on any given surface, and the unused attribute is inert — cheaper + // than teaching this controller which surface it is on. + element.setAttribute("data-mail--message-row-until-param", at.toISOString()); + element.setAttribute("data-mail--list-toolbar-until-param", at.toISOString()); + + const when = element.querySelector("[data-snooze-when]"); + + if (null !== when) { + when.textContent = this.#format(at); + } + } + } + + // ── Private ─────────────────────────────────────────────────────────── + + /** + * "Sat 08:00" — weekday and time, no date. The label above it already says + * which day in words; this is the confirmation, not the primary reading. + */ + #format(date) { + return date.toLocaleString(document.documentElement.lang || undefined, { + weekday: "short", + hour: "2-digit", + minute: "2-digit", + }); + } +} diff --git a/assets/controllers/ui/dropdown_controller.js b/assets/controllers/ui/dropdown_controller.js index cd06886a..015ce9e5 100644 --- a/assets/controllers/ui/dropdown_controller.js +++ b/assets/controllers/ui/dropdown_controller.js @@ -32,6 +32,11 @@ export default class extends Controller { open() { this.menuTarget.hidden = false; + // For menus whose contents go stale while closed — the snooze menu + // recomputes its wake times here, so one left open across midnight + // cannot offer a "tomorrow" that is already today. + this.dispatch("opened", { target: this.menuTarget }); + document.addEventListener("click", this._boundOutside, { capture: true }); document.addEventListener("keydown", this._boundEscape); } diff --git a/assets/snooze_options.js b/assets/snooze_options.js new file mode 100644 index 00000000..f71a1bcf --- /dev/null +++ b/assets/snooze_options.js @@ -0,0 +1,70 @@ +/** + * The wake times offered by the snooze menus. + * + * Shared by the per-row menu and the list toolbar so the two cannot drift — + * "Tomorrow" meaning 8am in one place and 9am in the other is the kind of + * difference nobody reports and everybody notices. + * + * Computed in the browser, deliberately. The server never sees a timezone for + * the session, so "tomorrow morning" resolved server-side would mean tomorrow + * morning wherever the container happens to think it is. The endpoint takes an + * absolute instant and stays ignorant of what the user called it. + */ + +/** Morning, for every option that lands on a future day. */ +const MORNING_HOUR = 8; + +/** "Later today" is a nudge, not a date — it only exists while it still fits. */ +const LATER_TODAY_HOUR = 18; + +function at(date, hour) { + const result = new Date(date); + result.setHours(hour, 0, 0, 0); + return result; +} + +function addDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + +/** + * Days until the coming Saturday. Today counts as "this weekend" only if it is + * not already Saturday or Sunday — on those, "this weekend" is now, which is + * not a snooze, so it rolls to the next one. + */ +function daysUntilSaturday(now) { + const day = now.getDay(); // 0 Sun … 6 Sat + const delta = (6 - day + 7) % 7; + return 0 === delta ? 7 : delta; +} + +/** Days until the coming Monday. */ +function daysUntilMonday(now) { + const delta = (1 - now.getDay() + 7) % 7; + return 0 === delta ? 7 : delta; +} + +/** + * @param {Date} [now] + * @returns {{ key: string, at: Date }[]} in menu order, soonest first + */ +export function snoozeOptions(now = new Date()) { + const options = []; + + // Dropped rather than pushed to tomorrow once the evening has passed: an + // option labelled "later today" that resolves to a time already gone would + // fire on the very next sweep. + const laterToday = at(now, LATER_TODAY_HOUR); + + if (laterToday > now) { + options.push({ key: "later_today", at: laterToday }); + } + + options.push({ key: "tomorrow", at: at(addDays(now, 1), MORNING_HOUR) }); + options.push({ key: "this_weekend", at: at(addDays(now, daysUntilSaturday(now)), MORNING_HOUR) }); + options.push({ key: "next_week", at: at(addDays(now, daysUntilMonday(now)), MORNING_HOUR) }); + + return options; +} diff --git a/src/Command/Maintenance/WakeSnoozedCommand.php b/src/Command/Maintenance/WakeSnoozedCommand.php new file mode 100644 index 00000000..f65b1100 --- /dev/null +++ b/src/Command/Maintenance/WakeSnoozedCommand.php @@ -0,0 +1,82 @@ +threadRepository->findDueSnoozed(new \DateTimeImmutable(), self::BATCH); + + if ([] === $due) { + return Command::SUCCESS; + } + + $woken = 0; + + foreach ($due as $thread) { + try { + $this->snoozeService->wake($thread); + ++$woken; + } catch (\Throwable $error) { + // One thread that cannot be woken — a deleted account, a + // provider that rejected the label change — must not strand + // every other thread behind it. The snooze stays set, so the + // next run tries again. + $io->warning(sprintf( + 'Could not wake thread %d: %s', + (int) $thread->getId(), + $error->getMessage(), + )); + + $this->entityManager->clear(); + } + } + + $io->success(sprintf('Woke %d conversation(s).', $woken)); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Mail/MailController.php b/src/Controller/Mail/MailController.php index cf2c06b8..7ff2199f 100644 --- a/src/Controller/Mail/MailController.php +++ b/src/Controller/Mail/MailController.php @@ -203,6 +203,33 @@ final class MailController extends AbstractController ]); } + /** + * Snoozed role view — where conversations wait for their wake time. + * + * Ordered by lastMessageAt like every other list rather than by wake time, + * which is the less obvious choice: the row shows when it comes back, so + * sorting by it would be legible. But this list is reached to *find* a + * conversation, and "the one from Tuesday" is how people look, not "the + * one due third". + */ + #[Route('/snoozed', name: 'snoozed')] + public function snoozed(Request $request): Response + { + $user = $this->getUser(); + $page = max(1, (int) $request->query->get('page', 1)); + $threads = $this->threadRepository->findForRole($user, LabelRole::Snoozed, $page); + $total = $this->threadRepository->countForRole($user, LabelRole::Snoozed); + + $this->threadRepository->preloadLabels($threads); + + return $this->render('mail/snoozed.html.twig', [ + 'threads' => $threads, + 'page' => $page, + 'total' => $total, + 'per_page' => 50, + ]); + } + #[Route('/account/{account}/folders', name: 'account_folders')] public function accountFolders(Account $account): Response { diff --git a/src/Controller/Mail/ThreadStatusController.php b/src/Controller/Mail/ThreadStatusController.php index c5278a2a..6f2df3cd 100644 --- a/src/Controller/Mail/ThreadStatusController.php +++ b/src/Controller/Mail/ThreadStatusController.php @@ -17,6 +17,7 @@ use App\Repository\Mail\MessageThreadRepository; use App\Service\Label\LabelChangePropagator; use App\Service\Label\LabelResolver; use App\Service\Label\ThreadLabelSynchronizer; +use App\Service\Mail\ThreadSnoozeService; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -48,6 +49,7 @@ class ThreadStatusController extends AbstractController private readonly LabelChangePropagator $propagator, private readonly ThreadLabelSynchronizer $threadLabelSynchronizer, private readonly StateManager $stateManager, + private readonly ThreadSnoozeService $snoozeService, ) {} #[Route('/star', name: 'star', methods: ['POST'])] @@ -211,19 +213,34 @@ class ThreadStatusController extends AbstractController $body = json_decode($request->getContent(), true); $until = null; - if (true === array_key_exists('until', $body)) { + if (true === is_array($body) && true === array_key_exists('until', $body)) { if (null !== $body['until']) { try { - $until = new DateTimeImmutable($body['until']); - } catch (\Exception $e) { + $until = new DateTimeImmutable((string) $body['until']); + } catch (\Exception) { $until = new DateTimeImmutable('in 1 day'); } } } - $thread->setSnoozedUntil($until); - $this->recordJmapUpdates($messages); - $this->em->flush(); + // Through the service, not setSnoozedUntil(): snoozing has to move the + // Inbox label off and propagate that outward. Writing the column here + // is what this endpoint used to do, and it left the conversation + // sitting in the inbox — locally and at the provider — while the row + // vanished from the list, until the sweep "woke" a thread that had + // never left. Thread/set goes through the same service for the same + // reason. The one deliberate difference is above: a form post gets the + // "in 1 day" fallback on an unparseable date, where the JMAP method + // refuses it — see ThreadSetMethod::snoozeDate(). + if (null === $until) { + $this->snoozeService->wake($thread); + } else { + $this->snoozeService->snooze($thread, $until); + } + + // No recordJmapUpdates/flush: the service records its own state + // changes and flushes, because the label moves have to be visible to + // the propagator jobs it queues. return $this->renderTurboStream('thread/status/_snooze.stream.html.twig', [ 'thread' => $thread, diff --git a/src/Domain/Enum/Mail/LabelRole.php b/src/Domain/Enum/Mail/LabelRole.php index 80f57f06..16069cbe 100644 --- a/src/Domain/Enum/Mail/LabelRole.php +++ b/src/Domain/Enum/Mail/LabelRole.php @@ -22,6 +22,17 @@ enum LabelRole: string case Trash = 'trash'; case Spam = 'spam'; case Archive = 'archive'; + /** + * Where a conversation waits while it is snoozed. + * + * Unlike the others this has no IMAP special-use and no provider + * counterpart — nothing sets it but plMail, and MailboxSpecialUse + * therefore never maps to it. It exists so that snoozing is expressible in + * the same vocabulary as everything else: a conversation leaves the Inbox + * and carries this instead, which keeps the "a message is always in at + * least one label" invariant without a second mechanism. + */ + case Snoozed = 'snoozed'; public static function fromSpecialUse(MailboxSpecialUse $specialUse): self { @@ -44,6 +55,7 @@ enum LabelRole: string self::Trash => 'Trash', self::Spam => 'Spam', self::Archive => 'Archive', + self::Snoozed => 'Snoozed', }; } @@ -60,6 +72,9 @@ enum LabelRole: string self::Spam => 30, self::Trash => 40, self::Archive => 50, + // After Archive, before custom labels: it is a system place, but + // the least-visited one. + self::Snoozed => 60, }; } diff --git a/src/Infrastructure/Scheduler/MaintenanceSchedule.php b/src/Infrastructure/Scheduler/MaintenanceSchedule.php index 2f9510e9..3f90b530 100644 --- a/src/Infrastructure/Scheduler/MaintenanceSchedule.php +++ b/src/Infrastructure/Scheduler/MaintenanceSchedule.php @@ -52,6 +52,12 @@ final class MaintenanceSchedule implements ScheduleProviderInterface // gone degraded. RecurringMessage::cron('0 4 * * *', new RunCommandMessage('app:push:renew --repair')), + // Snooze is only a snooze if something brings the thread + // back. Every minute, because a minute is the unit people + // pick a wake time in, and the sweep costs one indexed query + // when nothing is due — which is almost always. + RecurringMessage::cron('* * * * *', new RunCommandMessage('app:mail:wake-snoozed')), + // Log entries and dead heartbeats. RecurringMessage::cron('30 4 * * *', new RunCommandMessage('app:monitoring:prune')), diff --git a/src/Jmap/Mapper/MailboxMapper.php b/src/Jmap/Mapper/MailboxMapper.php index a559681c..d90f41c9 100644 --- a/src/Jmap/Mapper/MailboxMapper.php +++ b/src/Jmap/Mapper/MailboxMapper.php @@ -125,6 +125,11 @@ final class MailboxMapper 'Starred', 'Flagged' => 'flagged', 'Important' => 'important', 'All', 'AllMail' => 'all', + // Vendor role: there is no IANA special-use for snoozed, so this + // is plMail's own. A standard client reads an unrecognised role + // exactly as this method's default does — no role, mailbox still + // listed — which is the intended degradation. + 'Snoozed' => 'snoozed', default => null, }; } diff --git a/src/Jmap/Method/Mail/ThreadGetMethod.php b/src/Jmap/Method/Mail/ThreadGetMethod.php index 60581fe2..43e1d141 100644 --- a/src/Jmap/Method/Mail/ThreadGetMethod.php +++ b/src/Jmap/Method/Mail/ThreadGetMethod.php @@ -77,6 +77,20 @@ final class ThreadGetMethod implements JmapMethod $list[] = [ 'id' => (string) $thread->getId(), 'emailIds' => $emailIds, + // plMail extension. RFC 8621's Thread has only id and emailIds; + // this is the one piece of thread state the product owns, and + // the web UI already acts on it. A client that computed snooze + // locally instead would disagree with the browser and lose it + // on reinstall, which is exactly the case the client guide + // names as ask-don't-work-around. + // + // Null when never snoozed *and* when the snooze has already + // expired — a past timestamp is not a state anything should + // render, and leaving it would have every client re-implement + // the comparison. + 'snoozedUntil' => $this->utcOrNull( + $thread->isSnoozed() ? $thread->getSnoozedUntil() : null, + ), ]; } @@ -87,4 +101,14 @@ final class ThreadGetMethod implements JmapMethod 'notFound' => array_values(array_diff($requestedIds, $found)), ]; } + + /** + * The same shape EmailMapper::utcOrNull emits, and for the same reason: + * clients parse this format exactly, and a value left in the server's + * local zone would be read as UTC and land hours out. + */ + private function utcOrNull(?\DateTimeImmutable $date): ?string + { + return $date?->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\\TH:i:s\\Z'); + } } diff --git a/src/Jmap/Method/Mail/ThreadSetMethod.php b/src/Jmap/Method/Mail/ThreadSetMethod.php new file mode 100644 index 00000000..2e9fc3d5 --- /dev/null +++ b/src/Jmap/Method/Mail/ThreadSetMethod.php @@ -0,0 +1,209 @@ +accountResolver->resolve($context->user, $arguments['accountId'] ?? null); + $accountId = (int) $account->getId(); + + $oldState = $this->stateManager->stateFor($accountId, JmapObjectType::Thread); + + foreach (['create', 'destroy'] as $unsupported) { + $value = $arguments[$unsupported] ?? null; + + if (null !== $value && [] !== $value) { + throw new MethodException( + 'invalidArguments', + sprintf('Thread/set does not support "%s".', $unsupported), + ); + } + } + + $update = $arguments['update'] ?? []; + + if (false === is_array($update)) { + throw new MethodException('invalidArguments', '"update" must be an object.'); + } + + if (count($update) > self::MAX_OBJECTS) { + throw new MethodException( + 'requestTooLarge', + sprintf('At most %d objects per Thread/set.', self::MAX_OBJECTS), + ); + } + + $updated = []; + $notUpdated = []; + + foreach ($update as $rawId => $patch) { + $id = $context->resolveId((string) $rawId) ?? (string) $rawId; + + // Scoped by account, so another user's thread id resolves to + // nothing rather than being reachable by guessing an integer. + $threads = $this->threadRepository->findByAccountAndIds($accountId, [(int) $id]); + $thread = $threads[0] ?? null; + + if (null === $thread) { + $notUpdated[(string) $rawId] = [ + 'type' => 'notFound', + 'description' => 'No such thread in this account.', + ]; + + continue; + } + + try { + $this->applyPatch($thread, $patch); + } catch (MethodException $error) { + $notUpdated[(string) $rawId] = $error->toError(); + + continue; + } + + // Null: nothing was changed beyond what was asked for, which is + // what RFC 8620 §5.3 wants said here. The label moves are not a + // server-side surprise — they are what snoozing means. + $updated[(string) $id] = null; + } + + // No flush here: the snooze service records its own state changes and + // flushes, because the label moves it makes have to be visible to the + // propagator jobs it queues. + + return [ + 'accountId' => (string) $accountId, + 'oldState' => $oldState, + 'newState' => $this->stateManager->stateFor($accountId, JmapObjectType::Thread), + 'created' => null, + 'updated' => (object) $updated, + 'destroyed' => [], + 'notCreated' => (object) [], + 'notUpdated' => (object) $notUpdated, + 'notDestroyed' => (object) [], + ]; + } + + /** + * @param mixed $patch the JMAP patch object for one thread + * + * @throws MethodException when the patch names anything but snoozedUntil + */ + private function applyPatch(MessageThread $thread, mixed $patch): void + { + if (false === is_array($patch)) { + throw new MethodException('invalidPatch', 'Each update must be an object.'); + } + + foreach (array_keys($patch) as $property) { + if ('snoozedUntil' !== $property) { + throw new MethodException( + 'invalidProperties', + sprintf('"%s" is not a settable Thread property.', (string) $property), + ); + } + } + + if (false === array_key_exists('snoozedUntil', $patch)) { + // An empty patch is a no-op rather than an error: it changes + // nothing, and refusing it would make a client's "apply whatever + // changed" loop fail on the case where nothing did. + return; + } + + $until = $this->snoozeDate($patch['snoozedUntil']); + + // Not a field assignment: snoozing moves the conversation out of the + // Inbox and clearing brings it back, both propagated outward. Writing + // the column directly would leave a thread marked snoozed and still + // sitting in the inbox. + if (null === $until) { + $this->snoozeService->wake($thread); + } else { + $this->snoozeService->snooze($thread, $until); + } + } + + /** + * Null clears the snooze — the same convention the web UI's endpoint uses. + * + * A malformed date is refused rather than coerced. The web endpoint quietly + * substitutes "in 1 day" on a parse failure, which is defensible for a form + * post and wrong for an API: a client would be told its write succeeded and + * find the thread reappearing at a time it never asked for. + */ + private function snoozeDate(mixed $value): ?\DateTimeImmutable + { + if (null === $value) { + return null; + } + + if (false === is_string($value) || '' === trim($value)) { + throw new MethodException( + 'invalidProperties', + '"snoozedUntil" must be a UTC date-time string or null.', + ); + } + + try { + return new \DateTimeImmutable($value); + } catch (\Exception) { + throw new MethodException( + 'invalidProperties', + sprintf('"%s" is not a parseable date-time.', $value), + ); + } + } +} diff --git a/src/Repository/Mail/MessageThreadRepository.php b/src/Repository/Mail/MessageThreadRepository.php index b7904b58..4508db02 100644 --- a/src/Repository/Mail/MessageThreadRepository.php +++ b/src/Repository/Mail/MessageThreadRepository.php @@ -553,6 +553,52 @@ class MessageThreadRepository extends ServiceEntityRepository ); } + /** + * How many of the user's conversations are snoozed right now. + * + * Counts the column rather than the Snoozed label, because the column is + * what the wake sweep acts on: a thread whose time has passed but which + * the sweep has not reached yet is no longer snoozed, and counting the + * label would keep it in the total for up to a minute. Only used to decide + * whether the sidebar shows the entry at all. + */ + public function countSnoozedForUser(UserInterface $user): int + { + return (int) $this->createQueryBuilder('t') + ->select('COUNT(t.id)') + ->join('t.account', 'a') + ->where('a.usr = :user') + ->andWhere('a.isActive = true') + ->andWhere('t.snoozedUntil IS NOT NULL') + ->andWhere('t.snoozedUntil > :now') + ->setParameter('user', $user) + ->setParameter('now', new \DateTimeImmutable()) + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * Snoozed conversations whose wake time has passed. + * + * Ordered oldest-first so a backlog drains in the order the user asked + * for, and capped so one enormous backlog cannot hold the worker. + * + * @return list + */ + public function findDueSnoozed(\DateTimeImmutable $now, int $limit): array + { + return $this->createQueryBuilder('t') + ->addSelect('m') + ->leftJoin('t.messages', 'm') + ->where('t.snoozedUntil IS NOT NULL') + ->andWhere('t.snoozedUntil <= :now') + ->setParameter('now', $now) + ->orderBy('t.snoozedUntil', 'ASC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + /** * JMAP Thread/get: fetch by id, scoped to the account. Messages are * eager-joined because a Thread object is nothing but its email id list. diff --git a/src/Service/Mail/ThreadSnoozeService.php b/src/Service/Mail/ThreadSnoozeService.php new file mode 100644 index 00000000..15880286 --- /dev/null +++ b/src/Service/Mail/ThreadSnoozeService.php @@ -0,0 +1,167 @@ +getMessages()->toArray(); + + if ([] === $messages) { + return; + } + + $account = $messages[0]->getAccount(); + $inbox = $this->labelRepository->findOneByRoleForUser(LabelRole::Inbox, $account->getUsr()); + $snoozed = $this->labelResolver->systemLabel(LabelRole::Snoozed, $account); + + // Before the labels move, so an IMAP job still sees the source folder. + // Archiving is what snoozing does to the provider; the difference is + // only that plMail remembers to undo it. + $this->propagator->archive($messages); + $this->propagator->attachLabel($messages, $snoozed); + + foreach ($messages as $message) { + if (null !== $inbox) { + $message->removeLabel($inbox); + } + + $message->addLabel($snoozed); + } + + $thread->setSnoozedUntil($until); + + $this->finish($thread, $messages); + } + + /** + * Brings a conversation back to the Inbox and clears its snooze. + * + * Marks it unread, which is the whole point: a thread that returns in the + * state you left it in is one you have already learned to scroll past. The + * read state it had is genuinely lost — that is a deliberate trade, not an + * oversight. + * + * Safe to call on a thread that is not snoozed; it then only ensures the + * Inbox label, which is what a caller racing the sweep wants anyway. + */ + public function wake(MessageThread $thread): void + { + $messages = $thread->getMessages()->toArray(); + + if ([] === $messages) { + $thread->setSnoozedUntil(null); + + return; + } + + $account = $messages[0]->getAccount(); + $inbox = $this->labelResolver->systemLabel(LabelRole::Inbox, $account); + $snoozed = $this->labelRepository->findOneByRoleForUser( + LabelRole::Snoozed, + $account->getUsr(), + ); + + $inboxMailbox = $inbox->bindingFor($account)?->mailbox; + $unread = 0; + + foreach ($messages as $message) { + $message->addLabel($inbox); + + if (null !== $snoozed) { + $message->removeLabel($snoozed); + } + + // Plain-IMAP: the message physically comes back to INBOX. + if (null !== $message->getImapUid() && null !== $inboxMailbox) { + $message->setMailbox($inboxMailbox); + } + + $message->removeFlag(MessageFlag::SEEN)->setSeenAt(null); + ++$unread; + } + + $thread->setUnreadCount($unread); + $thread->setSnoozedUntil(null); + + if (null !== $snoozed) { + $this->propagator->detachLabel($messages, $snoozed); + } + + $this->propagator->attachLabel($messages, $inbox); + $this->propagator->markRead($messages, false); + + $this->finish($thread, $messages); + } + + /** + * @param list<\App\Entity\Mail\Message> $messages + */ + private function finish(MessageThread $thread, array $messages): void + { + $this->threadLabelSynchronizer->sync($thread); + + foreach ($messages as $message) { + $this->stateManager->recordUpdated( + (int) $message->getAccount()->getId(), + JmapObjectType::Email, + (string) $message->getId(), + ); + } + + $this->stateManager->recordThreadsTouched( + (int) $messages[0]->getAccount()->getId(), + [(string) $thread->getId()], + ); + + $this->entityManager->flush(); + } +} diff --git a/src/Twig/SidebarCounts.php b/src/Twig/SidebarCounts.php index eb2a106d..d89dce17 100644 --- a/src/Twig/SidebarCounts.php +++ b/src/Twig/SidebarCounts.php @@ -24,6 +24,7 @@ class SidebarCounts private ?array $roleCounts = null; private ?array $labelCounts = null; private ?int $starredCount = null; + private ?int $snoozedCount = null; private ?array $userLabelTree = null; private ?array $visibleLabels = null; @@ -122,6 +123,27 @@ class SidebarCounts return $children; } + /** + * True when anything is currently snoozed — controls the Snoozed entry in + * the system nav block. + * + * Gated rather than always shown, on the same reasoning as Archive: the + * label is created lazily the first time something is snoozed, so on an + * install that has never used the feature there is nothing to link to. + */ + public function hasSnoozed(): bool + { + if (null === $this->snoozedCount) { + $user = $this->security->getUser(); + + $this->snoozedCount = null === $user + ? 0 + : $this->threadRepository->countSnoozedForUser($user); + } + + return $this->snoozedCount > 0; + } + /** * True when the user's Archive label is switched visible — controls the * Archive entry in the system nav block. diff --git a/templates/_partials/_list_toolbar.html.twig b/templates/_partials/_list_toolbar.html.twig index 5fd2ea4f..76cf55a7 100644 --- a/templates/_partials/_list_toolbar.html.twig +++ b/templates/_partials/_list_toolbar.html.twig @@ -209,7 +209,16 @@ The button itself carries `group` and `relative`. {{ m.tbtn('click->mail--list-toolbar#deleteSelected', 'fa-solid fa-trash', 'toolbar.actions.delete'|trans, 'hover:text-danger') }} {{ m.tbtn('click->mail--list-toolbar#markReadSelected', 'fa-solid fa-envelope-open', 'toolbar.actions.mark_read'|trans) }} {{ m.tbtn('click->mail--list-toolbar#markUnreadSelected', 'fa-solid fa-envelope', 'toolbar.actions.mark_unread'|trans) }} - {{ m.tbtn('click->mail--list-toolbar#snoozeSelected', 'fa-solid fa-clock', 'toolbar.actions.snooze'|trans) }} + {# A menu, not a direct action: this used to hardcode "tomorrow 8am" + with a comment asking for a picker. #} +
+ {{ m.tbtn('click->ui--dropdown#toggle', 'fa-solid fa-clock', 'toolbar.actions.snooze'|trans) }} + {% include '_partials/_snooze_menu.html.twig' with { + controller: 'mail--list-toolbar', + method: 'snoozeSelected', + snoozed: false, + } only %} +
{% include '_partials/_label_menu.html.twig' with { labels: sidebarCounts.flattenedUserLabels, targetType: 'thread', diff --git a/templates/_partials/_sidebar.html.twig b/templates/_partials/_sidebar.html.twig index ed4ba3a5..b0e3e240 100644 --- a/templates/_partials/_sidebar.html.twig +++ b/templates/_partials/_sidebar.html.twig @@ -186,6 +186,19 @@ the layout. {{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts), 'role:drafts') }} + {% if sidebarCounts.hasSnoozed %} + + + + {{ 'sidebar.nav.snoozed'|trans }} + + {{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Snoozed), 'role:snoozed') }} + + {% endif %} {% if sidebarCounts.hasVisibleArchive %} ui--dropdown#close' %} + + diff --git a/templates/_partials/_thread_row.html.twig b/templates/_partials/_thread_row.html.twig index 31e3fe09..4c13dcaa 100644 --- a/templates/_partials/_thread_row.html.twig +++ b/templates/_partials/_thread_row.html.twig @@ -194,13 +194,26 @@ the conversation it answers. {% if false == draft %} - + {# The button opens a menu rather than snoozing directly: + there is no defensible default wake time, and the one + this had (none at all) cleared the snooze instead of + setting it. #} +
+ + + {% include '_partials/_snooze_menu.html.twig' with { + controller: 'mail--message-row', + method: 'snooze', + url: path('app_status_snooze', {'type': type, 'id': rowId}), + snoozed: thread is defined and thread.snoozed|default(false), + } only %} +