Files
pl_mail/assets/controllers/mail/message_row_controller.js
T
Paul LütznerandClaude Opus 5 59cbbc9827 feat(mail): delete for good, and a way back out of the bin
Reported as: deleting a mail that is already in the trash takes the row off the
list and leaves the mail in the trash. And, separately: why can spam and trashed
mail be snoozed and archived?

Both are the same cause. Trashing is "add the Trash label", so trashing
something already trashed is a no-op on the server — while the list removed the
row anyway, which is why the mail looked deleted twice and was there both times.
Archive and Snooze in the bin are the same question from the other side.

## The action set now depends on where the mail sits

MailPlacement reads the conversation's own labels rather than the current route,
because a thread reached from a search result, a live update or a URL has no
list behind it and a route check would go quietly back to offering the wrong
controls in exactly those cases. Archive, Delete and Snooze are hidden in trash
and spam, on the row, in the thread header and in the overflow menu.

It is a service the Twig function delegates to, not a Twig function alone: the
purge route refuses anything MailPlacement does not call discarded, and an
interface that hides a control while the route behind it stays open is a habit
rather than a rule.

## Delete forever

Hiding the control left the bin with no way to be emptied, which is the wrong
place to stop, so this is the real thing.

Most of it turned out to be written and unconnected: ApplyImapFlagsHandler has
implemented `'delete' => $imapMessage->delete(expunge: true)` all along, under a
comment saying the row was "on its way to having no row at all", and
MailChangeRecorder::emailDestroyed() existed with no callers. What was missing
was the part that removes the local copy — and one thing that could not have
been reused.

**LabelChangePropagator cannot carry a purge, and that is the whole design.**
Every propagation message names local Message rows and lets the handler look
them up. That works while the row survives the operation. A purge deletes it, so
a handler would find nothing, log "no messages found" and return — leaving the
mail on the server to come back at the next sync as a message the user had
explicitly destroyed. Invisible against a fake provider. So
PurgeRemoteMessagesMessage carries the ADDRESS instead: IMAP folder and UID,
Gmail id, Graph id. Onto the export queue with every other outbound push, so a
purge cannot overtake a relabel already queued for the same mail.

Note propagator::delete() is NOT dead code and is untouched — it is
ComposeController::discard(), where TRASH at Gmail is right rather than a
compromise: a draft that was never sent belongs in the bin its own client would
put it in. Its docblock now says so, because I misread it as dead on the way
past.

Gmail gets messages.batchDelete, needing the full mail scope. Without it the API
answers insufficientPermissions, which assertSuccess() already classifies as
unrecoverable — so it lands in the log once rather than retrying forever.
Falling back to TRASH would be the worst available answer: the user is told it
is gone, plMail can no longer see it, and Google still has it.

The files go too. A deletion that leaves the bytes on disk is a lie in the way
that matters most for a mail client. Parts are removed explicitly rather than by
adding cascade to the mapping, which would change every other path that touches
a message.

## Restore

The counterpart the bin never had: mail could be thrown away and not fetched
back, except by finding it and attaching Inbox by hand from the label menu.
Removes both Trash and Spam, not only the one it was found under — a spam mail
somebody then deleted carries both, and one returned to the inbox still marked
SPAM gets filed away again by Gmail without plMail doing anything.

## Also here

- Pasted markup is reduced to content (CODE-REVIEW U-02): style, class, id,
  bgcolor, font and every alignment attribute go; lists, links and bold stay.
  Whitelisted rather than flattened to plain text, which is simpler and throws
  away what people are usually pasting for. The `<style>` block was the worst of
  them — it restyled the whole composer, not just the pasted text.
- Confirmations go through the app's dialog, not the browser's. Note the row's
  Delete forever asks in JavaScript rather than through data-turbo-confirm:
  Turbo only consults that attribute for links and form submissions, so on a
  fetch-posting button it would have looked like a confirmation and never been
  read.

Verified: 4009 unit tests green, PHPStan clean over src/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 13:16:18 +02:00

