14 Commits
Author SHA1 Message Date
maniandClaude Sonnet 5.5 c795eeeee1 fix(mail): re-resolve what a deleted tab held, and say exactly what the guard guarantees
Removing a category cleared the pins of the conversations in it but left them
in whatever built-in tab they had when they were dragged, until their next
message; the removal now re-resolves exactly those conversations, in the same
transaction. The "wrong tab" report is no longer offered for a conversation in
a tab the reader made: it is evidence about plMail's rules, and "should be
Promotions" was refused there as already true.

The inbox keeps its own two query ceilings (28 and 16) while the other lists
keep 27 and 15, so one view's extra read cannot loosen five guards. The two
partial indexes are declared in the mappings so Doctrine agrees with the
migration. A restored category name is trimmed and cut to the column.

The docs, changelog and the filer's own docblock now state the guard's limits:
it checks the message, not the conversation; it needs a filter that names the
sender; and a filter that does not name one files any sender that
authenticates for its own domain. Dead delegating methods, a reference to a
class that does not exist and two stale comments are removed or corrected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:16 +02:00
maniandClaude Sonnet 5.5 92adf5133b fix(mail): select nothing for a vanished own tab, and keep the Gmail rule true after filing
Select-all on a tab the reader made, after that tab was deleted elsewhere, fell
back to Primary: the whole-view job resolves its view when it RUNS, so "archive
everything" would have reached up to ten thousand conversations that were never
on screen. A key shaped like an own tab that no longer resolves now selects
nothing, as a label that is not the reader's does; an unknown built-in name
keeps meaning Primary.

A filter's verdict on Gmail-labelled mail counted only at filing time, so
switching "sort over Google" off later, which queues a re-sort, left Google's
mail in a tab of the reader's own. Both recompute statements and the threader
now apply the same test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:16 +02:00
maniandClaude Sonnet 5.5 1298d1edce refactor(mail): make a category's owner and name non-null, and satisfy PHPStan
InboxCategory::$usr and $name are NOT NULL columns, so they are declared that
way (Message::$account is the precedent) instead of nullable with a
check nobody can reach. FilterDescriber keys its category names by the user
identifier rather than reading an id off a UserInterface, which adds nothing
to the baseline. Test fixtures stop storing threads they never read back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:16 +02:00
maniandClaude Sonnet 5.5 23a3ada6c0 test(mail): pin select-all on an own tab and the new-mail values of its counts
Select-all while looking at a tab the reader made must act on that tab, never
on Primary; and the counts endpoint must carry the right values for the tab's
new-mail dot and sender hint, not merely the keys.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:15 +02:00
maniandClaude Sonnet 5.5 44d5cefec5 docs: describe inbox tabs of your own and the filter action that fills them
The filters page gets the action, a worked carrier example with the marketing
exclusion, what the action will and will not file (authenticated senders only;
on Gmail only when plMail is told to sort over Google; nothing but a tab) and
three "Things that bite". The mail page gets "Tabs of your own", the backup page
the new rows, the ingest page why the verdict has a column of its own, and the
JMAP README why the five-value vocabulary is untouched. German pages follow with
their hash markers refreshed. CHANGELOG notes the migration and the one extra
inbox read.

Also pins, in the JMAP category test, that a conversation in a reader's own tab
is still served as its built-in category and that a custom key is refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:15 +02:00
maniandClaude Sonnet 5.5 2497ad05de feat(backup): carry a reader's own inbox tabs and the filters that file into them
The config backup exports each user's categories in their strip order, and the
restore creates them before the filters and rewrites a "File in category"
action to the id the new row was given. An action naming a category the file
does not carry is dropped, like a label it does not carry. A file written
before categories existed restores as before.

ConfigBackupUsers takes the category repository, so the completeness test that
builds it by hand passes a stub.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:15 +02:00
maniandClaude Sonnet 5.5 7adccfb6f5 feat(settings): let a reader make, rename and delete their own inbox tabs
A card on Settings -> General beside the sorting card: a list of the reader's
categories with rename and delete, and a form to add one. A name is trimmed,
at most 40 characters, not a built-in tab's name and not one the reader already
has (case-insensitively); a refused name comes back as a toast saying why.

