Notifications with the app closed, for every signed-in account
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m3s
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped

Second of three for background push across accounts (multi-account
spec, MA-8 part 2).

Turning on "Notify me even when inbuxa is closed" now registers this
browser in every signed-in account, each through its own session (the
route from the previous change), and renewal keeps them all current.
GET /api/auth/accounts says which mail account each session is, so the
subscription can name it. The remembered endpoint is kept per account,
and survives the clean-up that follows switching accounts.

The service worker is told who the other accounts are. A push for one
of them is titled with that account's address, shown even while a tab
is focused (the tab only shows the front account's mail), and its
Mark read and Archive act through that account's session. Clicking it
brings that account to the front and opens the message. While push is
on, the tab's own polling of other accounts stops notifying, so nothing
arrives twice.

Signing out of one account removes this device's subscription there
only; signing out of all, or turning push off, removes every one.

Also fixes where every background notification opened: the worker
linked to /mail/inbox/<thread>, and the route takes a mailbox id there,
so a click landed on the inbox list with "That folder no longer
exists". The worker now gets each account's inbox id and links to the
message.

Checked end to end in Chrome against a local server with two accounts:
both registered and verified, a message to the account not in front
showed a notification under its address, and clicking it switched
accounts and opened the message. No new strings. typecheck, tests
(web 1547, server 279) and build pass.
This commit is contained in:
jcoffey-dev committed 2026-10-05 20:38:44 -07:00
1 parent 6b979c5ac7
commit 9fcf4812f3
13 files changed
+393 -38

No files matched your search

+8
View File
@@ -190,3 +190,11 @@ test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in
const front = (await b.accounts()).find((a) => a.front)!; const front = (await b.accounts()).find((a) => a.front)!;
assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404); assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404);
}); });
test("inbuxa MA-8: each listed account says which mail account it is", async () => {
const b = new Browser();
await b.signIn("[email protected]", "first-password");
await b.signIn("[email protected]", "second-password", true);
const res = await b.call("/api/auth/accounts");
for (const a of res.body.accounts) assert.equal(typeof a.mailAccountId, "string", JSON.stringify(a));
});
+15 -5
View File
@@ -802,11 +802,21 @@ export function createApp(basePath = config.basePath): Hono<Env> {
setOthersCookie(c, others.map((o) => o.cookie)); setOthersCookie(c, others.map((o) => o.cookie));
} }
const canAdd = (await addRefusal(c, front)) === null; const canAdd = (await addRefusal(c, front)) === null;
return c.json({ // inbuxa MA-8: each one's mail account, which its push subscription and
accounts: [front, ...others.map((o) => o.session)].map((s, i) => ({ id: s.id, username: s.username, front: i === 0 })), // a notification's buttons need
canAdd, const accounts = await Promise.all(
max: MAX_ACCOUNTS, [front, ...others.map((o) => o.session)].map(async (s, i) => {
}); let mailAccountId: string | null = null;
try {
const upstream = await getUpstreamSession(s.id, s.authorization, upstreamFor(s.username));
mailAccountId = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? null;
} catch {
/* unknown for now: push for it waits for the next start */
}
return { id: s.id, username: s.username, front: i === 0, mailAccountId };
}),
);
return c.json({ accounts, canAdd, max: MAX_ACCOUNTS });
}); });
/* /*
+80 -6
View File
@@ -386,8 +386,11 @@ async function readFacts() {
* rather than swallowed. A tap that silently does nothing is the failure worth * rather than swallowed. A tap that silently does nothing is the failure worth
* avoiding here: the reader has already put the phone down. * avoiding here: the reader has already put the phone down.
*/ */
async function jmap(methodCalls) { async function jmap(methodCalls, sessionId) {
const res = await fetch(`${BASE}/api/jmap`, { // inbuxa MA-8: an account not in front is reached through its own session,
// on the webmail server's narrow route for it
const path = sessionId ? `${BASE}/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap` : `${BASE}/api/jmap`;
const res = await fetch(path, {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" }, headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" },
@@ -456,6 +459,15 @@ self.addEventListener("push", (event) => {
const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : []; const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : [];
event.waitUntil((async () => { event.waitUntil((async () => {
const facts = await readFacts();
/*
* inbuxa MA-8: whose mail this is. The payload names its account; one that
* isn't the account in front is one of the others signed in here, or one
* that has been signed out since (said plainly, with nothing to act on).
*/
const forAccount = data && data.accountId && facts && data.accountId !== facts.accountId ? data.accountId : null;
const other = forAccount ? (facts.others || []).find((o) => o.accountId === forAccount) || null : null;
if (forAccount) return showOtherAccount(emails, facts, other);
/* /*
* Someone reading the app already knows. A focused, visible window of this * Someone reading the app already knows. A focused, visible window of this
* app gets its new mail from its own event stream, so a notification on * app gets its new mail from its own event stream, so a notification on
@@ -464,7 +476,6 @@ self.addEventListener("push", (event) => {
*/ */
const windows = await self.clients.matchAll({ type: "window" }); const windows = await self.clients.matchAll({ type: "window" });
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return; if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
const facts = await readFacts();
const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" }; const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" };
/* /*
* Mark the app icon, without claiming a number. * Mark the app icon, without claiming a number.
@@ -502,8 +513,7 @@ self.addEventListener("push", (event) => {
// be drawn. // be drawn.
actions: email.id ? actionsFor(facts) : [], actions: email.id ? actionsFor(facts) : [],
data: { data: {
// The route names a conversation, and `m` the message in it. url: messageUrl(facts && facts.inboxId, email),
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`,
id: email.id || null, id: email.id || null,
title, title,
accountId: facts?.accountId ?? null, accountId: facts?.accountId ?? null,
@@ -515,6 +525,70 @@ self.addEventListener("push", (event) => {
})()); })());
}); });
/*
* inbuxa MA-8: new mail for a signed-in account that isn't in front.
*
* Shown even while a tab is focused: that tab's own stream only carries the
* account in front, so nothing else would tell. The account's address is the
* title, so it can't be taken for the front account's mail; the tag carries
* the account, so two accounts' notifications don't replace each other; the
* buttons act through that account's session; and opening it brings that
* account forward before showing the message.
*/
async function showOtherAccount(emails, facts, other) {
const strings = facts.strings;
const icon = `${BASE}/img/icon-192.png?v=${BRAND_V}`;
const badge = `${BASE}/img/favicon-64.png?v=${BRAND_V}`;
if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {});
if (!other || !emails.length) {
// Signed out since, or nothing to show: say only what is true
await self.registration.showNotification(other ? other.username : strings.newMail, {
body: other ? strings.newMail : undefined,
icon, badge,
tag: `ihasmail-other-${other ? other.accountId : "unknown"}`,
data: { url: other ? openUrl(other, `${BASE}/mail`) : `${BASE}/mail` },
});
return;
}
for (const email of emails.slice(0, 5)) {
const { title, body, preview } = textOf(email, strings);
const at = messageUrl(other.inboxId, email);
await self.registration.showNotification(other.username, {
body: `${title}: ${body}${preview ? `\n${preview}` : ""}`,
icon, badge,
tag: `ihasmail-${other.accountId}-${email.id || body}`,
actions: email.id ? actionsFor({ ...facts, archiveId: other.archiveId }) : [],
data: {
url: openUrl(other, at),
id: email.id || null,
title: other.username,
accountId: other.accountId,
archiveId: other.archiveId,
sessionId: other.sessionId,
failed: strings.failed ?? null,
},
});
}
}
/** Where opening a notification for an account not in front goes: it comes forward first. */
/**
* Where a notification opens. The route names a mailbox by id and then a
* conversation, and `m` the message in it. It used to say `inbox` where the id
* goes, which the app reads as a folder that no longer exists, so every click
* landed on the inbox list with "That folder no longer exists" instead of the
* message. Without an inbox id from the briefing, the inbox is the honest
* landing.
*/
function messageUrl(inboxId, email) {
if (!inboxId || !email.id || !email.threadId) return `${BASE}/mail`;
return `${BASE}/mail/${encodeURIComponent(inboxId)}/${encodeURIComponent(email.threadId)}?m=${encodeURIComponent(email.id)}`;
}
function openUrl(other, next) {
return `${BASE}/?account=${encodeURIComponent(other.sessionId)}&next=${encodeURIComponent(next)}`;
}
/* /*
* Do what the button said, without opening anything. * Do what the button said, without opening anything.
* *
@@ -535,7 +609,7 @@ async function runAction(action, data) {
: { "keywords/$seen": true }; : { "keywords/$seen": true };
try { try {
if (action === "archive" && !archiveId) throw new Error("no archive mailbox"); if (action === "archive" && !archiveId) throw new Error("no archive mailbox");
await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]); await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]], data.sessionId || null);
} catch { } catch {
await self.registration.showNotification(data.title || "ihasmail", { await self.registration.showNotification(data.title || "ihasmail", {
body: data.failed || "Could not do that — open ihasmail and try again", body: data.failed || "Could not do that — open ihasmail and try again",
+3 -2
View File
@@ -330,13 +330,14 @@ function AuthedApp() {
* See lib/swFacts.ts. * See lib/swFacts.ts.
*/ */
const archiveId = useMail((s) => s.roleId("archive")); const archiveId = useMail((s) => s.roleId("archive"));
const inboxId = useMail((s) => s.roleId("inbox"));
const languageVersion = useLanguageVersion(); const languageVersion = useLanguageVersion();
useEffect(() => { useEffect(() => {
// inbuxa AL-7: the worker acts on the reader's own mail; while a // inbuxa AL-7: the worker acts on the reader's own mail; while a
// delegated account is in view, the archive folder here is its // delegated account is in view, the archive folder here is its
if (viewing) return; if (viewing) return;
void publishWorkerFacts(accountId, archiveId); void publishWorkerFacts(accountId, archiveId, inboxId);
}, [accountId, archiveId, languageVersion, viewing]); }, [accountId, archiveId, inboxId, languageVersion, viewing]);
// Request notification permission lazily when enabled // Request notification permission lazily when enabled
const notif = useSettings((s) => s.settings.desktopNotifications); const notif = useSettings((s) => s.settings.desktopNotifications);
@@ -65,4 +65,21 @@ describe("other accounts' unread mail", () => {
expect(shown).toEqual([]); expect(shown).toEqual([]);
expect(useOtherUnread.getState().unread).toEqual({ b: 9 }); expect(useOtherUnread.getState().unread).toEqual({ b: 9 });
}); });
it("leaves telling to the worker where background notifications are on", async () => {
const { setDeviceTrusted } = await import("@/lib/storage");
const { setPushEnabledHere } = await import("@/lib/notify/webpush");
setDeviceTrusted(true);
setPushEnabledHere(true);
try {
await pollOtherUnread();
counts = { b: 12 };
await pollOtherUnread();
expect(shown).toEqual([]);
expect(useOtherUnread.getState().unread).toEqual({ b: 12 });
} finally {
setPushEnabledHere(false);
setDeviceTrusted(false);
}
});
}); });
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { clearSignedInData, setDeviceTrusted } from "@/lib/storage";
import { deviceClientId, registeredEndpoint, rememberEndpoint, unsubscribeAccount, type JmapCall } from "@/lib/notify/webpush";
/** inbuxa MA-8 part 2: every signed-in account on this device has its own push subscription. */
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
describe("push for every signed-in account", () => {
it("remembers each account's endpoint, and keeps them through a switch", () => {
rememberEndpoint("https://push.example/old");
// An account with nothing of its own yet reads the endpoint from before
expect(registeredEndpoint("acc-a")).toBe("https://push.example/old");
rememberEndpoint("https://push.example/a", "acc-a");
rememberEndpoint("https://push.example/b", "acc-b");
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
expect(registeredEndpoint("acc-b")).toBe("https://push.example/b");
localStorage.setItem("ihasmail:cached-mail", "x");
clearSignedInData();
expect(registeredEndpoint("acc-a")).toBe("https://push.example/a");
expect(localStorage.getItem("ihasmail:cached-mail")).toBeNull();
rememberEndpoint(null, "acc-a");
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
});
it("removes only this device's subscription from one account", async () => {
// A device id is kept only where the device is trusted
setDeviceTrusted(true);
const mine = deviceClientId();
const destroyed: string[] = [];
const call: JmapCall = async <T,>(method: string, args: Record<string, unknown>) => {
if (method === "PushSubscription/get") {
return { list: [{ id: "p1", deviceClientId: mine }, { id: "p2", deviceClientId: "ihasmail-other-phone" }] } as T;
}
destroyed.push(...((args.destroy as string[]) ?? []));
return {} as T;
};
rememberEndpoint("https://push.example/a", "acc-a");
await unsubscribeAccount(call, "acc-a");
expect(destroyed).toEqual(["p1"]);
expect(localStorage.getItem("ihasmail:pushEndpoint:acc-a")).toBeNull();
setDeviceTrusted(false);
});
});
+30 -5
View File
@@ -337,18 +337,29 @@ export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall
*/ */
const ENDPOINT_KEY = "ihasmail:pushEndpoint"; const ENDPOINT_KEY = "ihasmail:pushEndpoint";
export function registeredEndpoint(): string | null { /*
* inbuxa MA-8: one per account, since each signed-in account on this device
* has its own subscription, and kept when switching between them (see
* KEEP_ON_SIGN_OUT in lib/storage) so a switch doesn't register them afresh.
* Without an account, the key from before: the account in front.
*/
function endpointKey(accountId?: Id | null): string {
return accountId ? `${ENDPOINT_KEY}:${accountId}` : ENDPOINT_KEY;
}
export function registeredEndpoint(accountId?: Id | null): string | null {
try { try {
return localStorage.getItem(ENDPOINT_KEY); return localStorage.getItem(endpointKey(accountId)) ?? (accountId ? localStorage.getItem(ENDPOINT_KEY) : null);
} catch { } catch {
return null; return null;
} }
} }
export function rememberEndpoint(endpoint: string | null): void { export function rememberEndpoint(endpoint: string | null, accountId?: Id | null): void {
try { try {
if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint); const key = endpointKey(accountId);
else localStorage.removeItem(ENDPOINT_KEY); if (endpoint) localStorage.setItem(key, endpoint);
else localStorage.removeItem(key);
} catch { } catch {
/* private mode: every start is then a fresh registration, which still works */ /* private mode: every start is then a fresh registration, which still works */
} }
@@ -376,6 +387,20 @@ export async function destroySubscription(id: Id): Promise<void> {
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]); await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: [id] }, [CAP.core, VAPID_CAP]);
} }
/**
* inbuxa MA-8: remove this device's subscription in one account only, leaving
* the browser's push subscription and the switch alone -- for signing out of
* the account in front while others stay signed in and keep notifying.
*/
export async function unsubscribeAccount(call: JmapCall = frontCall, accountId?: Id | null): Promise<void> {
try {
await destroySubscriptions(mySubscriptions(await listSubscriptions(call), deviceClientId()).map((s) => s.id), call);
} catch {
/* signing out must not fail over this */
}
rememberEndpoint(null, accountId);
}
/** Remove every subscription this browser registered. Used when signing out. */ /** Remove every subscription this browser registered. Used when signing out. */
export async function unsubscribeThisDevice(): Promise<void> { export async function unsubscribeThisDevice(): Promise<void> {
const mine = deviceClientId(); const mine = deviceClientId();
+109 -16
View File
@@ -5,7 +5,11 @@
* testable: everything here touches the browser's service worker and * testable: everything here touches the browser's service worker and
* permission prompt, none of which exists under a test runner. * permission prompt, none of which exists under a test runner.
*/ */
import { CAP } from "@/jmap/client"; import { apiFetch, CAP } from "@/jmap/client";
import type { GetResponse, Id, Mailbox } from "@/jmap/types";
import { otherAccountCall } from "@/lib/notify/otherAccount";
import { setOtherAccountFacts, type OtherAccountFacts } from "@/lib/sw/swFacts";
import type { SignedInAccount } from "@/store/session";
import { withBase } from "../basePath"; import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "../sw/swCache"; import { SW_CACHE_NAME } from "../sw/swCache";
import { isDeviceTrusted } from "@/lib/storage"; import { isDeviceTrusted } from "@/lib/storage";
@@ -18,6 +22,8 @@ import {
destroySubscriptions, destroySubscriptions,
deviceClientId, deviceClientId,
extendSubscription, extendSubscription,
frontCall,
type JmapCall,
findSubscription, findSubscription,
listSubscriptions, listSubscriptions,
mySubscriptions, mySubscriptions,
@@ -29,6 +35,7 @@ import {
roomToMake, roomToMake,
setPushEnabledHere, setPushEnabledHere,
subscriptionPayload, subscriptionPayload,
unsubscribeAccount,
unsubscribeThisDevice, unsubscribeThisDevice,
verifySubscription, verifySubscription,
webPushAvailable, webPushAvailable,
@@ -48,7 +55,7 @@ export function listenForVerification(): void {
listening = true; listening = true;
navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => { navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => {
const d = e.data as { type?: string; id?: string; code?: string } | undefined; const d = e.data as { type?: string; id?: string; code?: string } | undefined;
if (d?.type === "push-verification" && d.id && d.code) void verifySubscription(d.id, d.code).catch(() => {}); if (d?.type === "push-verification" && d.id && d.code) void verifyAnywhere(d.id, d.code);
}); });
void collectStoredVerification(); void collectStoredVerification();
} }
@@ -64,12 +71,44 @@ async function collectStoredVerification(): Promise<void> {
if (!hit) return; if (!hit) return;
const { id, code } = (await hit.json()) as { id?: string; code?: string }; const { id, code } = (await hit.json()) as { id?: string; code?: string };
await cache.delete(key); await cache.delete(key);
if (id && code) await verifySubscription(id, code); if (id && code) await verifyAnywhere(id, code);
} catch { } catch {
/* nothing waiting, or no cache: not a failure */ /* nothing waiting, or no cache: not a failure */
} }
} }
/**
* inbuxa MA-8: a verification code belongs to one account's subscription, and
* the worker doesn't say which: the account in front first, then each other
* signed-in account until one takes it.
*/
async function verifyAnywhere(id: Id, code: string): Promise<void> {
try {
await verifySubscription(id, code);
return;
} catch {
/* not the front account's */
}
for (const account of await otherAccounts()) {
try {
await verifySubscription(id, code, otherAccountCall(account.id));
return;
} catch {
/* not this one's either */
}
}
}
/** The signed-in accounts not in front, as the server lists them now. */
async function otherAccounts(): Promise<SignedInAccount[]> {
try {
const answer = await apiFetch<{ accounts: SignedInAccount[] }>("/api/auth/accounts");
return answer.accounts.filter((a) => !a.front);
} catch {
return [];
}
}
/** /**
* Subscribe this browser. Safe to call again: see `registerThisBrowser`. * Subscribe this browser. Safe to call again: see `registerThisBrowser`.
* *
@@ -97,6 +136,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
await registerThisBrowser(key); await registerThisBrowser(key);
setPushEnabledHere(true); setPushEnabledHere(true);
listenForVerification(); listenForVerification();
// inbuxa MA-8: and every other account signed in here
await registerOtherAccounts(key);
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." }; return { ok: false, reason: (err as Error).message || "Could not subscribe to notifications." };
@@ -128,7 +169,23 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso
* gave up there, leaving push off for good with the switch still saying it was * gave up there, leaving push off for good with the switch still saying it was
* on. * on.
*/ */
async function registerThisBrowser(key: string): Promise<void> { interface PushTarget {
call: JmapCall;
/** The account's mail account, which the subscription names. */
accountId: Id | null;
inboxId: Id | null;
/** Whose remembered endpoint to compare with: the account's own (MA-8). */
endpointOf: Id | null;
}
function frontTarget(): PushTarget {
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view
const accountId = useSession.getState().ownAccountFor(CAP.mail);
return { call: frontCall, accountId, inboxId: ownInboxId(), endpointOf: accountId };
}
async function registerThisBrowser(key: string, target: PushTarget = frontTarget()): Promise<void> {
const { call } = target;
const reg = await navigator.serviceWorker.ready; const reg = await navigator.serviceWorker.ready;
const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({
// Web Push requires it, and Chrome refuses a subscription without it. // Web Push requires it, and Chrome refuses a subscription without it.
@@ -136,35 +193,61 @@ async function registerThisBrowser(key: string): Promise<void> {
applicationServerKey: decodeApplicationServerKey(key), applicationServerKey: decodeApplicationServerKey(key),
})); }));
const deviceId = deviceClientId(); const deviceId = deviceClientId();
const subs = await listSubscriptions(); const subs = await listSubscriptions(call);
const mine = mySubscriptions(subs, deviceId); const mine = mySubscriptions(subs, deviceId);
const [newest, ...extra] = mine; const [newest, ...extra] = mine;
if (newest && registeredEndpoint() === sub.endpoint) { if (newest && registeredEndpoint(target.endpointOf) === sub.endpoint) {
if (extra.length) await destroySubscriptions(extra.map((s) => s.id)); if (extra.length) await destroySubscriptions(extra.map((s) => s.id), call);
const at = newest.expires ? Date.parse(newest.expires) : Number.NaN; const at = newest.expires ? Date.parse(newest.expires) : Number.NaN;
if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return; if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return;
try { try {
await extendSubscription(newest.id); await extendSubscription(newest.id, Date.now(), call);
return; return;
} catch { } catch {
/* not extendable: replaced below */ /* not extendable: replaced below */
} }
} }
if (mine.length) await destroySubscriptions(mine.map((s) => s.id)); if (mine.length) await destroySubscriptions(mine.map((s) => s.id), call);
// inbuxa AL-7: the reader's own inbox, never a delegated account's in view const payload = subscriptionPayload(sub, target.accountId, target.inboxId);
const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), ownInboxId());
try { try {
await createSubscription(payload); await createSubscription(payload, call);
} catch (err) { } catch (err) {
if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err; if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err;
const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId); const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId);
if (!room.length) throw err; if (!room.length) throw err;
await destroySubscriptions(room); await destroySubscriptions(room, call);
await createSubscription(payload); await createSubscription(payload, call);
} }
rememberEndpoint(sub.endpoint); rememberEndpoint(sub.endpoint, target.endpointOf);
}
/**
* inbuxa MA-8: register this browser in every other account signed in here,
* each through its own session, and tell the worker who they are so their
* notifications say whose they are and their buttons act on the right mail.
* One account failing doesn't stop the rest; the next start tries it again.
*/
async function registerOtherAccounts(key: string): Promise<void> {
const facts: OtherAccountFacts[] = [];
for (const account of await otherAccounts()) {
if (!account.mailAccountId) continue;
const call = otherAccountCall(account.id);
try {
const boxes = await call<GetResponse<Mailbox>>(
"Mailbox/get",
{ accountId: account.mailAccountId, ids: null, properties: ["role"] },
[CAP.mail],
);
const roleId = (role: string) => boxes.list.find((m) => m.role === role)?.id ?? null;
await registerThisBrowser(key, { call, accountId: account.mailAccountId, inboxId: roleId("inbox"), endpointOf: account.mailAccountId });
facts.push({ accountId: account.mailAccountId, sessionId: account.id, username: account.username, archiveId: roleId("archive"), inboxId: roleId("inbox") });
} catch {
/* this one waits for the next start */
}
}
await setOtherAccountFacts(facts);
} }
/** /**
@@ -190,16 +273,26 @@ export async function renewWebPush(): Promise<void> {
// subscription is close to expiring, missing, or duplicated. // subscription is close to expiring, missing, or duplicated.
await registerThisBrowser(key); await registerThisBrowser(key);
listenForVerification(); listenForVerification();
await registerOtherAccounts(key);
} catch { } catch {
/* offline, or the server said no: the next start tries again */ /* offline, or the server said no: the next start tries again */
} }
} }
/** Remove this browser's subscription, at the browser and at the server. */ /** Remove this browser's subscription, at the browser and at the server, in every signed-in account. */
export async function disableWebPush(): Promise<void> { export async function disableWebPush(): Promise<void> {
await unsubscribeOtherAccounts();
await unsubscribeThisDevice(); await unsubscribeThisDevice();
} }
/** inbuxa MA-8: remove this device's subscription from every account not in front. */
export async function unsubscribeOtherAccounts(): Promise<void> {
for (const account of await otherAccounts()) {
await unsubscribeAccount(otherAccountCall(account.id), account.mailAccountId);
}
await setOtherAccountFacts([]);
}
/** /**
* Whether *this browser* has a subscription registered at the server. * Whether *this browser* has a subscription registered at the server.
* *
+7 -2
View File
@@ -6,13 +6,15 @@
* when one rises while the app is open, a desktop notification names the * 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. * 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 * Only while a tab is open, and only where background notifications are off:
* added account's own Web Push subscription, which is still to come. * with them on, each account has its own Web Push subscription and the worker
* notifies (lib/notify/webpushEnable, public/sw.js).
*/ */
import { useEffect } from "react"; import { useEffect } from "react";
import { create } from "zustand"; import { create } from "zustand";
import { apiFetch } from "@/jmap/client"; import { apiFetch } from "@/jmap/client";
import { showNotification } from "@/lib/notify/notify"; import { showNotification } from "@/lib/notify/notify";
import { pushEnabledHere } from "@/lib/notify/webpush";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useSettings } from "@/store/settings"; import { useSettings } from "@/store/settings";
@@ -48,6 +50,9 @@ export async function pollOtherUnread(): Promise<void> {
const before = useOtherUnread.getState().unread; const before = useOtherUnread.getState().unread;
useOtherUnread.getState().set(next); useOtherUnread.getState().set(next);
if (!useSettings.getState().settings.desktopNotifications) return; if (!useSettings.getState().settings.desktopNotifications) return;
// With background notifications on here, the worker tells about these
// accounts already (MA-8 part 2): the counts stay, a second telling doesn't
if (pushEnabledHere()) return;
const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username])); const names = new Map(useSession.getState().signedIn.map((a) => [a.id, a.username]));
for (const id of risen(before, next)) { for (const id of risen(before, next)) {
const name = names.get(id); const name = names.get(id);
+3
View File
@@ -87,6 +87,9 @@ function ownKeys(): string[] {
export function clearSignedInData(): void { export function clearSignedInData(): void {
for (const key of ownKeys()) { for (const key of ownKeys()) {
if (KEEP_ON_SIGN_OUT.includes(key)) continue; if (KEEP_ON_SIGN_OUT.includes(key)) continue;
// inbuxa MA-8: each account's registered push endpoint, which is not mail
// and is cleared with its subscription (webpush.ts)
if (key.startsWith("pushEndpoint:")) continue;
removeKey(key); removeKey(key);
} }
} }
+8
View File
@@ -44,6 +44,14 @@ describe("the worker's briefing", () => {
expect(facts.archiveId).toBe("mb-archive"); expect(facts.archiveId).toBe("mb-archive");
}); });
it("names the inbox a notification opens in", async () => {
// The route takes a mailbox id. The worker used to put the word `inbox`
// there, and every click landed on "That folder no longer exists".
const { store } = fakeCaches();
await publishWorkerFacts("a1", "mb-archive", "mb-inbox");
expect(written(store).inboxId).toBe("mb-inbox");
});
it("carries the worker's text in the language the tab is in", async () => { it("carries the worker's text in the language the tab is in", async () => {
// The worker has no catalog. Everything it will say has to be said here // The worker has no catalog. Everything it will say has to be said here
// first, or a German reader gets English buttons on their lock screen. // first, or a German reader gets English buttons on their lock screen.
+28 -1
View File
@@ -28,6 +28,13 @@ export interface WorkerFacts {
accountId: string; accountId: string;
/** Where Archive files to; null where the account has no archive folder. */ /** Where Archive files to; null where the account has no archive folder. */
archiveId: string | null; archiveId: string | null;
/** The inbox a notification opens in; the route names a mailbox by id. */
inboxId?: string | null;
/**
* inbuxa MA-8: the other accounts signed in here, so a push for one of them
* says whose it is and its buttons act through that account's session.
*/
others?: OtherAccountFacts[];
/** The worker's own user-visible text, in the language this tab is in. */ /** The worker's own user-visible text, in the language this tab is in. */
strings: { strings: {
newMail: string; newMail: string;
@@ -47,11 +54,31 @@ export interface WorkerFacts {
* reading in a week's time. Rewriting it is one cache put; there is nothing to * reading in a week's time. Rewriting it is one cache put; there is nothing to
* gain by working out whether it differs. * gain by working out whether it differs.
*/ */
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise<void> { export interface OtherAccountFacts {
accountId: string;
sessionId: string;
username: string;
archiveId: string | null;
inboxId?: string | null;
}
let lastFront: { accountId: string | null; archiveId: string | null; inboxId: string | null } = { accountId: null, archiveId: null, inboxId: null };
let others: OtherAccountFacts[] = [];
/** inbuxa MA-8: record the other accounts and write the briefing again with them. */
export async function setOtherAccountFacts(list: OtherAccountFacts[]): Promise<void> {
others = list;
await publishWorkerFacts(lastFront.accountId, lastFront.archiveId, lastFront.inboxId);
}
export async function publishWorkerFacts(accountId: string | null, archiveId: string | null, inboxId: string | null = null): Promise<void> {
lastFront = { accountId, archiveId, inboxId };
if (typeof caches === "undefined" || !accountId) return; if (typeof caches === "undefined" || !accountId) return;
const facts: WorkerFacts = { const facts: WorkerFacts = {
accountId, accountId,
archiveId, archiveId,
inboxId,
others,
strings: { strings: {
newMail: t("New mail"), newMail: t("New mail"),
newMessage: t("New message"), newMessage: t("New message"),
+40 -1
View File
@@ -6,10 +6,11 @@ import { accountForCapability, ownAccountForCapability } from "@/lib/accountRout
import { setServerLocale } from "@/lib/datetime"; import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync"; import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild"; import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
import { unsubscribeThisDevice } from "@/lib/notify/webpush"; import { unsubscribeAccount, unsubscribeThisDevice } from "@/lib/notify/webpush";
import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage"; import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage";
import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout"; import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout";
import { delegationOf, type Delegation } from "@/lib/delegation"; import { delegationOf, type Delegation } from "@/lib/delegation";
import { withBase } from "@/lib/basePath";
import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail"; import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail";
export type AuthStatus = "loading" | "anonymous" | "authenticated"; export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -75,6 +76,31 @@ export interface SignedInAccount {
id: string; id: string;
username: string; username: string;
front: boolean; front: boolean;
/** Its mail account, for its push subscription (MA-8); null when not known yet. */
mailAccountId?: string | null;
}
/**
* inbuxa MA-8: a notification for an account not in front opens
* `?account=<session>&next=<where>`: bring that account forward, then go
* there. Only a path inside the app is followed.
*/
// Read as the app starts: the router sends `/` on to `/mail` without its query.
let launchParams: URLSearchParams | null = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
async function openFromNotification(accounts: SignedInAccount[]): Promise<void> {
const params = launchParams;
launchParams = null;
const wanted = params?.get("account");
if (!params || !wanted) return;
const raw = params.get("next") ?? "";
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : withBase("/mail");
const account = accounts.find((a) => a.id === wanted);
if (account && !account.front) {
await apiFetch(`/api/auth/accounts/${encodeURIComponent(wanted)}/front`, { method: "POST" });
clearSignedInData();
}
window.location.replace(next);
} }
/** Which sign-out: the account in front, or every one (MA-B). */ /** Which sign-out: the account in front, or every one (MA-B). */
@@ -126,7 +152,19 @@ export const useSession = create<SessionState>((set, get) => ({
// without removing it leaves this browser notifying for a mailbox nobody is // without removing it leaves this browser notifying for a mailbox nobody is
// signed into. On a shared machine that is somebody else's mail. // signed into. On a shared machine that is somebody else's mail.
try { try {
const everyone = signOutPath.endsWith("logout-all");
const othersRemain = !everyone && get().signedIn.some((a) => !a.front);
if (everyone) {
// inbuxa MA-8: every account's subscription goes, the others' first
const { unsubscribeOtherAccounts } = await import("@/lib/notify/webpushEnable");
await unsubscribeOtherAccounts();
}
if (othersRemain) {
// inbuxa MA-8: only this account's; the browser keeps notifying for the rest
await unsubscribeAccount(undefined, get().ownAccountFor(CAP.mail));
} else {
await unsubscribeThisDevice(); await unsubscribeThisDevice();
}
} catch { } catch {
/* never block signing out over this */ /* never block signing out over this */
} }
@@ -171,6 +209,7 @@ export const useSession = create<SessionState>((set, get) => ({
try { try {
const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts"); const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts");
set({ signedIn: answer.accounts, canAddAccount: answer.canAdd }); set({ signedIn: answer.accounts, canAddAccount: answer.canAdd });
await openFromNotification(answer.accounts);
} catch { } catch {
set({ signedIn: [], canAddAccount: false }); set({ signedIn: [], canAddAccount: false });
} }