New mail in the other signed-in accounts: counts, a dot, and a notification
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 2m24s
ci / announce (pull_request) Skipped

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
  [email protected]"); 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.
This commit is contained in:
jcoffey-dev committed 2026-10-05 20:10:07 -07:00
1 parent 68c91a1ad4
commit 5f27923ce6
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)!;