Merge pull request 'New mail in the other signed-in accounts: counts, a dot, and a notification' (#51) from feat/other-accounts-unread into main
ci / node (push) Skipped
ci / version (push) Skipped
github/ci (branch) GitHub Actions
ci / github (push) Successful in 3m2s
ci / docker-build (push) Skipped
ci / publish (push) Skipped
ci / announce (push) Skipped

This commit was merged in pull request #51.
This commit is contained in:
jcoffey-dev committed 2026-10-06 03:12:56 +00:00
commit 8acd30e8a1
6 files changed
+234 -2

No files matched your search

+15
View File
@@ -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("[email protected]", "first-password");
// Alone, there is nothing to report
assert.deepEqual((await b.call("/api/auth/accounts/unread")).body.accounts, []);
await b.signIn("[email protected]", "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));
});
+50
View File
@@ -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<string, { unread: number | null; at: number }>();
/** The Inbox's unread count for one session's account, or null when it has none. */
async function inboxUnread(session: LiveSession): Promise<number | null> {
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<string | null> {
if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full";
@@ -756,6 +780,32 @@ export function createApp(basePath = config.basePath): Hono<Env> {
});
});
/*
* 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)!;
@@ -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<string, unknown> }[] = [];
vi.mock("@/lib/notify/notify", () => ({
showNotification: (title: string, opts: Record<string, unknown>) => 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<string, number>;
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: "[email protected]", front: true },
{ id: "b", username: "[email protected]", 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("[email protected]");
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 });
});
});
+76
View File
@@ -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<string, number>;
set(unread: Record<string, number>): void;
}
export const useOtherUnread = create<OtherUnreadState>((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<string, number>, after: Record<string, number>): string[] {
return Object.entries(after)
.filter(([id, n]) => id in before && n > before[id]!)
.map(([id]) => id);
}
export async function pollOtherUnread(): Promise<void> {
const answer = await apiFetch<{ accounts: { id: string; unread: number | null }[] }>("/api/auth/accounts/unread");
const next: Record<string, number> = {};
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]);
}
+2
View File
@@ -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; }
+23 -2
View File
@@ -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 }) {
<Settings size={21} />
</Link>
)}
<button className="icon-btn" style={{ width: "auto", padding: "0 2px", borderRadius: 999 }} onClick={acctMenu.open} aria-label={t("Account")}>
<button
className="icon-btn"
style={{ width: "auto", padding: "0 2px", borderRadius: 999, position: "relative" }}
onClick={acctMenu.open}
aria-label={anyOtherUnread ? t("Account: new mail in another account") : t("Account")}
>
<Avatar who={{ name: session?.username, email: session?.username }} size="sm" />
{/* inbuxa MA-8: another signed-in account has unread mail */}
{anyOtherUnread && <span className="acct-dot" aria-hidden />}
</button>
<Popover anchor={acctMenu.anchor} onClose={acctMenu.close} align="end" width={280}>
<div style={{ padding: "10px 10px 6px", display: "flex", gap: 10, alignItems: "center" }}>
@@ -259,7 +271,16 @@ export function AppShell({ children }: { children: ReactNode }) {
<MenuItem
key={account.id}
icon={account.front ? <Check size={16} /> : <Mail size={16} />}
label={<span className="notranslate" translate="no">{account.username}</span>}
label={
<span className="row" style={{ gap: 8, alignItems: "center" }}>
<span className="notranslate grow truncate" translate="no">{account.username}</span>
{!account.front && (otherUnread[account.id] ?? 0) > 0 && (
<span className="nav-count" aria-label={t("Unread in the Inbox: {count}", { count: otherUnread[account.id]! })}>
{otherUnread[account.id]! > 9999 ? "9999+" : otherUnread[account.id]}
</span>
)}
</span>
}
active={account.front}
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
/>