Archived mail had no list to be browsed in, only search. All Mail lists every conversation outside Spam and the bin (what arrived, what was sent, drafts and archived mail), as Gmail's All Mail does, across the reader's active accounts or within one.
It is LabelRole::AllMail, a view and not a place, like Unread: nothing attaches it to mail, it is created hidden when label settings is opened, it is bound to no account and it refuses moves. Its eye in Settings → Mail → Labels shows or hides both the row beside Drafts and the row at the top of each account's folders. Archiving or snoozing from it leaves the row in place. One list scope, `all_mail`, with the account id as its value or empty for every account; AllMailView holds the rules the row buttons, the bulk toolbar, Move to and select-all ask.
No migration.
Co-authored-by: shady2k <3366415+shady2k@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dropping on one of the reader's own tabs files the conversation there and pins
it, leaving the built-in category alone (JMAP clients are served that column).
Dropping on a built-in tab from one clears the reader's tab, so a conversation
is never in two. "Already there" is a statement about both columns. The route
resolves the posted key against the dropping reader's own categories, so
somebody else's category is refused like a made-up one and nothing moves.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Settings → Mail → Disposal has one per-user switch, off until somebody turns it on. It decides whether Delete forever exists at all: the flame on a row in Spam and the bin, and a new Delete forever above a selection there. Both purge routes answer 403 while the switch is off.
A selection extended with Select all N empties the whole of Spam or the bin in a background job. Any other view is refused, and the worker checks the switch and each conversation's placement again.
The bin no longer offers Delete above a selection: it trashed mail that was already in the bin, took the rows off the page and they were back on the next load.
The choice lives in the user's settings bag and a config backup carries it. No migration.
Continues #56.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A button beside Delete — on a list row, in an open conversation and
above a selection. On one conversation it opens a menu: move it to
Spam, or move it and create a filter for the sender, or for the
sender's whole domain. The domain filter asks once more first, and for
a mail provider's domain it sits behind "More options". The filter is
an ordinary rule under Settings → Filters, a second press on the same
sender makes no second one, and Undo takes back the move and a filter
the same click created.
The filters match on two new conditions, fromAddress and fromDomain,
which compare the whole address or the whole domain. `from` is a
substring over address and display name, so a filter for a@x.de built
on it also caught ba@x.de. Both are in the filter editor and in JMAP
Email/query.
Refs #31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Twenty-one stream responses each spelled out the same append-to-
toast-region wrapper around _toast.html.twig. They now include
_partials/_toast.stream.html.twig with the same message and type, so the
markup is unchanged. Compose streams (owned elsewhere) and
_send_outcome (passes extra hide_after) are left as they are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dragging a conversation onto "Receipts" took it out of the inbox,
because every drop was a move. That is right for the rows above LABELS
in the sidebar — Inbox, Archive, Spam, Trash, and each account's own
folders are places, and dropping into one means the mail is there and
nowhere else. It is wrong for a label, which is something a conversation
wears rather than somewhere it is.
The distinction belongs to the TARGET rather than to the label, and it
has to: the same custom label is rendered in this sidebar twice. Under
LABELS it means "everywhere" and dropping there tags; under an account
it names that account's own folder and dropping there files. Nothing
about the label can tell those apart — only the row can. So the sidebar
emits `data-dnd-folder` for one and `data-dnd-label` for the other, and
the controller reads which it landed on.
Two attributes rather than a `mode="move|label"` string because the
controller selects on them, and a selector for an attribute VALUE is one
typo from matching nothing quietly.
/status/bulk/label applyLabel(attach: true), refusing system
labels for the reason /status/{type}/{id}/label
refuses them — attaching Inbox without
detaching what the mail carries is a half-move.
_labelled.stream `leaves: false`, which is the whole difference
from a move: the rows are redrawn rather than
removed, and that is also what puts the new
chip beside the subject.
AND A REAL ONE OUT OF THE WORKER LOG, reported alongside.
WARNING ApplyImapFlagsHandler: destination not resolvable
ERROR Could not create the IMAP folder a move needed
folder: INBOX.spambucket
error: NO [ALREADYEXISTS] Mailbox already exists
ImapFolderProvisioner caught every Throwable from CREATE alike, so the
one outcome that already satisfies the request — the folder is there —
was handled as a refusal: return null, "destination not resolvable", and
the mail stays put. It happens whenever the server has a mailbox plMail
has not recorded: made in another client since the last LIST, differing
only in case, under a namespace the sync did not walk.
Already-exists is success now, and it still records the folder locally,
because the folder existing on the server and NOT in our database is
precisely why the destination could not be resolved — returning the path
without writing the row would fail the same way on the next move.
record() reuses an existing row rather than building a second on the
same full path, which that path can now reach. Every other refusal stays
a refusal.
The browser test for a move was rewritten rather than repaired: it took
`[data-dnd-folder]` first, which is Inbox, which correctly refuses drops
on the inbox view — a passing rule reading as a broken test. It archives
a conversation and drags it home instead, which is a real move in a
direction somebody makes, and uses the one system label a freshly seeded
user is guaranteed to have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Filing mail meant a menu: "Label as" for a folder, and for a category
nothing at all — the inbox tabs were computed from headers and there was
no way to disagree with them. Both are now a drag.
The two ends of the gesture are in different parts of the document — the
rows are in the list frame, the folders are in a sidebar that is rendered
twice, and the tab strip is a third place — so mail--dnd listens on
<body>. It uses HTML5 drag rather than pointer events, unlike the
calendar's block-dragging: this gesture has a discrete destination, so
the platform's own auto-scroll, drag image, refusal cursor and Escape
are worth more than the live preview a pointer implementation buys. The
cost is touch, which is smaller than it sounds — on a phone the sidebar
is a closed drawer while the list is on screen.
A move is deliberately not "attach the destination label". Nobody drags
something onto a folder and expects to find it in both places, so
ThreadStatusUpdater::move() detaches the location labels the mail
carries and leaves the tags alone; which labels count as a location is
the one judgement in it, and getting it wrong in the generous direction
would strip the user's own labels off. The three system destinations
delegate to archive/trash/restore, which already own their provider
semantics.
A drop posts to the bulk endpoint the toolbar's buttons post to, and for
the reason that endpoint exists: a row-shaped response cannot fix the
pager or the empty state, and a private route for drags would have been
a second implementation of what happens to the list afterwards.
MessageThread category_pinned_at, so a category chosen by hand
survives the next message in the thread — and
a run of `app:backfill category`, which is the
same overwrite applied to a whole account at
once.
LabelRole acceptsMoves(), read by the sidebar to decide
which rows offer a drop and by the controller to
refuse the rest. An interface that hides a
control while the route stays open is a habit,
not a rule.
SidebarCounts roleLabelId(), because the system rows are
anchors to routes and a route is not a thing a
conversation can be moved into.
inbox.html.twig ghost tabs for the categories nothing is filed
under yet, revealed only during a drag. Without
them the only categories you could move mail
into were the ones it might already be in.
Three things the browser found that no unit test could. The list
animates in, so a box measured on arrival belongs to whichever row was
passing through that coordinate — the drag worked perfectly and moved a
different conversation. A multi-step mouse move dispatches one drag
event per step and the last one lags, so the highlight belonged to the
tab next door. And enter/leave cannot track a target made of several
children during a native drag, where relatedTarget is routinely null;
dragover alone can, because "somewhere else" is simply the next one
arriving.
Deliberately not done: an inbox where every message is Primary has no
tab strip, so it has nowhere to drop and cannot acquire a second
category. Rendering the strip there anyway, hidden until a drag, was
built and taken out — it makes the list jump under the pointer that has
just picked a row up. The honest fix is a preference, and that is a
different change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four reported items, and the feature that turned out to be the fix for two of
them.
**Selecting past the page.** "Select all" meant the fifty rows on screen, which
is the only thing a checkbox over a paginated list can mean on its own — and not
what anybody wants in a bin holding a hundred and ninety-five. When the page is
fully selected and the view holds more, the toolbar now offers the rest, and the
same gesture covers "every starred mail".
The view arrives as a named scope, not as the URL the user is on. Posting the
URL would make every mail list route an input to a bulk WRITE, so a route added
later for reading becomes a way of deleting without anyone deciding that. The
scope names are the ones the list templates already use for themselves
(`{% block list_scope %}`), so there is nothing new to learn and the two cannot
drift. Unread-only travels with it: a "select all" that ignored the filter would
act on mail the person cannot see.
**One request, not one per conversation.** The toolbar posted per thread, in
parallel — fine for fifty rows, impossible for a folder. That also could not
answer for the LIST: each response redrew its own row and knew nothing else, so
an emptied list still said "1–5 of 5" (Schreibtest 10). The bulk response
carries the rows AND the pane re-reads the list, because the pager and the empty
state are properties of the list that no row-shaped stream can carry.
Two single-thread assumptions had to go with it, and both were correct until
now. `finish()` resynced only `$messages[0]->thread`, so a bulk archive answered
200, reported 71, and changed nothing on screen — the threads kept the Inbox
label their messages no longer had. `markRead()` wrote the whole selection's
unread count onto the first thread, so four conversations were marked read and
three rows went on rendering as unread. Both now work per thread, and the bulk
controller groups by ACCOUNT because ThreadStatusUpdater resolves the
destination folder from the first message's account.
**The 404 was English and in the wrong theme** (Befunde 2). Not an oversight in
the template: a 404 is thrown by the router at priority 32 of kernel.request,
before the firewall at 8 and before UserLocaleSubscriber at 6 — nothing has
authenticated when the exception is handled, so there is no user, no locale and
no theme. The error template must not look one up, and its own docblock explains
why at length. So both hints ride in a cookie written on ordinary responses.
**Sie/du** (Befunde 8). Seven strings addressed the reader formally, and the
report found them in Aliases because that is where the reader happened to look.
Guarded by a test that uses the one rule that can be checked: German capitalises
both the polite second person and the first word of a sentence, so a capitalised
form MID-sentence can only be formal address. A naive search flags a dozen good
sentences, gets an exception list, and the exception list then swallows the next
real one.
**A revert, recorded rather than hidden.** The label menu's escape to the
browser's top layer is gone. The bug is real — the panes' `backdrop-filter`
makes each a stacking context and the containing block for `position: fixed`, so
nothing inside one can rise above the chrome — but the fix broke clicking:
`elementFromPoint` at an option's own coordinates answers null and the click
never lands. It did the same to the snooze menu. Three specs caught it. A menu
that is visible and does nothing is worse than one that is clipped, and I could
not find the cause without seeing the real layout, so the spec is marked fixme
with the whole story in it.
Also: a shared `rowAction()` for the specs. A row's actions are
`visibility: hidden` until hovered, which makes them easy to lose under four
parallel workers and — because a hidden element is not in the accessibility tree
— makes `getByRole` report a missing action for one that is merely unhovered.
That was three of my own specs failing on a different run each time.
4027 unit tests, PHPStan clean, 550 browser tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>