From 5f27923ce66743cb4b208c9050a69f988f993605 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 20:10:07 -0700 Subject: [PATCH] New mail in the other signed-in accounts: counts, a dot, and a notification With more than one account signed in (#49), mail arriving in one that isn't in front went unseen until someone switched to it (multi-account spec, MA-8). - GET /api/auth/accounts/unread answers the Inbox unread count of each account not in front, asked through that account's own session (its OAuth token renewed first if due), kept a minute per account. - The web app asks every two minutes while another account is signed in. The account menu shows each one's count beside its name, and the avatar carries a dot when any of them has unread mail. - When a count rises while the app is open and desktop notifications are on, a notification names the account ("New mail for support@example.com"); clicking it switches to that account. An account seen for the first time doesn't notify: its mail was already there. Not in this change: notifications with the app closed, which need each added account's own Web Push subscription. New strings (3, English only in the other ten catalogs): "New mail for {name}", "Unread in the Inbox: {count}", "Account: new mail in another account". Tests: the server answers the other account's count and nothing when alone; the client keeps the counts, notifies only on a rise and only with notifications on. Checked in Chrome against the mock. typecheck, tests (web 1541, server 277) and build pass. --- server/src/accounts.test.ts | 15 ++++ server/src/app.ts | 50 +++++++++++++ web/src/lib/__tests__/other-accounts.test.ts | 68 ++++++++++++++++++ web/src/lib/otherAccounts.ts | 76 ++++++++++++++++++++ web/src/styles/app.css | 2 + web/src/views/AppShell.tsx | 25 ++++++- 6 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/__tests__/other-accounts.test.ts create mode 100644 web/src/lib/otherAccounts.ts diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index 431dd5e..c7519a2 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -139,3 +139,18 @@ test("an organization that doesn't allow it keeps adding off", async () => { delete process.env.MOCK_NO_ADD_ACCOUNTS; } }); + +test("inbuxa MA-8: the accounts not in front report their Inbox unread count", async () => { + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + // Alone, there is nothing to report + assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []); + await b.signIn("second@example.com", "second-password", true); + const res = await b.call("/api/auth/accounts/unread"); + assert.equal(res.status, 200); + assert.equal(res.body.accounts.length, 1, "only the account not in front"); + const [other] = res.body.accounts; + const listed = (await b.accounts()).find((a) => !a.front)!; + assert.equal(other.id, listed.id); + assert.equal(typeof other.unread, "number", JSON.stringify(res.body)); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 86fec4b..790e98f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -363,6 +363,30 @@ function liveOthers(c: Context): { cookie: string; session: LiveSession }[] { return out; } +/** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */ +const UNREAD_CACHE_MS = 60_000; +const unreadCache = new Map(); + +/** The Inbox's unread count for one session's account, or null when it has none. */ +async function inboxUnread(session: LiveSession): Promise { + const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); + const accountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"]; + if (!accountId) return null; + const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), { + method: "POST", + headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + methodCalls: [["Mailbox/get", { accountId, ids: null, properties: ["role", "unreadEmails"] }, "0"]], + }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (!res.ok) return null; + const body = (await res.json()) as { methodResponses?: [string, { list?: { role?: string | null; unreadEmails?: number }[] }, string][] }; + const inbox = body.methodResponses?.[0]?.[1]?.list?.find((m) => m.role === "inbox"); + return typeof inbox?.unreadEmails === "number" ? inbox.unreadEmails : null; +} + /** Whether the account in front may have more beside it, or why not. */ async function addRefusal(c: Context, front: LiveSession): Promise { if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full"; @@ -756,6 +780,32 @@ export function createApp(basePath = config.basePath): Hono { }); }); + /* + * inbuxa MA-8: the Inbox unread count of each account not in front, asked + * through that account's own session, so the menu can say where new mail + * is. A minute's cache per account: the web app asks every few minutes, and + * several tabs may ask at once. + */ + api.get("/auth/accounts/unread", requireSession, async (c) => { + const answers = await Promise.all( + liveOthers(c).map(async ({ cookie, session }) => { + const cached = unreadCache.get(session.id); + if (cached && Date.now() - cached.at < UNREAD_CACHE_MS) return { id: session.id, unread: cached.unread }; + try { + let live: LiveSession | null = session; + if (live.tokens && needsRefresh(live.tokens)) live = await refreshSession(cookie, live); + if (!live) return { id: session.id, unread: null }; + const unread = await inboxUnread(live); + unreadCache.set(session.id, { unread, at: Date.now() }); + return { id: session.id, unread }; + } catch { + return { id: session.id, unread: null }; + } + }), + ); + return c.json({ accounts: answers }); + }); + /* inbuxa MA-B: bring another signed-in account to the front. */ api.post("/auth/accounts/:id/front", requireSession, async (c) => { const frontCookie = getCookie(c, config.cookieName)!; diff --git a/web/src/lib/__tests__/other-accounts.test.ts b/web/src/lib/__tests__/other-accounts.test.ts new file mode 100644 index 0000000..8221113 --- /dev/null +++ b/web/src/lib/__tests__/other-accounts.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** inbuxa MA-8: new mail in the accounts not in front. */ + +const shown: { title: string; opts: Record }[] = []; +vi.mock("@/lib/notify/notify", () => ({ + showNotification: (title: string, opts: Record) => shown.push({ title, opts }), +})); + +const { risen, pollOtherUnread, useOtherUnread } = await import("@/lib/otherAccounts"); +const { useSession } = await import("@/store/session"); +const { useSettings } = await import("@/store/settings"); + +let counts: Record; + +beforeEach(() => { + shown.length = 0; + counts = { b: 3 }; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + const body = { accounts: Object.entries(counts).map(([id, unread]) => ({ id, unread })) }; + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response; + }), + ); + useOtherUnread.getState().set({}); + useSession.setState({ + signedIn: [ + { id: "a", username: "me@example.com", front: true }, + { id: "b", username: "support@example.com", front: false }, + ], + }); + useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: true } })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("other accounts' unread mail", () => { + it("counts only a rise, and never an account seen for the first time", () => { + expect(risen({}, { b: 4 })).toEqual([]); + expect(risen({ b: 4 }, { b: 4 })).toEqual([]); + expect(risen({ b: 4 }, { b: 2 })).toEqual([]); + expect(risen({ b: 4, c: 1 }, { b: 5, c: 1 })).toEqual(["b"]); + }); + + it("keeps the counts, and names the account when new mail arrives", async () => { + await pollOtherUnread(); + expect(useOtherUnread.getState().unread).toEqual({ b: 3 }); + expect(shown).toEqual([]); + + counts = { b: 5 }; + await pollOtherUnread(); + expect(shown).toHaveLength(1); + expect(shown[0]!.title).toContain("support@example.com"); + expect(shown[0]!.opts.tag).toBe("other-account-b"); + }); + + it("stays quiet when desktop notifications are off", async () => { + useSettings.setState((s) => ({ settings: { ...s.settings, desktopNotifications: false } })); + await pollOtherUnread(); + counts = { b: 9 }; + await pollOtherUnread(); + expect(shown).toEqual([]); + expect(useOtherUnread.getState().unread).toEqual({ b: 9 }); + }); +}); diff --git a/web/src/lib/otherAccounts.ts b/web/src/lib/otherAccounts.ts new file mode 100644 index 0000000..c0a3c6c --- /dev/null +++ b/web/src/lib/otherAccounts.ts @@ -0,0 +1,76 @@ +/** + * inbuxa MA-8: new mail in the accounts not in front. + * + * The webmail server answers each one's Inbox unread count through that + * account's own session (/api/auth/accounts/unread). The menu shows the counts; + * when one rises while the app is open, a desktop notification names the + * account, so mail for support@ isn't missed while someone works in their own. + * + * Only while a tab is open. A notification with the app closed needs each + * added account's own Web Push subscription, which is still to come. + */ +import { useEffect } from "react"; +import { create } from "zustand"; +import { apiFetch } from "@/jmap/client"; +import { showNotification } from "@/lib/notify/notify"; +import { t } from "@/lib/i18n"; +import { useSession } from "@/store/session"; +import { useSettings } from "@/store/settings"; + +/** How often to ask. The server keeps each answer a minute. */ +export const POLL_MS = 2 * 60_000; + +interface OtherUnreadState { + /** Unread count per session id; absent until first asked, or when unknown. */ + unread: Record; + set(unread: Record): void; +} + +export const useOtherUnread = create((set) => ({ + unread: {}, + set: (unread) => set({ unread }), +})); + +/** + * Which accounts gained unread mail since the last answer. An account seen for + * the first time is not "new": its mail was already there when it was added. + */ +export function risen(before: Record, after: Record): string[] { + return Object.entries(after) + .filter(([id, n]) => id in before && n > before[id]!) + .map(([id]) => id); +} + +export async function pollOtherUnread(): Promise { + const answer = await apiFetch<{ accounts: { id: string; unread: number | null }[] }>("/api/auth/accounts/unread"); + const next: Record = {}; + for (const a of answer.accounts) if (typeof a.unread === "number") next[a.id] = a.unread; + const before = useOtherUnread.getState().unread; + useOtherUnread.getState().set(next); + if (!useSettings.getState().settings.desktopNotifications) return; + const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username])); + for (const id of risen(before, next)) { + const name = names.get(id); + if (!name) continue; + showNotification(t("New mail for {name}", { name }), { + body: t("Unread in the Inbox: {count}", { count: next[id]! }), + tag: `other-account-${id}`, + onClick: () => void useSession.getState().switchTo(id), + }); + } +} + +/** Keeps the counts fresh while more than one account is signed in. */ +export function useOtherAccountsUnread(): void { + const others = useSession((s) => s.signedIn.filter((a) => !a.front).length); + useEffect(() => { + if (others === 0) { + useOtherUnread.getState().set({}); + return; + } + const tick = () => void pollOtherUnread().catch(() => undefined); + tick(); + const timer = setInterval(tick, POLL_MS); + return () => clearInterval(timer); + }, [others]); +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 1c7452c..8628fda 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1259,6 +1259,8 @@ a.menu-item:hover { color: var(--fg); } .delegated-bar strong { font-weight: 700; } /* MA-A: a shared or group mailbox is the reader's to be in, so the palette's own color, not the locked account's red. */ .delegated-bar.shared { background: var(--accent); color: var(--accent-fg); } +/* MA-8: new mail in another signed-in account, on the avatar */ +.acct-dot { position: absolute; top: 0; right: 0; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); } .delegated-bar button { flex: none; border: 1px solid rgba(255, 255, 255, .7); background: transparent; color: #fff; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; } .delegated-bar button:hover, .delegated-bar button:focus-visible { background: rgba(255, 255, 255, .15); } .topbar .brand.locked .brand-name, .topbar .brand.locked .brand-lock { color: #dc2626; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index b032e04..1128a49 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -12,6 +12,7 @@ import { delegatedAccounts } from "@/lib/delegation"; import { toast } from "@/ui/toast"; import { DelegatedBar } from "./DelegatedBar"; import { AddAccountDialog } from "./AddAccountDialog"; +import { useOtherAccountsUnread, useOtherUnread } from "@/lib/otherAccounts"; import { Avatar, useIsMobile } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Splitter } from "@/ui/Splitter"; @@ -78,6 +79,10 @@ export function AppShell({ children }: { children: ReactNode }) { const signedIn = useSession((s) => s.signedIn); const canAddAccount = useSession((s) => s.canAddAccount); const [addingAccount, setAddingAccount] = useState(false); + // inbuxa MA-8: new mail in the accounts not in front + useOtherAccountsUnread(); + const otherUnread = useOtherUnread((s) => s.unread); + const anyOtherUnread = Object.values(otherUnread).some((n) => n > 0); const bringForward = (sessionId: string) => { acctMenu.close(); // A message being written belongs to the account it was started in @@ -236,8 +241,15 @@ export function AppShell({ children }: { children: ReactNode }) { )} -
@@ -259,7 +271,16 @@ export function AppShell({ children }: { children: ReactNode }) { : } - label={{account.username}} + label={ + + {account.username} + {!account.front && (otherUnread[account.id] ?? 0) > 0 && ( + + {otherUnread[account.id]! > 9999 ? "9999+" : otherUnread[account.id]} + + )} + + } active={account.front} onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))} />