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 });
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')]
|
#[Route('/account/{account}/folders', name: 'account_folders')]
|
||||||
public function accountFolders(Account $account): Response
|
public function accountFolders(Account $account): Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 $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.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}) }}"
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user