diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index e54428a..9e55092 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -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)!; 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("first@example.com", "first-password"); + await b.signIn("second@example.com", "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)); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 2335c52..9c7dbb1 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -802,11 +802,21 @@ export function createApp(basePath = config.basePath): Hono { setOthersCookie(c, others.map((o) => o.cookie)); } const canAdd = (await addRefusal(c, front)) === null; - return c.json({ - accounts: [front, ...others.map((o) => o.session)].map((s, i) => ({ id: s.id, username: s.username, front: i === 0 })), - canAdd, - max: MAX_ACCOUNTS, - }); + // inbuxa MA-8: each one's mail account, which its push subscription and + // a notification's buttons need + const accounts = await Promise.all( + [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 }); }); /* diff --git a/web/public/sw.js b/web/public/sw.js index 53aab1f..fc9dc46 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -386,8 +386,11 @@ async function readFacts() { * rather than swallowed. A tap that silently does nothing is the failure worth * avoiding here: the reader has already put the phone down. */ -async function jmap(methodCalls) { - const res = await fetch(`${BASE}/api/jmap`, { +async function jmap(methodCalls, sessionId) { + // 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", credentials: "same-origin", 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 : []; 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 * 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" }); 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)" }; /* * Mark the app icon, without claiming a number. @@ -502,8 +513,7 @@ self.addEventListener("push", (event) => { // be drawn. actions: email.id ? actionsFor(facts) : [], data: { - // The route names a conversation, and `m` the message in it. - url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(email.id)}` : `${BASE}/mail`, + url: messageUrl(facts && facts.inboxId, email), id: email.id || null, title, 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. * @@ -535,7 +609,7 @@ async function runAction(action, data) { : { "keywords/$seen": true }; try { 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 { await self.registration.showNotification(data.title || "ihasmail", { body: data.failed || "Could not do that — open ihasmail and try again", diff --git a/web/src/App.tsx b/web/src/App.tsx index fd8aab1..f14801c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -330,13 +330,14 @@ function AuthedApp() { * See lib/swFacts.ts. */ const archiveId = useMail((s) => s.roleId("archive")); + const inboxId = useMail((s) => s.roleId("inbox")); const languageVersion = useLanguageVersion(); useEffect(() => { // 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 if (viewing) return; - void publishWorkerFacts(accountId, archiveId); - }, [accountId, archiveId, languageVersion, viewing]); + void publishWorkerFacts(accountId, archiveId, inboxId); + }, [accountId, archiveId, inboxId, languageVersion, viewing]); // Request notification permission lazily when enabled const notif = useSettings((s) => s.settings.desktopNotifications); diff --git a/web/src/lib/__tests__/other-accounts.test.ts b/web/src/lib/__tests__/other-accounts.test.ts index 8221113..fa753c5 100644 --- a/web/src/lib/__tests__/other-accounts.test.ts +++ b/web/src/lib/__tests__/other-accounts.test.ts @@ -65,4 +65,21 @@ describe("other accounts' unread mail", () => { expect(shown).toEqual([]); 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); + } + }); }); diff --git a/web/src/lib/notify/__tests__/push-every-account.test.ts b/web/src/lib/notify/__tests__/push-every-account.test.ts new file mode 100644 index 0000000..6952c42 --- /dev/null +++ b/web/src/lib/notify/__tests__/push-every-account.test.ts @@ -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 (method: string, args: Record) => { + 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); + }); +}); diff --git a/web/src/lib/notify/webpush.ts b/web/src/lib/notify/webpush.ts index 8fab8d1..f9a0d86 100644 --- a/web/src/lib/notify/webpush.ts +++ b/web/src/lib/notify/webpush.ts @@ -337,18 +337,29 @@ export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall */ 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 { - return localStorage.getItem(ENDPOINT_KEY); + return localStorage.getItem(endpointKey(accountId)) ?? (accountId ? localStorage.getItem(ENDPOINT_KEY) : null); } catch { return null; } } -export function rememberEndpoint(endpoint: string | null): void { +export function rememberEndpoint(endpoint: string | null, accountId?: Id | null): void { try { - if (endpoint) localStorage.setItem(ENDPOINT_KEY, endpoint); - else localStorage.removeItem(ENDPOINT_KEY); + const key = endpointKey(accountId); + if (endpoint) localStorage.setItem(key, endpoint); + else localStorage.removeItem(key); } catch { /* private mode: every start is then a fresh registration, which still works */ } @@ -376,6 +387,20 @@ export async function destroySubscription(id: Id): Promise { await client.call>("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 { + 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. */ export async function unsubscribeThisDevice(): Promise { const mine = deviceClientId(); diff --git a/web/src/lib/notify/webpushEnable.ts b/web/src/lib/notify/webpushEnable.ts index 7f310e2..c4a7212 100644 --- a/web/src/lib/notify/webpushEnable.ts +++ b/web/src/lib/notify/webpushEnable.ts @@ -5,7 +5,11 @@ * testable: everything here touches the browser's service worker and * 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 { SW_CACHE_NAME } from "../sw/swCache"; import { isDeviceTrusted } from "@/lib/storage"; @@ -18,6 +22,8 @@ import { destroySubscriptions, deviceClientId, extendSubscription, + frontCall, + type JmapCall, findSubscription, listSubscriptions, mySubscriptions, @@ -29,6 +35,7 @@ import { roomToMake, setPushEnabledHere, subscriptionPayload, + unsubscribeAccount, unsubscribeThisDevice, verifySubscription, webPushAvailable, @@ -48,7 +55,7 @@ export function listenForVerification(): void { listening = true; navigator.serviceWorker.addEventListener("message", (e: MessageEvent) => { 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(); } @@ -64,12 +71,44 @@ async function collectStoredVerification(): Promise { if (!hit) return; const { id, code } = (await hit.json()) as { id?: string; code?: string }; await cache.delete(key); - if (id && code) await verifySubscription(id, code); + if (id && code) await verifyAnywhere(id, code); } catch { /* 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 { + 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 { + 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`. * @@ -97,6 +136,8 @@ export async function enableWebPush(): Promise<{ ok: true } | { ok: false; reaso await registerThisBrowser(key); setPushEnabledHere(true); listenForVerification(); + // inbuxa MA-8: and every other account signed in here + await registerOtherAccounts(key); return { ok: true }; } catch (err) { 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 * on. */ -async function registerThisBrowser(key: string): Promise { +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 { + const { call } = target; const reg = await navigator.serviceWorker.ready; const sub = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ // Web Push requires it, and Chrome refuses a subscription without it. @@ -136,35 +193,61 @@ async function registerThisBrowser(key: string): Promise { applicationServerKey: decodeApplicationServerKey(key), })); const deviceId = deviceClientId(); - const subs = await listSubscriptions(); + const subs = await listSubscriptions(call); const mine = mySubscriptions(subs, deviceId); const [newest, ...extra] = mine; - if (newest && registeredEndpoint() === sub.endpoint) { - if (extra.length) await destroySubscriptions(extra.map((s) => s.id)); + if (newest && registeredEndpoint(target.endpointOf) === sub.endpoint) { + if (extra.length) await destroySubscriptions(extra.map((s) => s.id), call); const at = newest.expires ? Date.parse(newest.expires) : Number.NaN; if (!newest.expires || (!Number.isNaN(at) && at - Date.now() > RENEW_WITHIN_MS)) return; try { - await extendSubscription(newest.id); + await extendSubscription(newest.id, Date.now(), call); return; } catch { /* not extendable: replaced below */ } } - if (mine.length) await destroySubscriptions(mine.map((s) => s.id)); - // inbuxa AL-7: the reader's own inbox, never a delegated account's in view - const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), ownInboxId()); + if (mine.length) await destroySubscriptions(mine.map((s) => s.id), call); + const payload = subscriptionPayload(sub, target.accountId, target.inboxId); try { - await createSubscription(payload); + await createSubscription(payload, call); } catch (err) { if (!(err instanceof PushSetError) || err.type !== "overQuota") throw err; const room = roomToMake(subs.filter((s) => !mine.includes(s)), deviceId); if (!room.length) throw err; - await destroySubscriptions(room); - await createSubscription(payload); + await destroySubscriptions(room, call); + 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 { + const facts: OtherAccountFacts[] = []; + for (const account of await otherAccounts()) { + if (!account.mailAccountId) continue; + const call = otherAccountCall(account.id); + try { + const boxes = await call>( + "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 { // subscription is close to expiring, missing, or duplicated. await registerThisBrowser(key); listenForVerification(); + await registerOtherAccounts(key); } catch { /* 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 { + await unsubscribeOtherAccounts(); await unsubscribeThisDevice(); } +/** inbuxa MA-8: remove this device's subscription from every account not in front. */ +export async function unsubscribeOtherAccounts(): Promise { + 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. * diff --git a/web/src/lib/otherAccounts.ts b/web/src/lib/otherAccounts.ts index c0a3c6c..8c9ff96 100644 --- a/web/src/lib/otherAccounts.ts +++ b/web/src/lib/otherAccounts.ts @@ -6,13 +6,15 @@ * 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. + * Only while a tab is open, and only where background notifications are off: + * 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 { create } from "zustand"; import { apiFetch } from "@/jmap/client"; import { showNotification } from "@/lib/notify/notify"; +import { pushEnabledHere } from "@/lib/notify/webpush"; import { t } from "@/lib/i18n"; import { useSession } from "@/store/session"; import { useSettings } from "@/store/settings"; @@ -48,6 +50,9 @@ export async function pollOtherUnread(): Promise { const before = useOtherUnread.getState().unread; useOtherUnread.getState().set(next); 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])); for (const id of risen(before, next)) { const name = names.get(id); diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts index 7652163..0d992a1 100644 --- a/web/src/lib/storage.ts +++ b/web/src/lib/storage.ts @@ -87,6 +87,9 @@ function ownKeys(): string[] { export function clearSignedInData(): void { for (const key of ownKeys()) { 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); } } diff --git a/web/src/lib/sw/__tests__/swFacts.test.ts b/web/src/lib/sw/__tests__/swFacts.test.ts index d7640ce..7a62b3d 100644 --- a/web/src/lib/sw/__tests__/swFacts.test.ts +++ b/web/src/lib/sw/__tests__/swFacts.test.ts @@ -44,6 +44,14 @@ describe("the worker's briefing", () => { 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 () => { // 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. diff --git a/web/src/lib/sw/swFacts.ts b/web/src/lib/sw/swFacts.ts index 1dcb949..1d88c16 100644 --- a/web/src/lib/sw/swFacts.ts +++ b/web/src/lib/sw/swFacts.ts @@ -28,6 +28,13 @@ export interface WorkerFacts { accountId: string; /** Where Archive files to; null where the account has no archive folder. */ 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. */ strings: { 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 * gain by working out whether it differs. */ -export async function publishWorkerFacts(accountId: string | null, archiveId: string | null): Promise { +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 { + 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 { + lastFront = { accountId, archiveId, inboxId }; if (typeof caches === "undefined" || !accountId) return; const facts: WorkerFacts = { accountId, archiveId, + inboxId, + others, strings: { newMail: t("New mail"), newMessage: t("New message"), diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 9f46b14..0ffd0f2 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -6,10 +6,11 @@ import { accountForCapability, ownAccountForCapability } from "@/lib/accountRout import { setServerLocale } from "@/lib/datetime"; import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync"; 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 { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout"; import { delegationOf, type Delegation } from "@/lib/delegation"; +import { withBase } from "@/lib/basePath"; import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -75,6 +76,31 @@ export interface SignedInAccount { id: string; username: string; 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=&next=`: 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 { + 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). */ @@ -126,7 +152,19 @@ export const useSession = create((set, get) => ({ // without removing it leaves this browser notifying for a mailbox nobody is // signed into. On a shared machine that is somebody else's mail. try { - await unsubscribeThisDevice(); + 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(); + } } catch { /* never block signing out over this */ } @@ -171,6 +209,7 @@ export const useSession = create((set, get) => ({ try { const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts"); set({ signedIn: answer.accounts, canAddAccount: answer.canAdd }); + await openFromNotification(answer.accounts); } catch { set({ signedIn: [], canAddAccount: false }); }