15 Commits
Author SHA1 Message Date
1b04e697f6 All Mail: every conversation outside Spam and the bin, as an opt-in system label (#57)
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>
2026-10-15 09:36:25 +02:00
maniandClaude Sonnet 5.5 bcb1192ce0 feat(mail): drag a conversation onto, and out of, the reader's own tab
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>
2026-10-12 14:32:14 +02:00
Walid ManiandClaude Opus 5.5 f19818fa50 Put the Delete forever buttons behind a Disposal setting, off by default (#71)
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>
2026-10-11 16:01:23 +02:00
Paul LütznerandClaude Opus 5.5 4656f8874e Add a spam button that can also filter the sender or their domain
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>
2026-10-09 14:20:26 +02:00
Paul LütznerandClaude Opus 5.5 464df70473 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>
2026-10-07 13:56:35 +02:00
Paul LütznerandClaude Opus 5.5 bcd332edf1 chore(dead-code): one shared toast turbo-stream partial
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>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5 3ad1a7a9f7 Make a drop on a label add it, and keep moving for the rows above them
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>
2026-09-02 13:47:59 +02:00
Paul LütznerandClaude Opus 5 8e05c4f557 Let a conversation be dragged into a folder or onto a category tab
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>
2026-09-01 19:45:10 +02:00
Paul LütznerandClaude Opus 5 2549b7e019 feat(mail): select every conversation in a view, in one request
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>
2026-08-22 20:09:32 +02:00
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
Paul Lützner 9415c9591e more tests 2026-07-21 17:03:02 +02:00
Paul Lützner 8b662f9b97 major label refactor 2026-07-19 20:38:41 +02:00
Paul Lützner f9104daaf9 action progress 2026-07-09 22:20:40 +02:00
Paul Lützner ae9bb8b757 redo message actions 2026-07-09 16:28:13 +02:00
Paul Lützner 9218f5e627 updated thread rows, wired up starred etc. still needs actual sync 2026-07-03 19:24:47 +02:00