diff --git a/web/src/App.tsx b/web/src/App.tsx
index d3fd548..dd5f944 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -24,6 +24,7 @@ import { listenForVerification, renewWebPush } from "@/lib/webpushEnable";
import { plural, t, useLanguageVersion, whenLanguageReady } from "@/lib/i18n";
import { confirmLeaveUnsaved, hasUnsavedChanges } from "@/lib/unsavedChanges";
import { BASE_PATH, withBase } from "@/lib/basePath";
+import { DEFAULT_APP_NAME } from "@/lib/brand";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -254,7 +255,7 @@ function AuthedApp() {
const id = s.roleId("inbox");
return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0;
});
- const appName = useSession((s) => s.session?.ihasmail?.appName ?? "ihasmail");
+ const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
useEffect(() => {
void import("@/lib/notify").then((m) => {
m.setBaseTitle(appName);
diff --git a/web/src/lib/__tests__/brand.test.ts b/web/src/lib/__tests__/brand.test.ts
new file mode 100644
index 0000000..90a4f12
--- /dev/null
+++ b/web/src/lib/__tests__/brand.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_APP_NAME } from "@/lib/brand";
+
+/*
+ * The name an instance calls itself.
+ *
+ * `APP_NAME` is a runtime variable, so every place showing the name has to ask
+ * the server rather than have it written in. The sign-in page did not (#236's
+ * neighbour): it fetched `/api/config`, received the name and used only
+ * `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new
+ * user meets first. These pin the shape of the answer rather than the name.
+ */
+
+const nameFrom = (config: { appName?: unknown } | null) =>
+ config && typeof config.appName === "string" && config.appName.trim() ? config.appName.trim() : DEFAULT_APP_NAME;
+
+describe("resolving the instance name", () => {
+ it("uses what the server says", () => {
+ expect(nameFrom({ appName: "Acme Mail" })).toBe("Acme Mail");
+ });
+
+ it("trims it, because a name with an edge of whitespace is a layout bug", () => {
+ expect(nameFrom({ appName: " Acme Mail " })).toBe("Acme Mail");
+ });
+
+ it("falls back when the request failed", () => {
+ // A sign-in form with no name on it is worse than one with the wrong name.
+ expect(nameFrom(null)).toBe(DEFAULT_APP_NAME);
+ });
+
+ it("falls back on a name that is empty or only spaces", () => {
+ expect(nameFrom({ appName: "" })).toBe(DEFAULT_APP_NAME);
+ expect(nameFrom({ appName: " " })).toBe(DEFAULT_APP_NAME);
+ });
+
+ it("falls back on a name that is not a string at all", () => {
+ expect(nameFrom({ appName: 42 })).toBe(DEFAULT_APP_NAME);
+ expect(nameFrom({})).toBe(DEFAULT_APP_NAME);
+ });
+});
diff --git a/web/src/lib/brand.ts b/web/src/lib/brand.ts
new file mode 100644
index 0000000..086a4dc
--- /dev/null
+++ b/web/src/lib/brand.ts
@@ -0,0 +1,13 @@
+/**
+ * What this instance calls itself, when nothing has said otherwise yet.
+ *
+ * `APP_NAME` is a runtime environment variable, so the real answer arrives
+ * from the server -- on `/api/config` before anybody signs in, and on the
+ * session afterwards. This is what stands in until it does, and what stands
+ * for good if the request fails: a sign-in form with no name on it would be
+ * worse than one with the wrong name.
+ *
+ * One constant rather than the string written out at each of them, because
+ * three copies of a default is how two of them end up stale.
+ */
+export const DEFAULT_APP_NAME = "ihasmail";
diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx
index ee809b0..adcd35d 100644
--- a/web/src/views/AppShell.tsx
+++ b/web/src/views/AppShell.tsx
@@ -3,6 +3,7 @@ import { Link, useLocation } from "wouter";
import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PenSquare, Plus, RefreshCw, Settings, 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 { useEffectiveTheme, useSettings } from "@/store/settings";
import { toggleTarget } from "@/lib/palette";
import { useMail } from "@/store/mail";
@@ -37,6 +38,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const pushState = useSession((s) => s.pushState);
const session = useSession((s) => s.session);
const logout = useSession((s) => s.logout);
+ const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
const acctMenu = useMenu();
/*
* "Go to folder" (#233), hosted here rather than in the mail view because
@@ -90,10 +92,12 @@ export function AppShell({ children }: { children: ReactNode }) {
- {/* A product name, not a word. "ihasmail" translated is a different
- product, and the one on the tab beside it is still called this. */}
+ {/* 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
+ title has taken it from there all along. */}
- ihasmail
+ {appName}