Skip to content

fix: sidebar tag unread counts go stale on read/unread changes#607

Merged
rathlinus merged 1 commit into
bulwarkmail:mainfrom
honzup:fix/tag-counts-stale-on-read
Jul 13, 2026
Merged

fix: sidebar tag unread counts go stale on read/unread changes#607
rathlinus merged 1 commit into
bulwarkmail:mainfrom
honzup:fix/tag-counts-stale-on-read

Conversation

@honzup

@honzup honzup commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Problem

Mark a mailbox as read and the folder unread counts clear, but the sidebar's tag unread counts do not: a tag goes on showing for example 47 / 65 in bold with nothing actually unread. The same happens for individual read/unread toggles. Only a page reload puts it right.

Cause

tagCounts is fetched from the server, not derived from state: fetchTagCountsclient.getTagCounts(tagIds), which runs an Email/query per $label:<id> keyword. Nothing in the read/unread path refreshes or adjusts it.

The per-mailbox unreadEmails counters don't have this problem, because the store already keeps them current with a local delta on every mutation (applyMailboxCounterUpdate and friends). Tags simply never got the equivalent, so the only thing that ever corrected them was a full refetch — on mount, on a Mailbox/Email state change pushed from the server, or on a reload.

Fix

Add applyTagCountReadDelta alongside the existing mailbox-counter helpers and apply it wherever the affected emails are known locally — markAsRead, batchMarkAsRead, setEmailKeywordsLocal. It walks the configured keywords, and for each $label:<id> an email carries, moves that tag's unread by the delta. No server round-trip, so a single toggle stays instant.

Three details worth calling out:

  • Only a genuine $seen flip moves a count, reusing the wasRead/isUnread the callers already compute. Re-marking a read email as read cannot drift the count.
  • unread is clamped at zero, as the mailbox counters are.
  • A read-state change never touches a tag's total.

markMailboxAsRead is the deliberate exception and refetches instead. It is a server-side bulk operation over a whole mailbox, so it also marks emails that were never loaded into state.emails — a local delta would only see the loaded ones and leave the counts high. It fires fetchTagCounts after its optimistic update, in the same style as the existing refetch in handleStateChange. The asymmetry is commented in the code, since it's the non-obvious part.

Not addressed here

Two pre-existing gaps I found while in this code and deliberately did not widen the diff to cover:

Colour-tagging from a Pro Interface tab appears not to persist at all. handleSetColorTag in components/pro/pro-email-tab-body.tsx:193-207 calls setEmailKeywordsLocal and patches its own component state, but nothing in components/pro/ calls updateEmailKeywords — and setEmailKeywordsLocal takes no JMAP client, so by construction it can only patch memory. The mail page does the same operation as a trio (app/(main)/[locale]/page.tsx:1737-1744): updateEmailKeywordssetEmailKeywordsLocalfetchTagCounts. The Pro tab has only the middle step, so the tag should be lost on reload, and the tag counts (total as well as unread) never move. That is a persistence bug rather than a counter bug, so it is out of scope here — but it is why I have not tried to keep total in step from the client: the delta in this PR deliberately only touches unread, which is the part a read/unread change can actually affect. Flagging it rather than fixing it silently; happy to open a separate PR. (Read from the code — I have not reproduced it in a running Pro Interface, which is experimental and off by default.)

tagCounts has no per-account dimension. It is a flat Record<tagId, {total, unread}>, and fetchTagCounts only ever queries the active client (getTagCounts runs its Email/query against that client's single accountId), so the counts describe one account. state.emails can hold mail from several accounts in a unified view, so the delta in this PR will move a count for an email belonging to an account those counts never included. The counts were already approximate in that configuration — a refetch corrects them — but this PR gives the approximation a new way to drift, so it seems worth stating. Giving tagCounts an account dimension (as accountMailboxes has) looked like a bigger change than this fix should carry; happy to take it on if you would rather it were handled here.

Tests

11 new tests in stores/__tests__/email-store-tag-counts.test.ts, six of which fail against main: the delta on read and unread, an email carrying two tags, an untagged email moving nothing, no double-decrement when re-marking an already-read email, unread never going negative, total never altered, and markMailboxAsRead triggering a refetch rather than relying on a delta.

npm run typecheck and npm run lint are clean. The full suite passes apart from one pre-existing failure in contact-store (should expand group members when group name matches), which reproduces unchanged on a clean main checkout — getAutocomplete now returns a group as a single Outlook-style entry while the test still asserts the old expand-to-members behaviour.

Verified by hand against a live Stalwart instance: marking the inbox all-read now clears the tag counts and the bold immediately, with no reload.

Marking mail as read left the sidebar's tag unread counts untouched — the
folder counts cleared, but a tag went on showing "47 unread" in bold until
the page was reloaded.

tagCounts is fetched from the server (Email/query per $label keyword) rather
than derived from state, and no read/unread mutation refreshed or adjusted
it. The per-mailbox unreadEmails counters were kept current by a local delta;
tags simply had no equivalent.

Add applyTagCountReadDelta alongside the existing mailbox-counter helpers and
apply it wherever the affected emails are known locally: markAsRead,
batchMarkAsRead, and setEmailKeywordsLocal. Only a genuine $seen flip moves a
count, so re-marking a read email as read cannot drift it, and unread is
clamped at zero. A tag's total is never touched by a read-state change.

markMailboxAsRead is the exception and refetches instead: it is a server-side
bulk operation over an entire mailbox, so it also marks emails that were never
loaded into state.emails, and a local delta would leave the counts high.
@rathlinus rathlinus merged commit 9072bf8 into bulwarkmail:main Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants