mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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:
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 });
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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')),
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* eager-joined because a Thread object is nothing but its email id list.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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. #}
|
||||
<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 {
|
||||
labels: sidebarCounts.flattenedUserLabels,
|
||||
targetType: 'thread',
|
||||
|
||||
@@ -186,6 +186,19 @@ the layout.
|
||||
</span>
|
||||
{{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts), 'role:drafts') }}
|
||||
</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 %}
|
||||
<a href="{{ path('app_mail_archive') }}"
|
||||
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>
|
||||
@@ -194,13 +194,26 @@ the conversation it answers.
|
||||
</button>
|
||||
|
||||
{% if false == draft %}
|
||||
<button type="button" data-action="click->mail--message-row#snooze"
|
||||
data-mail--message-row-snooze-url-param="{{ path('app_status_snooze', {'type': type, 'id': rowId}) }}"
|
||||
aria-label="{{ 'thread_row.actions.snooze'|trans }}"
|
||||
title="{{ 'thread_row.actions.snooze'|trans }}"
|
||||
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">
|
||||
<i class="fa-solid fa-clock w-4 h-4" aria-hidden="true"></i>
|
||||
</button>
|
||||
{# 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. #}
|
||||
<div class="relative z-10" data-controller="ui--dropdown">
|
||||
<button type="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"
|
||||
data-mail--message-row-read-url-param="{{ path('app_status_mark_read', {'type': type, 'id': rowId}) }}"
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -1088,10 +1088,24 @@ sidebar:
|
||||
drafts: Entwürfe
|
||||
inbox: Posteingang
|
||||
sent: Gesendet
|
||||
snoozed: Zurückgestellt
|
||||
starred: Markiert
|
||||
trash: Papierkorb
|
||||
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:
|
||||
empty: Keine markierten Nachrichten.
|
||||
heading: Markiert
|
||||
|
||||
@@ -1069,10 +1069,24 @@ sidebar:
|
||||
drafts: Drafts
|
||||
inbox: Inbox
|
||||
sent: Sent
|
||||
snoozed: Snoozed
|
||||
starred: Starred
|
||||
trash: Trash
|
||||
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:
|
||||
empty: No starred messages.
|
||||
heading: Starred
|
||||
|
||||
@@ -1064,10 +1064,24 @@ sidebar:
|
||||
drafts: Scrolls in the works
|
||||
inbox: Bottles washed ashore
|
||||
sent: Cast off
|
||||
snoozed: Kept below deck
|
||||
starred: Marked with an X
|
||||
trash: Davy Jones' locker
|
||||
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:
|
||||
empty: Naught marked with an X.
|
||||
heading: Marked with an X
|
||||
|
||||
Reference in new issue
Block a user