160 lines
5.1 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
import { jsonCsrfHeaders } from "../../csrf.js";
import { announceWrite } from "../../mail_writes.js";
import { askConfirm } from "../../confirm.js";
/**
* Handles per-row status actions in the message list.
*
* Values:
* id — entity ID (thread or message)
* type — "thread" (default) | "message"
*
* Routes used:
* thread → /thread/{id}/status/{action}
* message → /message/{id}/status/{action}
*/
export default class extends Controller {
static values = {
id: Number,
type: { type: String, default: "thread" },
};
stop(event) {
event.stopPropagation();
}
toggleSelect(event) {
event.stopPropagation();
const toolbar = document.querySelector("[data-controller~='mail--list-toolbar']");
if (toolbar) {
toolbar.dispatchEvent(
new CustomEvent("mail--list-toolbar:row-changed", { bubbles: false }),
);
}
}
async toggleStar(event) {
const { starUrl } = event.params;
event.stopPropagation();
await this.#post(starUrl);
}
async archive(event) {
const { archiveUrl } = event.params;
event.stopPropagation();
await this.#post(archiveUrl);
}
async trash(event) {
const { trashUrl } = event.params;
event.stopPropagation();
await this.#post(trashUrl);
}
/**
* Delete for good — offered only on mail already in the trash or in spam.
*
* Asks here rather than through `data-turbo-confirm`, which Turbo only
* consults for links and form submissions. This posts with fetch(), so the
* attribute would have sat on the button looking like a confirmation and
* never been read — the row would simply have vanished for good on the
* first click.
*/
async purge(event) {
const { purgeUrl, purgeConfirm } = event.params;
event.stopPropagation();
if (false === await askConfirm(purgeConfirm)) {
return;
}
await this.#post(purgeUrl);
}
/** Back to the inbox from the trash or from spam. */
async restore(event) {
const { restoreUrl } = event.params;
event.stopPropagation();
await this.#post(restoreUrl);
}
/**
* The wake time arrives as a param, set by the snooze menu — see
* mail--snooze-menu, which computes it in the browser because the server
* has no timezone for the session.
*
* Absent, this clears the snooze. That used to be the *only* behaviour:
* the signature took `until = null` as a second argument, and Stimulus
* calls actions with the event alone, so the row's snooze button silently
* unsnoozed instead of snoozing.
*/
async snooze(event) {
const { snoozeUrl, until = null } = event.params;
event.stopPropagation();
await this.#post(snoozeUrl, { until });
}
/**
* Call off a scheduled send from the row.
*
* The row is an <li> with an absolutely-positioned overlay <a> across it —
* so this stops the event the way every other row action does, and the
* button sits above the anchor rather than inside a nested form. The answer
* is a Turbo Stream that replaces this very row, which is why nothing here
* touches the badge itself: the server decides what the row now says.
*
* `preventDefault` as well as `stopPropagation`, unlike its neighbours: in
* the Drafts list the whole row is a link, and a click that reaches the
* document's default action opens the composer over the toast that just
* said the hold was lifted.
*/
async cancelSchedule(event) {
const { cancelScheduleUrl } = event.params;
event.preventDefault();
event.stopPropagation();
await this.#post(cancelScheduleUrl);
}
async markRead(event) {
const { read, readUrl } = event.params;
event.stopPropagation();
await this.#post(readUrl, { read });
}
// ── Private ───────────────────────────────────────────────────────────
async #post(url, body = {}) {
const response = await fetch(url, {
method: "POST",
headers: jsonCsrfHeaders(),
body: JSON.stringify(body),
});
if (!response.ok) {
console.error(`[message-row] status update failed: ${url}`, response.status);
return;
}
const html = await response.text();
if (html.trim() !== "") {
Turbo.renderStreamMessage(html);
}
// Here rather than in markRead() alone, because every action on this
// row moves a number in the sidebar: archive and trash take the mail
// out of Inbox, snooze takes it out and puts it back later, star moves
// the Starred count. The row redraws itself from the stream; nothing
// else does. Announced after the render so the badges and the row do
// not disagree in between.
announceWrite();
}
}