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>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-07-31 12:14:18 +02:00
1 parent c51fb95d59
commit 4656665ffd
28 files changed
+2011 -23

No files matched your search

@@ -148,15 +148,18 @@ export default class extends Controller {
await this._bulkPost(ids, "read", { read: false }); 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(); const ids = this._selectedIds();
if (ids.length === 0) { return; } if (ids.length === 0) { return; }
// Default: snooze until tomorrow morning.
// Replace with a date-picker dispatch if you add a snooze UI. const { until = null } = event?.params ?? {};
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1); await this._bulkPost(ids, "snooze", { until });
tomorrow.setHours(8, 0, 0, 0);
await this._bulkPost(ids, "snooze", { until: tomorrow.toISOString() });
} }
// ── Private ─────────────────────────────────────────────────────────── // ── Private ───────────────────────────────────────────────────────────
@@ -51,8 +51,18 @@ export default class extends Controller {
await this.#post(trashUrl); 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(); event.stopPropagation();
await this.#post(snoozeUrl, { until }); await this.#post(snoozeUrl, { until });
@@ -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",
});
}
}
@@ -32,6 +32,11 @@ export default class extends Controller {
open() { open() {
this.menuTarget.hidden = false; 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("click", this._boundOutside, { capture: true });
document.addEventListener("keydown", this._boundEscape); document.addEventListener("keydown", this._boundEscape);
} }
+70
View File
@@ -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;
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Command\Maintenance;
use App\Repository\Mail\MessageThreadRepository;
use App\Service\Mail\ThreadSnoozeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Returns snoozed conversations to the Inbox once their time is up.
*
* This is the half of snooze that makes it a snooze rather than a filing
* cabinet: without it a snoozed thread leaves the inbox and never comes back.
* It is scheduled every minute — the unit a user picks a wake time in — and is
* cheap when there is nothing due, which is almost always.
*
* Idempotent, and safe to run concurrently with itself: waking an already-woken
* thread only re-asserts the Inbox label. That matters because the scheduler
* replays a missed run when a worker comes back up.
*/
#[AsCommand(
name: 'app:mail:wake-snoozed',
description: 'Return snoozed conversations to the inbox once their wake time has passed.',
)]
final class WakeSnoozedCommand extends Command
{
/**
* Per run, so one enormous backlog cannot hold the worker for minutes.
* Whatever is left is picked up by the next run a minute later.
*/
private const int BATCH = 200;
public function __construct(
private readonly MessageThreadRepository $threadRepository,
private readonly ThreadSnoozeService $snoozeService,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$due = $this->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;
}
}
+27
View File
@@ -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')] #[Route('/account/{account}/folders', name: 'account_folders')]
public function accountFolders(Account $account): Response public function accountFolders(Account $account): Response
{ {
+23 -6
View File
@@ -17,6 +17,7 @@ use App\Repository\Mail\MessageThreadRepository;
use App\Service\Label\LabelChangePropagator; use App\Service\Label\LabelChangePropagator;
use App\Service\Label\LabelResolver; use App\Service\Label\LabelResolver;
use App\Service\Label\ThreadLabelSynchronizer; use App\Service\Label\ThreadLabelSynchronizer;
use App\Service\Mail\ThreadSnoozeService;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -48,6 +49,7 @@ class ThreadStatusController extends AbstractController
private readonly LabelChangePropagator $propagator, private readonly LabelChangePropagator $propagator,
private readonly ThreadLabelSynchronizer $threadLabelSynchronizer, private readonly ThreadLabelSynchronizer $threadLabelSynchronizer,
private readonly StateManager $stateManager, private readonly StateManager $stateManager,
private readonly ThreadSnoozeService $snoozeService,
) {} ) {}
#[Route('/star', name: 'star', methods: ['POST'])] #[Route('/star', name: 'star', methods: ['POST'])]
@@ -211,19 +213,34 @@ class ThreadStatusController extends AbstractController
$body = json_decode($request->getContent(), true); $body = json_decode($request->getContent(), true);
$until = null; $until = null;
if (true === array_key_exists('until', $body)) { if (true === is_array($body) && true === array_key_exists('until', $body)) {
if (null !== $body['until']) { if (null !== $body['until']) {
try { try {
$until = new DateTimeImmutable($body['until']); $until = new DateTimeImmutable((string) $body['until']);
} catch (\Exception $e) { } catch (\Exception) {
$until = new DateTimeImmutable('in 1 day'); $until = new DateTimeImmutable('in 1 day');
} }
} }
} }
$thread->setSnoozedUntil($until); // Through the service, not setSnoozedUntil(): snoozing has to move the
$this->recordJmapUpdates($messages); // Inbox label off and propagate that outward. Writing the column here
$this->em->flush(); // 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', [ return $this->renderTurboStream('thread/status/_snooze.stream.html.twig', [
'thread' => $thread, 'thread' => $thread,
+15
View File
@@ -22,6 +22,17 @@ enum LabelRole: string
case Trash = 'trash'; case Trash = 'trash';
case Spam = 'spam'; case Spam = 'spam';
case Archive = 'archive'; 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 public static function fromSpecialUse(MailboxSpecialUse $specialUse): self
{ {
@@ -44,6 +55,7 @@ enum LabelRole: string
self::Trash => 'Trash', self::Trash => 'Trash',
self::Spam => 'Spam', self::Spam => 'Spam',
self::Archive => 'Archive', self::Archive => 'Archive',
self::Snoozed => 'Snoozed',
}; };
} }
@@ -60,6 +72,9 @@ enum LabelRole: string
self::Spam => 30, self::Spam => 30,
self::Trash => 40, self::Trash => 40,
self::Archive => 50, self::Archive => 50,
// After Archive, before custom labels: it is a system place, but
// the least-visited one.
self::Snoozed => 60,
}; };
} }
@@ -52,6 +52,12 @@ final class MaintenanceSchedule implements ScheduleProviderInterface
// gone degraded. // gone degraded.
RecurringMessage::cron('0 4 * * *', new RunCommandMessage('app:push:renew --repair')), 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. // Log entries and dead heartbeats.
RecurringMessage::cron('30 4 * * *', new RunCommandMessage('app:monitoring:prune')), RecurringMessage::cron('30 4 * * *', new RunCommandMessage('app:monitoring:prune')),
+5
View File
@@ -125,6 +125,11 @@ final class MailboxMapper
'Starred', 'Flagged' => 'flagged', 'Starred', 'Flagged' => 'flagged',
'Important' => 'important', 'Important' => 'important',
'All', 'AllMail' => 'all', '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, default => null,
}; };
} }
+24
View File
@@ -77,6 +77,20 @@ final class ThreadGetMethod implements JmapMethod
$list[] = [ $list[] = [
'id' => (string) $thread->getId(), 'id' => (string) $thread->getId(),
'emailIds' => $emailIds, '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)), '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');
}
} }
+209
View File
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Jmap\Method\Mail;
use App\Entity\Mail\MessageThread;
use App\Jmap\Account\AccountResolver;
use App\Jmap\Method\JmapMethod;
use App\Jmap\Protocol\Exception\MethodException;
use App\Jmap\Protocol\JmapContext;
use App\Jmap\State\JmapObjectType;
use App\Jmap\State\StateManager;
use App\Repository\Mail\MessageThreadRepository;
use App\Service\Mail\ThreadSnoozeService;
use Doctrine\ORM\EntityManagerInterface;
/**
* "Thread/set" — a plMail extension, not RFC 8621.
*
* The spec's Thread is read-only: it has no /set, because a thread is derived
* from its Emails and there is nothing on it to change. plMail's is different
* in exactly one way — a thread can be snoozed, and that state belongs to the
* thread rather than to any message in it.
*
* Snoozing is not a flag. Setting `snoozedUntil` moves the conversation out of
* the Inbox and into Snoozed, and app:mail:wake-snoozed brings it back later —
* see ThreadSnoozeService, which the web UI goes through as well, so a snooze
* means the same thing whichever client set it.
*
* Deliberately narrow. `create` and `destroy` are refused outright: threads
* come into being by mail arriving and go away when their last message does,
* and a client that could conjure one would be describing something the rest
* of the system has no meaning for. `update` accepts one property.
*
* Standard clients neither know nor need this method — Thread/get still
* answers the spec's two properties plus one they will ignore.
*/
final class ThreadSetMethod implements JmapMethod
{
private const int MAX_OBJECTS = 500;
public function __construct(
private readonly AccountResolver $accountResolver,
private readonly MessageThreadRepository $threadRepository,
private readonly StateManager $stateManager,
private readonly EntityManagerInterface $entityManager,
private readonly ThreadSnoozeService $snoozeService,
) {
}
public function name(): string
{
return 'Thread/set';
}
public function handle(array $arguments, JmapContext $context): array
{
$account = $this->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),
);
}
}
}
@@ -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<MessageThread>
*/
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 * 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. * eager-joined because a Thread object is nothing but its email id list.
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageFlag;
use App\Entity\Mail\MessageThread;
use App\Jmap\State\JmapObjectType;
use App\Jmap\State\StateManager;
use App\Repository\Label\LabelRepository;
use App\Service\Label\LabelChangePropagator;
use App\Service\Label\LabelResolver;
use App\Service\Label\ThreadLabelSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
/**
* Snoozing a conversation, and bringing it back.
*
* Snooze is archive-with-a-timer: the thread leaves the Inbox now and returns
* to it later. 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 so the snoozed pile is reachable by every path that
* already works on labels: the sidebar, a query, the unified feed.
*
* The label change **propagates outward**, exactly as archiving does. That is
* the point rather than a side effect: a snoozed conversation should be out of
* the way in Gmail's inbox too, not just in plMail's view of it.
*
* One service rather than one implementation per caller. The JMAP method and
* the web UI both go through here, because a snooze that meant something
* different depending on which client did it is worse than no snooze.
*/
final class ThreadSnoozeService
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LabelRepository $labelRepository,
private readonly LabelResolver $labelResolver,
private readonly ThreadLabelSynchronizer $threadLabelSynchronizer,
private readonly LabelChangePropagator $propagator,
private readonly StateManager $stateManager,
) {
}
/**
* Puts a conversation away until `$until`.
*
* A time already in the past is not rejected: it simply means the next
* sweep wakes it, which is a harmless way to express "bring this back
* now" and saves callers a clock comparison.
*/
public function snooze(MessageThread $thread, \DateTimeImmutable $until): void
{
$messages = $thread->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();
}
}
+22
View File
@@ -24,6 +24,7 @@ class SidebarCounts
private ?array $roleCounts = null; private ?array $roleCounts = null;
private ?array $labelCounts = null; private ?array $labelCounts = null;
private ?int $starredCount = null; private ?int $starredCount = null;
private ?int $snoozedCount = null;
private ?array $userLabelTree = null; private ?array $userLabelTree = null;
private ?array $visibleLabels = null; private ?array $visibleLabels = null;
@@ -122,6 +123,27 @@ class SidebarCounts
return $children; 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 * True when the user's Archive label is switched visible — controls the
* Archive entry in the system nav block. * Archive entry in the system nav block.
+10 -1
View File
@@ -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#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#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#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. #}
<div class="relative" data-controller="ui--dropdown">
{{ 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 %}
</div>
{% include '_partials/_label_menu.html.twig' with { {% include '_partials/_label_menu.html.twig' with {
labels: sidebarCounts.flattenedUserLabels, labels: sidebarCounts.flattenedUserLabels,
targetType: 'thread', targetType: 'thread',
+13
View File
@@ -186,6 +186,19 @@ the layout.
</span> </span>
{{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts), 'role:drafts') }} {{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts), 'role:drafts') }}
</a> </a>
{% if sidebarCounts.hasSnoozed %}
<a href="{{ path('app_mail_snoozed') }}"
class="nav-item flex items-center justify-between gap-2 px-3 py-2 rounded-lg transition-colors"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.snoozed'|trans }}">
<span class="flex items-center gap-2">
<i class="fa-solid fa-clock w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.snoozed'|trans }}</span>
</span>
{{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Snoozed), 'role:snoozed') }}
</a>
{% endif %}
{% if sidebarCounts.hasVisibleArchive %} {% if sidebarCounts.hasVisibleArchive %}
<a href="{{ path('app_mail_archive') }}" <a href="{{ path('app_mail_archive') }}"
class="nav-item flex items-center justify-between gap-2 px-3 py-2 rounded-lg transition-colors" class="nav-item flex items-center justify-between gap-2 px-3 py-2 rounded-lg transition-colors"
@@ -0,0 +1,58 @@
{#
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>
+20 -7
View File
@@ -194,13 +194,26 @@ the conversation it answers.
</button> </button>
{% if false == draft %} {% if false == draft %}
<button type="button" data-action="click->mail--message-row#snooze" {# The button opens a menu rather than snoozing directly:
data-mail--message-row-snooze-url-param="{{ path('app_status_snooze', {'type': type, 'id': rowId}) }}" there is no defensible default wake time, and the one
aria-label="{{ 'thread_row.actions.snooze'|trans }}" this had (none at all) cleared the snooze instead of
title="{{ 'thread_row.actions.snooze'|trans }}" setting it. #}
class="z-10 p-1.5 cursor-pointer rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"> <div class="relative z-10" data-controller="ui--dropdown">
<i class="fa-solid fa-clock w-4 h-4" aria-hidden="true"></i> <button type="button"
</button> data-action="click->ui--dropdown#toggle click->mail--message-row#stop"
aria-label="{{ 'thread_row.actions.snooze'|trans }}"
title="{{ 'thread_row.actions.snooze'|trans }}"
class="p-1.5 cursor-pointer rounded-lg text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<i class="fa-solid fa-clock w-4 h-4" aria-hidden="true"></i>
</button>
{% 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 %}
</div>
<button type="button" data-action="click->mail--message-row#markRead" <button type="button" data-action="click->mail--message-row#markRead"
data-mail--message-row-read-url-param="{{ path('app_status_mark_read', {'type': type, 'id': rowId}) }}" data-mail--message-row-read-url-param="{{ path('app_status_mark_read', {'type': type, 'id': rowId}) }}"
+20
View File
@@ -0,0 +1,20 @@
{# mail/snoozed.html.twig #}
{% extends "_layout/_mailbox.html.twig" %}
{% block list_scope %}snoozed{% endblock %}
{% block title %}{{ "sidebar.nav.snoozed"|trans }} — {{ "app.name"|trans }}{% endblock %}
{% block message_list %}
{% if threads is empty %}
<li class="px-4 py-8 text-center text-sm text-ink-faint">
{{ "snoozed.empty"|trans }}
</li>
{% else %}
{% for thread in threads %}
{% include "_partials/_thread_row.html.twig" with {
thread: thread,
} %}
{% endfor %}
{% endif %}
{% endblock %}
+221
View File
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Mail\Account;
use App\Entity\Mail\Mailbox;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Service\Label\LabelResolver;
use App\Service\Mail\ThreadSnoozeService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* The sweep is what makes a snooze a snooze.
*
* Without it a snoozed conversation leaves the inbox and never comes back,
* which is strictly worse than not offering the feature — the mail is not lost
* but the user has no reason to expect it anywhere. The scheduler runs this
* every minute; these tests cover what it does when it fires.
*/
final class WakeSnoozedCommandTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private LabelResolver $labelResolver;
private ThreadSnoozeService $snoozeService;
private CommandTester $command;
private Account $account;
private Mailbox $mailbox;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->labelResolver = $container->get(LabelResolver::class);
$this->snoozeService = $container->get(ThreadSnoozeService::class);
$this->connection->beginTransaction();
$this->account = $this->seedAccount();
$this->mailbox = $this->seedMailbox();
$this->command = new CommandTester(
new Application(self::$kernel)->find('app:mail:wake-snoozed'),
);
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testAThreadWhoseTimeHasPassedComesBack(): void
{
$thread = $this->snoozedThread('-5 minutes');
$this->command->execute([]);
self::assertSame(0, $this->command->getStatusCode());
self::assertStringContainsString('Woke 1', $this->command->getDisplay());
self::assertNull($thread->getSnoozedUntil());
self::assertContains(LabelRole::Inbox, $this->rolesOn($thread));
}
/** The whole point of a wake time: before it, nothing happens. */
public function testAThreadStillInTheFutureIsLeftAlone(): void
{
$thread = $this->snoozedThread('+2 days');
$this->command->execute([]);
self::assertNotNull($thread->getSnoozedUntil());
self::assertNotContains(LabelRole::Inbox, $this->rolesOn($thread));
}
/**
* The scheduler replays a missed run when a worker comes back up, so a
* second sweep over the same threads must be a no-op rather than a second
* round of label churn and provider calls.
*/
public function testASecondSweepDoesNothing(): void
{
$thread = $this->snoozedThread('-5 minutes');
$this->command->execute([]);
$this->command->execute([]);
// Silence, not "Woke 0": with nothing due the command returns before
// it writes anything, which is what keeps a once-a-minute sweep out of
// the logs.
self::assertSame('', $this->command->getDisplay());
self::assertSame(0, $this->command->getStatusCode());
self::assertNull($thread->getSnoozedUntil());
}
/** Nothing due is the normal case, and must stay quiet and successful. */
public function testAnEmptySweepSucceeds(): void
{
$this->command->execute([]);
self::assertSame(0, $this->command->getStatusCode());
}
// ── Fixtures ──────────────────────────────────────────────────────────
/** @return list<LabelRole|null> */
private function rolesOn(MessageThread $thread): array
{
$roles = [];
foreach ($thread->getMessages() as $message) {
foreach ($message->getLabels() as $label) {
$roles[] = $label->role;
}
}
return $roles;
}
private function snoozedThread(string $when): MessageThread
{
$thread = new MessageThread();
$thread
->setAccount($this->account)
->setSubject('Wake fixture')
->setNormalizedSubject('wake fixture')
->setLastMessageAt(new \DateTimeImmutable('-1 hour'))
->setThreadingMethod(ThreadingMethod::References)
->setUnreadCount(0);
$this->em->persist($thread);
$message = new Message();
$message
->setAccount($this->account)
->setSubject('Wake fixture')
->setFromAddress('sender@example.test')
->setReceivedAt(new \DateTimeImmutable('-1 hour'))
->setHasAttachments(false)
->setMessageId(sprintf('<wake-%s@example.test>', uniqid('', true)))
->setMailbox($this->mailbox)
->setImapUid(7000)
->addLabel($this->labelResolver->systemLabel(LabelRole::Inbox, $this->account));
$thread->addMessage($message);
$this->em->persist($message);
$this->em->flush();
// Through the service, so the fixture is in the state a real snooze
// leaves behind rather than one this test invented.
$this->snoozeService->snooze($thread, new \DateTimeImmutable($when));
return $thread;
}
private function seedAccount(): Account
{
$user = new User();
$user
->setEmail('wake-' . uniqid('', true) . '@example.test')
->setNameFirst('Wake')
->setNameLast('Fixture')
->setRoles(['ROLE_USER'])
->setPassword('x');
$this->em->persist($user);
$account = new Account();
$account
->setUsr($user)
->setEmail('Wake Fixture')
->setUsername('wake-fixture@example.test')
->setImapHost('localhost')
->setImapPort(993)
->setImapEncryption('ssl')
->setSmtpHost('localhost')
->setSmtpPort(587)
->setSmtpEncryption('starttls')
->setPassword('x')
->setAuthType('password')
->setIsActive(true);
$this->em->persist($account);
$this->em->flush();
return $account;
}
private function seedMailbox(): Mailbox
{
$mailbox = new Mailbox();
$mailbox
->setAccount($this->account)
->setName('INBOX')
->setFullPath('INBOX')
->setIsSyncEnabled(true)
->setIsIdleEnabled(false)
->setCreatedAt(new \DateTimeImmutable())
->setUpdatedAt(new \DateTimeImmutable());
$this->em->persist($mailbox);
$this->em->flush();
return $mailbox;
}
}
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Mail\Account;
use App\Entity\Mail\Mailbox;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Service\Label\LabelResolver;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* The web snooze endpoint has to mean the same thing as Thread/set.
*
* It did not. `POST /status/thread/{id}/snooze` wrote snoozedUntil and nothing
* else — no labels moved, nothing propagated — so the conversation stayed in
* the Inbox locally and at the provider while its row vanished from the list,
* until the sweep "woke" a thread that had never left. Both callers now go
* through ThreadSnoozeService, and this pins the endpoint to that.
*
* Deliberately asserted on labels rather than on the column: the column was
* always written correctly, and a test that checked it would have passed
* against the broken implementation.
*/
final class ThreadSnoozeEndpointTest extends WebTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private LabelResolver $labelResolver;
private Account $account;
protected function tearDown(): void
{
if (isset($this->connection) && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testSnoozingMovesTheThreadOutOfTheInbox(): void
{
$client = $this->signIn();
$threadId = (int) $this->inboxThread()->getId();
$client->request(
'POST',
sprintf('/status/thread/%d/snooze', $threadId),
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode(['until' => (new \DateTimeImmutable('+1 day'))->format(DATE_ATOM)]),
);
self::assertResponseIsSuccessful();
$thread = $this->reload($threadId);
// Looked up, never created: systemLabel() would mint a Snoozed label
// as a side effect of asserting, so a broken endpoint would fail here
// on fixture mechanics instead of on the thing being tested.
$roles = [];
foreach ($thread->getMessages() as $message) {
foreach ($message->getLabels() as $label) {
$roles[] = $label->role;
}
}
self::assertNotContains(
LabelRole::Inbox,
$roles,
'the endpoint wrote the column but left the thread in the inbox',
);
self::assertContains(
LabelRole::Snoozed,
$roles,
'the endpoint wrote the column but never applied the Snoozed label',
);
self::assertNotNull($thread->getSnoozedUntil());
}
public function testSendingNoUntilClearsTheSnoozeAndRestoresTheInbox(): void
{
$client = $this->signIn();
$threadId = (int) $this->inboxThread()->getId();
$path = sprintf('/status/thread/%d/snooze', $threadId);
$client->request(
'POST',
$path,
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode(['until' => (new \DateTimeImmutable('+1 day'))->format(DATE_ATOM)]),
);
$client->request(
'POST',
$path,
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode(['until' => null]),
);
self::assertResponseIsSuccessful();
$thread = $this->reload($threadId);
$roles = [];
foreach ($thread->getMessages()->first()->getLabels() as $label) {
$roles[] = $label->role;
}
self::assertNull($thread->getSnoozedUntil());
self::assertContains(LabelRole::Inbox, $roles);
self::assertNotContains(LabelRole::Snoozed, $roles);
}
// ── Fixtures ──────────────────────────────────────────────────────────
/**
* Re-read the thread from the container's current EntityManager.
*
* By id rather than refresh(): the kernel is rebooted between requests, so
* the instance the fixtures returned is detached by the time the
* assertions run and refresh() would reject it.
*/
private function reload(int $id): MessageThread
{
$em = static::getContainer()->get(EntityManagerInterface::class);
$em->clear();
$thread = $em->find(MessageThread::class, $id);
self::assertNotNull($thread, 'thread vanished');
return $thread;
}
private function signIn(): KernelBrowser
{
$client = static::createClient();
// One kernel across the whole test. The client reboots between
// requests by default, which detaches the EntityManager this test
// holds — and the unsnooze case needs two requests against the same
// fixtures.
$client->disableReboot();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->labelResolver = $container->get(LabelResolver::class);
// Rolled back in tearDown. Without it this test commits an Account
// onto a shared user, and AccountFormControllersTest — which renders
// the onboarding account step — starts seeing a different page.
$this->connection->beginTransaction();
// Its own user rather than the seeded admin, for the same reason: the
// fixtures here change what onboarding thinks the account holds.
$user = new User();
$user
->setEmail('snooze-endpoint-' . uniqid('', true) . '@example.test')
->setNameFirst('Snooze')
->setNameLast('Endpoint')
->setRoles(['ROLE_USER'])
->setPassword('x');
$this->em->persist($user);
$this->em->flush();
$client->loginUser($user);
$this->account = new Account();
$this->account
->setUsr($user)
->setEmail('Snooze Endpoint')
->setUsername('snooze-endpoint-' . uniqid('', true) . '@example.test')
->setImapHost('localhost')
->setImapPort(993)
->setImapEncryption('ssl')
->setSmtpHost('localhost')
->setSmtpPort(587)
->setSmtpEncryption('starttls')
->setPassword('x')
->setAuthType('password')
->setIsActive(true);
$this->em->persist($this->account);
$this->em->flush();
return $client;
}
private function inboxThread(): MessageThread
{
$mailbox = new Mailbox();
$mailbox
->setAccount($this->account)
->setName('INBOX')
->setFullPath('INBOX')
->setIsSyncEnabled(true)
->setIsIdleEnabled(false)
->setCreatedAt(new \DateTimeImmutable())
->setUpdatedAt(new \DateTimeImmutable());
$this->em->persist($mailbox);
$thread = new MessageThread();
$thread
->setAccount($this->account)
->setSubject('Endpoint fixture')
->setNormalizedSubject('endpoint fixture')
->setLastMessageAt(new \DateTimeImmutable('-1 hour'))
->setThreadingMethod(ThreadingMethod::References)
->setUnreadCount(0);
$this->em->persist($thread);
$message = new Message();
$message
->setAccount($this->account)
->setSubject('Endpoint fixture')
->setFromAddress('sender@example.test')
->setReceivedAt(new \DateTimeImmutable('-1 hour'))
->setHasAttachments(false)
->setMessageId(sprintf('<snooze-endpoint-%s@example.test>', uniqid('', true)))
->setMailbox($mailbox)
->setImapUid(4242)
->addLabel($this->labelResolver->systemLabel(LabelRole::Inbox, $this->account));
$thread->addMessage($message);
$this->em->persist($message);
$this->em->flush();
return $thread;
}
}
+279
View File
@@ -0,0 +1,279 @@
<?php
declare(strict_types=1);
namespace App\Tests\Jmap\Method;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Mail\Account;
use App\Entity\Mail\Mailbox;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Jmap\Method\Mail\ThreadSetMethod;
use App\Jmap\Protocol\Exception\MethodException;
use App\Jmap\Protocol\JmapContext;
use App\Service\Label\LabelResolver;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* "Thread/set" is a plMail extension, so its shape is ours to get right.
*
* RFC 8621's Thread is read-only. This adds exactly one settable property, and
* the tests below are mostly about what it refuses: a method that accepted
* `create`, or silently ignored an unknown property, would be inventing
* semantics the rest of the system has no meaning for.
*
* The snooze behaviour itself lives in ThreadSnoozeServiceTest — here the
* question is only whether the method routes to it.
*/
final class ThreadSetMethodTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private LabelResolver $labelResolver;
private ThreadSetMethod $method;
private User $user;
private Account $account;
private Mailbox $mailbox;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->labelResolver = $container->get(LabelResolver::class);
$this->method = $container->get(ThreadSetMethod::class);
$this->connection->beginTransaction();
$this->seed();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testItIsNamedThreadSet(): void
{
self::assertSame('Thread/set', $this->method->name());
}
public function testSettingSnoozedUntilSnoozesTheThread(): void
{
$thread = $this->inboxThread();
$result = $this->handle([
'update' => [
(string) $thread->getId() => [
'snoozedUntil' => (new \DateTimeImmutable('+1 day'))->format('Y-m-d\TH:i:s\Z'),
],
],
]);
self::assertArrayHasKey((string) $thread->getId(), (array) $result['updated']);
self::assertNotNull($thread->getSnoozedUntil());
self::assertNotContains(LabelRole::Inbox, $this->rolesOn($thread));
}
public function testNullClearsTheSnooze(): void
{
$thread = $this->inboxThread();
$id = (string) $thread->getId();
$this->handle([
'update' => [$id => ['snoozedUntil' => (new \DateTimeImmutable('+1 day'))->format('Y-m-d\TH:i:s\Z')]],
]);
$this->handle(['update' => [$id => ['snoozedUntil' => null]]]);
self::assertNull($thread->getSnoozedUntil());
self::assertContains(LabelRole::Inbox, $this->rolesOn($thread));
}
/**
* Threads come into being by mail arriving. A client that could conjure one
* would be describing something nothing else in the system understands.
*/
public function testCreateIsRefused(): void
{
$this->expectException(MethodException::class);
$this->handle(['create' => ['tmp' => ['snoozedUntil' => null]]]);
}
public function testDestroyIsRefused(): void
{
$this->expectException(MethodException::class);
$this->handle(['destroy' => ['1']]);
}
/**
* Refused rather than ignored: a client told its write succeeded, when the
* property was silently dropped, has no way to discover otherwise.
*/
public function testAnUnknownPropertyIsRejectedForThatThread(): void
{
$thread = $this->inboxThread();
$result = $this->handle([
'update' => [(string) $thread->getId() => ['subject' => 'nope']],
]);
self::assertArrayHasKey((string) $thread->getId(), (array) $result['notUpdated']);
self::assertCount(0, (array) $result['updated']);
}
/**
* The web endpoint substitutes "in 1 day" on an unparseable date, which is
* defensible for a form post. For an API it is not: the client would be
* told it succeeded and find the thread back at a time it never chose.
*/
public function testAMalformedDateIsRefusedRatherThanCoerced(): void
{
$thread = $this->inboxThread();
$result = $this->handle([
'update' => [(string) $thread->getId() => ['snoozedUntil' => 'next tuesday-ish']],
]);
self::assertArrayHasKey((string) $thread->getId(), (array) $result['notUpdated']);
self::assertNull($thread->getSnoozedUntil());
}
/** Scoped by account, so guessing an integer reaches nothing. */
public function testAThreadOutsideTheAccountIsNotFound(): void
{
$result = $this->handle(['update' => ['999999' => ['snoozedUntil' => null]]]);
self::assertSame('notFound', ((array) $result['notUpdated'])['999999']['type']);
}
/** An empty patch changes nothing, and must not be an error. */
public function testAnEmptyPatchIsANoOp(): void
{
$thread = $this->inboxThread();
$result = $this->handle(['update' => [(string) $thread->getId() => []]]);
self::assertArrayHasKey((string) $thread->getId(), (array) $result['updated']);
self::assertNull($thread->getSnoozedUntil());
}
// ── Helpers ───────────────────────────────────────────────────────────
/**
* @param array<string,mixed> $arguments
*
* @return array<string,mixed>
*/
private function handle(array $arguments): array
{
return $this->method->handle(
$arguments + ['accountId' => (string) $this->account->getId()],
new JmapContext($this->user),
);
}
/** @return list<LabelRole|null> */
private function rolesOn(MessageThread $thread): array
{
$roles = [];
foreach ($thread->getMessages() as $message) {
foreach ($message->getLabels() as $label) {
$roles[] = $label->role;
}
}
return $roles;
}
private function inboxThread(): MessageThread
{
$thread = new MessageThread();
$thread
->setAccount($this->account)
->setSubject('Thread/set fixture')
->setNormalizedSubject('thread/set fixture')
->setLastMessageAt(new \DateTimeImmutable('-1 hour'))
->setThreadingMethod(ThreadingMethod::References)
->setUnreadCount(0);
$this->em->persist($thread);
$message = new Message();
$message
->setAccount($this->account)
->setSubject('Thread/set fixture')
->setFromAddress('sender@example.test')
->setReceivedAt(new \DateTimeImmutable('-1 hour'))
->setHasAttachments(false)
->setMessageId(sprintf('<threadset-%s@example.test>', uniqid('', true)))
->setMailbox($this->mailbox)
->setImapUid(8000)
->addLabel($this->labelResolver->systemLabel(LabelRole::Inbox, $this->account));
$thread->addMessage($message);
$this->em->persist($message);
$this->em->flush();
return $thread;
}
private function seed(): void
{
$this->user = new User();
$this->user
->setEmail('threadset-' . uniqid('', true) . '@example.test')
->setNameFirst('Thread')
->setNameLast('Set')
->setRoles(['ROLE_USER'])
->setPassword('x');
$this->em->persist($this->user);
$this->account = new Account();
$this->account
->setUsr($this->user)
->setEmail('Thread Set')
->setUsername('threadset-fixture@example.test')
->setImapHost('localhost')
->setImapPort(993)
->setImapEncryption('ssl')
->setSmtpHost('localhost')
->setSmtpPort(587)
->setSmtpEncryption('starttls')
->setPassword('x')
->setAuthType('password')
->setIsActive(true);
$this->em->persist($this->account);
$this->mailbox = new Mailbox();
$this->mailbox
->setAccount($this->account)
->setName('INBOX')
->setFullPath('INBOX')
->setIsSyncEnabled(true)
->setIsIdleEnabled(false)
->setCreatedAt(new \DateTimeImmutable())
->setUpdatedAt(new \DateTimeImmutable());
$this->em->persist($this->mailbox);
$this->em->flush();
// getAccounts() is what AccountResolver scopes on, and the inverse
// side is not populated by persisting the owning side alone.
$this->user->addAccount($this->account);
}
}
@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageFlag;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Mail\Account;
use App\Entity\Mail\Mailbox;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Repository\Label\LabelRepository;
use App\Service\Label\LabelResolver;
use App\Service\Mail\ThreadSnoozeService;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
/**
* Snooze is a label move, not a column write.
*
* That distinction is the whole subject here. The web endpoint used to set
* snoozedUntil directly and nothing else, which left the conversation sitting
* in the Inbox — locally and at the provider — while the row disappeared from
* the list, until the sweep "woke" a thread that had never left.
*
* Against a real container and a real database rather than doubles, for two
* reasons. Every collaborator this service takes is `final`, so none of them
* can be doubled anyway; and the behaviour worth pinning is the one that
* emerges from them together — labels moved, an outward propagation queued —
* which a set of mocks would assert into existence rather than observe.
*/
final class ThreadSnoozeServiceTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private ThreadSnoozeService $service;
private LabelResolver $labelResolver;
private LabelRepository $labelRepository;
private Account $account;
private Mailbox $inboxMailbox;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->service = $container->get(ThreadSnoozeService::class);
$this->labelResolver = $container->get(LabelResolver::class);
$this->labelRepository = $container->get(LabelRepository::class);
// Never committed, so the suite leaves nothing behind and can be run
// repeatedly against the same database.
$this->connection->beginTransaction();
$this->account = $this->seedAccount();
$this->inboxMailbox = $this->seedMailbox();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testSnoozeMovesTheThreadOutOfTheInbox(): void
{
$thread = $this->inboxThread(2);
$until = new \DateTimeImmutable('+1 day');
$this->service->snooze($thread, $until);
$inbox = $this->role(LabelRole::Inbox);
$snoozed = $this->role(LabelRole::Snoozed);
foreach ($thread->getMessages() as $message) {
self::assertFalse($message->getLabels()->contains($inbox), 'Inbox label should be gone');
self::assertTrue($message->getLabels()->contains($snoozed), 'Snoozed label should be set');
}
self::assertSame($until, $thread->getSnoozedUntil());
self::assertTrue($thread->isSnoozed());
}
/**
* The regression that matters most: a snooze the provider never hears about
* leaves the conversation in the Gmail inbox, which is exactly what
* snoozing is supposed to prevent. The propagator works by queueing, so the
* observable fact is that something was queued.
*/
public function testSnoozeQueuesTheOutwardChange(): void
{
$transport = $this->transport();
$before = count($transport->getSent());
$this->service->snooze($this->inboxThread(1), new \DateTimeImmutable('+1 day'));
self::assertGreaterThan(
$before,
count($transport->getSent()),
'snoozing must propagate outward, not only move local labels',
);
}
public function testWakeReturnsTheThreadToTheInboxAndClearsTheSnooze(): void
{
$thread = $this->inboxThread(2);
$this->service->snooze($thread, new \DateTimeImmutable('+1 day'));
$this->service->wake($thread);
$inbox = $this->role(LabelRole::Inbox);
$snoozed = $this->role(LabelRole::Snoozed);
foreach ($thread->getMessages() as $message) {
self::assertTrue($message->getLabels()->contains($inbox));
self::assertFalse($message->getLabels()->contains($snoozed));
}
self::assertNull($thread->getSnoozedUntil());
self::assertFalse($thread->isSnoozed());
}
/**
* Waking marks unread on purpose — a thread that comes back in the state it
* left in is one the reader has already trained themselves to scroll past.
*/
public function testWakeMarksEveryMessageUnread(): void
{
$thread = $this->inboxThread(3);
foreach ($thread->getMessages() as $message) {
$message->addFlag(MessageFlag::SEEN)->setSeenAt(new \DateTimeImmutable());
}
$this->em->flush();
$this->service->wake($thread);
foreach ($thread->getMessages() as $message) {
self::assertFalse($message->hasFlag(MessageFlag::SEEN));
self::assertNull($message->getSeenAt());
}
self::assertSame(3, $thread->getUnreadCount());
}
/**
* The sweep and a user clicking "unsnooze" can race, and the scheduler
* replays a missed run — so waking twice has to be harmless.
*/
public function testWakeIsIdempotent(): void
{
$thread = $this->inboxThread(1);
$this->service->snooze($thread, new \DateTimeImmutable('+1 day'));
$this->service->wake($thread);
$this->service->wake($thread);
self::assertNull($thread->getSnoozedUntil());
self::assertTrue(
$thread->getMessages()->first()->getLabels()->contains($this->role(LabelRole::Inbox)),
);
}
/**
* A past time is accepted rather than rejected: it is a legitimate way to
* say "bring this back on the next sweep", and saves every caller a clock
* comparison.
*/
public function testSnoozeAcceptsATimeInThePast(): void
{
$thread = $this->inboxThread(1);
$past = new \DateTimeImmutable('-1 hour');
$this->service->snooze($thread, $past);
self::assertSame($past, $thread->getSnoozedUntil());
self::assertFalse($thread->isSnoozed(), 'already due, so not snoozed');
}
/** A thread with no messages has no labels to move; it must not fatal. */
public function testEmptyThreadIsSafe(): void
{
$thread = new MessageThread();
$thread
->setAccount($this->account)
->setSubject('Empty')
->setNormalizedSubject('empty')
->setLastMessageAt(new \DateTimeImmutable('-1 hour'))
->setThreadingMethod(ThreadingMethod::References)
->setSnoozedUntil(new \DateTimeImmutable('+1 day'));
$this->em->persist($thread);
$this->em->flush();
$this->service->snooze($thread, new \DateTimeImmutable('+2 days'));
$this->service->wake($thread);
self::assertNull($thread->getSnoozedUntil());
}
// ── Fixtures ──────────────────────────────────────────────────────────
private function role(LabelRole $role): object
{
return $this->labelResolver->systemLabel($role, $this->account);
}
private function inboxThread(int $messages): MessageThread
{
$inbox = $this->role(LabelRole::Inbox);
$thread = new MessageThread();
$thread
->setAccount($this->account)
->setSubject('Snooze fixture')
->setNormalizedSubject('snooze fixture')
->setLastMessageAt(new \DateTimeImmutable('-1 hour'))
->setThreadingMethod(ThreadingMethod::References)
->setUnreadCount(0);
$this->em->persist($thread);
for ($i = 0; $i < $messages; $i++) {
$message = new Message();
$message
->setAccount($this->account)
->setSubject(sprintf('Snooze fixture %d', $i))
->setFromAddress('sender@example.test')
->setReceivedAt(new \DateTimeImmutable('-1 hour'))
->setHasAttachments(false)
->setMessageId(sprintf('<snooze-%s-%d@example.test>', uniqid('', true), $i))
// A mailbox and a UID, because LabelChangePropagator skips
// messages that have neither — without them the propagation
// assertion would be testing the fixture, not the service.
->setMailbox($this->inboxMailbox)
->setImapUid(1000 + $i);
$message->addLabel($inbox);
$thread->addMessage($message);
$this->em->persist($message);
}
$this->em->flush();
return $thread;
}
private function seedAccount(): Account
{
$user = new User();
$user
->setEmail('snooze-' . uniqid('', true) . '@example.test')
->setNameFirst('Snooze')
->setNameLast('Fixture')
->setRoles(['ROLE_USER'])
->setPassword('x');
$this->em->persist($user);
$account = new Account();
$account
->setUsr($user)
->setEmail('Snooze Fixture')
->setUsername('snooze-fixture@example.test')
->setImapHost('localhost')
->setImapPort(993)
->setImapEncryption('ssl')
->setSmtpHost('localhost')
->setSmtpPort(587)
->setSmtpEncryption('starttls')
->setPassword('x')
->setAuthType('password')
->setIsActive(true);
$this->em->persist($account);
$this->em->flush();
return $account;
}
private function seedMailbox(): Mailbox
{
$mailbox = new Mailbox();
$mailbox
->setAccount($this->account)
->setName('INBOX')
->setFullPath('INBOX')
->setIsSyncEnabled(true)
->setIsIdleEnabled(false)
->setCreatedAt(new \DateTimeImmutable())
->setUpdatedAt(new \DateTimeImmutable());
$this->em->persist($mailbox);
$this->em->flush();
return $mailbox;
}
private function transport(): InMemoryTransport
{
return self::getContainer()->get('messenger.transport.async');
}
}
+14
View File
@@ -1088,10 +1088,24 @@ sidebar:
drafts: Entwürfe drafts: Entwürfe
inbox: Posteingang inbox: Posteingang
sent: Gesendet sent: Gesendet
snoozed: Zurückgestellt
starred: Markiert starred: Markiert
trash: Papierkorb trash: Papierkorb
toggle: Seitenleiste ein-/ausblenden toggle: Seitenleiste ein-/ausblenden
snooze:
later_today: Später heute
next_week: Nächste Woche
pick_date: Datum und Uhrzeit wählen
this_weekend: Dieses Wochenende
tomorrow: Morgen
unsnooze: Zurückholen
snoozed:
empty: Nichts ist zurückgestellt.
heading: Zurückgestellt
until: Kommt %when% zurück
starred: starred:
empty: Keine markierten Nachrichten. empty: Keine markierten Nachrichten.
heading: Markiert heading: Markiert
+14
View File
@@ -1069,10 +1069,24 @@ sidebar:
drafts: Drafts drafts: Drafts
inbox: Inbox inbox: Inbox
sent: Sent sent: Sent
snoozed: Snoozed
starred: Starred starred: Starred
trash: Trash trash: Trash
toggle: Show or hide the sidebar toggle: Show or hide the sidebar
snooze:
later_today: Later today
next_week: Next week
pick_date: Pick a date and time
this_weekend: This weekend
tomorrow: Tomorrow
unsnooze: Unsnooze
snoozed:
empty: Nothing is snoozed.
heading: Snoozed
until: Comes back %when%
starred: starred:
empty: No starred messages. empty: No starred messages.
heading: Starred heading: Starred
+14
View File
@@ -1064,10 +1064,24 @@ sidebar:
drafts: Scrolls in the works drafts: Scrolls in the works
inbox: Bottles washed ashore inbox: Bottles washed ashore
sent: Cast off sent: Cast off
snoozed: Kept below deck
starred: Marked with an X starred: Marked with an X
trash: Davy Jones' locker trash: Davy Jones' locker
toggle: Raise or lower th' side riggin' toggle: Raise or lower th' side riggin'
snooze:
later_today: Later this watch
next_week: Next se'nnight
pick_date: Name th' day and th' hour
this_weekend: Come th' weekend
tomorrow: On th' morrow
unsnooze: Haul it back up
snoozed:
empty: Naught kept below deck.
heading: Kept below deck
until: Surfaces %when%
starred: starred:
empty: Naught marked with an X. empty: Naught marked with an X.
heading: Marked with an X heading: Marked with an X