Removing a category un-files its mail through the foreign keys and clears the
pins of everything in it first, in one transaction: a foreign key cannot clear a
pin, and a conversation pinned into a category that has gone would be skipped
by every recompute for ever. A pin to a built-in tab or to another category is
left alone. The filter list shows a category chip naming its category, or
"(deleted category)". The unused colour column is dropped from the new table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:14 +02:00
maniandClaude Sonnet 5.5 2a564c34b2 feat(mail): say in the message panel when a conversation is in the reader's own tab
The category row names the tab the reader made when the conversation is in one,
and the reason is "filed there by one of your filters" (or the existing pinned
wording after a drag), while the built-in category the rules decided stays what
`filed` reports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:14 +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
maniandClaude Sonnet 5.5 b2b9a90840 feat(mail): show the reader's own categories as inbox tabs
A category the reader made is a tab beside the five, shown while it holds mail
(or is being looked at) and otherwise only as a drop target. A conversation is
in exactly one tab: its own when it has been filed in one, else the built-in
one, so a carrier's notice is listed in Shipping and no longer in Promotions.
The grouped counts, the new-mail dots, the sender hint, the select-all view and
the counts endpoint all key a tab the same way.

InboxTab is the one value for "a tab", whose key for a built-in is exactly what
it always was. InboxTabs resolves a key against the asking reader's own
categories, answers a key that cannot be custom without a query, and reads the
categories once per request. Another reader's category, or a deleted one, is no
tab and opens the inbox like any unknown key.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:13 +02:00
maniandClaude Sonnet 5.5 34ffa5590a test(mail): let the inbox spend one more read on the reader's own tabs
The inbox visit was at its query ceiling of 27 and the inbox frame at 15, both
exactly. The reader's own tabs need one indexed read of inbox_category, made
once per request, so the two ceilings move to 28 and 16 with the reason written
beside each. The other five lists are unchanged.

This is the only pre-existing assertion the feature changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:13 +02:00
maniandClaude Sonnet 5.5 74e5e28984 feat(rules): add a "File in category" filter action
A filter can now file the mail it matches under one of the reader's own
categories. RuleCategoryFiler decides whether it may: only a message whose
sender passes authentication (a forged From line must not sit in a tab named
after the real sender), only into a category the rule's owner owns, and, on a
Gmail label, only when the reader asked plMail to overrule the provider. A
refusal leaves the message to the ordinary rules. When two filters match, the
one that runs last decides.

The editor offers the action only to somebody who has a category (or whose
rule already carries one), so nobody else's editor changes. Ownership voting,
the plain-English restatement and the three catalogues learn the new action.

RuleActionExecutor takes the filer as a constructor argument, so the one test
that builds it by hand passes a real one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:13 +02:00
maniandClaude Sonnet 5.5 4c1b3dc238 feat(mail): resolve a conversation's own-category tab like the built-in one
A thread is shown in one of the reader's own tabs when its newest message was
filed there: MessageThreader adopts the verdict alongside the built-in
category (and moves the thread back out when a newer message is filed
nowhere), and both recompute statements carry it in the same pass. A pinned
thread keeps its tab, as it keeps its built-in category.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:12 +02:00
maniandClaude Sonnet 5.5 d3e0782cf7 feat(mail): add inbox categories a reader makes themselves
A new inbox_category table, and two nullable columns that file mail under
one: message.rule_category_id (a filter's verdict, kept apart from
message.category because five writers recompute that and would erase it) and
message_thread.user_category_id (the conversation's tab). Both references are
ON DELETE SET NULL, so removing a category un-files its mail in the database.

The built-in category columns, their enum mapping and everything JMAP serves
from them are untouched. Nothing is backfilled and nobody's inbox changes
until a category and a filter filing into it exist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:11 +02:00