Closes#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.
Add a 'Search Engine Indexing' toggle under Settings -> General. Off (the
default) emits robots noindex/nofollow in the document head - the safe default
for a private webmail; on lets an admin opt the deployment into indexing.
Backed by the existing admin config-manager (SEARCH_ENGINE_INDEXING env var /
admin override / revert), read server-side in the root generateMetadata().
The PWA install prompt was hardcoded English regardless of the selected UI
language (and the large English block tripped Chrome's translate popup on
Android). Add a pwa_install namespace to all 17 locales, switch the component
to useTranslations, and move <PWAInstallPrompt /> from (main)/layout into
(main)/[locale]/layout so it renders inside the IntlProvider. The title keeps
the dynamic {appName}, so per-domain branding still applies.
The root (main)/layout renders <html> ABOVE the [locale] segment, so next-intl's
getLocale() returns the default locale there - emitting <html lang="en"> on every
page regardless of UI language (e.g. /de) and a hardcoded English <head>
description. Both are strong "translate this page" triggers in Chrome.
proxy.ts already exposes the nonce to server components via the
x-middleware-request-* mechanism; expose the request pathname the same way as
x-pathname, and have the root layout derive the locale from it (falling back to
getLocale() when the path has no locale segment) for both <html lang> and the
localized meta_description (new key in all 17 locales; the English value is
unchanged).