close
Skip to content

feat: show unread count badge on favicon#608

Merged
rathlinus merged 1 commit into
bulwarkmail:mainfrom
honzup:feat/favicon-unread-badge
Jul 16, 2026
Merged

feat: show unread count badge on favicon#608
rathlinus merged 1 commit into
bulwarkmail:mainfrom
honzup:feat/favicon-unread-badge

Conversation

@honzup

@honzup honzup commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Closes #560.

Draws the active inbox's unread count onto the browser-tab favicon, so new mail is visible on a tab that is not focused — including when the browser collapses tabs to icon-only, where a title-based count disappears entirely.

1 unread 12 unread 99+ unread
unread 1 unread 12 unread 99+

On by default, switchable off in Settings → Layout, alongside the other appearance toggles — a badged tab icon is not to everyone's taste.

unreadcountontab

What it does

The badge is composed over the base favicon as SVG and served as a percent-encoded data: URL. The base icon is read from the rendered <link rel="icon"> rather than from config, so admin and per-domain faviconUrl branding overrides are inherited for free — the count is drawn on whatever logo the deployment actually serves, including the per-domain override added in #585. Staying in SVG rather than rasterising to a canvas also means the browser renders it at whatever size it asks for, so a HiDPI tab is not handed a 16px bitmap.

The count is the active account's inbox, read with the store's canonical selector (role === 'inbox' && !isShared), so a delegated setup cannot badge someone else's inbox.

Three things that are deliberate, and non-obvious

We append our own icon link, and never touch one we did not create. Next's metadata icons are rendered by React, which keeps a fiber pointing at that DOM node. Removing it — the usual recipe for this feature — leaves React holding a detached node, and the next commit that deletes that fiber throws Cannot read properties of null (reading 'removeChild') in the commit phase. So the badge is an additional <link rel="icon">, marked as ours and appended last (last-declared wins). Non-SVG fallback links keep their type/sizes, and nothing of React's is disturbed.

Every state change is an insertion, never a mutation or a removal. That is the only signal a browser reliably re-reads the favicon on. Firefox ignores an in-place href change — and it equally ignores a removal, so clearing the badge by deleting our link left a stale count painted on the tab until a hard reload. Clearing it instead inserts a fresh link of ours carrying the original base href. Switching the setting off rides the same path, which is why the plain icon returns immediately.

Holding last place has to be defended. On a client-side navigation React re-hoists its metadata icon link into <head>, landing after ours, and the base icon silently wins again — the badge vanished on the way to Calendar and never came back. A MutationObserver on <head> moves our own link back to the end when a foreign icon link appears, and no-ops once it is last, so a move cannot feed itself.

The badge design

A white box with black digits, in the bottom-right corner, sized to the label and growing leftwards. The geometry is measured from Gmail's own 16px favicon rather than invented: box height 0.625 of the icon, digit cap height 0.44, flush to the bottom and right edges, corners rounded by about a pixel, weight 500. A single digit occupies roughly a third of the icon's width, so the left of the base mark stays uncovered and the artwork remains recognisable; a three-glyph label fills the width, and its font shrinks modestly to fit.

The corner radius is the load-bearing part. A shape with fully round ends — a disc, or a rounded-end pill — squanders its width on those ends, which is exactly the space three glyphs need: at 16px, 99+ in a pill was an illegible smudge, and a single digit read as a plain circle. Squaring the corners off is what makes a compact, label-sized box work.

Neutral white rather than the conventional red, because faviconUrl is admin-overridable and Bulwark's own icon is rgb(219,45,84) — a red badge sat red-on-red.

Counts run 1–99, then 99+.

Security

The base SVG may be admin-uploaded, and app/api/admin/branding/[filename] deliberately serves it under a sandboxing CSP because SVG can carry script. Re-emitting it verbatim as a same-origin data: URL would un-fence exactly that, so script, foreignObject and every on* handler are stripped before serialising. The badge's own colours are set via a style attribute as well as presentation attributes, so a branded base carrying <style>rect{fill:…}</style> cannot recolour it.

Mounted app-wide

In the root layout, not on the mail route: the badge belongs to the tab, so mounting it on the page cleared it on every hop to settings, calendar or contacts.

A question for you: should deployments be able to enforce this?

The setting added here is a per-user preference, which is the right shape for "I don't want a badged icon".

