inbuxa
diff --git a/web/public/manifest.webmanifest b/web/public/manifest.webmanifest
index 7175155..f70b5a7 100644
--- a/web/public/manifest.webmanifest
+++ b/web/public/manifest.webmanifest
@@ -51,17 +51,17 @@
"theme_color": "#0f766e",
"icons": [
{
- "src": "img/icon-192.png",
+ "src": "img/icon-192.png?v=2026-09-27a",
"sizes": "192x192",
"type": "image/png"
},
{
- "src": "img/icon-512.png",
+ "src": "img/icon-512.png?v=2026-09-27a",
"sizes": "512x512",
"type": "image/png"
},
{
- "src": "img/icon-maskable.png",
+ "src": "img/icon-maskable.png?v=2026-09-27a",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
diff --git a/web/public/sw.js b/web/public/sw.js
index 843c97a..53aab1f 100644
--- a/web/public/sw.js
+++ b/web/public/sw.js
@@ -18,7 +18,9 @@ const VERSION = "ihasmail-v2";
* eventually would.
*/
const BASE = new URL("./", self.location).pathname.replace(/\/$/, "");
-const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
+// The brand images' version; the same value as BRAND_V in src/lib/brand.ts.
+const BRAND_V = "2026-09-27a";
+const SHELL = [`${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png?v=${BRAND_V}`, `${BASE}/img/icon-192.png?v=${BRAND_V}`, `${BASE}/favicon.ico?v=${BRAND_V}`];
/*
* Only the app page may be kept as the app page.
@@ -482,7 +484,7 @@ self.addEventListener("push", (event) => {
// or a payload too large to carry the message. Say something true
// rather than inventing a sender.
await self.registration.showNotification(strings.newMail, {
- icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
+ icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`, badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` },
});
return;
}
@@ -492,8 +494,8 @@ self.addEventListener("push", (event) => {
const { title, body, preview } = textOf(email, strings);
await self.registration.showNotification(title, {
body: preview ? `${body}\n${preview}` : body,
- icon: `${BASE}/img/icon-192.png`,
- badge: `${BASE}/img/favicon-64.png`,
+ icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
+ badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
tag: `ihasmail-${email.id || body}`,
// Only where there is a message to act on: a payload without an id can
// be shown but not archived, and a button that cannot work should not
@@ -537,8 +539,8 @@ async function runAction(action, data) {
} catch {
await self.registration.showNotification(data.title || "ihasmail", {
body: data.failed || "Could not do that — open ihasmail and try again",
- icon: `${BASE}/img/icon-192.png`,
- badge: `${BASE}/img/favicon-64.png`,
+ icon: `${BASE}/img/icon-192.png?v=${BRAND_V}`,
+ badge: `${BASE}/img/favicon-64.png?v=${BRAND_V}`,
tag: `ihasmail-failed-${id}`,
data: { url: data.url },
});
diff --git a/web/src/lib/brand.ts b/web/src/lib/brand.ts
index db9006d..83c674f 100644
--- a/web/src/lib/brand.ts
+++ b/web/src/lib/brand.ts
@@ -1,4 +1,5 @@
import { useSession } from "@/store/session";
+import { withBase } from "@/lib/basePath";
/**
* What this instance calls itself, when nothing has said otherwise yet.
@@ -36,3 +37,25 @@ export function useAppName(): string {
export function currentAppName(): string {
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
}
+
+/**
+ * The brand images' version, carried as `?v=` on every URL that names one.
+ *
+ * The images live in `public/img` under fixed names and are served with a
+ * browser cache of hours, so replacing one (the mark changed on 2026-09-27)
+ * left returning visitors on the old picture until their copy expired -- and
+ * the favicon and an installed app's icon hold on longer still. A new value
+ * here is a new URL everywhere at once.
+ *
+ * Date-stamped with a letter for a second change the same day, never a
+ * counter, for the reason the sites give for ASSET_V: a value that could have
+ * been requested before may already be cached, with old bytes behind it.
+ * `index.html`, `public/manifest.webmanifest` and `public/sw.js` can't import
+ * this, so they carry the same value written out; keep the four in step.
+ */
+export const BRAND_V = "2026-09-27a";
+
+/** A brand image's URL under the mount, versioned: `brandImage("/img/logo.png")`. */
+export function brandImage(path: string): string {
+ return withBase(`${path}?v=${BRAND_V}`);
+}
diff --git a/web/src/lib/notify/notify.ts b/web/src/lib/notify/notify.ts
index 3cb013e..4c01cd4 100644
--- a/web/src/lib/notify/notify.ts
+++ b/web/src/lib/notify/notify.ts
@@ -1,4 +1,4 @@
-import { withBase } from "../basePath";
+import { brandImage } from "@/lib/brand";
let baseTitle = "inbuxa";
let faviconCanvas: HTMLCanvasElement | null = null;
@@ -40,13 +40,13 @@ export function setUnreadBadge(count: number): void {
if (!link) return;
if (!baseFavicon) {
baseFavicon = new Image();
- baseFavicon.src = withBase("/img/favicon-64.png");
+ baseFavicon.src = brandImage("/img/favicon-64.png");
baseFavicon.onload = () => setUnreadBadge(count);
return;
}
if (!baseFavicon.complete) return;
if (count <= 0) {
- link.href = withBase("/img/favicon-64.png");
+ link.href = brandImage("/img/favicon-64.png");
return;
}
faviconCanvas ??= document.createElement("canvas");
@@ -95,7 +95,7 @@ export function showNotification(title: string, opts: NotificationOptions & { on
if (!("Notification" in window) || Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return;
const { onClick, ...options } = opts;
- const full = { icon: withBase("/img/icon-192.png"), badge: withBase("/img/favicon-64.png"), ...options };
+ const full = { icon: brandImage("/img/icon-192.png"), badge: brandImage("/img/favicon-64.png"), ...options };
const viaWorker = navigator.serviceWorker?.controller ? navigator.serviceWorker.ready : null;
if (viaWorker) {
void viaWorker.then((reg) => reg.showNotification(title, full)).catch(() => undefined);
diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx
index 3a47ac7..eff482d 100644
--- a/web/src/views/AppShell.tsx
+++ b/web/src/views/AppShell.tsx
@@ -2,8 +2,7 @@ import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "rea
import { Link, useLocation } from "wouter";
import { Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react";
import { useSession } from "@/store/session";
-import { withBase } from "@/lib/basePath";
-import { DEFAULT_APP_NAME } from "@/lib/brand";
+import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
import { useEffectiveTheme, useSettings } from "@/store/settings";
import { toggleTarget } from "@/lib/palette";
@@ -148,7 +147,7 @@ export function AppShell({ children }: { children: ReactNode }) {
-
+
{/* A product name, not a word: translated it is a different product.
Read from the session rather than written here, so a deployment
that set APP_NAME is called what it calls itself -- the document
diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx
index ad8b1e3..68288f2 100644
--- a/web/src/views/Login.tsx
+++ b/web/src/views/Login.tsx
@@ -5,7 +5,7 @@ import { ApiError } from "@/jmap/client";
import { withBase } from "@/lib/basePath";
import { APP_VERSION } from "@/lib/version";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
-import { DEFAULT_APP_NAME } from "@/lib/brand";
+import { DEFAULT_APP_NAME, brandImage } from "@/lib/brand";
import { t } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -92,7 +92,7 @@ export function LoginPage() {