mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-31 12:39:52 +00:00
Move a conversation to a label, and take it out of the list it was in
Issue #25. Filing mail the way Gmail does took two actions here: the label button adds a label and leaves the mail where it is, and a drop on a folder needs a pointer and a sidebar in reach. "Move to" is one click, beside the label button, in the list toolbar and in the reading pane: the target goes on and the label of the list being looked at comes off. The client names the view; the server decides what that removes. A body that lists labels to strip is not an instruction the route has. Inbox target on, archived (Archive on, Inbox off) — or into the folder, where the target is one a label that label swapped for the target Trash, Spam target on, out of the bin, not into the inbox anywhere else target on, Inbox off where it was on to Inbox restore(), then the view's label off to Trash, Spam the existing trash and spam paths, not a label swap MoveToService composes ThreadStatusUpdater's operations rather than writing labels itself, inside one transaction, because each of those carries provider behaviour that took a bug report to get right. Only the messages that carry the leaving label lose it: a sent reply in an inbox thread gains the target and is not archived. The issue asks for "tag(s)", plural. This is Gmail's single target; several labels are still what the label button is for. Undo came with it, for archive and labels as the issue asks, and for the drag onto a folder since the mechanism was there. It remembers each message's labels before the action, in the session, and puts exactly those back — not the inverse of the action, which is wrong for a mixed selection. A whole-view action runs in a worker and has none. Three things fell out: - The toolbar's label menu posted once per ticked row. With a toast and an Undo on the answer that was five of each, so it posts the selection once now, to /status/bulk/label, which learned `attach`. - Inbox, Spam and Trash are asked for by role. Their labels are made lazily, so a picker that carried ids had no Trash row for anybody who had never deleted anything. - Font Awesome keeps its folder-with-arrow in Pro, so the button is an inline SVG. Not verified against a live Gmail or Exchange account: the tests pin local label state and the IMAP jobs that are queued. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a7d56862c
commit
464df70473
37 files changed
+3554
-53
No files matched your search
@@ -6,6 +6,32 @@ so anything that changes the schema irreversibly is called out explicitly.
|
||||
The published image tags: `latest` follows the most recent release below,
|
||||
`main` follows the tip of the default branch, and `sha-…` pins one commit.
|
||||
|
||||
## Unreleased
|
||||
|
||||
**"Move to" files a conversation under a label in one step.** There was a label button, which adds a
|
||||
label and leaves the mail where it is, and dragging onto a folder, which only works with a pointer
|
||||
and a sidebar in reach. Filing mail the way Gmail does — label it and get it out of the list you are
|
||||
in — took two actions. A folder button beside the label button now does it in one, for a selection
|
||||
in the list and for an open conversation: the label you pick goes on, and the label of the list you
|
||||
were looking at comes off. From the inbox that is "label and archive"; from a label it swaps one
|
||||
label for the other; from **Trash** or **Spam** it takes the mail out of the bin without putting it
|
||||
back in the inbox. Everything else the conversation wears stays.
|
||||
|
||||
- **The picker offers the Inbox, your labels, Spam and Trash**, and applies the one you click at
|
||||
once. The list you are already in, a label the conversation already has, and the bin for mail
|
||||
already in it are left off. See [Mail → Move to](docs/features/mail.md#move-to).
|
||||
- **Archive, label changes and moves can be undone.** Each shows an **Undo** for eight seconds, and
|
||||
it puts back exactly the labels every conversation had, rather than reversing the action — so an
|
||||
already-archived conversation that was ticked along with the rest stays archived. Dragging onto a
|
||||
folder gets the same Undo. An action over a whole view runs in the background and has none.
|
||||
- **Labelling a selection is one request, not one per conversation.** The toolbar's label menu
|
||||
posted once for every ticked row; it posts once now, which is also why there is one toast and one
|
||||
Undo for the lot.
|
||||
|
||||
### Before you upgrade
|
||||
|
||||
- **Nothing has to change.** There is no migration and no new setting.
|
||||
|
||||
## v0.2.58 — 2026-10-04
|
||||
|
||||
**A link wrapped around a table is a link again.** Newsletters and job alerts build each card as one
|
||||
|
||||
@@ -6,6 +6,10 @@ import { requestFailed } from "../../request_errors.js";
|
||||
* "Label as" dropdown. Toggles a label on the target thread/message via the
|
||||
* status endpoint and re-renders through the returned Turbo Stream.
|
||||
*
|
||||
* Routes used:
|
||||
* single → POST /status/{type}/{id}/label
|
||||
* bulk → POST /status/bulk/label, once for the whole selection
|
||||
*
|
||||
* Values:
|
||||
* targetType — "thread" | "message"
|
||||
* targetId — entity id; omitted in bulk mode, in which case the ids are
|
||||
@@ -78,18 +82,29 @@ export default class extends Controller {
|
||||
|
||||
const targets = this._resolveTargets();
|
||||
|
||||
// Stops at the first failure: one toast rather than one per row, and
|
||||
// the tick below stays as it was instead of promising a change that
|
||||
// did not happen.
|
||||
for (const target of targets) {
|
||||
const ok = await this._post(
|
||||
`/status/${target.type}/${target.id}/label`,
|
||||
if (0 === targets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bulk mode is ONE request for the whole selection. It used to be one
|
||||
// per ticked row, which was merely chatty until the answer gained a
|
||||
// toast with an Undo in it — five conversations labelled was five
|
||||
// toasts, each offering to take the label back off one of them.
|
||||
// /status/bulk/label answers once, with one Undo for all of it.
|
||||
const ok = this.hasTargetIdValue
|
||||
? await this._post(
|
||||
`/status/${targets[0].type}/${targets[0].id}/label`,
|
||||
{ labelId: labelId, attach: attach },
|
||||
)
|
||||
: await this._post(
|
||||
"/status/bulk/label",
|
||||
{ ids: targets.map((target) => target.id), labelId: labelId, attach: attach },
|
||||
);
|
||||
|
||||
if (false === ok) {
|
||||
return;
|
||||
}
|
||||
// The tick below stays as it was instead of promising a change that
|
||||
// did not happen.
|
||||
if (false === ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.dataset.attached = attach ? "true" : "false";
|
||||
|
||||
@@ -192,6 +192,28 @@ export default class extends Controller {
|
||||
await this._bulkPost("snooze", { until });
|
||||
}
|
||||
|
||||
/**
|
||||
* "Move to", for the selection. The target arrives in the event the move
|
||||
* menu dispatches (mail--move-menu:picked) rather than as a param, because
|
||||
* the menu is a controller of its own and only this one knows what is
|
||||
* selected — the ticked rows, or the whole view.
|
||||
*
|
||||
* The view goes with every bulk post already, and here it is not just for
|
||||
* resolving a whole-view selection: it is how the server knows which label
|
||||
* the move takes off. See App\Service\Mail\MoveToService.
|
||||
*/
|
||||
async moveSelected(event) {
|
||||
const { labelId, role } = event.detail ?? {};
|
||||
|
||||
if (!labelId && !role) {
|
||||
return;
|
||||
}
|
||||
|
||||
// One or the other: a label the user made by id, a system place by
|
||||
// role. Whichever the menu sent is passed on as it came.
|
||||
await this._bulkPost("move-to", role ? { role } : { labelId });
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────
|
||||
|
||||
/** All row checkboxes in the visible list */
|
||||
@@ -229,8 +251,8 @@ export default class extends Controller {
|
||||
/**
|
||||
* One request for the whole selection.
|
||||
*
|
||||
* @param {string} action - POST /status/bulk/{action}: archive | trash | read | restore | snooze
|
||||
* @param {object} body - optional JSON body (e.g. { read: true }, { until })
|
||||
* @param {string} action - POST /status/bulk/{action}: archive | trash | read | restore | snooze | move-to
|
||||
* @param {object} body - optional JSON body (e.g. { read: true }, { until }, { labelId } or { role })
|
||||
*
|
||||
* It used to be one request per conversation, fired in parallel. That is
|
||||
* survivable for the fifty rows a page holds and impossible for what this
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import LabelMenuController from "./label_menu_controller.js";
|
||||
import { jsonCsrfHeaders } from "../../csrf.js";
|
||||
import { announceWrite } from "../../mail_writes.js";
|
||||
import { requestFailed } from "../../request_errors.js";
|
||||
|
||||
/**
|
||||
* "Move to" dropdown: pick one destination, and the conversation goes there.
|
||||
*
|
||||
* Extends the label menu rather than copying it, because everything hard about
|
||||
* that controller is about the PANEL and none of it is about labels — lifting
|
||||
* it into the top layer so the reading pane's backdrop blur cannot trap it,
|
||||
* fitting it inside whatever clips, dismissing it on scroll. A second copy of
|
||||
* that would be two places to fix the next time a pane grows a filter.
|
||||
*
|
||||
* What differs is what a click means. There it toggles one of many and the
|
||||
* panel stays open; here it is the whole action, and the panel closes.
|
||||
*
|
||||
* Values:
|
||||
* targetId — the thread, in the reading pane. Omitted in the list toolbar,
|
||||
* where the pick is dispatched as `mail--move-menu:picked` and
|
||||
* mail--list-toolbar posts it for the selection it owns.
|
||||
* carried — ids of the labels that thread already has, which are not
|
||||
* offered. Reading pane only; the list reads the ticked rows.
|
||||
* placement — `trash` or `spam` when that thread is already there, which
|
||||
* takes that entry off too. Reading pane only; in the list the
|
||||
* view being looked at says the same thing.
|
||||
*
|
||||
* Routes used:
|
||||
* POST /status/bulk/move-to — one route for one conversation or fifty. The
|
||||
* body names the target and the list being looked at; the server works
|
||||
* out which label comes off (App\Service\Mail\MoveToService).
|
||||
*/
|
||||
export default class extends LabelMenuController {
|
||||
static targets = ["panel"];
|
||||
|
||||
static values = {
|
||||
targetId: Number,
|
||||
carried: { type: Array, default: [] },
|
||||
placement: { type: String, default: "" },
|
||||
};
|
||||
|
||||
async move(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
// A label the user made is named by id; the Inbox, Spam and Trash by
|
||||
// role, because their labels may not exist yet — see _move_menu.
|
||||
const { moveRole = "", labelId = "" } = event.currentTarget.dataset;
|
||||
const target = "" !== moveRole ? { role: moveRole } : { labelId: Number(labelId) };
|
||||
|
||||
this._close();
|
||||
|
||||
if (false === this.hasTargetIdValue) {
|
||||
this.dispatch("picked", { detail: target });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.#view();
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await fetch("/status/bulk/move-to", {
|
||||
method: "POST",
|
||||
headers: jsonCsrfHeaders(),
|
||||
body: JSON.stringify({
|
||||
ids: [this.targetIdValue],
|
||||
...target,
|
||||
scope: view.scope,
|
||||
value: view.value,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
requestFailed(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestFailed(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Turbo.renderStreamMessage(await response.text());
|
||||
announceWrite();
|
||||
|
||||
// The conversation has been filed, so the pane showing it closes — the
|
||||
// same ending archive has in mail--message-actions, and for the same
|
||||
// reason: what is on screen is no longer in the list behind it.
|
||||
this.#closePane();
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called by the inherited toggle() each time the panel opens. The label
|
||||
* menu ticks what the selection carries; this has no ticks, and uses the
|
||||
* same moment to hide the entries that would do nothing:
|
||||
*
|
||||
* - the list the person is already looking at, and
|
||||
* - any label the conversation already has. Moving mail to where it
|
||||
* already is reads as a destination and is not one.
|
||||
*
|
||||
* Read from the DOM at open time rather than rendered into the panel,
|
||||
* because the reading pane's copy comes from a request with no list behind
|
||||
* it: only the page knows which list is underneath.
|
||||
*/
|
||||
_syncFromSelection() {
|
||||
const view = this.#view();
|
||||
const carried = this.#carried();
|
||||
|
||||
for (const entry of this.panelTarget.querySelectorAll("[role='menuitem']")) {
|
||||
const id = entry.dataset.labelId ?? "";
|
||||
|
||||
const role = entry.dataset.moveRole ?? "";
|
||||
|
||||
// The Inbox, the bin and spam name themselves by role in both
|
||||
// places: the list's scope is `inbox` | `trash` | `spam`, and so
|
||||
// is the entry's. A label view matches by id instead.
|
||||
const current = "" !== role
|
||||
? role === view.scope
|
||||
: "label" === view.scope && "" !== view.value && id === view.value;
|
||||
|
||||
// Already in the bin, whichever list it was opened from — a
|
||||
// search result, say. Never true of the Inbox entry: placement is
|
||||
// only ever `trash` or `spam` here.
|
||||
const there = "" !== role && role === this.placementValue;
|
||||
|
||||
entry.hidden = current || there || ("" !== id && carried.has(id));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The label ids already on what is about to be moved.
|
||||
*
|
||||
* One conversation says so itself. For a selection it is the labels EVERY
|
||||
* ticked row carries — the reading the label menu's ticks use, and for the
|
||||
* same reason: a label only some of them have is still a real destination
|
||||
* for the rest. Both sources name the user's own labels only, so the
|
||||
* Inbox is never hidden this way: in the Inbox the view rule above hides
|
||||
* it, and from a label "Move to Inbox" still takes that label off.
|
||||
*/
|
||||
#carried() {
|
||||
if (this.hasTargetIdValue) {
|
||||
return new Set(this.carriedValue.map(String));
|
||||
}
|
||||
|
||||
const rows = [...document.querySelectorAll("[data-thread-select]:checked")]
|
||||
.map((box) => box.closest("[data-label-ids]"))
|
||||
.filter((row) => null !== row)
|
||||
.map((row) => (row.dataset.labelIds ?? "").split(",").filter((id) => "" !== id));
|
||||
|
||||
if (0 === rows.length) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(rows[0].filter((id) => rows.every((ids) => ids.includes(id))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which list is on screen, in the list toolbar's own words.
|
||||
*
|
||||
* The toolbar already carries the view as values for the bulk actions
|
||||
* (see mail--list-toolbar), so this reads them from there instead of
|
||||
* naming the view a second time. No toolbar means no list — a conversation
|
||||
* opened by URL — and an empty scope is the server's "nowhere in
|
||||
* particular", where a move only ever takes the Inbox off.
|
||||
*/
|
||||
#view() {
|
||||
const toolbar = document.querySelector("[data-controller~='mail--list-toolbar']");
|
||||
|
||||
return {
|
||||
scope: toolbar?.getAttribute("data-mail--list-toolbar-view-scope-value") ?? "",
|
||||
value: toolbar?.getAttribute("data-mail--list-toolbar-view-value-value") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** mail-pane lives on <body> (see _layout/app.html.twig). */
|
||||
#closePane() {
|
||||
const element = document.querySelector('[data-controller~="mail--mail-pane"]');
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.application
|
||||
.getControllerForElementAndIdentifier(element, "mail--mail-pane")
|
||||
?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import { csrfToken } from "../../csrf.js";
|
||||
import { announceWrite } from "../../mail_writes.js";
|
||||
import { requestFailed } from "../../request_errors.js";
|
||||
|
||||
/**
|
||||
* The Undo button in the toast after an archive, a label change or a move.
|
||||
*
|
||||
* A sibling of compose--undo-send, chosen by the toast partial through
|
||||
* `extra.undo_controller`, and deliberately the same shape — same two values,
|
||||
* same `abort` action — so the partial has one button and not two.
|
||||
*
|
||||
* It is not that controller because of what has to happen after the request.
|
||||
* Calling off a send changes nothing on screen but the toast. Putting mail
|
||||
* back changes the list: rows that left have to return in their sorted places,
|
||||
* and the pager and the sidebar numbers move with them. No stream can carry
|
||||
* that — the reason bulk actions re-read the list frame as well as redrawing
|
||||
* rows — so this asks the mail pane for the frame and announces the write.
|
||||
*
|
||||
* Values:
|
||||
* url — POST /status/undo/{token}; the token is single use
|
||||
* hideAfter — how long the toast stays, in ms. Unused here beyond keeping
|
||||
* the two controllers interchangeable: ui--toast removes the
|
||||
* toast, and this button with it.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
url: String,
|
||||
hideAfter: Number,
|
||||
};
|
||||
|
||||
async abort() {
|
||||
// Once. The token is spent by the first request, so a second click
|
||||
// would be answered "too late" for something that had just worked.
|
||||
this.element.disabled = true;
|
||||
|
||||
let response = null;
|
||||
|
||||
try {
|
||||
response = await fetch(this.urlValue, {
|
||||
method: "POST",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRF-Token": csrfToken() },
|
||||
});
|
||||
} catch {
|
||||
// Handled below with response still null.
|
||||
}
|
||||
|
||||
// Nothing was put back, and the token may still be good — a dropped
|
||||
// connection never reached the server — so the button is given back.
|
||||
if (requestFailed(response)) {
|
||||
this.element.disabled = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Turbo.renderStreamMessage(await response.text());
|
||||
|
||||
this.#refreshList();
|
||||
announceWrite();
|
||||
|
||||
this.element.closest("[data-controller~='ui--toast']")?.remove();
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────
|
||||
|
||||
/** mail-pane lives on <body> (see _layout/app.html.twig). */
|
||||
#refreshList() {
|
||||
const element = document.querySelector('[data-controller~="mail--mail-pane"]');
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.application
|
||||
.getControllerForElementAndIdentifier(element, "mail--mail-pane")
|
||||
?.refreshNow();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: features/mail.md sha1:5691e30fa0a7d9cce02dc27f07c9c5dc7edbfc37 -->
|
||||
<!-- translated-from: features/mail.md sha1:e4c2f01bebced62a2b1f4fcc67d4c72d5e8869f4 -->
|
||||
|
||||
# Mail
|
||||
|
||||
@@ -415,6 +415,35 @@ Label-Dialog der Seitenleiste ebenso wie aus **Einstellungen → Labels**, und d
|
||||
klar, dass das Label aus jedem Konto verschwindet und wie viele untergeordnete Labels mitgehen —
|
||||
statt dich etwas bestätigen zu lassen, dessen Reichweite sie verschwiegen hat.
|
||||
|
||||
### Verschieben nach
|
||||
|
||||
Der Ordner-Knopf neben dem Label-Knopf — in der Leiste über einer Auswahl und in einer geöffneten
|
||||
Konversation — legt Mail in einem Schritt unter einem Label ab: **Das gewählte Label kommt dazu,
|
||||
und das Label der Liste, in der du gerade warst, fällt weg.** Alle anderen Labels bleiben.
|
||||
|
||||
| Wo du bist | Was „Verschieben nach *Belege*“ tut |
|
||||
|---|---|
|
||||
| **Posteingang** | Vergibt *Belege* und archiviert die Konversation |
|
||||
| Ein Label | Tauscht dieses Label gegen *Belege* |
|
||||
| **Papierkorb** oder **Spam** | Vergibt *Belege* und nimmt die Konversation aus Papierkorb und Spam — ohne sie zurück in den Posteingang zu legen |
|
||||
| Sonst überall — Suche, **Markiert**, **Gesendet** | Vergibt *Belege* und nimmt die Konversation aus dem Posteingang, falls sie dort war |
|
||||
|
||||
Zur Auswahl stehen der **Posteingang**, deine Labels, **Spam** und **Papierkorb**. Ein Klick wendet
|
||||
es sofort an; es gibt nichts zu bestätigen. **Posteingang** holt eine Konversation zurück und
|
||||
entfernt das Label, in dem du warst; **Spam** und **Papierkorb** tun, was ihre eigenen Knöpfe tun.
|
||||
Die Liste, in der du schon bist, jedes Label, das die Konversation bereits trägt, und **Papierkorb**
|
||||
oder **Spam** für Mail, die schon dort liegt, fehlen in der Auswahl.
|
||||
|
||||
Das ist nicht dasselbe, wie eine Konversation auf einen Ordner unter einem Konto zu ziehen: Das
|
||||
legt sie in diesen Ordner und nimmt sie aus jedem anderen. Nimm den Label-Knopf, wenn du ein Label
|
||||
vergeben und die Mail lassen willst, wo sie ist.
|
||||
|
||||
Verschieben, Labeln und Archivieren zeigen für ein paar Sekunden **Rückgängig**. Es stellt genau
|
||||
die Labels wieder her, die jede Konversation vorher hatte — bei einer gemischten Auswahl ist das
|
||||
nicht dasselbe wie die Aktion umzukehren: Eine schon archivierte Konversation, die mit angehakt
|
||||
war, bleibt archiviert. Für eine Aktion, die im Hintergrund über eine ganze Ansicht lief, gibt es
|
||||
kein Rückgängig.
|
||||
|
||||
## Suche
|
||||
|
||||
Das Suchfeld steht auf jeder Seite oben und antwortet unter `/mail/search`. Freier Text läuft
|
||||
|
||||
@@ -377,6 +377,34 @@ as well as from **Settings → Labels**, and the confirmation says plainly that
|
||||
every account and how many nested labels are going with it — rather than asking you to agree to
|
||||
something whose reach it has not mentioned.
|
||||
|
||||
### Move to
|
||||
|
||||
The folder button beside the label button — in the toolbar above a selection, and in an open
|
||||
conversation — files mail under one label in a single step: **the label you pick goes on, and the
|
||||
label of the list you were looking at comes off.** Every other label stays.
|
||||
|
||||
| Where you are | What "Move to *Receipts*" does |
|
||||
|---|---|
|
||||
| **Inbox** | Adds *Receipts* and archives the conversation |
|
||||
| A label | Swaps that label for *Receipts* |
|
||||
| **Trash** or **Spam** | Adds *Receipts* and takes the conversation out of the bin and out of spam — without putting it back in the inbox |
|
||||
| Anywhere else — search, **Starred**, **Sent** | Adds *Receipts*, and takes the conversation out of the inbox if it was in it |
|
||||
|
||||
The picker offers the **Inbox**, your labels, **Spam** and **Trash**. Picking one applies it at
|
||||
once; there is nothing to confirm. **Inbox** brings a conversation back and removes the label you
|
||||
were in; **Spam** and **Trash** do what their own buttons do. The list you are already in, any
|
||||
label the conversation already has, and **Trash** or **Spam** for mail that is already there, are
|
||||
left off the picker.
|
||||
|
||||
This is not the same as dragging a conversation onto a folder under an account, which puts it in
|
||||
that folder and takes it out of every other one. Use the label button when you want to add a label
|
||||
and leave the mail where it is.
|
||||
|
||||
Moving, labelling and archiving each show an **Undo** for a few seconds. It puts back exactly the
|
||||
labels each conversation had before, which for a mixed selection is not the same as reversing the
|
||||
action: an already-archived conversation that was ticked along with the rest stays archived. Undo
|
||||
is not offered when the action ran over a whole view in the background.
|
||||
|
||||
## Search
|
||||
|
||||
The search box is at the top of every page and answers at `/mail/search`. Free text runs as a real
|
||||
|
||||
@@ -15,6 +15,8 @@ use App\Infrastructure\Messaging\Message\RunBulkStatusMessage;
|
||||
use App\Repository\Label\LabelRepository;
|
||||
use App\Repository\Mail\MessageThreadRepository;
|
||||
use App\Security\Voter\OwnershipVoter;
|
||||
use App\Service\Mail\MoveToService;
|
||||
use App\Service\Mail\StatusUndoService;
|
||||
use App\Service\Mail\ThreadSnoozeService;
|
||||
use App\Service\Mail\ThreadStatusUpdater;
|
||||
use DateTimeImmutable;
|
||||
@@ -51,6 +53,11 @@ final class BulkStatusController extends AbstractController
|
||||
* them can arrive in. Spelled as a list rather than inline so the guard
|
||||
* below and this reasoning stay in one place as they are joined.
|
||||
*
|
||||
* `label` has a second caller now — the toolbar's label menu, which sends
|
||||
* the ticked rows — and still no whole-view one. `move-to` is absent on
|
||||
* purpose: it is a toolbar button, the toolbar can select a whole view,
|
||||
* and JobKind::MoveTo runs it.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array EXPLICIT_ONLY = ['move', 'label', 'category', 'star'];
|
||||
@@ -62,6 +69,8 @@ final class BulkStatusController extends AbstractController
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly ThreadSnoozeService $snoozeService,
|
||||
private readonly MoveToService $moveTo,
|
||||
private readonly StatusUndoService $undo,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -83,7 +92,7 @@ final class BulkStatusController extends AbstractController
|
||||
* than as the URL the user is on — it is resolved by
|
||||
* RunBulkStatusHandler now, where the work happens.
|
||||
*/
|
||||
#[Route('/{action}', name: 'run', methods: ['POST'], requirements: ['action' => 'archive|trash|read|restore|snooze|move|label|category|star'])]
|
||||
#[Route('/{action}', name: 'run', methods: ['POST'], requirements: ['action' => 'archive|trash|read|restore|snooze|move|move-to|label|category|star'])]
|
||||
public function bulk(Request $request, string $action): Response
|
||||
{
|
||||
$this->assertCsrf($request, 'ajax');
|
||||
@@ -106,6 +115,36 @@ final class BulkStatusController extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
// Resolved and refused ahead of the whole-view hand-off below as well
|
||||
// as ahead of the inline path, because this is the one payload-carrying
|
||||
// action that can take either: a job started for a label that is
|
||||
// somebody else's would fail in a worker, minutes later, in a log.
|
||||
$target = null;
|
||||
|
||||
if ('move-to' === $action) {
|
||||
// The Inbox, Spam and Trash rows name a role rather than an id —
|
||||
// see MoveToService::systemTarget() for why they cannot rely on
|
||||
// one. Everything else is a label the user made, by id.
|
||||
$target = '' !== (string) ($body['role'] ?? '')
|
||||
? $this->moveTo->systemTarget($user, (string) $body['role'])
|
||||
: $this->labelRepository->find((int) ($body['labelId'] ?? 0));
|
||||
|
||||
if (null === $target) {
|
||||
throw $this->createAccessDeniedException('Unknown destination label.');
|
||||
}
|
||||
|
||||
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $target);
|
||||
|
||||
// The picker offers the Inbox, the user's labels, Spam and Trash.
|
||||
// MoveToService::accepts() is that list for a caller that is not
|
||||
// the picker.
|
||||
if (false === $this->moveTo->accepts($target)) {
|
||||
throw $this->createAccessDeniedException(
|
||||
sprintf('Mail cannot be moved to %s.', $target->role?->value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A WHOLE VIEW GOES TO A WORKER. An explicit list of ids does not.
|
||||
//
|
||||
// The two are different sizes by construction: a list of ids comes from
|
||||
@@ -121,7 +160,7 @@ final class BulkStatusController extends AbstractController
|
||||
// finishes in milliseconds, and answering it with "started" instead of
|
||||
// the result would make every ordinary archive feel slower.
|
||||
if (true === ($body['all'] ?? false)) {
|
||||
return $this->startJob($user, $action, $body);
|
||||
return $this->startJob($user, $action, $body, $target?->id);
|
||||
}
|
||||
|
||||
// THE DROP PAYLOADS ARE READ AND REFUSED BEFORE ANY WORK IS DONE.
|
||||
@@ -182,6 +221,10 @@ final class BulkStatusController extends AbstractController
|
||||
// correct, and there are rarely more than three.
|
||||
$byAccount = [];
|
||||
|
||||
// The same messages, ungrouped: what an Undo has to remember is the
|
||||
// selection as a whole, whichever account each part of it is on.
|
||||
$selected = [];
|
||||
|
||||
foreach ($threads as $thread) {
|
||||
// Every thread, not just the first: the ids arrive from a browser
|
||||
// and a selection can be edited before it is posted.
|
||||
@@ -189,6 +232,7 @@ final class BulkStatusController extends AbstractController
|
||||
|
||||
foreach ($thread->messages as $message) {
|
||||
$byAccount[(int) $message->account?->id][] = $message;
|
||||
$selected[] = $message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +244,41 @@ final class BulkStatusController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
// MOVE TO — the target on, the label of the list it was started from
|
||||
// off. The request names that list and nothing more; which label comes
|
||||
// off is MoveToService::plan()'s answer, not the client's.
|
||||
if ('move-to' === $action) {
|
||||
$scope = (string) ($body['scope'] ?? '');
|
||||
$value = (string) ($body['value'] ?? '');
|
||||
$move = $this->moveTo->plan($user, $target, $scope, $value);
|
||||
|
||||
// Moving a conversation to the list it is already in. The picker
|
||||
// hides that entry; answered as "nothing happened" rather than
|
||||
// refused, because that is what it is.
|
||||
if (true === $move->isNoop) {
|
||||
return $this->renderTurboStream('thread/status/_bulk.stream.html.twig', [
|
||||
'count' => 0,
|
||||
'threads' => [],
|
||||
'leaves' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$undoToken = $this->undo->remember($selected);
|
||||
|
||||
$this->moveTo->move($threads, $move);
|
||||
|
||||
return $this->renderTurboStream('thread/status/_moved_to.stream.html.twig', [
|
||||
'count' => count($threads),
|
||||
'threads' => $threads,
|
||||
'label' => $target,
|
||||
'staying' => array_values(array_filter(
|
||||
$threads,
|
||||
fn ($thread): bool => $this->moveTo->staysInView($thread, $user, $scope, $value),
|
||||
)),
|
||||
'undoToken' => $undoToken,
|
||||
]);
|
||||
}
|
||||
|
||||
// SNOOZE IS PER CONVERSATION, through the same service the single-row
|
||||
// route and Thread/set use — it moves the Inbox label off, queues the
|
||||
// provider archive and remembers when to undo it. Not the per-account
|
||||
@@ -292,14 +371,24 @@ final class BulkStatusController extends AbstractController
|
||||
throw $this->createAccessDeniedException('System state is moved into, not labelled with.');
|
||||
}
|
||||
|
||||
// Absent means attach, which is all a drop has ever asked for. The
|
||||
// toolbar's label menu is the caller that sends it: it used to
|
||||
// post once per ticked row to /status/thread/{id}/label, which was
|
||||
// one toast and one Undo per conversation the moment that route
|
||||
// gained them.
|
||||
$attach = false !== ($body['attach'] ?? true);
|
||||
$undoToken = $this->undo->remember($selected);
|
||||
|
||||
foreach ($byAccount as $messages) {
|
||||
$this->status->applyLabel($messages, $label, true);
|
||||
$this->status->applyLabel($messages, $label, $attach);
|
||||
}
|
||||
|
||||
return $this->renderTurboStream('thread/status/_labelled.stream.html.twig', [
|
||||
'count' => count($threads),
|
||||
'threads' => $threads,
|
||||
'label' => $label,
|
||||
'count' => count($threads),
|
||||
'threads' => $threads,
|
||||
'label' => $label,
|
||||
'attach' => $attach,
|
||||
'undoToken' => $undoToken,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -309,6 +398,8 @@ final class BulkStatusController extends AbstractController
|
||||
// know nothing about, and only the reader can see that the two
|
||||
// conditions are the same condition.
|
||||
if ('move' === $action) {
|
||||
$undoToken = $this->undo->remember($selected);
|
||||
|
||||
foreach ($byAccount as $messages) {
|
||||
$this->status->move($messages, $destination);
|
||||
}
|
||||
@@ -321,12 +412,20 @@ final class BulkStatusController extends AbstractController
|
||||
// conversation gone from the list and nothing on screen saying
|
||||
// where it went.
|
||||
return $this->renderTurboStream('thread/status/_move.stream.html.twig', [
|
||||
'count' => count($threads),
|
||||
'threads' => $threads,
|
||||
'label' => $destination,
|
||||
'count' => count($threads),
|
||||
'threads' => $threads,
|
||||
'label' => $destination,
|
||||
'undoToken' => $undoToken,
|
||||
]);
|
||||
}
|
||||
|
||||
// Archive is the one of these four with an Undo, so it is the one whose
|
||||
// "before" is written down. Trash has the bin and Restore; read has
|
||||
// the button beside it.
|
||||
$undoToken = 'archive' === $action
|
||||
? $this->undo->remember($selected)
|
||||
: null;
|
||||
|
||||
foreach ($byAccount as $messages) {
|
||||
match ($action) {
|
||||
'archive' => $this->status->archive($messages),
|
||||
@@ -339,9 +438,9 @@ final class BulkStatusController extends AbstractController
|
||||
// requirement and forgets this arm, a silent no-op is the worst
|
||||
// possible answer for an action that says it deleted things.
|
||||
//
|
||||
// move, label, category and star are not here on purpose: each is
|
||||
// handled by a branch of its own above, which is where the payload
|
||||
// they need is resolved.
|
||||
// move, move-to, label, category and star are not here on purpose:
|
||||
// each is handled by a branch of its own above, which is where the
|
||||
// payload they need is resolved.
|
||||
default => throw $this->createNotFoundException(sprintf('Unknown bulk action "%s".', $action)),
|
||||
};
|
||||
}
|
||||
@@ -353,7 +452,9 @@ final class BulkStatusController extends AbstractController
|
||||
// from. Archive, trash and restore all move it somewhere else;
|
||||
// marking read leaves it exactly where it was and only changes how
|
||||
// it draws.
|
||||
'leaves' => 'read' !== $action,
|
||||
'leaves' => 'read' !== $action,
|
||||
'undoToken' => $undoToken,
|
||||
'undoToast' => 'toast.archived',
|
||||
]);
|
||||
}
|
||||
/**
|
||||
@@ -364,8 +465,11 @@ final class BulkStatusController extends AbstractController
|
||||
* instead of a spinner waiting for a worker to pick the envelope up.
|
||||
*
|
||||
* @param array<string, mixed> $body
|
||||
* @param int|null $labelId the resolved target of a move-to;
|
||||
* the id rather than what the body said,
|
||||
* because the body may have named a role
|
||||
*/
|
||||
private function startJob(User $user, string $action, array $body): Response
|
||||
private function startJob(User $user, string $action, array $body, ?int $labelId = null): Response
|
||||
{
|
||||
$until = 'snooze' === $action ? self::snoozeUntil($body) : null;
|
||||
$job = new BackgroundJob($user, JobKind::forAction($action, true === ($body['read'] ?? true), null === $until));
|
||||
@@ -376,12 +480,17 @@ final class BulkStatusController extends AbstractController
|
||||
'unreadOnly' => true === ($body['unreadOnly'] ?? false),
|
||||
];
|
||||
|
||||
// The one action with a payload the worker needs. On the job with the
|
||||
// The two actions with a payload the worker needs. On the job with the
|
||||
// view rather than in the envelope, for the reason the view is there.
|
||||
if ('snooze' === $action) {
|
||||
$job->view['until'] = $until?->format(DATE_ATOM);
|
||||
}
|
||||
|
||||
// Already resolved, owned and accepted by bulk() before it got here.
|
||||
if ('move-to' === $action) {
|
||||
$job->view['labelId'] = $labelId;
|
||||
}
|
||||
|
||||
$this->entityManager->persist($job);
|
||||
$this->entityManager->flush();
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Controller\ChecksCsrf;
|
||||
use App\Controller\RendersTurboStreams;
|
||||
use App\Entity\User\User;
|
||||
use App\Service\Mail\StatusUndoService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The Undo in the toast after an archive, a label change or a "Move to".
|
||||
*
|
||||
* A controller of its own for the reason BulkStatusController is one: the
|
||||
* other two status controllers are prefixed with the thing they act on — one
|
||||
* conversation in the path, or a selection in the body — and an undo is
|
||||
* addressed by neither. It names an earlier action, by the token that action's
|
||||
* toast was given, and the server already knows which messages that was.
|
||||
*
|
||||
* Nothing is rendered for the list. The rows that left it have to come back in
|
||||
* their sorted places, with the pager and the counts they change, and the only
|
||||
* thing that knows all of that is the list itself — so the client re-reads the
|
||||
* list frame when this answers. See mail--status-undo.
|
||||
*/
|
||||
#[Route('/status/undo', name: 'app_status_undo_')]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
final class StatusUndoController extends AbstractController
|
||||
{
|
||||
use ChecksCsrf;
|
||||
use RendersTurboStreams;
|
||||
|
||||
public function __construct(
|
||||
private readonly StatusUndoService $undo,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/{token}', name: 'run', methods: ['POST'], requirements: ['token' => '[a-f0-9]{32}'])]
|
||||
public function undo(Request $request, string $token): Response
|
||||
{
|
||||
$this->assertCsrf($request, 'ajax');
|
||||
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
// Null is a token that names nothing any more — used once already, or
|
||||
// pushed out by newer actions. Said rather than answered with a quiet
|
||||
// 200, because the person pressed a button that promised to put
|
||||
// something back and it has not been put back.
|
||||
$restored = $this->undo->restore($token, $user);
|
||||
|
||||
return $this->renderTurboStream('thread/status/_undone.stream.html.twig', [
|
||||
'restored' => null !== $restored,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use App\Security\Voter\OwnershipVoter;
|
||||
use App\Service\Mail\ThreadSnoozeService;
|
||||
use App\Service\Mail\MailPlacement;
|
||||
use App\Service\Mail\MessagePurger;
|
||||
use App\Service\Mail\StatusUndoService;
|
||||
use App\Service\Mail\ThreadStatusUpdater;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -46,6 +47,7 @@ class ThreadStatusController extends AbstractController
|
||||
private readonly ThreadSnoozeService $snoozeService,
|
||||
private readonly MessagePurger $purger,
|
||||
private readonly MailPlacement $placement,
|
||||
private readonly StatusUndoService $undo,
|
||||
) {}
|
||||
|
||||
#[Route('/star', name: 'star', methods: ['POST'])]
|
||||
@@ -66,10 +68,16 @@ class ThreadStatusController extends AbstractController
|
||||
{
|
||||
$messages = $this->resolveMessages($request, $type, $id);
|
||||
|
||||
// Before the archive, not after: what Undo puts back is what the
|
||||
// messages carried, and a snapshot taken a line later is a record of
|
||||
// the thing to be undone. See StatusUndoService.
|
||||
$undoToken = $this->undo->remember($messages);
|
||||
|
||||
$this->status->archive($messages);
|
||||
|
||||
return $this->renderTurboStream('thread/status/_archive.stream.html.twig', [
|
||||
$type => 'message' === $type ? $messages[0] : $messages[0]->thread,
|
||||
$type => 'message' === $type ? $messages[0] : $messages[0]->thread,
|
||||
'undoToken' => $undoToken,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -169,12 +177,15 @@ class ThreadStatusController extends AbstractController
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$undoToken = $this->undo->remember($messages);
|
||||
|
||||
$this->status->applyLabel($messages, $label, $attach);
|
||||
|
||||
return $this->renderTurboStream('thread/status/_label.stream.html.twig', [
|
||||
$type => 'message' === $type ? $messages[0] : $messages[0]->thread,
|
||||
'label' => $label,
|
||||
'attach' => $attach,
|
||||
$type => 'message' === $type ? $messages[0] : $messages[0]->thread,
|
||||
'label' => $label,
|
||||
'attach' => $attach,
|
||||
'undoToken' => $undoToken,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\DTO\Mail;
|
||||
|
||||
use App\Entity\Label\Label;
|
||||
|
||||
/**
|
||||
* One "Move to", worked out: what a conversation gains and what it gives up.
|
||||
*
|
||||
* The answer to a question only the server may answer. The client says which
|
||||
* list the person was looking at and where they pointed; which label that
|
||||
* takes OFF is derived from those two, by MoveToService::plan(), and arrives
|
||||
* here. A client-supplied "remove these" list would be a way of stripping any
|
||||
* label off any conversation through a route whose button says "move".
|
||||
*
|
||||
* $leaving is nullable because not every move takes something away: filed from
|
||||
* a search result, a conversation that was never in the inbox simply gains the
|
||||
* target. And it is only ever removed from the messages that carry it — a
|
||||
* reply sitting in Sent is part of the thread and was never in the Inbox.
|
||||
*/
|
||||
final readonly class LabelMove
|
||||
{
|
||||
public function __construct(
|
||||
public Label $target,
|
||||
public ?Label $leaving,
|
||||
/**
|
||||
* The target is the list the person is already looking at. Nothing is
|
||||
* written and nothing is announced: the picker hides that entry, and
|
||||
* this is the other half of that for a caller that posts it anyway.
|
||||
*/
|
||||
public bool $isNoop = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,12 @@ enum JobKind: string
|
||||
case Snooze = 'snooze';
|
||||
case Wake = 'wake';
|
||||
|
||||
/**
|
||||
* "Move to" over a whole view. The target label travels on the job beside
|
||||
* the view (`view.labelId`), as a snooze's wake time does.
|
||||
*/
|
||||
case MoveTo = 'move_to';
|
||||
|
||||
/**
|
||||
* Asking the assistant again about mail it has already sorted.
|
||||
*
|
||||
@@ -65,6 +71,7 @@ enum JobKind: string
|
||||
self::Trash => 'trash',
|
||||
self::Restore => 'restore',
|
||||
self::Snooze, self::Wake => 'snooze',
|
||||
self::MoveTo => 'move-to',
|
||||
// Reclassify has no bulk action behind it and no caller that could
|
||||
// want one: RunBulkStatusHandler is the only reader of this method
|
||||
// and only ever holds a kind it was dispatched for. Throwing beats
|
||||
@@ -90,6 +97,7 @@ enum JobKind: string
|
||||
'archive' => self::Archive,
|
||||
'trash' => self::Trash,
|
||||
'restore' => self::Restore,
|
||||
'move-to' => self::MoveTo,
|
||||
default => throw new \InvalidArgumentException(sprintf('Unknown bulk action "%s".', $action)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ use App\Entity\Job\BackgroundJob;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Infrastructure\Messaging\Message\RunBulkStatusMessage;
|
||||
use App\Repository\Job\BackgroundJobRepository;
|
||||
use App\Repository\Label\LabelRepository;
|
||||
use App\Repository\Mail\MessageThreadRepository;
|
||||
use App\Service\Job\JobNotifier;
|
||||
use App\Service\Mail\ListViewResolver;
|
||||
use App\Service\Mail\MoveToService;
|
||||
use App\Service\Mail\ThreadSnoozeService;
|
||||
use App\Service\Mail\ThreadStatusUpdater;
|
||||
use DateTimeImmutable;
|
||||
@@ -90,6 +92,8 @@ final readonly class RunBulkStatusHandler
|
||||
private ListViewResolver $views,
|
||||
private ThreadStatusUpdater $status,
|
||||
private ThreadSnoozeService $snooze,
|
||||
private MoveToService $moveTo,
|
||||
private LabelRepository $labels,
|
||||
private JobNotifier $notifier,
|
||||
private EntityManagerInterface $em,
|
||||
private ManagerRegistry $registry,
|
||||
@@ -222,6 +226,15 @@ final readonly class RunBulkStatusHandler
|
||||
$userId = (int) $job->usr->id;
|
||||
$jobId = (int) $job->id;
|
||||
|
||||
// Scalars, for the reason the thread ids below are: the chunk loop
|
||||
// clears the EntityManager, so the job this is read from does not
|
||||
// survive its first pass. Only a move-to job carries a label.
|
||||
$view = [
|
||||
'scope' => (string) $job->view['scope'],
|
||||
'value' => (string) $job->view['value'],
|
||||
'labelId' => (int) ($job->view['labelId'] ?? 0),
|
||||
];
|
||||
|
||||
// IDS, NOT ENTITIES, and this is the whole correctness argument for the
|
||||
// loop below. Every chunk ends in an EntityManager clear, which
|
||||
// detaches every object this list is holding — so from the second chunk
|
||||
@@ -242,7 +255,7 @@ final readonly class RunBulkStatusHandler
|
||||
$processed = 0;
|
||||
|
||||
foreach (array_chunk($ids, self::CHUNK) as $chunk) {
|
||||
$this->applyWithRetry($chunk, $action, $read, $userId, $until);
|
||||
$this->applyWithRetry($chunk, $action, $read, $userId, $until, $view);
|
||||
|
||||
$processed += count($chunk);
|
||||
|
||||
@@ -314,13 +327,14 @@ final readonly class RunBulkStatusHandler
|
||||
* wait that timed out. Anything else is a real failure and goes straight
|
||||
* up, unretried.
|
||||
*
|
||||
* @param list<int> $chunk
|
||||
* @param list<int> $chunk
|
||||
* @param array{scope: string, value: string, labelId: int} $view
|
||||
*/
|
||||
private function applyWithRetry(array $chunk, string $action, bool $read, int $userId, ?DateTimeImmutable $until = null): void
|
||||
private function applyWithRetry(array $chunk, string $action, bool $read, int $userId, ?DateTimeImmutable $until, array $view): void
|
||||
{
|
||||
for ($attempt = 1; ; ++$attempt) {
|
||||
try {
|
||||
$this->apply($chunk, $action, $read, $userId, $until);
|
||||
$this->apply($chunk, $action, $read, $userId, $until, $view);
|
||||
|
||||
return;
|
||||
} catch (RetryableException $e) {
|
||||
@@ -355,11 +369,13 @@ final readonly class RunBulkStatusHandler
|
||||
* Takes IDS and loads the threads itself, because the caller's list does
|
||||
* not survive the clear at the foot of this method. See run().
|
||||
*
|
||||
* @param list<int> $threadIds
|
||||
* @param list<int> $threadIds
|
||||
* @param array{scope: string, value: string, labelId: int} $view
|
||||
*/
|
||||
private function apply(array $threadIds, string $action, bool $read, int $userId, ?DateTimeImmutable $until = null): void
|
||||
private function apply(array $threadIds, string $action, bool $read, int $userId, ?DateTimeImmutable $until, array $view): void
|
||||
{
|
||||
$byAccount = [];
|
||||
$owned = [];
|
||||
|
||||
foreach ($this->threads->findBy(['id' => $threadIds]) as $thread) {
|
||||
// The last check before a write. The resolver selected these for
|
||||
@@ -380,11 +396,37 @@ final readonly class RunBulkStatusHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
$owned[] = $thread;
|
||||
|
||||
foreach ($thread->messages as $message) {
|
||||
$byAccount[(int) $message->account?->id][] = $message;
|
||||
}
|
||||
}
|
||||
|
||||
// The same service and the same plan the inline path uses, so a move
|
||||
// over a whole view means exactly what it means for three ticked rows.
|
||||
// The target is re-read per chunk because the clear below detaches it,
|
||||
// and re-checked because BulkStatusController accepted it minutes ago:
|
||||
// a label deleted or handed to a role since then is a move to nowhere.
|
||||
if ('move-to' === $action) {
|
||||
$target = $this->labels->find($view['labelId']);
|
||||
|
||||
if (null === $target || (int) $target->usr?->id !== $userId || false === $this->moveTo->accepts($target)) {
|
||||
throw new \LogicException('The label this move was started for is no longer one mail can be moved to.');
|
||||
}
|
||||
|
||||
if ([] !== $owned) {
|
||||
$this->moveTo->move(
|
||||
$owned,
|
||||
$this->moveTo->plan($owned[0]->account->usr, $target, $view['scope'], $view['value']),
|
||||
);
|
||||
}
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($byAccount as $messages) {
|
||||
match ($action) {
|
||||
'archive' => $this->status->archive($messages),
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Mail;
|
||||
|
||||
use App\Domain\DTO\Mail\LabelMove;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Entity\Label\Label;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Label\LabelRepository;
|
||||
use App\Service\Graph\GraphLabelPolicy;
|
||||
use App\Service\Label\LabelResolver;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* "Move to": the target label on, the label of the list you were looking at
|
||||
* off, as one action.
|
||||
*
|
||||
* Gmail's meaning of the phrase, and the third way of filing a conversation
|
||||
* this interface has. The other two are each half of it: the label menu adds a
|
||||
* label and leaves the mail where it was, and a drop on a folder
|
||||
* (ThreadStatusUpdater::move()) puts it in that folder and takes it out of
|
||||
* every other place. This one is defined by where the person is standing —
|
||||
* from the Inbox it is "label and archive", from a label it swaps that label
|
||||
* for another — and every other label the conversation wears is left alone.
|
||||
*
|
||||
* ## The view arrives named, and the server decides what it means
|
||||
*
|
||||
* The client sends the same scope and value the bulk toolbar already sends
|
||||
* (see ListViewResolver for why a view is named rather than posted as a URL)
|
||||
* and plan() turns that into the one label that comes off. Anything it does
|
||||
* not recognise is a list with no label of its own, where the only thing a
|
||||
* move takes away is the Inbox.
|
||||
*
|
||||
* ## Built from the operations that already exist
|
||||
*
|
||||
* Nothing here writes a label or talks to a provider itself. Each arm is one
|
||||
* or two calls into ThreadStatusUpdater, because every one of those methods
|
||||
* carries provider behaviour that took a bug report to get right — archive
|
||||
* attaches Archive as well as detaching Inbox, a detach on plain IMAP is a
|
||||
* physical move when the label was the folder the mail sat in — and a fourth
|
||||
* implementation of "remove Inbox" would start without any of it.
|
||||
*
|
||||
* What that costs is that a move is several flushes rather than one, so the
|
||||
* whole of it runs inside a transaction: a conversation that gained the target
|
||||
* and then failed to leave the Inbox is the half-move this action exists not
|
||||
* to be.
|
||||
*/
|
||||
final readonly class MoveToService
|
||||
{
|
||||
/**
|
||||
* The system places the picker offers beside the user's own labels.
|
||||
*
|
||||
* Narrower than LabelRole::acceptsMoves(), which also admits Archive: that
|
||||
* is the rule for a drop on the sidebar, where Archive is a row. Here
|
||||
* archiving already has a button of its own two icons to the left.
|
||||
*
|
||||
* @var list<LabelRole>
|
||||
*/
|
||||
private const array SYSTEM_TARGETS = [LabelRole::Inbox, LabelRole::Spam, LabelRole::Trash];
|
||||
|
||||
/**
|
||||
* The two places mail is thrown away to. Leaving either means leaving
|
||||
* both — see moveWithinAccount().
|
||||
*
|
||||
* @var list<LabelRole>
|
||||
*/
|
||||
private const array DISCARDED = [LabelRole::Trash, LabelRole::Spam];
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private LabelRepository $labels,
|
||||
private ThreadStatusUpdater $status,
|
||||
private GraphLabelPolicy $graphLabels,
|
||||
private LabelResolver $labelResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's label for one of the system places the picker offers, by the
|
||||
* role's name — made if it does not exist yet.
|
||||
*
|
||||
* System labels are created lazily: a mailbox nobody has deleted from has
|
||||
* no Trash label, so the picker has no id to put on that row, and a picker
|
||||
* that offered Trash only to people who had already used it would be
|
||||
* missing the entry on exactly the first occasion it is wanted. The row
|
||||
* names the role instead and this turns it into the label.
|
||||
*
|
||||
* Null for anything outside the three, including roles that exist: asking
|
||||
* by name is not a way round accepts().
|
||||
*/
|
||||
public function systemTarget(User $user, string $role): ?Label
|
||||
{
|
||||
$role = LabelRole::tryFrom($role);
|
||||
|
||||
if (null === $role || false === in_array($role, self::SYSTEM_TARGETS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->labelResolver->userSystemLabel($role, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a label is somewhere "Move to" can file mail.
|
||||
*
|
||||
* Read by the controller before anything is loaded, so a request naming
|
||||
* Sent or Drafts is refused whether or not it has a selection to act on.
|
||||
*/
|
||||
public function accepts(Label $target): bool
|
||||
{
|
||||
return null === $target->role || true === in_array($target->role, self::SYSTEM_TARGETS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out what a move from this view to this target takes off.
|
||||
*
|
||||
* @param string $scope the list's own name for itself — `inbox`, `label`,
|
||||
* or anything else, which is treated as a list with
|
||||
* no label of its own
|
||||
* @param string $value the label id for `label`; ignored otherwise
|
||||
*/
|
||||
public function plan(User $user, Label $target, string $scope, string $value): LabelMove
|
||||
{
|
||||
$inbox = $this->labels->findOneByRoleForUser(LabelRole::Inbox, $user);
|
||||
$view = match ($scope) {
|
||||
'inbox' => $inbox,
|
||||
// The bin and spam are lists with a label of their own, and
|
||||
// moving out of them leaves it behind — what Gmail does, and what
|
||||
// the word says: mail filed under Receipts from the bin that was
|
||||
// still in the bin would not have moved. A user who has neither
|
||||
// label yet has nothing in either list.
|
||||
'trash' => $this->labels->findOneByRoleForUser(LabelRole::Trash, $user),
|
||||
'spam' => $this->labels->findOneByRoleForUser(LabelRole::Spam, $user),
|
||||
'label' => $this->viewedLabel($user, (int) $value),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null !== $view && $view === $target) {
|
||||
return new LabelMove($target, null, true);
|
||||
}
|
||||
|
||||
if (null !== $view) {
|
||||
return new LabelMove($target, $view);
|
||||
}
|
||||
|
||||
// A list with no label of its own — search, Starred, Sent, an account's
|
||||
// folders. The one thing a move takes away there is the Inbox, and only
|
||||
// from mail that is in it. Unless the Inbox is where it is going.
|
||||
return new LabelMove($target, LabelRole::Inbox === $target->role ? null : $inbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry a planned move out on a set of conversations.
|
||||
*
|
||||
* @param list<MessageThread> $threads
|
||||
*/
|
||||
public function move(array $threads, LabelMove $move): void
|
||||
{
|
||||
if (true === $move->isNoop) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Grouped by account for the reason BulkStatusController gives:
|
||||
// ThreadStatusUpdater resolves folders from the first message's
|
||||
// account, so a selection spanning two mailboxes has to arrive as two
|
||||
// calls.
|
||||
$byAccount = [];
|
||||
|
||||
foreach ($threads as $thread) {
|
||||
foreach ($thread->messages as $message) {
|
||||
$byAccount[(int) $message->account->id][] = $message;
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->wrapInTransaction(function () use ($byAccount, $move): void {
|
||||
foreach ($byAccount as $messages) {
|
||||
$this->moveWithinAccount($messages, $move);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a conversation still belongs in the list it was moved from.
|
||||
*
|
||||
* Asked after the move, of the labels the thread now carries, so the
|
||||
* answer cannot disagree with what was actually written. A list that is a
|
||||
* label — the Inbox, a custom label, the Archive — keeps the row only
|
||||
* while the thread still wears it. A list that is not, keeps it: a search
|
||||
* result filed under Receipts is still a search result. The bin and spam
|
||||
* are the exception, because every list but their own leaves them out.
|
||||
*
|
||||
* The list frame is re-read after a bulk action either way, so this only
|
||||
* decides what the click looks like in the moment before that lands.
|
||||
*/
|
||||
public function staysInView(MessageThread $thread, User $user, string $scope, string $value): bool
|
||||
{
|
||||
$role = LabelRole::tryFrom($scope);
|
||||
$view = match (true) {
|
||||
'label' === $scope => $this->labels->find((int) $value),
|
||||
// Unread is a role and not a label anything wears — the list is
|
||||
// read off the unread counts — so asking whether the thread still
|
||||
// carries it would empty the list on every move.
|
||||
null !== $role && LabelRole::Unread !== $role => $this->labels->findOneByRoleForUser($role, $user),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null !== $view) {
|
||||
return $thread->labels->contains($view);
|
||||
}
|
||||
|
||||
foreach ($thread->labels as $label) {
|
||||
if (LabelRole::Trash === $label->role || LabelRole::Spam === $label->role) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param non-empty-list<Message> $messages all from one account
|
||||
*/
|
||||
private function moveWithinAccount(array $messages, LabelMove $move): void
|
||||
{
|
||||
$target = $move->target;
|
||||
$leaving = $move->leaving;
|
||||
|
||||
// Only the messages that carry it. A thread is its messages, and they
|
||||
// are not all in the same place: the reply you sent is in Sent and was
|
||||
// never in the Inbox, so "take it out of the Inbox" has nothing to say
|
||||
// about it — and archiving it anyway would file a sent copy under
|
||||
// Archive, which is what the archive button has always done to a whole
|
||||
// thread and is not what a move from the Inbox means.
|
||||
$carriers = null === $leaving ? [] : array_values(array_filter(
|
||||
$messages,
|
||||
static fn (Message $message): bool => $message->hasLabel($leaving),
|
||||
));
|
||||
|
||||
$fromTheBin = true === in_array($leaving?->role, self::DISCARDED, true);
|
||||
|
||||
// Out of the bin or out of spam, BOTH come off: a spam mail somebody
|
||||
// then deleted carries the two, and one that left Trash still marked
|
||||
// as spam would be filed away again by the next rule run. The reason
|
||||
// restore() removes both, applied to who counts as leaving.
|
||||
if (true === $fromTheBin) {
|
||||
$carriers = array_values(array_filter($messages, static function (Message $message): bool {
|
||||
foreach ($message->labels as $label) {
|
||||
if (true === in_array($label->role, self::DISCARDED, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
// The bin and spam are not labels to be worn alongside the others —
|
||||
// each is a provider operation with a history. Through move(), which
|
||||
// sends Trash to trash() and Spam down the path that resolves to a
|
||||
// physical move into the junk folder.
|
||||
if (LabelRole::Trash === $target->role || LabelRole::Spam === $target->role) {
|
||||
$this->status->move($messages, $target);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Back to the inbox is restore(): Inbox on, and Trash, Spam and Archive
|
||||
// off, because a conversation that is in the inbox and still in the
|
||||
// archive is in two places at once. The view's own label comes off
|
||||
// AFTER it, and the order is the point — restore() re-points a plain
|
||||
// IMAP message at the inbox folder, so by the time the label is
|
||||
// detached it is no longer the folder the mail sits in and the detach
|
||||
// is the database change it should be rather than a second move.
|
||||
if (LabelRole::Inbox === $target->role) {
|
||||
$this->status->restore($messages);
|
||||
|
||||
if ([] !== $carriers && null === $leaving?->role) {
|
||||
$this->status->applyLabel($carriers, $leaving, false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Attached first, to the whole conversation, and before anything is
|
||||
// detached: on plain IMAP the detach decides where the mail physically
|
||||
// goes from the labels it still carries, so the destination has to be
|
||||
// among them. The ordering ThreadStatusUpdater::moveToFolder() is built
|
||||
// around, for the same reason.
|
||||
$this->status->applyLabel($messages, $target, true);
|
||||
|
||||
if ([] === $carriers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// OUT OF THE BIN, UNDER A LABEL — and not into the inbox. There is no
|
||||
// single operation for that, so it is the two that exist, in the order
|
||||
// that leaves every provider in the right place: restore() is what
|
||||
// un-trashes (Gmail drops TRASH and SPAM, IMAP moves the message out
|
||||
// of the folder), and archive() then takes it straight back out of
|
||||
// the inbox restore() put it in. Detaching Trash as though it were an
|
||||
// ordinary label would tell Gmail to remove a label by a local id and
|
||||
// tell a plain IMAP server to "archive" a message the row still
|
||||
// claimed was in the bin.
|
||||
//
|
||||
// A real folder on this account is simpler: move() puts the mail in it
|
||||
// and takes it out of every other place, the bin included.
|
||||
if (true === $fromTheBin) {
|
||||
if (true === $this->isFolderOn($target, $this->status->accountOf($messages[0]))) {
|
||||
$this->status->move($carriers, $target);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->status->restore($carriers);
|
||||
$this->status->archive($carriers);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Leaving the Inbox for a label that is only a tag IS archiving, and
|
||||
// has to be done as one: plMail has no All Mail, so mail that is
|
||||
// neither in the Inbox nor filed in a folder lives in Archive, and
|
||||
// archive() is the one place that knows what that means per provider.
|
||||
// Detaching Inbox by hand would tell a plain IMAP server to archive
|
||||
// the message and leave the row still pointing at the inbox folder.
|
||||
//
|
||||
// When the target is a real folder on this account the detach is the
|
||||
// right call instead: the mail goes INTO that folder, and Archive has
|
||||
// nothing to do with it.
|
||||
if (LabelRole::Inbox === $leaving->role && false === $this->isFolderOn($target, $this->status->accountOf($messages[0]))) {
|
||||
$this->status->archive($carriers);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->status->applyLabel($carriers, $leaving, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this label is a place on this account rather than a tag.
|
||||
*
|
||||
* Per account, because that is where the answer lives: the binding records
|
||||
* the IMAP folder or the Exchange folder behind a label, and the same
|
||||
* label can be a folder on one mailbox and a tag on the next.
|
||||
*/
|
||||
private function isFolderOn(Label $label, Account $account): bool
|
||||
{
|
||||
return null !== $label->bindingFor($account)?->mailbox
|
||||
|| true === $this->graphLabels->pushesAsFolder($label, $account);
|
||||
}
|
||||
|
||||
/**
|
||||
* The label a `label` view is showing, if it is one a move can leave.
|
||||
*
|
||||
* Somebody else's label resolves to nothing rather than to an error, as it
|
||||
* does in ListViewResolver: the honest reading of "I was looking at a
|
||||
* label that is not mine" is a view with no label of its own.
|
||||
*
|
||||
* The label route takes any label the user owns, system ones included, so
|
||||
* the Inbox can be reached as `/mail/label/{id}` and is treated as the
|
||||
* Inbox, and the bin and spam likewise. Every other role is refused here —
|
||||
* Sent and Drafts say how a message came to exist and are not something a
|
||||
* move takes off.
|
||||
*/
|
||||
private function viewedLabel(User $user, int $id): ?Label
|
||||
{
|
||||
$label = $this->labels->find($id);
|
||||
|
||||
if (null === $label || $label->usr?->id !== $user->id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (null !== $label->role && false === in_array($label->role, self::SYSTEM_TARGETS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Mail;
|
||||
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Entity\Label\Label;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Label\LabelRepository;
|
||||
use App\Repository\Mail\MessageRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* Undo for the actions that file mail: archive, label, move to.
|
||||
*
|
||||
* All three end with a row gone from the list or a chip changed on it, and a
|
||||
* toast saying so. The Undo in that toast has to put back **exactly the labels
|
||||
* each message had** — not "the inverse of the action", which is a different
|
||||
* thing as soon as the selection is mixed. Un-archiving five conversations by
|
||||
* calling restore() on them is right for the four that were in the Inbox and
|
||||
* wrong for the one that was already archived and happened to be ticked.
|
||||
*
|
||||
* So the state is remembered, not the action. remember() is called before the
|
||||
* mutation and writes down each message's label ids; restore() reads the
|
||||
* difference between that and what the message carries now, and closes it.
|
||||
*
|
||||
* ## Remembered in the session, not sent to the browser
|
||||
*
|
||||
* The toast carries a token and nothing else. The snapshot stays on the
|
||||
* server, in the session of the person who made the change, because an undo
|
||||
* that accepted a label list from the client would be a route for writing any
|
||||
* label set onto any message. A handful are kept, newest last: the toast is on
|
||||
* screen for a few seconds and nothing can reach an older one.
|
||||
*
|
||||
* ## Closing the difference goes through the same operations
|
||||
*
|
||||
* Where the mail lives is put right first, with ThreadStatusUpdater::move()
|
||||
* and its siblings, because those are what tell a provider to move something —
|
||||
* an undo that only rewrote the rows would leave the conversation in the inbox
|
||||
* here and in the archive on the server until the next sync moved it back out.
|
||||
* The user's own labels follow through applyLabel(), and whatever still
|
||||
* differs after that is local bookkeeping, set directly. See resetLabels().
|
||||
*/
|
||||
final readonly class StatusUndoService
|
||||
{
|
||||
private const string SESSION_KEY = 'mail.status_undo';
|
||||
|
||||
/**
|
||||
* How many snapshots a session holds.
|
||||
*
|
||||
* More than one, because two actions in quick succession leave two toasts
|
||||
* on screen and both Undo buttons should work. Not many, because each is a
|
||||
* selection's worth of ids kept in the session for the sake of a button
|
||||
* that is gone in seconds.
|
||||
*/
|
||||
private const int KEPT = 5;
|
||||
|
||||
/**
|
||||
* Where a message can be said to live, most decisive first.
|
||||
*
|
||||
* The order restore() reads a label set in: something in the bin is in the
|
||||
* bin whatever else it wears, and Archive is last because it is what mail
|
||||
* is in when it is nowhere else.
|
||||
*
|
||||
* @var list<LabelRole>
|
||||
*/
|
||||
private const array PLACES_FIRST = [LabelRole::Trash, LabelRole::Spam, LabelRole::Inbox];
|
||||
|
||||
public function __construct(
|
||||
private RequestStack $requestStack,
|
||||
private EntityManagerInterface $em,
|
||||
private MessageRepository $messages,
|
||||
private LabelRepository $labels,
|
||||
private ThreadStatusUpdater $status,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write down what these messages carry now, and answer with the token an
|
||||
* undo is asked for by.
|
||||
*
|
||||
* Called BEFORE the mutation. A snapshot taken afterwards is a record of
|
||||
* the thing to be undone.
|
||||
*
|
||||
* @param iterable<Message> $messages
|
||||
*/
|
||||
public function remember(iterable $messages): string
|
||||
{
|
||||
$snapshot = ['messages' => [], 'threads' => []];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$snapshot['messages'][(int) $message->id] = array_values(
|
||||
$message->labels->map(static fn (Label $label): int => (int) $label->id)->toArray(),
|
||||
);
|
||||
|
||||
// A move into the bin ends a snooze (ThreadStatusUpdater::move()),
|
||||
// and putting the Snoozed label back without its wake time would
|
||||
// leave a conversation waiting for a moment that never comes.
|
||||
$thread = $message->thread;
|
||||
|
||||
if (null !== $thread) {
|
||||
$snapshot['threads'][(int) $thread->id] = $thread->snoozedUntil?->format(DATE_ATOM);
|
||||
}
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$session = $this->requestStack->getSession();
|
||||
|
||||
$kept = (array) $session->get(self::SESSION_KEY, []);
|
||||
$kept[$token] = $snapshot;
|
||||
|
||||
$session->set(self::SESSION_KEY, array_slice($kept, -self::KEPT, null, true));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back what remember() wrote down.
|
||||
*
|
||||
* Single use: the snapshot is taken out of the session before anything is
|
||||
* written, so a double click cannot run it twice and a second undo of the
|
||||
* same action answers "too late" instead of quietly re-applying an old
|
||||
* state over whatever happened since.
|
||||
*
|
||||
* @return int|null how many messages were put back, or null when the token
|
||||
* names nothing — expired, used, or never issued
|
||||
*/
|
||||
public function restore(string $token, User $user): ?int
|
||||
{
|
||||
$session = $this->requestStack->getSession();
|
||||
$kept = (array) $session->get(self::SESSION_KEY, []);
|
||||
|
||||
if (false === isset($kept[$token])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = $kept[$token];
|
||||
|
||||
unset($kept[$token]);
|
||||
$session->set(self::SESSION_KEY, $kept);
|
||||
|
||||
$messages = [];
|
||||
|
||||
foreach ($this->messages->findBy(['id' => array_keys($snapshot['messages'])]) as $message) {
|
||||
// The snapshot came out of this user's own session, so these are
|
||||
// theirs by construction — checked anyway, because it is the last
|
||||
// thing before a write and a session is not a schema.
|
||||
if ($message->account->usr->id === $user->id) {
|
||||
$messages[] = $message;
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $messages) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$before = $this->labelsBefore($snapshot['messages'], $user);
|
||||
|
||||
$this->em->wrapInTransaction(function () use ($messages, $before, $snapshot): void {
|
||||
$this->returnToPlaces($messages, $before);
|
||||
$this->returnUserLabels($messages, $before);
|
||||
|
||||
$this->wakeTimes($messages, $snapshot['threads']);
|
||||
$this->status->resetLabels($messages, $before);
|
||||
});
|
||||
|
||||
return count($messages);
|
||||
}
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Move every message that is not where it was back to where it was.
|
||||
*
|
||||
* Grouped by account and destination, because ThreadStatusUpdater resolves
|
||||
* folders from the first message it is handed.
|
||||
*
|
||||
* @param list<Message> $messages
|
||||
* @param array<int, list<Label>> $before
|
||||
*/
|
||||
private function returnToPlaces(array $messages, array $before): void
|
||||
{
|
||||
$returning = [];
|
||||
$placeless = [];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$account = $message->account;
|
||||
$was = $this->placeAmong($before[(int) $message->id] ?? [], $account);
|
||||
$now = $this->placeAmong($message->labels->toArray(), $account);
|
||||
|
||||
if ($was === $now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (null === $was) {
|
||||
$placeless[(int) $account->id][] = $message;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = sprintf('%d:%d', $account->id, $was->id);
|
||||
|
||||
$returning[$key] ??= ['place' => $was, 'messages' => []];
|
||||
$returning[$key]['messages'][] = $message;
|
||||
}
|
||||
|
||||
foreach ($returning as $group) {
|
||||
// move() and not a label swap: it sends Inbox to restore(), Archive
|
||||
// to archive() and Trash to trash(), which is what makes this a
|
||||
// move at the provider as well as here.
|
||||
$this->status->move($group['messages'], $group['place']);
|
||||
}
|
||||
|
||||
// Mail that was in none of these places and is in one now — a Gmail
|
||||
// conversation that wore only a custom label and was moved to the
|
||||
// Inbox. There is no "put it nowhere" operation, so it is taken out of
|
||||
// the bin if it is in it and then out of the inbox, which is what
|
||||
// tells the provider; resetLabels() drops the Archive label that
|
||||
// leaves behind.
|
||||
foreach ($placeless as $group) {
|
||||
$discarded = array_values(array_filter($group, fn (Message $message): bool => $this->carriesRole($message, LabelRole::Trash) || $this->carriesRole($message, LabelRole::Spam)));
|
||||
|
||||
if ([] !== $discarded) {
|
||||
$this->status->restore($discarded);
|
||||
}
|
||||
|
||||
$inInbox = array_values(array_filter($group, fn (Message $message): bool => $this->carriesRole($message, LabelRole::Inbox)));
|
||||
|
||||
if ([] !== $inInbox) {
|
||||
$this->status->archive($inInbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach and detach the user's own labels, through the path that tells
|
||||
* Gmail and Exchange about them.
|
||||
*
|
||||
* Attached before anything is detached, for the reason every label move in
|
||||
* ThreadStatusUpdater gives: on plain IMAP a detach picks the folder the
|
||||
* mail goes to from the labels still on it.
|
||||
*
|
||||
* @param list<Message> $messages
|
||||
* @param array<int, list<Label>> $before
|
||||
*/
|
||||
private function returnUserLabels(array $messages, array $before): void
|
||||
{
|
||||
$attach = [];
|
||||
$detach = [];
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$was = $before[(int) $message->id] ?? [];
|
||||
$now = $message->labels->toArray();
|
||||
|
||||
foreach ($was as $label) {
|
||||
if (null === $label->role && false === in_array($label, $now, true)) {
|
||||
$this->group($attach, $message, $label);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($now as $label) {
|
||||
if (null === $label->role && false === in_array($label, $was, true)) {
|
||||
$this->group($detach, $message, $label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($attach as $group) {
|
||||
$this->status->applyLabel($group['messages'], $group['label'], true);
|
||||
}
|
||||
|
||||
foreach ($detach as $group) {
|
||||
$this->status->applyLabel($group['messages'], $group['label'], false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{label: Label, messages: list<Message>}> $groups
|
||||
*/
|
||||
private function group(array &$groups, Message $message, Label $label): void
|
||||
{
|
||||
$key = sprintf('%d:%d', $message->account->id, $label->id);
|
||||
|
||||
$groups[$key] ??= ['label' => $label, 'messages' => []];
|
||||
$groups[$key]['messages'][] = $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Message> $messages
|
||||
* @param array<int, string|null> $wakeTimes keyed by thread id
|
||||
*/
|
||||
private function wakeTimes(array $messages, array $wakeTimes): void
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
$thread = $message->thread;
|
||||
|
||||
if (null === $thread || false === array_key_exists((int) $thread->id, $wakeTimes)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$until = $wakeTimes[(int) $thread->id];
|
||||
|
||||
$thread->snoozedUntil = null === $until ? null : new DateTimeImmutable($until);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot's ids as labels, per message.
|
||||
*
|
||||
* A label deleted since the snapshot was taken is simply absent — there is
|
||||
* nothing to put back — and one that is not this user's is dropped for the
|
||||
* reason restore() checks the messages.
|
||||
*
|
||||
* @param array<int, list<int>> $idsByMessage
|
||||
*
|
||||
* @return array<int, list<Label>>
|
||||
*/
|
||||
private function labelsBefore(array $idsByMessage, User $user): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_merge(...array_values($idsByMessage))));
|
||||
|
||||
$byId = [];
|
||||
|
||||
foreach ($this->labels->findBy(['id' => $ids]) as $label) {
|
||||
if ($label->usr?->id === $user->id) {
|
||||
$byId[(int) $label->id] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$before = [];
|
||||
|
||||
foreach ($idsByMessage as $messageId => $labelIds) {
|
||||
$before[(int) $messageId] = array_values(array_filter(array_map(
|
||||
static fn (int $id): ?Label => $byId[$id] ?? null,
|
||||
$labelIds,
|
||||
)));
|
||||
}
|
||||
|
||||
return $before;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one label in a set that says where the mail is, or null.
|
||||
*
|
||||
* A custom label counts only when it is a real folder on this account —
|
||||
* the distinction ThreadStatusUpdater::locationLabelsOf() draws, and for
|
||||
* the same reason: a tag is not somewhere mail can be moved back to.
|
||||
*
|
||||
* @param list<Label> $labels
|
||||
*/
|
||||
private function placeAmong(array $labels, Account $account): ?Label
|
||||
{
|
||||
foreach (self::PLACES_FIRST as $role) {
|
||||
foreach ($labels as $label) {
|
||||
if ($role === $label->role) {
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($labels as $label) {
|
||||
if (null === $label->role && null !== $label->bindingFor($account)?->mailbox) {
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($labels as $label) {
|
||||
if (LabelRole::Archive === $label->role) {
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function carriesRole(Message $message, LabelRole $role): bool
|
||||
{
|
||||
foreach ($message->labels as $label) {
|
||||
if ($role === $label->role) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,51 @@ final readonly class ThreadStatusUpdater
|
||||
$this->finish($messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make each message carry exactly the labels it is given, in the database
|
||||
* and nowhere else.
|
||||
*
|
||||
* The last step of an undo, and only that — see StatusUndoService, its one
|
||||
* caller. By the time this runs the provider has been told everything it
|
||||
* needs to hear, through restore(), archive(), move() and applyLabel()
|
||||
* above: where the mail lives, and which of the user's labels it wears.
|
||||
* What is left over is plMail's own bookkeeping. Undoing an archive goes
|
||||
* through restore(), which also takes Archive off — right for a
|
||||
* conversation coming back to the inbox, and one label short for a message
|
||||
* that carried Archive before the action being undone ever touched it.
|
||||
*
|
||||
* Deliberately not propagated. Pushing the difference would mean a second
|
||||
* move for a message the calls above have already put where it belongs,
|
||||
* and the labels this adjusts are ones no provider keeps: Archive on
|
||||
* Gmail is the absence of INBOX, and Snoozed exists only here.
|
||||
*
|
||||
* @param list<Message> $messages
|
||||
* @param array<int, list<Label>> $labelsByMessage keyed by message id; a
|
||||
* message missing from it is left alone
|
||||
*/
|
||||
public function resetLabels(array $messages, array $labelsByMessage): void
|
||||
{
|
||||
foreach ($messages as $message) {
|
||||
$wanted = $labelsByMessage[(int) $message->id] ?? null;
|
||||
|
||||
if (null === $wanted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($message->labels->toArray() as $label) {
|
||||
if (false === in_array($label, $wanted, true)) {
|
||||
$message->removeLabel($label);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($wanted as $label) {
|
||||
$message->addLabel($label);
|
||||
}
|
||||
}
|
||||
|
||||
$this->finish($messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Message> $messages
|
||||
*/
|
||||
|
||||
@@ -238,6 +238,12 @@ The button itself carries `group` and `relative`.
|
||||
labels: sidebarCounts.flattenedUserLabels,
|
||||
targetType: 'thread',
|
||||
} %}
|
||||
{# Directly beside the label button, because it is the other half of
|
||||
the same thought: that one adds a label and leaves the mail here,
|
||||
this one files it under a label and takes it out of this list. #}
|
||||
{% include '_partials/_move_menu.html.twig' with {
|
||||
labels: sidebarCounts.flattenedUserLabels,
|
||||
} only %}
|
||||
<span
|
||||
data-mail--list-toolbar-target="selectionCount"
|
||||
class="ml-1 text-xs text-ink-muted tabular-nums whitespace-nowrap"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
{#
|
||||
_move_menu.html.twig
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
"Move to" picker for the list toolbar and the thread view toolbar. Sits beside
|
||||
_label_menu.html.twig and is the same panel on purpose — same button, same
|
||||
surface, same indentation for nested labels — so the two read as a pair.
|
||||
|
||||
What it does not share is the interaction. The label menu is a set of ticks:
|
||||
it stays open, and each click adds or removes one label. This is a choice of
|
||||
ONE destination: no ticks, no confirm, the click applies the move and the panel
|
||||
closes. So there is no `attached` state here and nothing to keep in step with
|
||||
the selection.
|
||||
|
||||
No "Create new" row either, and not by oversight: a move needs somewhere that
|
||||
exists, and the modal that creates a label comes back to a panel that has
|
||||
closed. The label menu next door is where labels are made.
|
||||
|
||||
The entry for the list being looked at is hidden by the controller when the
|
||||
panel opens, not here. The reading pane's copy is rendered by a request that
|
||||
has no list behind it, so only the browser knows which view that is.
|
||||
|
||||
Variables:
|
||||
labels Label[] flat list of the user's custom labels (role null),
|
||||
pre-ordered; indentation derives from label.depth
|
||||
targetId int|null the thread, in the reading pane; when null the pick is
|
||||
handed to mail--list-toolbar, which owns the selection
|
||||
carried int[] ids of the labels the thread already has (reading pane
|
||||
only); those are not offered, because a conversation
|
||||
cannot be moved to where it already is. In the list the
|
||||
controller reads the same thing off the ticked rows.
|
||||
placement string where the thread sits, from mail_placement() (reading
|
||||
pane only): `trash` or `spam` takes that entry off the
|
||||
picker. In the list, the view says the same thing.
|
||||
#}
|
||||
{% set LabelRole = enum('App\\Domain\\Enum\\Mail\\LabelRole') %}
|
||||
{% set entry_class = 'w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left
|
||||
text-ink-soft hover:bg-hover transition-colors cursor-pointer' %}
|
||||
|
||||
<div data-controller="mail--move-menu"
|
||||
{% if targetId is defined and targetId is not null %}data-mail--move-menu-target-id-value="{{ targetId }}"{% endif %}
|
||||
data-mail--move-menu-carried-value="{{ carried|default([])|json_encode }}"
|
||||
data-mail--move-menu-placement-value="{{ placement|default('') }}"
|
||||
{# The bulk copy only announces what was picked. The toolbar it sits in
|
||||
knows the selection — including "everything in this view", which no
|
||||
row checkbox records — so the toolbar is what posts it. Left off the
|
||||
reading pane's copy, which has no toolbar above it to hear. #}
|
||||
{% if targetId is not defined or targetId is null %}data-action="mail--move-menu:picked->mail--list-toolbar#moveSelected"{% endif %}
|
||||
class="relative">
|
||||
|
||||
<button type="button"
|
||||
data-action="click->mail--move-menu#toggle"
|
||||
aria-label="{{ 'toolbar.actions.move_to'|trans }}"
|
||||
title="{{ 'toolbar.actions.move_to'|trans }}"
|
||||
aria-haspopup="menu"
|
||||
class="group relative flex items-center justify-center w-8 h-8 rounded-full
|
||||
text-ink-muted
|
||||
hover:text-ink
|
||||
hover:bg-hover
|
||||
transition-colors duration-100 cursor-pointer">
|
||||
{# Drawn here rather than taken from the icon set: a folder with an
|
||||
arrow into it is the sign Gmail taught everybody for this action,
|
||||
and Font Awesome keeps its version of it (folder-arrow-right) in
|
||||
Pro. The nearest free glyphs say something else — an open folder is
|
||||
"browse", an arrow into a bracket is "sign in". One path, the arrow
|
||||
cut out of the folder with evenodd, in currentColor so it follows
|
||||
the button's hover and every theme like the glyphs beside it.
|
||||
|
||||
The viewBox is cropped to the folder itself. Font Awesome's glyphs
|
||||
fill their box edge to edge, so a folder drawn with the usual
|
||||
margin inside a 24-unit square came out visibly smaller than the
|
||||
tag next to it at the same nominal size. #}
|
||||
<svg class="w-[17px] h-[15px]" viewBox="3 4 18 16" fill="currentColor" fill-rule="evenodd" aria-hidden="true">
|
||||
<path d="M3 6a2 2 0 0 1 2-2h4.6l2 2H19a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm9.5 3.5V12h-5v3h5v2.5l5-4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div data-mail--move-menu-target="panel"
|
||||
role="menu"
|
||||
aria-label="{{ 'toolbar.actions.move_to'|trans }}"
|
||||
class="hidden absolute right-0 top-full mt-1 z-30 w-56 max-h-72 overflow-y-auto rounded-pane
|
||||
bg-pane-soft backdrop-blur-sm
|
||||
border border-line
|
||||
shadow-pop py-1">
|
||||
|
||||
<p class="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-ink-faint">
|
||||
{{ 'toolbar.actions.move_to'|trans }}
|
||||
</p>
|
||||
|
||||
{# The three system places name a ROLE, not a label id. Their labels
|
||||
are made lazily — the first sync, the first delete — so a mailbox
|
||||
nobody has deleted from has no Trash label to point at, and the
|
||||
server resolves the role instead (MoveToService::systemTarget()).
|
||||
The id still rides along where there is one, because that is what
|
||||
the controller matches the Inbox row against to hide it. #}
|
||||
<button type="button" role="menuitem"
|
||||
data-action="click->mail--move-menu#move"
|
||||
data-move-role="inbox"
|
||||
data-label-id="{{ sidebarCounts.roleLabelId(LabelRole.Inbox) }}"
|
||||
class="{{ entry_class }}">
|
||||
<i class="fa-solid fa-inbox w-3.5 text-ink-faint" aria-hidden="true"></i>
|
||||
<span class="truncate">{{ LabelRole.Inbox|role_name }}</span>
|
||||
</button>
|
||||
|
||||
{% for label in labels %}
|
||||
<button type="button" role="menuitem"
|
||||
data-action="click->mail--move-menu#move"
|
||||
data-label-id="{{ label.id }}"
|
||||
class="{{ entry_class }}"
|
||||
style="padding-left: {{ 12 + label.depth * 14 }}px">
|
||||
<i class="fa-solid fa-tag w-3.5 text-ink-faint" aria-hidden="true"></i>
|
||||
<span class="truncate">{{ label|label_name }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
<div class="border-t border-line mt-1 pt-1">
|
||||
{% for role, icon in { spam: 'fa-circle-exclamation', trash: 'fa-trash' } %}
|
||||
<button type="button" role="menuitem"
|
||||
data-action="click->mail--move-menu#move"
|
||||
data-move-role="{{ role }}"
|
||||
class="{{ entry_class }}">
|
||||
<i class="fa-solid {{ icon }} w-3.5 text-ink-faint" aria-hidden="true"></i>
|
||||
<span class="truncate">{{ LabelRole.from(role)|role_name }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,12 +39,19 @@ reporting on.
|
||||
<span class="min-w-0" data-toast-message>{{ message }}</span>
|
||||
|
||||
{% if extra.undo_url is defined %}
|
||||
{# Which controller answers the click. A send is called off by
|
||||
compose--undo-send, which is what this button was built for and
|
||||
stays the default; a filing action is put back by
|
||||
mail--status-undo, which also has to re-read the list. Both take
|
||||
the same two values and the same action name, so the choice is
|
||||
one identifier rather than a second button. #}
|
||||
{% set undo = extra.undo_controller ?? 'compose--undo-send' %}
|
||||
<span data-ui--toast-target="countdown" class="text-ink-faint tabular-nums"></span>
|
||||
<button
|
||||
data-controller="compose--undo-send"
|
||||
data-compose--undo-send-url-value="{{ extra.undo_url }}"
|
||||
data-compose--undo-send-hide-after-value="{{ extra.hide_after ?? 4000 }}"
|
||||
data-action="click->compose--undo-send#abort"
|
||||
data-controller="{{ undo }}"
|
||||
data-{{ undo }}-url-value="{{ extra.undo_url }}"
|
||||
data-{{ undo }}-hide-after-value="{{ extra.hide_after ?? 4000 }}"
|
||||
data-action="click->{{ undo }}#abort"
|
||||
class="text-accent underline underline-offset-2 font-semibold
|
||||
hover:opacity-70 transition-opacity cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,8 @@ between them.
|
||||
|
||||
message already translated
|
||||
type success | error | info (default success)
|
||||
extra optional, see _toast.html.twig (hide_after, undo_url, undo_label)
|
||||
extra optional, see _toast.html.twig (hide_after, undo_url, undo_label,
|
||||
undo_controller)
|
||||
#}
|
||||
<turbo-stream action="append" target="toast-region">
|
||||
<template>
|
||||
|
||||
@@ -9,8 +9,16 @@ self-contained: no {% extends %}, no block dependencies.
|
||||
{% import 'mail/_thread_summary.html.twig' as thread_summary %}
|
||||
|
||||
{% set threadLabelIds = [] %}
|
||||
{# The same, without the system labels — what the Move to picker leaves out.
|
||||
Built as a list here rather than filtered in place: `|filter|map` on a
|
||||
collection is an iterator with its original keys, and that reaches the
|
||||
Stimulus value as a JSON object instead of the array it declares. #}
|
||||
{% set threadOwnLabelIds = [] %}
|
||||
{% for label in thread.labels %}
|
||||
{% set threadLabelIds = threadLabelIds|merge([label.id]) %}
|
||||
{% if label.role is null %}
|
||||
{% set threadOwnLabelIds = threadOwnLabelIds|merge([label.id]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# The whole pane is a white sheet while a conversation is open (see the
|
||||
@@ -57,6 +65,19 @@ self-contained: no {% extends %}, no block dependencies.
|
||||
activeLabels: threadLabelIds,
|
||||
} only %}
|
||||
|
||||
{# `carried` is the user's own labels only, which is also all a list row publishes
|
||||
(`data-label-ids`), so the two pickers hide the same things. The
|
||||
Inbox is left out on purpose: from a label view, "Move to Inbox" on
|
||||
mail that is already in the inbox is still a real action — it takes
|
||||
this label off — and hiding it would remove the only way to say so
|
||||
from here. #}
|
||||
{% include '_partials/_move_menu.html.twig' with {
|
||||
labels: sidebarCounts.flattenedUserLabels(),
|
||||
targetId: thread.id,
|
||||
carried: threadOwnLabelIds,
|
||||
placement: mail_placement(thread),
|
||||
} only %}
|
||||
|
||||
<span class="ml-2 text-sm text-ink-faint truncate min-w-0">
|
||||
{{ thread.subject ?: ('thread.no_subject'|trans) }}
|
||||
</span>
|
||||
|
||||
@@ -1,2 +1,12 @@
|
||||
<turbo-stream action="remove" target="thread_{{ message is defined ? message.id : thread.id }}">
|
||||
</turbo-stream>
|
||||
|
||||
{# Archive is a button whose result is a row that is no longer there, which is
|
||||
also what a slip of the pointer looks like. The toast says which of the two
|
||||
it was and offers the way back. #}
|
||||
{% if undoToken|default(null) is not null %}
|
||||
{% include 'thread/status/_undo_toast.stream.html.twig' with {
|
||||
message: 'toast.archived'|trans,
|
||||
undoToken: undoToken,
|
||||
} only %}
|
||||
{% endif %}
|
||||
@@ -40,6 +40,9 @@ Variables:
|
||||
threads the conversations, for redrawing their rows
|
||||
leaves true when the action takes them out of THIS list (archive, trash,
|
||||
restore) rather than changing them in place (read)
|
||||
undoToken optional; when set the action can be undone, and says so in a
|
||||
toast — see _undo_toast.stream.html.twig
|
||||
undoToast the translation key for that toast's sentence
|
||||
#}
|
||||
<!-- plmail-bulk: {{ count }} -->
|
||||
|
||||
@@ -55,3 +58,10 @@ Variables:
|
||||
</turbo-stream>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if undoToken|default(null) is not null and count > 0 %}
|
||||
{% include 'thread/status/_undo_toast.stream.html.twig' with {
|
||||
message: undoToast|trans,
|
||||
undoToken: undoToken,
|
||||
} only %}
|
||||
{% endif %}
|
||||
@@ -3,6 +3,12 @@ Turbo stream after a label attach/detach.
|
||||
NOTE: mirror the structure of _star.stream.html.twig — replace the affected
|
||||
thread/message row so its label chips re-render. Adjust the target id
|
||||
pattern to whatever _star.stream uses (e.g. "thread_row_{id}").
|
||||
|
||||
Variables:
|
||||
thread | message what was labelled
|
||||
label the label
|
||||
attach true when it was put on, false when it was taken off
|
||||
undoToken optional; puts an Undo in the toast
|
||||
#}
|
||||
{% if thread is defined %}
|
||||
<turbo-stream action="replace" target="thread_{{ thread.id }}">
|
||||
@@ -11,3 +17,10 @@ pattern to whatever _star.stream uses (e.g. "thread_row_{id}").
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% endif %}
|
||||
|
||||
{% if undoToken|default(null) is not null %}
|
||||
{% include 'thread/status/_undo_toast.stream.html.twig' with {
|
||||
message: (attach ? 'toast.labelled' : 'toast.unlabelled')|trans({ '%label%': label|label_name }),
|
||||
undoToken: undoToken,
|
||||
} only %}
|
||||
{% endif %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{#
|
||||
After a drop on a label.
|
||||
After a drop on a label, or a tick or untick in the toolbar's label menu.
|
||||
|
||||
`leaves: false`, and that one flag is the whole difference from
|
||||
thread/status/_move.stream.html.twig. A label is additive — the conversation
|
||||
@@ -14,14 +14,23 @@ itself by the row disappearing, and this one changes a chip most of the way
|
||||
across the screen from where the pointer let go.
|
||||
|
||||
Variables:
|
||||
count how many conversations were labelled
|
||||
threads the conversations, so their rows can be redrawn with the new chip
|
||||
label what was attached, for the toast
|
||||
count how many conversations were labelled
|
||||
threads the conversations, so their rows can be redrawn with the new chip
|
||||
label what was attached, for the toast
|
||||
attach optional, default true; false when the menu took the label off
|
||||
undoToken optional; puts an Undo in the toast
|
||||
#}
|
||||
{% set attach = attach ?? true %}
|
||||
{% set message = (attach ? 'toast.labelled' : 'toast.unlabelled')|trans({ '%label%': label|label_name }) %}
|
||||
|
||||
{% include 'thread/status/_bulk.stream.html.twig' with {
|
||||
count: count,
|
||||
threads: threads,
|
||||
leaves: false,
|
||||
} only %}
|
||||
|
||||
{% include '_partials/_toast.stream.html.twig' with { message: 'toast.labelled'|trans({ '%label%': label|label_name }) } only %}
|
||||
{% if undoToken|default(null) is not null %}
|
||||
{% include 'thread/status/_undo_toast.stream.html.twig' with { message: message, undoToken: undoToken } only %}
|
||||
{% else %}
|
||||
{% include '_partials/_toast.stream.html.twig' with { message: message } only %}
|
||||
{% endif %}
|
||||
@@ -21,16 +21,17 @@ tall, and it ends with the conversation gone from the list and no statement
|
||||
anywhere about where it landed. Naming the folder is what turns "it vanished"
|
||||
into "it is in Receipts".
|
||||
|
||||
No undo offered, deliberately. It would have to remember the label each
|
||||
conversation came out of, which for a selection spanning folders is not one
|
||||
value; and unlike a send there is no window in which the thing can still be
|
||||
called back — the move has landed, and the way back is the folder now named on
|
||||
screen.
|
||||
Undo is offered, through the snapshot StatusUndoService takes before the move.
|
||||
This file used to argue against one — it would have had to remember the label
|
||||
each conversation came out of, which for a selection spanning folders is not
|
||||
one value. That is exactly what the snapshot is: every message's own labels,
|
||||
written down per message rather than as "the folder they were in".
|
||||
|
||||
Variables:
|
||||
count how many conversations were moved
|
||||
threads the conversations, so their rows can be removed
|
||||
label the destination, for the toast
|
||||
undoToken optional; puts an Undo in the toast
|
||||
#}
|
||||
{% include 'thread/status/_bulk.stream.html.twig' with {
|
||||
count: count,
|
||||
@@ -38,4 +39,11 @@ Variables:
|
||||
leaves: true,
|
||||
} only %}
|
||||
|
||||
{% include '_partials/_toast.stream.html.twig' with { message: 'toast.moved'|trans({ '%folder%': label|label_name }) } only %}
|
||||
{% if undoToken|default(null) is not null %}
|
||||
{% include 'thread/status/_undo_toast.stream.html.twig' with {
|
||||
message: 'toast.moved'|trans({ '%folder%': label|label_name }),
|
||||
undoToken: undoToken,
|
||||
} only %}
|
||||
{% else %}
|
||||
{% include '_partials/_toast.stream.html.twig' with { message: 'toast.moved'|trans({ '%folder%': label|label_name }) } only %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{#
|
||||
After "Move to" — from the list toolbar for a selection, or from the reading
|
||||
pane for the one conversation open in it.
|
||||
|
||||
Not thread/status/_move.stream.html.twig, which answers a drop on a folder, and
|
||||
the two differ in the one thing that file hard-codes: whether the rows leave. A
|
||||
drop always takes the conversation out of the list it was dragged from. This
|
||||
does only when the list was the label that came off — moved from the Inbox, the
|
||||
row goes; filed from a search result, it is still a search result and is redrawn
|
||||
with its new chip instead. Which is which is decided per conversation by
|
||||
MoveToService::staysInView(), after the move, from what the thread now carries.
|
||||
|
||||
Variables:
|
||||
count how many conversations were moved
|
||||
threads all of them
|
||||
staying the ones still in the list they were moved from
|
||||
label the target, for the toast
|
||||
undoToken from StatusUndoService::remember()
|
||||
#}
|
||||
<!-- plmail-bulk: {{ count }} -->
|
||||
|
||||
{% for thread in threads %}
|
||||
{% if thread in staying %}
|
||||
<turbo-stream action="replace" target="thread_{{ thread.id }}">
|
||||
<template>
|
||||
{% include '_partials/_thread_row.html.twig' with { thread: thread } only %}
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% else %}
|
||||
<turbo-stream action="remove" target="thread_{{ thread.id }}">
|
||||
</turbo-stream>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% include 'thread/status/_undo_toast.stream.html.twig' with {
|
||||
message: 'toast.moved'|trans({ '%folder%': label|label_name }),
|
||||
undoToken: undoToken,
|
||||
} only %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
The toast after an action that can be undone: what happened, and an Undo.
|
||||
|
||||
One file because three actions end the same way — archive, a label change and
|
||||
"Move to" — and the parts that must not drift between them are all here: which
|
||||
controller the button is wired to, where it posts, and how long it stays.
|
||||
|
||||
Eight seconds rather than the usual four. A plain toast is read and dismissed;
|
||||
this one has to be read, understood as a mistake, and reached with a pointer
|
||||
that was on the other side of the screen. The token is single use and the
|
||||
session holds only the last few (StatusUndoService), so the button going away
|
||||
with the toast is the interface agreeing with what the server would say.
|
||||
|
||||
Variables:
|
||||
message already translated
|
||||
undoToken from StatusUndoService::remember()
|
||||
#}
|
||||
{% include '_partials/_toast.stream.html.twig' with {
|
||||
message: message,
|
||||
extra: {
|
||||
undo_url: path('app_status_undo_run', { token: undoToken }),
|
||||
undo_controller: 'mail--status-undo',
|
||||
hide_after: 8000,
|
||||
},
|
||||
} only %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{#
|
||||
The answer to an Undo.
|
||||
|
||||
A toast and nothing else. The rows that come back are not drawn from here: they
|
||||
return to a sorted, paginated list whose counts they change, and the client
|
||||
re-reads the list frame for that — see mail--status-undo.
|
||||
|
||||
Variables:
|
||||
restored false when the token named nothing any more, which is said rather
|
||||
than passed off as success
|
||||
#}
|
||||
{% if restored %}
|
||||
{% include '_partials/_toast.stream.html.twig' with { message: 'toast.undone'|trans } only %}
|
||||
{% else %}
|
||||
{% include '_partials/_toast.stream.html.twig' with { message: 'toast.undo_expired'|trans, type: 'info' } only %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Mail;
|
||||
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Entity\Label\Label;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Tests\Support\Mail\SeedsMarkerFixtures;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The "Move to" route decides what comes off; the request only says where the
|
||||
* person was standing.
|
||||
*
|
||||
* MoveToServiceTest pins the calculation. This pins that the route is the
|
||||
* calculation and nothing wider — that a body cannot name labels to strip,
|
||||
* cannot file mail somewhere the picker does not offer, and that the Undo the
|
||||
* answer carries is a token the server holds the meaning of.
|
||||
*
|
||||
* One request for one conversation or many: the reading pane posts here with a
|
||||
* single id, so there is no second route to keep in step with this one.
|
||||
*/
|
||||
final class BulkMoveToTest extends WebTestCase
|
||||
{
|
||||
use SeedsMarkerFixtures;
|
||||
|
||||
private KernelBrowser $client;
|
||||
private Connection $connection;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient();
|
||||
|
||||
// Fixtures live in a transaction this test rolls back, and a rebooted
|
||||
// kernel takes the connection holding it with them.
|
||||
$this->client->disableReboot();
|
||||
|
||||
$container = static::getContainer();
|
||||
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->connection = $container->get(Connection::class);
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
$this->user = $this->seedUser();
|
||||
$this->account = $this->seedAccount();
|
||||
$this->inbox = $this->seedLabel('Inbox', LabelRole::Inbox);
|
||||
|
||||
$this->client->loginUser($this->user);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testASelectionMovesOutOfTheInboxInOneRequest(): void
|
||||
{
|
||||
$target = $this->seedLabel('Receipts');
|
||||
$first = $this->inboxThread('First');
|
||||
$second = $this->inboxThread('Second');
|
||||
|
||||
$this->post('move-to', [
|
||||
'ids' => [$first->id, $second->id],
|
||||
'labelId' => $target->id,
|
||||
'scope' => 'inbox',
|
||||
'value' => 'primary',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$body = (string) $this->client->getResponse()->getContent();
|
||||
|
||||
// Both rows leave the list they were moved from, and the answer says
|
||||
// where they went — with a way back.
|
||||
self::assertStringContainsString(sprintf('action="remove" target="thread_%d"', $first->id), $body);
|
||||
self::assertStringContainsString(sprintf('action="remove" target="thread_%d"', $second->id), $body);
|
||||
self::assertStringContainsString('Receipts', $body);
|
||||
self::assertMatchesRegularExpression('#/status/undo/[a-f0-9]{32}#', $body);
|
||||
|
||||
foreach ([$first, $second] as $thread) {
|
||||
self::assertSame(['Archive', 'Receipts'], $this->labelNames($thread));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of sending a view rather than a list. A body that names labels
|
||||
* to remove is not an instruction this route has: the tag survives, and
|
||||
* what came off is what the view says comes off.
|
||||
*/
|
||||
public function testAClientCannotNameLabelsToRemove(): void
|
||||
{
|
||||
$target = $this->seedLabel('Receipts');
|
||||
$tag = $this->seedLabel('Important');
|
||||
$thread = $this->inboxThread('Tagged', $tag);
|
||||
|
||||
$this->post('move-to', [
|
||||
'ids' => [$thread->id],
|
||||
'labelId' => $target->id,
|
||||
'scope' => 'inbox',
|
||||
'remove' => [$tag->id],
|
||||
'add' => [$this->seedLabel('Smuggled')->id],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
self::assertSame(['Archive', 'Important', 'Receipts'], $this->labelNames($thread));
|
||||
}
|
||||
|
||||
public function testUndoPutsTheSelectionBackExactlyAsItWas(): void
|
||||
{
|
||||
$target = $this->seedLabel('Receipts');
|
||||
$tag = $this->seedLabel('Important');
|
||||
$thread = $this->inboxThread('Tagged', $tag);
|
||||
$before = $this->labelNames($thread);
|
||||
|
||||
$this->post('move-to', ['ids' => [$thread->id], 'labelId' => $target->id, 'scope' => 'inbox']);
|
||||
|
||||
self::assertSame(1, preg_match('#/status/undo/[a-f0-9]{32}#', (string) $this->client->getResponse()->getContent(), $found));
|
||||
|
||||
$this->postTo($found[0], []);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
self::assertSame($before, $this->labelNames($thread));
|
||||
|
||||
// Single use: the same toast pressed again is told so, not obeyed.
|
||||
$this->postTo($found[0], []);
|
||||
|
||||
self::assertStringContainsString(
|
||||
'no longer be undone',
|
||||
(string) $this->client->getResponse()->getContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not refused and not announced. The picker hides the entry; a caller that
|
||||
* posts it anyway gets the honest answer, which is that nothing happened.
|
||||
*/
|
||||
public function testMovingToTheViewItIsInIsAnsweredAsNothing(): void
|
||||
{
|
||||
$thread = $this->inboxThread('Stays');
|
||||
|
||||
$this->post('move-to', ['ids' => [$thread->id], 'labelId' => $this->inbox->id, 'scope' => 'inbox']);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$body = (string) $this->client->getResponse()->getContent();
|
||||
|
||||
self::assertStringContainsString('plmail-bulk: 0', $body);
|
||||
self::assertStringNotContainsString('status/undo', $body, 'a move that did nothing offered to undo it');
|
||||
self::assertSame(['Inbox'], $this->labelNames($thread));
|
||||
}
|
||||
|
||||
public function testMailCannotBeMovedToAPlaceThePickerDoesNotOffer(): void
|
||||
{
|
||||
$sent = $this->seedLabel('Sent', LabelRole::Sent);
|
||||
|
||||
$this->post('move-to', ['ids' => [], 'labelId' => $sent->id, 'scope' => 'inbox']);
|
||||
|
||||
self::assertSame(403, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox, Spam and Trash are asked for by role, because their labels are
|
||||
* made lazily: this user has never deleted anything and has no Trash label
|
||||
* for the picker to carry an id for. The move makes it.
|
||||
*/
|
||||
public function testASystemPlaceIsNamedByRoleAndNeedNotExistYet(): void
|
||||
{
|
||||
$thread = $this->inboxThread('Binned');
|
||||
|
||||
$this->post('move-to', ['ids' => [$thread->id], 'role' => 'trash', 'scope' => 'inbox']);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
self::assertSame(['Trash'], $this->labelNames($thread));
|
||||
}
|
||||
|
||||
/** Asking by name is not a way round what the picker offers. */
|
||||
public function testARoleThePickerDoesNotOfferIsRefused(): void
|
||||
{
|
||||
$this->post('move-to', ['ids' => [], 'role' => 'sent', 'scope' => 'inbox']);
|
||||
|
||||
self::assertSame(403, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Indistinguishable from a label that is somebody else's, or the route is
|
||||
* an existence check over every label id on the server.
|
||||
*/
|
||||
public function testAnUnknownTargetIsRefused(): void
|
||||
{
|
||||
$this->post('move-to', ['ids' => [], 'labelId' => 999_999_999, 'scope' => 'inbox']);
|
||||
|
||||
self::assertSame(403, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* The toolbar's label menu posts the whole selection here now, and a tick
|
||||
* removed has to take the label off — `attach` absent still means attach,
|
||||
* which is all a drop has ever sent.
|
||||
*/
|
||||
public function testTheBulkLabelRouteCanTakeALabelOffAndOffersUndo(): void
|
||||
{
|
||||
$label = $this->seedLabel('Receipts');
|
||||
$thread = $this->inboxThread('Labelled', $label);
|
||||
|
||||
$this->post('label', ['ids' => [$thread->id], 'labelId' => $label->id, 'attach' => false]);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
self::assertMatchesRegularExpression('#/status/undo/[a-f0-9]{32}#', (string) $this->client->getResponse()->getContent());
|
||||
self::assertSame(['Inbox'], $this->labelNames($thread));
|
||||
}
|
||||
|
||||
public function testArchivingOffersUndo(): void
|
||||
{
|
||||
// Both made before the first request: it clears the entity manager
|
||||
// on its way out, and a fixture built afterwards hangs off an account
|
||||
// the manager no longer knows.
|
||||
$bulk = $this->inboxThread('From the toolbar');
|
||||
$row = $this->inboxThread('From the row');
|
||||
|
||||
$this->post('archive', ['ids' => [$bulk->id]]);
|
||||
|
||||
self::assertMatchesRegularExpression('#/status/undo/[a-f0-9]{32}#', (string) $this->client->getResponse()->getContent());
|
||||
|
||||
$this->postTo(sprintf('/status/thread/%d/archive', $row->id), []);
|
||||
|
||||
self::assertMatchesRegularExpression('#/status/undo/[a-f0-9]{32}#', (string) $this->client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A conversation whose MESSAGE is in the Inbox. The shared fixture labels
|
||||
* the thread only, which is enough for a list to show it and not enough
|
||||
* here: labels live on messages, and those are what a move reads.
|
||||
*/
|
||||
private function inboxThread(string $subject, Label ...$labels): MessageThread
|
||||
{
|
||||
$thread = $this->thread($subject);
|
||||
|
||||
foreach ($thread->messages as $message) {
|
||||
$message->addLabel($this->inbox);
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$message->addLabel($label);
|
||||
$thread->addLabel($label);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read back from the database rather than off the instance held here: the
|
||||
* request did its own work on its own copies.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function labelNames(MessageThread $thread): array
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
$fresh = $this->em->find(MessageThread::class, $thread->id);
|
||||
|
||||
self::assertNotNull($fresh);
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($fresh->messages as $message) {
|
||||
foreach ($message->labels as $label) {
|
||||
$names[] = (string) $label->name;
|
||||
}
|
||||
}
|
||||
|
||||
sort($names);
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $body */
|
||||
private function post(string $action, array $body): void
|
||||
{
|
||||
$this->postTo('/status/bulk/' . $action, $body);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $body */
|
||||
private function postTo(string $url, array $body): void
|
||||
{
|
||||
// The `ajax` token, read the way the real caller reads it — from the
|
||||
// layout's meta tag. See BulkMoveGuardTest::post().
|
||||
$token = (string) $this->client->request('GET', '/mail/inbox')
|
||||
->filter('meta[name="csrf-token"]')
|
||||
->attr('content');
|
||||
|
||||
$this->client->request(
|
||||
'POST',
|
||||
$url,
|
||||
[],
|
||||
[],
|
||||
['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $token],
|
||||
(string) json_encode($body),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,44 @@ final class RunBulkStatusHandlerTest extends KernelTestCase
|
||||
self::assertSame(0, $stillUnread, 'every message in the view should have been marked read');
|
||||
}
|
||||
|
||||
/**
|
||||
* "Move to" over a whole view, across the chunk boundary.
|
||||
*
|
||||
* The target label and the plan are both entities the chunk loop's clear
|
||||
* detaches, which is the same shape as the bug the test above exists for:
|
||||
* a move that worked for the first hundred conversations and threw on the
|
||||
* hundred-and-first. So this asserts the LAST message as well as the job.
|
||||
*/
|
||||
public function testAWholeViewIsMovedOutOfTheInbox(): void
|
||||
{
|
||||
$messageIds = $this->seedThreads();
|
||||
$target = $this->labelResolver->customChain(['Receipts'], $this->account);
|
||||
|
||||
self::assertNotNull($target);
|
||||
|
||||
$job = new BackgroundJob($this->user, JobKind::MoveTo);
|
||||
$job->view = ['scope' => 'inbox', 'value' => 'primary', 'unreadOnly' => false, 'labelId' => (int) $target->id];
|
||||
|
||||
$this->em->persist($job);
|
||||
$this->em->flush();
|
||||
|
||||
$this->handler()(new RunBulkStatusMessage((int) $job->id));
|
||||
|
||||
$this->em->clear();
|
||||
|
||||
$fresh = $this->em->find(BackgroundJob::class, $job->id);
|
||||
|
||||
self::assertNotNull($fresh);
|
||||
self::assertSame(JobState::Done, $fresh->state, (string) $fresh->failureReason);
|
||||
|
||||
$last = $this->em->find(Message::class, $messageIds[array_key_last($messageIds)]);
|
||||
$names = $last?->labels->map(static fn ($label): string => (string) $label->name)->toArray() ?? [];
|
||||
|
||||
sort($names);
|
||||
|
||||
self::assertSame(['Archive', 'Receipts'], $names, 'the last conversation in the view was not moved');
|
||||
}
|
||||
|
||||
/**
|
||||
* A deadlock on the first attempt is retried, not reported as a failure.
|
||||
*
|
||||
@@ -292,6 +330,8 @@ final class RunBulkStatusHandlerTest extends KernelTestCase
|
||||
$container->get(\App\Service\Mail\ListViewResolver::class),
|
||||
$container->get(\App\Service\Mail\ThreadStatusUpdater::class),
|
||||
$container->get(\App\Service\Mail\ThreadSnoozeService::class),
|
||||
$container->get(\App\Service\Mail\MoveToService::class),
|
||||
$container->get(\App\Repository\Label\LabelRepository::class),
|
||||
$container->get(\App\Service\Job\JobNotifier::class),
|
||||
$this->em,
|
||||
$container->get(\Doctrine\Persistence\ManagerRegistry::class),
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Mail;
|
||||
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\ThreadingMethod;
|
||||
use App\Entity\Label\Label;
|
||||
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\Infrastructure\Messaging\Message\ApplyImapFlagsMessage;
|
||||
use App\Service\Label\LabelResolver;
|
||||
use App\Service\Mail\MoveToService;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
|
||||
|
||||
/**
|
||||
* "Move to" takes off the label of the list it was started from — and only
|
||||
* that one.
|
||||
*
|
||||
* The whole subject is which label leaves, because that is the part the client
|
||||
* does not get to say. It names a view; the server derives the removal. Each
|
||||
* test below is one view type from the issue, stated as the label set a
|
||||
* message ends up with, since a move that arrived in the right place while
|
||||
* stripping a tag — or while leaving the conversation in the Inbox it was
|
||||
* moved out of — would pass any assertion about the target alone.
|
||||
*
|
||||
* Against a real container and a real database, for ThreadSnoozeServiceTest's
|
||||
* reasons: every collaborator is `final`, and what is worth pinning is what
|
||||
* the existing operations add up to when this service composes them. Leaving
|
||||
* the Inbox for a tag has to come out as an archive — Archive on — and that is
|
||||
* ThreadStatusUpdater's behaviour, observed here rather than restated.
|
||||
*/
|
||||
final class MoveToServiceTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private Connection $connection;
|
||||
private MoveToService $moveTo;
|
||||
private LabelResolver $labelResolver;
|
||||
|
||||
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->moveTo = $container->get(MoveToService::class);
|
||||
$this->labelResolver = $container->get(LabelResolver::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('INBOX');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── from the Inbox ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Label and archive, in one action. Archive ON is the half that is easy to
|
||||
* lose: plMail has no All Mail, so a conversation that left the Inbox and
|
||||
* gained nothing but a tag would sit in no folder-shaped list at all.
|
||||
*/
|
||||
public function testFromTheInboxTheTargetGoesOnAndTheInboxComesOff(): void
|
||||
{
|
||||
$receipts = $this->tag('Receipts');
|
||||
$other = $this->tag('Important');
|
||||
$message = $this->inboxMessage($other);
|
||||
|
||||
$this->moveFrom('inbox', '', $receipts, $message);
|
||||
|
||||
self::assertTrue($message->hasLabel($receipts), 'the conversation did not arrive under the target');
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Inbox)), 'the conversation is still in the Inbox it was moved out of');
|
||||
self::assertTrue($message->hasLabel($this->role(LabelRole::Archive)), 'out of the Inbox without Archive is mail in no list');
|
||||
self::assertTrue($message->hasLabel($other), 'a move stripped a label that was neither the target nor the view');
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread is its messages and they are not all in the same place. The
|
||||
* reply that was sent is in Sent and was never in the Inbox, so leaving
|
||||
* the Inbox has nothing to say about it — it gains the target with the
|
||||
* rest of the conversation and is not archived.
|
||||
*/
|
||||
public function testOnlyTheMessagesThatWereInTheInboxLeaveIt(): void
|
||||
{
|
||||
$receipts = $this->tag('Receipts');
|
||||
$sent = $this->role(LabelRole::Sent);
|
||||
|
||||
$thread = $this->thread();
|
||||
$received = $this->messageIn($thread, $this->role(LabelRole::Inbox));
|
||||
$reply = $this->messageIn($thread, $sent);
|
||||
|
||||
$this->moveTo->move([$thread], $this->moveTo->plan($this->account->usr, $receipts, 'inbox', ''));
|
||||
|
||||
self::assertTrue($received->hasLabel($this->role(LabelRole::Archive)));
|
||||
self::assertTrue($reply->hasLabel($receipts), 'the target belongs on the whole conversation');
|
||||
self::assertTrue($reply->hasLabel($sent));
|
||||
self::assertFalse(
|
||||
$reply->hasLabel($this->role(LabelRole::Archive)),
|
||||
'a sent reply was filed under Archive because the thread it is in left the Inbox',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the target is a real folder on the account, the mail goes INTO it
|
||||
* and Archive has nothing to do with it — the row has to name the folder
|
||||
* the mail is now in, or the next flag sync looks for it under a UID the
|
||||
* destination never issued.
|
||||
*/
|
||||
public function testAFolderTargetReceivesTheMailRatherThanTheArchive(): void
|
||||
{
|
||||
$folder = $this->seedMailbox('Receipts');
|
||||
$target = $this->tag('Receipts');
|
||||
$this->labelResolver->bindMailbox($target, $folder);
|
||||
$this->labelResolver->bindMailbox($this->role(LabelRole::Inbox), $this->inboxMailbox);
|
||||
$this->em->flush();
|
||||
|
||||
$message = $this->inboxMessage();
|
||||
|
||||
$this->moveFrom('inbox', '', $target, $message);
|
||||
|
||||
self::assertSame($folder, $message->mailbox);
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Inbox)));
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Archive)), 'mail filed in a folder was also archived');
|
||||
}
|
||||
|
||||
// ── from a label ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The swap. Everything else stays — including the Inbox, which is what
|
||||
* distinguishes this from the Inbox case: the view was the label, so the
|
||||
* label is what comes off.
|
||||
*/
|
||||
public function testFromALabelThatLabelComesOffAndEveryOtherOneStays(): void
|
||||
{
|
||||
$viewed = $this->tag('Todo');
|
||||
$target = $this->tag('Done');
|
||||
$other = $this->tag('Important');
|
||||
$message = $this->inboxMessage($viewed, $other);
|
||||
|
||||
$this->moveFrom('label', (string) $viewed->id, $target, $message);
|
||||
|
||||
self::assertTrue($message->hasLabel($target));
|
||||
self::assertFalse($message->hasLabel($viewed), 'the label of the view it was moved from is still on it');
|
||||
self::assertTrue($message->hasLabel($this->role(LabelRole::Inbox)), 'a move between labels took the conversation out of the Inbox');
|
||||
self::assertTrue($message->hasLabel($other));
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Archive)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The view is named by the client, so it has to be checked like anything
|
||||
* else from there. A label that is somebody else's is not a view this
|
||||
* person can have been looking at: it resolves to "nowhere in particular",
|
||||
* and above all its id is never used to take a label off.
|
||||
*/
|
||||
public function testALabelViewThatIsNotTheUsersIsNotALabelView(): void
|
||||
{
|
||||
$stranger = $this->foreignLabel();
|
||||
$target = $this->tag('Receipts');
|
||||
// System labels are made lazily, so the user has no Inbox until
|
||||
// something asks for it — and the fallback this asserts is the Inbox.
|
||||
$inbox = $this->role(LabelRole::Inbox);
|
||||
|
||||
$move = $this->moveTo->plan($this->account->usr, $target, 'label', (string) $stranger->id);
|
||||
|
||||
self::assertNotSame($stranger, $move->leaving);
|
||||
self::assertSame($inbox, $move->leaving, 'an unrecognised view should behave like any list with no label of its own');
|
||||
}
|
||||
|
||||
// ── from a list with no label of its own ─────────────────────────────────
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function unlabelledViews(): iterable
|
||||
{
|
||||
yield 'search results' => [''];
|
||||
yield 'starred' => ['starred'];
|
||||
yield 'sent' => ['sent'];
|
||||
yield 'an account’s folders' => ['*'];
|
||||
}
|
||||
|
||||
#[DataProvider('unlabelledViews')]
|
||||
public function testFromAnUnlabelledViewTheInboxComesOffWhereItWasOn(string $scope): void
|
||||
{
|
||||
$target = $this->tag('Receipts');
|
||||
$other = $this->tag('Important');
|
||||
$message = $this->inboxMessage($other);
|
||||
|
||||
$this->moveFrom($scope, '', $target, $message);
|
||||
|
||||
self::assertTrue($message->hasLabel($target));
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Inbox)));
|
||||
self::assertTrue($message->hasLabel($other));
|
||||
}
|
||||
|
||||
/**
|
||||
* "If present" is the whole of this one. A conversation that was already
|
||||
* archived gains the target and loses nothing — there is no Inbox to take
|
||||
* off, and nothing else is the view's to remove.
|
||||
*/
|
||||
#[DataProvider('unlabelledViews')]
|
||||
public function testFromAnUnlabelledViewMailOutsideTheInboxOnlyGainsTheTarget(string $scope): void
|
||||
{
|
||||
$target = $this->tag('Receipts');
|
||||
$archive = $this->role(LabelRole::Archive);
|
||||
$other = $this->tag('Important');
|
||||
|
||||
$message = $this->messageIn($this->thread(), $archive, $other);
|
||||
|
||||
$this->moveFrom($scope, '', $target, $message);
|
||||
|
||||
self::assertSameLabels(
|
||||
[$archive, $other, $target],
|
||||
$message->labels->toArray(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── from the bin and from spam ───────────────────────────────────────────
|
||||
|
||||
/** @return iterable<string, array{string, list<LabelRole>}> */
|
||||
public static function discardedViews(): iterable
|
||||
{
|
||||
yield 'the bin' => ['trash', [LabelRole::Trash]];
|
||||
yield 'spam' => ['spam', [LabelRole::Spam]];
|
||||
yield 'the bin, for spam then deleted' => ['trash', [LabelRole::Trash, LabelRole::Spam]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Out of the bin and under the label — and neither still in the bin nor
|
||||
* dropped into the inbox on the way. Both discarded labels go, whichever
|
||||
* list it was moved from: a deleted spam mail carries the two, and one
|
||||
* that came out still marked as spam would be filed away again.
|
||||
*
|
||||
* @param list<LabelRole> $carried
|
||||
*/
|
||||
#[DataProvider('discardedViews')]
|
||||
public function testFromTheBinTheMailLeavesItForTheLabelAndNotForTheInbox(string $scope, array $carried): void
|
||||
{
|
||||
$target = $this->tag('Receipts');
|
||||
$other = $this->tag('Important');
|
||||
$message = $this->messageIn($this->thread(), $other, ...array_map($this->role(...), $carried));
|
||||
|
||||
$this->moveFrom($scope, '', $target, $message);
|
||||
|
||||
self::assertSameLabels(
|
||||
[$this->role(LabelRole::Archive), $other, $target],
|
||||
$message->labels->toArray(),
|
||||
);
|
||||
self::assertContains('restore', $this->dispatchedActions(), 'nothing told the mail server to take the message out of the bin');
|
||||
}
|
||||
|
||||
// ── to the Inbox ─────────────────────────────────────────────────────────
|
||||
|
||||
public function testMovingToTheInboxFromALabelTakesThatLabelOff(): void
|
||||
{
|
||||
$viewed = $this->tag('Todo');
|
||||
$other = $this->tag('Important');
|
||||
$inbox = $this->role(LabelRole::Inbox);
|
||||
$message = $this->messageIn($this->thread(), $this->role(LabelRole::Archive), $viewed, $other);
|
||||
|
||||
$this->moveFrom('label', (string) $viewed->id, $inbox, $message);
|
||||
|
||||
self::assertTrue($message->hasLabel($inbox));
|
||||
self::assertFalse($message->hasLabel($viewed));
|
||||
self::assertTrue($message->hasLabel($other));
|
||||
self::assertFalse(
|
||||
$message->hasLabel($this->role(LabelRole::Archive)),
|
||||
'back in the inbox and still in the archive is a conversation in two places at once',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one move that takes nothing of the user's away: the Inbox goes on,
|
||||
* and with no view label there is nothing else to remove.
|
||||
*/
|
||||
public function testMovingToTheInboxFromAnUnlabelledViewRemovesNoneOfTheUsersLabels(): void
|
||||
{
|
||||
$tag = $this->tag('Important');
|
||||
$inbox = $this->role(LabelRole::Inbox);
|
||||
$message = $this->messageIn($this->thread(), $this->role(LabelRole::Archive), $tag);
|
||||
|
||||
$this->moveFrom('', '', $inbox, $message);
|
||||
|
||||
self::assertTrue($message->hasLabel($inbox));
|
||||
self::assertTrue($message->hasLabel($tag));
|
||||
}
|
||||
|
||||
// ── to the bin and to spam ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Through the trash action, not a label change — and the evidence is the
|
||||
* job. A label swap would have queued nothing for a plain IMAP server; the
|
||||
* action queues `trash`, which is what moves the message into the bin
|
||||
* there. The view's label is left on, as it is when the Delete button is
|
||||
* pressed in that same view.
|
||||
*/
|
||||
public function testMovingToTrashIsTheTrashAction(): void
|
||||
{
|
||||
$viewed = $this->tag('Todo');
|
||||
$trash = $this->role(LabelRole::Trash);
|
||||
$message = $this->inboxMessage($viewed);
|
||||
|
||||
$this->moveFrom('label', (string) $viewed->id, $trash, $message);
|
||||
|
||||
self::assertTrue($message->hasLabel($trash));
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Inbox)));
|
||||
self::assertTrue($message->hasLabel($viewed), 'trashing does not strip the user’s labels, and moving to Trash is trashing');
|
||||
self::assertContains('trash', $this->dispatchedActions(), 'nothing told the mail server to bin the message');
|
||||
}
|
||||
|
||||
public function testMovingToSpamTakesTheMailOutOfTheInbox(): void
|
||||
{
|
||||
$spam = $this->role(LabelRole::Spam);
|
||||
$message = $this->inboxMessage();
|
||||
|
||||
$this->moveFrom('inbox', '', $spam, $message);
|
||||
|
||||
self::assertTrue($message->hasLabel($spam));
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Inbox)));
|
||||
self::assertFalse(
|
||||
$message->hasLabel($this->role(LabelRole::Archive)),
|
||||
'junk was filed under Archive — moving to Spam went down the label path instead of the spam one',
|
||||
);
|
||||
}
|
||||
|
||||
// ── nothing to do ────────────────────────────────────────────────────────
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function ownViews(): iterable
|
||||
{
|
||||
yield 'the Inbox, from the Inbox' => ['inbox'];
|
||||
yield 'a label, from that label' => ['label'];
|
||||
yield 'the bin, from the bin' => ['trash'];
|
||||
yield 'spam, from spam' => ['spam'];
|
||||
}
|
||||
|
||||
#[DataProvider('ownViews')]
|
||||
public function testMovingToTheViewItIsAlreadyInChangesNothing(string $scope): void
|
||||
{
|
||||
$viewed = 'label' === $scope ? $this->tag('Todo') : $this->role(LabelRole::from($scope));
|
||||
$message = $this->messageIn($this->thread(), $viewed);
|
||||
$before = $message->labels->toArray();
|
||||
|
||||
$move = $this->moveTo->plan($this->account->usr, $viewed, $scope, (string) $viewed->id);
|
||||
|
||||
self::assertTrue($move->isNoop);
|
||||
|
||||
$this->moveTo->move([$message->thread], $move);
|
||||
|
||||
self::assertSameLabels($before, $message->labels->toArray());
|
||||
self::assertSame([], $this->dispatchedActions(), 'a move that does nothing still told the mail server to do something');
|
||||
}
|
||||
|
||||
// ── what may be moved to, and what stays on screen ───────────────────────
|
||||
|
||||
/** @return iterable<string, array{LabelRole, bool}> */
|
||||
public static function roles(): iterable
|
||||
{
|
||||
yield 'Inbox' => [LabelRole::Inbox, true];
|
||||
yield 'Spam' => [LabelRole::Spam, true];
|
||||
yield 'Trash' => [LabelRole::Trash, true];
|
||||
yield 'Archive' => [LabelRole::Archive, false];
|
||||
yield 'Sent' => [LabelRole::Sent, false];
|
||||
yield 'Drafts' => [LabelRole::Drafts, false];
|
||||
yield 'Snoozed' => [LabelRole::Snoozed, false];
|
||||
}
|
||||
|
||||
#[DataProvider('roles')]
|
||||
public function testOnlyThePlacesThePickerOffersAreAccepted(LabelRole $role, bool $accepted): void
|
||||
{
|
||||
self::assertSame($accepted, $this->moveTo->accepts($this->role($role)));
|
||||
}
|
||||
|
||||
/**
|
||||
* What decides whether the row is removed or redrawn. Moved out of the
|
||||
* Inbox it has left the list; filed from a search result it is still a
|
||||
* search result.
|
||||
*/
|
||||
public function testAMovedConversationLeavesALabelViewAndStaysInAnUnlabelledOne(): void
|
||||
{
|
||||
$target = $this->tag('Receipts');
|
||||
$message = $this->inboxMessage();
|
||||
$user = $this->account->usr;
|
||||
|
||||
$this->moveFrom('inbox', '', $target, $message);
|
||||
|
||||
self::assertFalse($this->moveTo->staysInView($message->thread, $user, 'inbox', ''));
|
||||
self::assertTrue($this->moveTo->staysInView($message->thread, $user, 'starred', ''));
|
||||
self::assertTrue($this->moveTo->staysInView($message->thread, $user, 'label', (string) $target->id));
|
||||
}
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function moveFrom(string $scope, string $value, Label $target, Message $message): void
|
||||
{
|
||||
$this->moveTo->move(
|
||||
[$message->thread],
|
||||
$this->moveTo->plan($this->account->usr, $target, $scope, $value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions queued for a plain IMAP server.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function dispatchedActions(): array
|
||||
{
|
||||
/** @var InMemoryTransport $transport */
|
||||
$transport = self::getContainer()->get('messenger.transport.export');
|
||||
|
||||
$actions = [];
|
||||
|
||||
foreach ($transport->getSent() as $envelope) {
|
||||
$job = $envelope->getMessage();
|
||||
|
||||
if ($job instanceof ApplyImapFlagsMessage) {
|
||||
$actions[] = $job->action;
|
||||
}
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compared by id, sorted. A Doctrine collection keeps the keys its
|
||||
* elements were added under, so a set that lost and regained a label is
|
||||
* the same set under different keys — which assertEquals reads as a
|
||||
* different array.
|
||||
*
|
||||
* @param array<array-key, Label> $expected
|
||||
* @param array<array-key, Label> $actual
|
||||
*/
|
||||
private static function assertSameLabels(array $expected, array $actual, string $message = ''): void
|
||||
{
|
||||
$names = static function (array $labels): array {
|
||||
$names = array_map(static fn (Label $label): string => sprintf('%s#%d', $label->name, $label->id), $labels);
|
||||
sort($names);
|
||||
|
||||
return $names;
|
||||
};
|
||||
|
||||
self::assertSame($names($expected), $names($actual), $message);
|
||||
}
|
||||
|
||||
private function role(LabelRole $role): Label
|
||||
{
|
||||
return $this->labelResolver->systemLabel($role, $this->account);
|
||||
}
|
||||
|
||||
/** A custom label with no folder — a tag the mail wears wherever it lives. */
|
||||
private function tag(string $name): Label
|
||||
{
|
||||
$label = $this->labelResolver->customChain([$name], $this->account);
|
||||
|
||||
self::assertNotNull($label);
|
||||
$this->em->flush();
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
/** A label owned by a different user entirely. */
|
||||
private function foreignLabel(): Label
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'stranger-' . uniqid('', true) . '@example.test';
|
||||
$user->nameFirst = 'Some';
|
||||
$user->nameLast = 'Stranger';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = 'x';
|
||||
$this->em->persist($user);
|
||||
|
||||
$label = new Label();
|
||||
$label->usr = $user;
|
||||
$label->name = 'Theirs';
|
||||
$this->em->persist($label);
|
||||
$this->em->flush();
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
/** One message, alone in its thread, in the Inbox — plus whatever else. */
|
||||
private function inboxMessage(Label ...$labels): Message
|
||||
{
|
||||
return $this->messageIn($this->thread(), $this->role(LabelRole::Inbox), ...$labels);
|
||||
}
|
||||
|
||||
private function messageIn(MessageThread $thread, Label ...$labels): Message
|
||||
{
|
||||
$message = new Message();
|
||||
$message->account = $this->account;
|
||||
$message->subject = 'Move-to fixture';
|
||||
$message->fromAddress = 'sender@example.test';
|
||||
$message->receivedAt = new \DateTimeImmutable('-1 hour');
|
||||
$message->hasAttachments = false;
|
||||
$message->messageId = sprintf('<move-to-%s@example.test>', uniqid('', true));
|
||||
// A mailbox and a UID, or the propagator treats the row as having no
|
||||
// address at a provider and queues nothing for it.
|
||||
$message->mailbox = $this->inboxMailbox;
|
||||
$message->imapUid = random_int(3000, 90000);
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$message->addLabel($label);
|
||||
$thread->addLabel($label);
|
||||
}
|
||||
|
||||
$thread->addMessage($message);
|
||||
$this->em->persist($message);
|
||||
$this->em->flush();
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function thread(): MessageThread
|
||||
{
|
||||
$thread = new MessageThread();
|
||||
$thread->account = $this->account;
|
||||
$thread->subject = 'Move-to fixture';
|
||||
$thread->normalizedSubject = 'move-to fixture';
|
||||
$thread->lastMessageAt = new \DateTimeImmutable('-1 hour');
|
||||
$thread->threadingMethod = ThreadingMethod::References;
|
||||
$thread->unreadCount = 0;
|
||||
$thread->messageCount = 1;
|
||||
$this->em->persist($thread);
|
||||
$this->em->flush();
|
||||
|
||||
return $thread;
|
||||
}
|
||||
|
||||
private function seedAccount(): Account
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'move-to-' . uniqid('', true) . '@example.test';
|
||||
$user->nameFirst = 'Move';
|
||||
$user->nameLast = 'To';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = 'x';
|
||||
$this->em->persist($user);
|
||||
|
||||
$email = 'move-to-fixture-' . uniqid('', true) . '@example.test';
|
||||
|
||||
$account = new Account();
|
||||
$account->usr = $user;
|
||||
$account->email = $email;
|
||||
$account->username = $email;
|
||||
$account->imapHost = 'localhost';
|
||||
$account->imapPort = 993;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->smtpHost = 'localhost';
|
||||
$account->smtpPort = 587;
|
||||
$account->smtpEncryption = 'starttls';
|
||||
$account->password = 'x';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
$this->em->persist($account);
|
||||
$this->em->flush();
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
private function seedMailbox(string $name): Mailbox
|
||||
{
|
||||
$mailbox = new Mailbox();
|
||||
$mailbox->account = $this->account;
|
||||
$mailbox->name = $name;
|
||||
$mailbox->fullPath = $name;
|
||||
$mailbox->isSyncEnabled = true;
|
||||
$mailbox->isIdleEnabled = false;
|
||||
$this->em->persist($mailbox);
|
||||
$this->em->flush();
|
||||
|
||||
return $mailbox;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Mail;
|
||||
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\ThreadingMethod;
|
||||
use App\Entity\Label\Label;
|
||||
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\MoveToService;
|
||||
use App\Service\Mail\StatusUndoService;
|
||||
use App\Service\Mail\ThreadStatusUpdater;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
|
||||
/**
|
||||
* Undo puts back the labels each message had, not the inverse of the action.
|
||||
*
|
||||
* The two are the same for one conversation and different for a selection,
|
||||
* which is where an undo built from inverses goes wrong without anybody
|
||||
* noticing: "un-archive these five" is right for the four that were in the
|
||||
* Inbox and wrong for the one that was already archived and happened to be
|
||||
* ticked. So every test here asserts the whole label set against the one that
|
||||
* was there before, rather than checking that the action's own label is gone.
|
||||
*
|
||||
* Real container and database, as in MoveToServiceTest and for its reasons.
|
||||
* The session is the one stand-in: the service remembers in the session of the
|
||||
* person who acted, and a kernel test has no request to carry one.
|
||||
*/
|
||||
final class StatusUndoServiceTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private Connection $connection;
|
||||
private StatusUndoService $undo;
|
||||
private ThreadStatusUpdater $status;
|
||||
private MoveToService $moveTo;
|
||||
private LabelResolver $labelResolver;
|
||||
|
||||
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->undo = $container->get(StatusUndoService::class);
|
||||
$this->status = $container->get(ThreadStatusUpdater::class);
|
||||
$this->moveTo = $container->get(MoveToService::class);
|
||||
$this->labelResolver = $container->get(LabelResolver::class);
|
||||
|
||||
$request = new Request();
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$container->get(RequestStack::class)->push($request);
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
$this->account = $this->seedAccount();
|
||||
$this->inboxMailbox = $this->seedMailbox('INBOX');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testUndoingAnArchiveReturnsTheConversationToTheInbox(): void
|
||||
{
|
||||
$tag = $this->tag('Important');
|
||||
$message = $this->message($this->role(LabelRole::Inbox), $tag);
|
||||
$before = $message->labels->toArray();
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->status->archive([$message]);
|
||||
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Inbox)), 'the fixture never left the Inbox');
|
||||
|
||||
$this->undo->restore($token, $this->account->usr);
|
||||
|
||||
self::assertSameLabels($before, $message->labels->toArray());
|
||||
self::assertSame($this->inboxMailbox, $message->mailbox, 'the labels came back and the row still points at the archive folder');
|
||||
}
|
||||
|
||||
/**
|
||||
* The case an inverse gets wrong. One of the two was already archived when
|
||||
* the selection was archived; undoing must leave it archived, not drag it
|
||||
* into an inbox it was never in.
|
||||
*/
|
||||
public function testUndoingABulkArchiveLeavesAlreadyArchivedMailWhereItWas(): void
|
||||
{
|
||||
$fromInbox = $this->message($this->role(LabelRole::Inbox));
|
||||
$wasArchived = $this->message($this->role(LabelRole::Archive));
|
||||
$archivedSet = $wasArchived->labels->toArray();
|
||||
|
||||
$token = $this->undo->remember([$fromInbox, $wasArchived]);
|
||||
$this->status->archive([$fromInbox, $wasArchived]);
|
||||
|
||||
$this->undo->restore($token, $this->account->usr);
|
||||
|
||||
self::assertTrue($fromInbox->hasLabel($this->role(LabelRole::Inbox)));
|
||||
self::assertSameLabels(
|
||||
$archivedSet,
|
||||
$wasArchived->labels->toArray(),
|
||||
'undo moved a conversation into the inbox that was not in it before the action',
|
||||
);
|
||||
}
|
||||
|
||||
public function testUndoingAMoveToRestoresTheExactLabelSet(): void
|
||||
{
|
||||
$target = $this->tag('Receipts');
|
||||
$tag = $this->tag('Important');
|
||||
$message = $this->message($this->role(LabelRole::Inbox), $tag);
|
||||
$before = $message->labels->toArray();
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->moveTo->move(
|
||||
[$message->thread],
|
||||
$this->moveTo->plan($this->account->usr, $target, 'inbox', ''),
|
||||
);
|
||||
|
||||
self::assertTrue($message->hasLabel($target), 'the fixture was never moved');
|
||||
|
||||
$this->undo->restore($token, $this->account->usr);
|
||||
|
||||
self::assertSameLabels(
|
||||
$before,
|
||||
$message->labels->toArray(),
|
||||
'undo left the target on, or left Archive behind from the archive the move performed',
|
||||
);
|
||||
self::assertSameLabels($before, $message->thread->labels->toArray(), 'the thread’s labels are what the list reads, and they did not follow');
|
||||
}
|
||||
|
||||
public function testUndoingALabelSwapPutsTheOldLabelBack(): void
|
||||
{
|
||||
$viewed = $this->tag('Todo');
|
||||
$target = $this->tag('Done');
|
||||
$message = $this->message($this->role(LabelRole::Inbox), $viewed);
|
||||
$before = $message->labels->toArray();
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->moveTo->move(
|
||||
[$message->thread],
|
||||
$this->moveTo->plan($this->account->usr, $target, 'label', (string) $viewed->id),
|
||||
);
|
||||
|
||||
$this->undo->restore($token, $this->account->usr);
|
||||
|
||||
self::assertSameLabels($before, $message->labels->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* What the final, database-only step is for. A conversation that wore
|
||||
* nothing but a custom label — ordinary on Gmail, where that is what
|
||||
* "archived and labelled" looks like — has no place to be moved back to.
|
||||
* Taking it out of the inbox again goes through archive(), which is what
|
||||
* tells the provider, and archive() attaches Archive. The message never
|
||||
* had it.
|
||||
*/
|
||||
public function testUndoingAMoveToTheInboxLeavesNoArchiveLabelBehind(): void
|
||||
{
|
||||
$viewed = $this->tag('Todo');
|
||||
$message = $this->message($viewed);
|
||||
$before = $message->labels->toArray();
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->moveTo->move(
|
||||
[$message->thread],
|
||||
$this->moveTo->plan($this->account->usr, $this->role(LabelRole::Inbox), 'label', (string) $viewed->id),
|
||||
);
|
||||
|
||||
self::assertTrue($message->hasLabel($this->role(LabelRole::Inbox)), 'the fixture was never moved');
|
||||
|
||||
$this->undo->restore($token, $this->account->usr);
|
||||
|
||||
self::assertSameLabels($before, $message->labels->toArray(), 'undo left behind a label the message did not have before');
|
||||
}
|
||||
|
||||
public function testUndoingAMoveToTrashTakesTheMailBackOutOfTheBin(): void
|
||||
{
|
||||
$message = $this->message($this->role(LabelRole::Inbox));
|
||||
$before = $message->labels->toArray();
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->moveTo->move(
|
||||
[$message->thread],
|
||||
$this->moveTo->plan($this->account->usr, $this->role(LabelRole::Trash), 'inbox', ''),
|
||||
);
|
||||
|
||||
$this->undo->restore($token, $this->account->usr);
|
||||
|
||||
self::assertSameLabels($before, $message->labels->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest way round there is — out of the bin, into the inbox, out of
|
||||
* the inbox again — and so the one most likely to come back with a label
|
||||
* it picked up on the way.
|
||||
*/
|
||||
public function testUndoingAMoveOutOfTheBinPutsTheMailBackInIt(): void
|
||||
{
|
||||
$target = $this->tag('Receipts');
|
||||
$message = $this->message($this->role(LabelRole::Trash));
|
||||
$before = $message->labels->toArray();
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->moveTo->move(
|
||||
[$message->thread],
|
||||
$this->moveTo->plan($this->account->usr, $target, 'trash', ''),
|
||||
);
|
||||
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Trash)), 'the fixture never left the bin');
|
||||
|
||||
$this->undo->restore($token, $this->account->usr);
|
||||
|
||||
self::assertSameLabels($before, $message->labels->toArray());
|
||||
}
|
||||
|
||||
public function testUndoingALabelTakesItOffAgain(): void
|
||||
{
|
||||
$label = $this->tag('Receipts');
|
||||
$message = $this->message($this->role(LabelRole::Inbox));
|
||||
$before = $message->labels->toArray();
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->status->applyLabel([$message], $label, true);
|
||||
|
||||
$this->undo->restore($token, $this->account->usr);
|
||||
|
||||
self::assertSameLabels($before, $message->labels->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* Single use, so a double click cannot run it twice and a stale toast
|
||||
* cannot re-apply an old state over whatever has happened since.
|
||||
*/
|
||||
public function testATokenWorksOnceAndAnUnknownOneNotAtAll(): void
|
||||
{
|
||||
$message = $this->message($this->role(LabelRole::Inbox));
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->status->archive([$message]);
|
||||
|
||||
self::assertSame(1, $this->undo->restore($token, $this->account->usr));
|
||||
self::assertNull($this->undo->restore($token, $this->account->usr), 'the same undo ran twice');
|
||||
self::assertNull($this->undo->restore(str_repeat('0', 32), $this->account->usr));
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot lives in the actor's own session, so this cannot arise in
|
||||
* the browser. It is checked because it is the last thing before a write.
|
||||
*/
|
||||
public function testASnapshotIsNotAppliedForAnotherUser(): void
|
||||
{
|
||||
$message = $this->message($this->role(LabelRole::Inbox));
|
||||
|
||||
$token = $this->undo->remember([$message]);
|
||||
$this->status->archive([$message]);
|
||||
|
||||
$stranger = new User();
|
||||
$stranger->email = 'stranger-' . uniqid('', true) . '@example.test';
|
||||
$stranger->nameFirst = 'Some';
|
||||
$stranger->nameLast = 'Stranger';
|
||||
$stranger->roles = ['ROLE_USER'];
|
||||
$stranger->password = 'x';
|
||||
$this->em->persist($stranger);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame(0, $this->undo->restore($token, $stranger));
|
||||
self::assertFalse($message->hasLabel($this->role(LabelRole::Inbox)));
|
||||
}
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compared by id, sorted. A Doctrine collection keeps the keys its
|
||||
* elements were added under, so a set that lost and regained a label is
|
||||
* the same set under different keys — which assertEquals reads as a
|
||||
* different array.
|
||||
*
|
||||
* @param array<array-key, Label> $expected
|
||||
* @param array<array-key, Label> $actual
|
||||
*/
|
||||
private static function assertSameLabels(array $expected, array $actual, string $message = ''): void
|
||||
{
|
||||
$names = static function (array $labels): array {
|
||||
$names = array_map(static fn (Label $label): string => sprintf('%s#%d', $label->name, $label->id), $labels);
|
||||
sort($names);
|
||||
|
||||
return $names;
|
||||
};
|
||||
|
||||
self::assertSame($names($expected), $names($actual), $message);
|
||||
}
|
||||
|
||||
private function role(LabelRole $role): Label
|
||||
{
|
||||
return $this->labelResolver->systemLabel($role, $this->account);
|
||||
}
|
||||
|
||||
private function tag(string $name): Label
|
||||
{
|
||||
$label = $this->labelResolver->customChain([$name], $this->account);
|
||||
|
||||
self::assertNotNull($label);
|
||||
$this->em->flush();
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
/** One message, alone in a thread of its own, carrying these labels. */
|
||||
private function message(Label ...$labels): Message
|
||||
{
|
||||
$thread = new MessageThread();
|
||||
$thread->account = $this->account;
|
||||
$thread->subject = 'Undo fixture';
|
||||
$thread->normalizedSubject = 'undo fixture';
|
||||
$thread->lastMessageAt = new \DateTimeImmutable('-1 hour');
|
||||
$thread->threadingMethod = ThreadingMethod::References;
|
||||
$thread->unreadCount = 0;
|
||||
$thread->messageCount = 1;
|
||||
$this->em->persist($thread);
|
||||
|
||||
$message = new Message();
|
||||
$message->account = $this->account;
|
||||
$message->subject = 'Undo fixture';
|
||||
$message->fromAddress = 'sender@example.test';
|
||||
$message->receivedAt = new \DateTimeImmutable('-1 hour');
|
||||
$message->hasAttachments = false;
|
||||
$message->messageId = sprintf('<undo-%s@example.test>', uniqid('', true));
|
||||
$message->mailbox = $this->inboxMailbox;
|
||||
$message->imapUid = random_int(3000, 90000);
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$message->addLabel($label);
|
||||
$thread->addLabel($label);
|
||||
}
|
||||
|
||||
$thread->addMessage($message);
|
||||
$this->em->persist($message);
|
||||
$this->em->flush();
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function seedAccount(): Account
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'undo-' . uniqid('', true) . '@example.test';
|
||||
$user->nameFirst = 'Status';
|
||||
$user->nameLast = 'Undo';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = 'x';
|
||||
$this->em->persist($user);
|
||||
|
||||
$email = 'undo-fixture-' . uniqid('', true) . '@example.test';
|
||||
|
||||
$account = new Account();
|
||||
$account->usr = $user;
|
||||
$account->email = $email;
|
||||
$account->username = $email;
|
||||
$account->imapHost = 'localhost';
|
||||
$account->imapPort = 993;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->smtpHost = 'localhost';
|
||||
$account->smtpPort = 587;
|
||||
$account->smtpEncryption = 'starttls';
|
||||
$account->password = 'x';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
$this->em->persist($account);
|
||||
$this->em->flush();
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
private function seedMailbox(string $name): Mailbox
|
||||
{
|
||||
$mailbox = new Mailbox();
|
||||
$mailbox->account = $this->account;
|
||||
$mailbox->name = $name;
|
||||
$mailbox->fullPath = $name;
|
||||
$mailbox->isSyncEnabled = true;
|
||||
$mailbox->isIdleEnabled = false;
|
||||
$this->em->persist($mailbox);
|
||||
$this->em->flush();
|
||||
|
||||
return $mailbox;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { test, expect } from "./support/test";
|
||||
import { INBOX_SUBJECTS, ajaxPost, mailRow, seed } from "./support/config";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* "Move to": the target label on, the label of the list you were in off.
|
||||
*
|
||||
* REMINDER: run `php bin/console asset-map:compile` after any JS change, or
|
||||
* Playwright reads the previous build and a fixed controller still fails.
|
||||
*
|
||||
* WHY THIS HAS TO BE A BROWSER TEST
|
||||
* ─────────────────────────────────
|
||||
* MoveToServiceTest has which label comes off in every view, and BulkMoveToTest
|
||||
* has what the route refuses. Neither can see the part that is new here: the
|
||||
* server is told which list the person was looking at BY THE PAGE. The picker
|
||||
* in the reading pane is rendered by a request with no list behind it, so it
|
||||
* reads the view off the list toolbar at the moment it is used — and if that
|
||||
* read comes back empty the move still answers 200, still shows its toast, and
|
||||
* quietly behaves like a move from nowhere. From a label view that means the
|
||||
* label is never taken off, which is the whole feature.
|
||||
*
|
||||
* So each test moves from a real list and then goes and LOOKS in both places:
|
||||
* gone from where it was, present under the target.
|
||||
*/
|
||||
const LABEL_NAME = "E2E Label";
|
||||
|
||||
test.beforeEach(() => {
|
||||
seed("seed-mail", "seed-label");
|
||||
});
|
||||
|
||||
/**
|
||||
* Selects a row. Set-and-dispatch rather than `check()` — the checkbox is a
|
||||
* clipped `sr-only` input, and label-bulk-toggle.spec.ts holds the account of
|
||||
* why a forced click on it is not reliable.
|
||||
*/
|
||||
async function select(row: Locator) {
|
||||
await row.locator("[data-thread-select]").evaluate((box) => {
|
||||
(box as HTMLInputElement).checked = true;
|
||||
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
/** Opens a "Move to" picker and picks one destination from it. */
|
||||
async function moveTo(scope: Locator, destination: string | RegExp) {
|
||||
await scope.getByRole("button", { name: "Move to" }).click();
|
||||
|
||||
const menu = scope.getByRole("menu", { name: "Move to" });
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
await menu.getByRole("menuitem", { name: destination }).click();
|
||||
|
||||
// Single-select: the pick IS the action, so the picker is gone again
|
||||
// without a confirm button having existed.
|
||||
await expect(menu).toBeHidden();
|
||||
}
|
||||
|
||||
function listToolbar(page: Page): Locator {
|
||||
return page.locator("[data-controller='mail--list-toolbar']").first();
|
||||
}
|
||||
|
||||
function toast(page: Page, text: string | RegExp): Locator {
|
||||
return page.locator("#toast-region [role='status']").filter({ hasText: text });
|
||||
}
|
||||
|
||||
/** The seeded label's id, read from the page that offers it as a destination. */
|
||||
async function labelId(page: Page): Promise<string> {
|
||||
const entry = listToolbar(page)
|
||||
.locator("[data-controller='mail--move-menu'] [data-label-id]", { hasText: LABEL_NAME })
|
||||
.first();
|
||||
|
||||
return (await entry.getAttribute("data-label-id")) ?? "";
|
||||
}
|
||||
|
||||
/** Puts the seeded label on a conversation that stays in the Inbox. */
|
||||
async function label(page: Page, subject: string): Promise<string> {
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
const id = await labelId(page);
|
||||
const thread = await mailRow(page, subject).getAttribute("data-mail--message-row-id-value");
|
||||
const labelled = await ajaxPost(page, "/status/bulk/label", { ids: [Number(thread)], labelId: Number(id) });
|
||||
|
||||
expect(labelled.ok(), "the fixture could not be labelled").toBe(true);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
test.describe("from the Inbox", () => {
|
||||
test("a selection leaves the Inbox and shows up under the label", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
await select(mailRow(page, INBOX_SUBJECTS.read));
|
||||
await select(mailRow(page, INBOX_SUBJECTS.star));
|
||||
|
||||
await moveTo(listToolbar(page), LABEL_NAME);
|
||||
|
||||
await expect(toast(page, `Moved to ${LABEL_NAME}`)).toBeVisible();
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.read)).toHaveCount(0);
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.star)).toHaveCount(0);
|
||||
|
||||
// Untouched: the move was the selection, not the list.
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.archive)).toHaveCount(1);
|
||||
|
||||
await page.goto(`/mail/label/${await labelId(page)}`);
|
||||
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.read)).toHaveCount(1);
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.star)).toHaveCount(1);
|
||||
|
||||
// And gone for good, not just hidden by a stream: a fresh Inbox.
|
||||
await page.goto("/mail/inbox");
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.archive)).toHaveCount(1);
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.read)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Undo brings the conversation back without the label", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
await select(mailRow(page, INBOX_SUBJECTS.read));
|
||||
await moveTo(listToolbar(page), LABEL_NAME);
|
||||
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.read)).toHaveCount(0);
|
||||
|
||||
await toast(page, `Moved to ${LABEL_NAME}`).getByRole("button", { name: "Undo" }).click();
|
||||
|
||||
// Back in the list it left, with no reload — the row is re-read into
|
||||
// its place — and not wearing the label the move had given it.
|
||||
const row = mailRow(page, INBOX_SUBJECTS.read);
|
||||
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row).not.toContainText(LABEL_NAME);
|
||||
|
||||
await page.goto(`/mail/label/${await labelId(page)}`);
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.read)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the open conversation is moved from the reading pane, which then closes", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
await mailRow(page, INBOX_SUBJECTS.archive).locator("a").first().click();
|
||||
|
||||
const sheet = page.locator("div.mail-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await moveTo(sheet, LABEL_NAME);
|
||||
|
||||
await expect(toast(page, `Moved to ${LABEL_NAME}`)).toBeVisible();
|
||||
|
||||
// The way archive ends: the pane showing a conversation that is no
|
||||
// longer in the list behind it closes, and the row is gone.
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.archive)).toHaveCount(0);
|
||||
|
||||
await page.goto(`/mail/label/${await labelId(page)}`);
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.archive)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("the Inbox is not offered as a destination while looking at the Inbox", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
await select(mailRow(page, INBOX_SUBJECTS.read));
|
||||
await listToolbar(page).getByRole("button", { name: "Move to" }).click();
|
||||
|
||||
const menu = listToolbar(page).getByRole("menu", { name: "Move to" });
|
||||
|
||||
await expect(menu.getByRole("menuitem", { name: LABEL_NAME })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Inbox" })).toHaveCount(0);
|
||||
|
||||
// Offered to a mailbox that has never deleted anything, too: these two
|
||||
// are asked for by role, so they do not wait for their labels to exist.
|
||||
await expect(menu.getByRole("menuitem", { name: "Spam" })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Trash" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("moving to Trash bins the conversation", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
await select(mailRow(page, INBOX_SUBJECTS.trash));
|
||||
await moveTo(listToolbar(page), "Trash");
|
||||
|
||||
await expect(toast(page, "Moved to Trash")).toBeVisible();
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.trash)).toHaveCount(0);
|
||||
|
||||
await page.goto("/mail/trash");
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.trash)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("from the bin", () => {
|
||||
test("a conversation moved to a label leaves the bin, and Trash is not offered there", async ({ page }) => {
|
||||
// Binned through the feature itself, so the fixture is whatever
|
||||
// "Move to Trash" really produces.
|
||||
await page.goto("/mail/inbox");
|
||||
await select(mailRow(page, INBOX_SUBJECTS.trash));
|
||||
await moveTo(listToolbar(page), "Trash");
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.trash)).toHaveCount(0);
|
||||
|
||||
await page.goto("/mail/trash");
|
||||
|
||||
const row = mailRow(page, INBOX_SUBJECTS.trash);
|
||||
await expect(row).toHaveCount(1);
|
||||
await select(row);
|
||||
|
||||
await listToolbar(page).getByRole("button", { name: "Move to" }).click();
|
||||
|
||||
const menu = listToolbar(page).getByRole("menu", { name: "Move to" });
|
||||
|
||||
// Already here. Spam is still somewhere else, so it stays.
|
||||
await expect(menu.getByRole("menuitem", { name: "Spam" })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Trash" })).toHaveCount(0);
|
||||
|
||||
await menu.getByRole("menuitem", { name: LABEL_NAME }).click();
|
||||
|
||||
await expect(toast(page, `Moved to ${LABEL_NAME}`)).toBeVisible();
|
||||
await expect(row, "moved to a label and still in the bin").toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.trash)).toHaveCount(0);
|
||||
|
||||
await page.goto(`/mail/label/${await labelId(page)}`);
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.trash)).toHaveCount(1);
|
||||
|
||||
// Not the inbox: it was filed, not restored.
|
||||
await page.goto("/mail/inbox");
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.trash)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the open conversation is not offered the bin it is already in", async ({ page }) => {
|
||||
await page.goto("/mail/inbox");
|
||||
await select(mailRow(page, INBOX_SUBJECTS.trash));
|
||||
await moveTo(listToolbar(page), "Trash");
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.trash)).toHaveCount(0);
|
||||
|
||||
await page.goto("/mail/trash");
|
||||
await mailRow(page, INBOX_SUBJECTS.trash).locator("a").first().click();
|
||||
|
||||
const sheet = page.locator("div.mail-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await sheet.getByRole("button", { name: "Move to" }).click();
|
||||
|
||||
const menu = sheet.getByRole("menu", { name: "Move to" });
|
||||
|
||||
await expect(menu.getByRole("menuitem", { name: "Inbox" })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Trash" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("what is offered", () => {
|
||||
test("a label the conversation already has is not a destination", async ({ page }) => {
|
||||
await label(page, INBOX_SUBJECTS.read);
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
// In the list: hidden for a row that has it, offered again as soon as
|
||||
// the selection includes one that does not.
|
||||
await select(mailRow(page, INBOX_SUBJECTS.read));
|
||||
|
||||
const button = listToolbar(page).getByRole("button", { name: "Move to" });
|
||||
const menu = listToolbar(page).getByRole("menu", { name: "Move to" });
|
||||
|
||||
await button.click();
|
||||
await expect(menu.getByRole("menuitem", { name: "Trash" })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: LABEL_NAME })).toHaveCount(0);
|
||||
await button.click();
|
||||
|
||||
await select(mailRow(page, INBOX_SUBJECTS.star));
|
||||
|
||||
await button.click();
|
||||
await expect(menu.getByRole("menuitem", { name: LABEL_NAME })).toBeVisible();
|
||||
await button.click();
|
||||
|
||||
// And in the reading pane, for the conversation that is open.
|
||||
await mailRow(page, INBOX_SUBJECTS.read).locator("a").first().click();
|
||||
|
||||
const sheet = page.locator("div.mail-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await sheet.getByRole("button", { name: "Move to" }).click();
|
||||
|
||||
const paneMenu = sheet.getByRole("menu", { name: "Move to" });
|
||||
|
||||
await expect(paneMenu.getByRole("menuitem", { name: "Trash" })).toBeVisible();
|
||||
await expect(paneMenu.getByRole("menuitem", { name: LABEL_NAME })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("from a label", () => {
|
||||
test("a selection moved to the Inbox loses the label it was listed under", async ({ page }) => {
|
||||
const id = await label(page, INBOX_SUBJECTS.read);
|
||||
|
||||
await page.goto(`/mail/label/${id}`);
|
||||
|
||||
const row = mailRow(page, INBOX_SUBJECTS.read);
|
||||
await expect(row).toHaveCount(1);
|
||||
|
||||
await select(row);
|
||||
|
||||
// The label being looked at is the one entry that would do nothing.
|
||||
await listToolbar(page).getByRole("button", { name: "Move to" }).click();
|
||||
const menu = listToolbar(page).getByRole("menu", { name: "Move to" });
|
||||
await expect(menu.getByRole("menuitem", { name: LABEL_NAME })).toHaveCount(0);
|
||||
await menu.getByRole("menuitem", { name: "Inbox" }).click();
|
||||
|
||||
await expect(toast(page, "Moved to Inbox")).toBeVisible();
|
||||
await expect(row, "the conversation is still listed under the label it was moved out of").toHaveCount(0);
|
||||
|
||||
await page.goto("/mail/inbox");
|
||||
|
||||
const inInbox = mailRow(page, INBOX_SUBJECTS.read);
|
||||
|
||||
await expect(inInbox).toHaveCount(1);
|
||||
await expect(inInbox, "the label of the view it was moved from is still on it").not.toContainText(LABEL_NAME);
|
||||
});
|
||||
|
||||
test("the open conversation is moved out of the label from the reading pane", async ({ page }) => {
|
||||
const id = await label(page, INBOX_SUBJECTS.star);
|
||||
|
||||
await page.goto(`/mail/label/${id}`);
|
||||
await mailRow(page, INBOX_SUBJECTS.star).locator("a").first().click();
|
||||
|
||||
const sheet = page.locator("div.mail-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// The pane's picker is rendered with no list behind it; that it knows
|
||||
// to move OUT of this label is read off the toolbar of the list
|
||||
// underneath. An empty read here moves from nowhere and leaves the
|
||||
// label on — see the header.
|
||||
await moveTo(sheet, "Inbox");
|
||||
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.star)).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
mailRow(page, INBOX_SUBJECTS.star),
|
||||
"the label was not taken off — the pane did not say which list it was opened from",
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto("/mail/inbox");
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.star)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("Undo puts the label back", async ({ page }) => {
|
||||
const id = await label(page, INBOX_SUBJECTS.read);
|
||||
|
||||
await page.goto(`/mail/label/${id}`);
|
||||
|
||||
await select(mailRow(page, INBOX_SUBJECTS.read));
|
||||
await moveTo(listToolbar(page), "Inbox");
|
||||
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.read)).toHaveCount(0);
|
||||
|
||||
await toast(page, "Moved to Inbox").getByRole("button", { name: "Undo" }).click();
|
||||
|
||||
await expect(mailRow(page, INBOX_SUBJECTS.read)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
@@ -2004,6 +2004,7 @@ jobs:
|
||||
restore: Wiederherstellen
|
||||
snooze: Zurückstellen
|
||||
wake: Zurückholen
|
||||
move_to: Verschieben
|
||||
reclassify: Sortiert neueste Mail neu
|
||||
state:
|
||||
done: '%count% erledigt'
|
||||
@@ -3786,6 +3787,7 @@ thread_row:
|
||||
unstar: Markierung dieser Nachricht entfernen
|
||||
|
||||
toast:
|
||||
archived: Unterhaltung archiviert.
|
||||
categorised: 'Nach %category% verschoben'
|
||||
dismiss: Schließen
|
||||
labelled: 'Label %label% vergeben'
|
||||
@@ -3795,6 +3797,9 @@ toast:
|
||||
send_failed: 'Diese Nachricht konnte nicht gesendet werden: %subject%'
|
||||
sent: Nachricht gesendet.
|
||||
undo: Rückgängig
|
||||
undo_expired: Das lässt sich nicht mehr rückgängig machen.
|
||||
undone: Rückgängig gemacht.
|
||||
unlabelled: 'Label %label% entfernt'
|
||||
|
||||
toolbar:
|
||||
view_select:
|
||||
@@ -3808,6 +3813,7 @@ toolbar:
|
||||
label: Label vergeben
|
||||
mark_read: Als gelesen markieren
|
||||
mark_unread: Als ungelesen markieren
|
||||
move_to: Verschieben nach
|
||||
snooze: Zurückstellen
|
||||
filter:
|
||||
show_all: Alle anzeigen
|
||||
|
||||
@@ -2004,6 +2004,7 @@ jobs:
|
||||
restore: Restoring
|
||||
snooze: Snoozing
|
||||
wake: Bringing back from snooze
|
||||
move_to: Moving
|
||||
reclassify: Sorting recent mail again
|
||||
state:
|
||||
done: '%count% done'
|
||||
@@ -3772,6 +3773,10 @@ thread_row:
|
||||
unstar: Unstar this message
|
||||
|
||||
toast:
|
||||
# After Archive, from a row, the reading pane or the toolbar. The row is
|
||||
# gone, which is also what a slip of the pointer looks like — this says
|
||||
# which it was, beside an Undo.
|
||||
archived: Conversation archived.
|
||||
# After a conversation is dropped on a category tab. The row it names
|
||||
# has just disappeared off the list, and the tab it went to may not be
|
||||
# on the strip at all — the strip only shows categories holding mail —
|
||||
@@ -3794,6 +3799,12 @@ toast:
|
||||
send_failed: 'That message could not be sent: %subject%'
|
||||
sent: Message sent.
|
||||
undo: Undo
|
||||
# The Undo was pressed twice, or after newer actions pushed it out. Said
|
||||
# plainly: the button promised to put something back and has not.
|
||||
undo_expired: That can no longer be undone.
|
||||
undone: Undone.
|
||||
# The label menu took a label off. The counterpart of `labelled`.
|
||||
unlabelled: 'Removed label %label%'
|
||||
|
||||
toolbar:
|
||||
view_select:
|
||||
@@ -3809,6 +3820,7 @@ toolbar:
|
||||
label: Label as
|
||||
mark_read: Mark as read
|
||||
mark_unread: Mark as unread
|
||||
move_to: Move to
|
||||
snooze: Snooze
|
||||
filter:
|
||||
show_all: Show all
|
||||
|
||||
@@ -1884,6 +1884,7 @@ jobs:
|
||||
restore: Haulin' back
|
||||
snooze: Lettin' it drift
|
||||
wake: Haulin' it back from the drift
|
||||
move_to: Haulin'
|
||||
reclassify: Stowin' recent post again
|
||||
state:
|
||||
done: '%count% done'
|
||||
@@ -3574,6 +3575,7 @@ thread_row:
|
||||
unstar: Rub out the X
|
||||
|
||||
toast:
|
||||
archived: Stowed in the hold.
|
||||
categorised: 'Stowed under %category%'
|
||||
dismiss: Away with ye
|
||||
labelled: 'Flyin'' the %label% flag'
|
||||
@@ -3583,6 +3585,9 @@ toast:
|
||||
send_failed: 'That message could not be hoisted: %subject%'
|
||||
sent: Message away!
|
||||
undo: Belay that
|
||||
undo_expired: Too late to belay that, matey.
|
||||
undone: Belayed.
|
||||
unlabelled: 'Struck the %label% flag'
|
||||
|
||||
toolbar:
|
||||
view_select:
|
||||
@@ -3596,6 +3601,7 @@ toolbar:
|
||||
label: Fly a flag
|
||||
mark_read: Mark as read
|
||||
mark_unread: Mark as unread
|
||||
move_to: Haul off to
|
||||
snooze: Let it drift
|
||||
filter:
|
||||
show_all: Show every last one
|
||||
|
||||
Reference in new issue
Block a user