It is not a branding control, and I have not pretended otherwise. If you would like an organisation that has set its own faviconUrl to be able to say "do not stamp anything across our logo", that needs an admin-level key — branded<boolean> in /api/config beside faviconUrl, with a toggle in the Branding tab. That is about four files and no i18n, and I am happy to add it. I left it out to keep the first cut small, and because it is a product decision that is yours rather than mine.

Tests

lib/__tests__/favicon-badge.test.ts, hooks/__tests__/use-favicon-badge.test.tsx, components/__tests__/favicon-badge.test.tsx and stores/__tests__/settings-store-favicon-badge.test.ts — covering the geometry (right-anchoring, label-fitted width, negative-origin and non-square viewBoxes, the corner radius being neither square nor h/2), the percent-encoding, the SVG sanitisation, the React-owned-link case, multi-link heads, clear-by-insertion, the observer's termination, the shared-inbox selector, and the setting (default on; off adds no link; toggling off restores the base icon by insertion).

npm run typecheck, npm run lint and the 22-locale key-parity test are clean. The setting's strings are present in all 22 locale files with the English text as a placeholder, matching how #561 landed.

A note on verification

The three browser behaviours above — React's metadata re-hoist, Firefox's refusal to repaint on a removal, and the commit-phase crash — are not things jsdom can see. Each was found by running the branch against a live Stalwart instance, and each fix was confirmed there in Firefox before being written up. The screenshots are from that deployment.

Closes bulwarkmail#560.

Composes the active inbox's unread count over the base favicon as an SVG
badge, served as a percent-encoded data: URL, so new mail is visible on a
tab that is not focused — including when the browser collapses tabs to
icon-only, where a title-based count disappears entirely.

The base icon is read from the rendered <link rel="icon"> rather than from
config, so admin and per-domain branding overrides are inherited for free:
the count is drawn on whatever logo the deployment actually serves. Keeping
the badge in SVG rather than rasterising to a canvas also means the browser
can rasterise it at whatever size it asks for, so a HiDPI tab is not served a
16px bitmap.

Notes on the approach:

- The badge link is an *additional* icon link that we append and mark as
  ours; we never remove or mutate a link we did not create. Next's metadata
  icons are rendered by React, which keeps a fiber pointing at that DOM node,
  so removing it would leave React holding a detached node and throw
  "Cannot read properties of null (reading 'removeChild')" on the next
  commit that deletes the fiber. Appending instead means the last-declared
  icon wins, and non-SVG fallback links survive with their type/sizes intact.
  (The usual recipe for this feature — assign canvas.toDataURL() to the
  existing link's href — does both of the things that break here.)

- Every change of state is an *insertion* of a fresh link of ours, never a
  mutation or a removal, because that is the only signal a browser reliably
  re-reads the favicon on. Firefox ignores an in-place href change, and it
  equally ignores a removal — so clearing the badge by deleting our link left
  a stale count painted on the tab until a hard reload. Clearing it instead
  inserts a new link of ours carrying the original base href.

- Holding last place has to be defended: on a client-side navigation React
  re-hoists its metadata icon link into <head>, landing after ours, and the
  base icon silently wins again. A MutationObserver on <head> moves our own
  link back to the end whenever a foreign icon link appears — moving only our
  node, never anyone else's. It no-ops once ours is last again, so a move
  cannot feed itself.

- The badge is a full-width band across the foot of the icon, drawn to the
  metrics measured from Gmail's own 16px favicon: band height 0.625 of the
  icon, digit cap height 0.44, flush to the edges, corners rounded by about a
  pixel. Full width is what keeps a three-glyph label legible — rounded ends
  waste exactly the horizontal space it needs. Neutral white with black digits
  rather than the conventional red: faviconUrl is admin-overridable and
  Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red.

- The base SVG may be admin-uploaded, and the branding route deliberately
  serves it under a sandboxing CSP because SVG can carry script. Re-emitting
  it as a same-origin data: URL would un-fence that, so script, foreignObject
  and every on* handler are stripped before serialising.

- Mounted in the root layout, not on the mail route: the badge belongs to the
  tab, so mounting it on the page would clear it on every hop to settings,
  calendar or contacts.
@rathlinus
rathlinus merged commit 334fdbf into bulwarkmail:main Jul 16, 2026
@honzup
honzup deleted the feat/favicon-unread-badge branch July 18, 2026 06:50
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.

[Feature]: Unread messages count in favicon

2 participants