From 3576e8e3f8f29ef7ff4f75ed63f5df4d9f889fb4 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 20 Oct 2026 13:40:31 -0700 Subject: [PATCH 1/5] Open a group's mailbox, or folders someone shared, from the account menu A group's members, and anyone a folder was shared with, could reach that mail over IMAP but not here: Mail read only the reader's own account. Calendars, contacts and files already list other people's shares beside the reader's own; Mail now offers them too. Such an account is listed under "Mail to show" in the account menu and opens in place of the reader's own mail. Only accounts whose Mailbox/get answers with a mailbox are offered: Stalwart advertises every capability on any shared account, so a colleague who shared one calendar would otherwise appear with mail. That is what sank the old account switcher, which moved the whole app; this switches Mail alone. While one is in view: - a bar in the palette's accent names it, with "Back to my mail", and the tab title names it; - every folder in it shows (subscriptions are the reader's own); - calendars, contacts, files, settings and push stay the reader's own; - writing a message uses that account's identities; - it is polled every minute, since push covers only the reader's own account, and new mail in the reader's own inbox is still announced. Losing the account (removed from the group, share withdrawn) takes the reader back to their own mail with a notice. A message being written blocks the switch. New strings: 6, in all ten catalogs. --- web/src/App.tsx | 62 ++++++++++- web/src/lib/notify/webpushEnable.ts | 5 +- web/src/lib/sharedMail.ts | 44 ++++++++ web/src/locales/de.ts | 7 ++ web/src/locales/es.ts | 7 ++ web/src/locales/fr.ts | 7 ++ web/src/locales/ja.ts | 7 ++ web/src/locales/nl.ts | 7 ++ web/src/locales/pt-BR.ts | 7 ++ web/src/locales/ru.ts | 7 ++ web/src/locales/tr.ts | 7 ++ web/src/locales/uk.ts | 7 ++ web/src/locales/zh-Hans.ts | 7 ++ web/src/store/__tests__/shared-mail.test.ts | 114 ++++++++++++++++++++ web/src/store/mail/index.ts | 61 ++++++++++- web/src/store/session.ts | 63 ++++++++++- web/src/styles/app.css | 6 ++ web/src/views/AppShell.tsx | 52 +++++++-- web/src/views/SharedBar.tsx | 30 ++++++ web/src/views/mail/MailboxTree.tsx | 8 +- 20 files changed, 489 insertions(+), 26 deletions(-) create mode 100644 web/src/lib/sharedMail.ts create mode 100644 web/src/store/__tests__/shared-mail.test.ts create mode 100644 web/src/views/SharedBar.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index a056160..d21435b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ -import { Fragment, lazy, Suspense, useEffect, useState } from "react"; +import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react"; import { Route, Switch, Redirect, useLocation, Router } from "wouter"; import { useSession } from "@/store/session"; -import { useMail } from "@/store/mail"; +import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail"; import { scheduleSupported, useScheduled } from "@/store/scheduled"; import { useContacts } from "@/store/contacts"; import { useCalendar } from "@/store/calendar"; @@ -231,6 +231,8 @@ function AuthedApp() { timer = null; for (const [a, types] of pending) { if (a === useMail.getState().accountId) void useMail.getState().applyChanges(types); + // The reader's own mail, while a shared mailbox is in view, is still announced + if (a === ownAccountAway() && types.has("Email")) void notifyOwnWhileAway(); if (a === useContacts.getState().accountId) useContacts.getState().applyChanges(types); if (a === useCalendar.getState().accountId) useCalendar.getState().applyChanges(types); if (a === useFiles.getState().accountId) useFiles.getState().applyChanges(types); @@ -260,10 +262,57 @@ function AuthedApp() { return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0; }); const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; + + /* + * A shared mailbox's mail, when it comes into view, and the reader's own + * when it goes back. Push carries nothing for an account only shared with + * the reader, so while one is open it is polled. + */ + const viewing = useSession((s) => s.viewing); + const viewedOnce = useRef(false); useEffect(() => { - setBaseTitle(appName); + if (!viewedOnce.current) { + viewedOnce.current = true; + if (!viewing) return; + } + const mail = useMail.getState(); + void mail.loadMailboxes(); + void mail.loadIdentities(); + if (!viewing) return; + const poll = window.setInterval(() => { + if (document.visibilityState === "visible") { + void useMail.getState().applyChanges(new Set(["Email", "Mailbox"])); + } + }, 60_000); + return () => window.clearInterval(poll); + }, [viewing]); + + // A mailbox shared or taken away while the app is open shows up when the + // reader comes back to it, without signing in again + useEffect(() => { + let last = 0; + const onVisible = () => { + if (document.visibilityState !== "visible" || Date.now() - last < 60_000) return; + last = Date.now(); + void useSession.getState().refresh(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => document.removeEventListener("visibilitychange", onVisible); + }, []); + + const viewEnded = useSession((s) => s.viewEnded); + useEffect(() => { + if (!viewEnded) return; + toast.show(t("You no longer have access to {name}. Back to your own mail.", { name: viewEnded })); + useSession.getState().clearViewEnded(); + }, [viewEnded]); + + // A shared mailbox in view is named in the tab + const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined)); + useEffect(() => { + setBaseTitle(viewingName ? `${viewingName} · ${appName}` : appName); setUnreadBadge(inboxUnread); - }, [inboxUnread, appName]); + }, [inboxUnread, appName, viewingName]); /* * Leave the service worker its briefing. @@ -277,8 +326,11 @@ function AuthedApp() { const archiveId = useMail((s) => s.roleId("archive")); const languageVersion = useLanguageVersion(); useEffect(() => { + // The worker acts on the reader's own mail; while a shared mailbox is in + // view, the archive folder here is its + if (viewing) return; void publishWorkerFacts(accountId, archiveId); - }, [accountId, archiveId, languageVersion]); + }, [accountId, archiveId, languageVersion, viewing]); // Request notification permission lazily when enabled const notif = useSettings((s) => s.settings.desktopNotifications); diff --git a/web/src/lib/notify/webpushEnable.ts b/web/src/lib/notify/webpushEnable.ts index 296c651..b0af4f3 100644 --- a/web/src/lib/notify/webpushEnable.ts +++ b/web/src/lib/notify/webpushEnable.ts @@ -10,7 +10,7 @@ import { withBase } from "../basePath"; import { SW_CACHE_NAME } from "../sw/swCache"; import { isDeviceTrusted } from "@/lib/storage"; import { useSession } from "@/store/session"; -import { useMail } from "@/store/mail"; +import { ownInboxId } from "@/store/mail"; import { applicationServerKey, createSubscription, @@ -153,7 +153,8 @@ async function registerThisBrowser(key: string): Promise { } if (mine.length) await destroySubscriptions(mine.map((s) => s.id)); - const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), useMail.getState().roleId("inbox")); + // The reader's own inbox, never a shared mailbox's in view + const payload = subscriptionPayload(sub, useSession.getState().ownAccountFor(CAP.mail), ownInboxId()); try { await createSubscription(payload); } catch (err) { diff --git a/web/src/lib/sharedMail.ts b/web/src/lib/sharedMail.ts new file mode 100644 index 0000000..976e30e --- /dev/null +++ b/web/src/lib/sharedMail.ts @@ -0,0 +1,44 @@ +/** + * Mail other people let the reader into: a group's mailbox, or folders + * someone shared. + * + * Either one arrives as another account in the session, `isPersonal: false`. + * That alone proves nothing about mail -- Stalwart advertises every + * capability on any account it lists, so a colleague who shared one calendar + * shows up with mail too. What does prove it is asking: an account whose + * `Mailbox/get` answers with at least one mailbox has mail the reader can + * open, and only those are offered. + */ + +import { CAP, client } from "@/jmap/client"; +import type { GetResponse, Id, JmapSession, Mailbox } from "@/jmap/types"; + +export interface SharedMailAccount { + id: Id; + name: string; +} + +type SessionLike = Pick; + +/** Accounts that might hold mail for the reader, by name; see the note above. */ +export function sharedMailCandidates(session: SessionLike | null): SharedMailAccount[] { + if (!session) return []; + return Object.entries(session.accounts) + .filter(([, account]) => account.isPersonal === false && CAP.mail in (account.accountCapabilities ?? {})) + .map(([id, account]) => ({ id, name: account.name })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** The candidates that answer with at least one mailbox. One that fails is left out. */ +export async function findSharedMail(session: SessionLike | null): Promise { + const candidates = sharedMailCandidates(session); + const answers = await Promise.all( + candidates.map((account) => + client.call>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then( + (res) => (res.list.length > 0 ? account : null), + () => null, + ), + ), + ); + return answers.filter((a): a is SharedMailAccount => a !== null); +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 10e0ec9..16288e5 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1730,6 +1730,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Einschätzung des Sprachmodells", "One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat", + // ── Shared and group mailboxes ── + "Mail to show": "E-Mails anzeigen von", + "My mail": "Meine E-Mails", + "Shared mailbox:": "Gemeinsames Postfach:", + "Back to my mail": "Zurück zu meinen E-Mails", + "Send or close the message you're writing first.": "Senden oder schließen Sie zuerst die Nachricht, die Sie gerade schreiben.", + "You no longer have access to {name}. Back to your own mail.": "Sie haben keinen Zugriff mehr auf {name}. Zurück zu Ihren eigenen E-Mails.", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 3aba8e7..bc8e94e 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1703,6 +1703,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Opinión del modelo de lenguaje", "One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta", + // ── Shared and group mailboxes ── + "Mail to show": "Correo que mostrar", + "My mail": "Mi correo", + "Shared mailbox:": "Buzón compartido:", + "Back to my mail": "Volver a mi correo", + "Send or close the message you're writing first.": "Envíe o cierre primero el mensaje que está escribiendo.", + "You no longer have access to {name}. Back to your own mail.": "Ya no tiene acceso a {name}. Vuelve a su propio correo.", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index e893a70..4d85520 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1708,6 +1708,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Avis du modèle de langage", "One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam", + // ── Shared and group mailboxes ── + "Mail to show": "Courrier à afficher", + "My mail": "Mon courrier", + "Shared mailbox:": "Boîte partagée :", + "Back to my mail": "Retour à mon courrier", + "Send or close the message you're writing first.": "Envoyez ou fermez d’abord le message que vous rédigez.", + "You no longer have access to {name}. Back to your own mail.": "Vous n’avez plus accès à {name}. Retour à votre propre courrier.", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 0b1849f..2de51ca 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1711,6 +1711,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "言語モデルの見解", "One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ", + // ── Shared and group mailboxes ── + "Mail to show": "表示するメール", + "My mail": "自分のメール", + "Shared mailbox:": "共有メールボックス:", + "Back to my mail": "自分のメールに戻る", + "Send or close the message you're writing first.": "作成中のメッセージを先に送信するか閉じてください。", + "You no longer have access to {name}. Back to your own mail.": "{name} にアクセスできなくなりました。自分のメールに戻ります。", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 2accb60..7fae0e1 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1703,6 +1703,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Oordeel van het taalmodel", "One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen", + // ── Shared and group mailboxes ── + "Mail to show": "Mail tonen van", + "My mail": "Mijn mail", + "Shared mailbox:": "Gedeeld postvak:", + "Back to my mail": "Terug naar mijn mail", + "Send or close the message you're writing first.": "Verzend of sluit eerst het bericht dat u schrijft.", + "You no longer have access to {name}. Back to your own mail.": "U hebt geen toegang meer tot {name}. Terug naar uw eigen mail.", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index c4149f2..9bdef19 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1706,6 +1706,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Opinião do modelo de linguagem", "One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam", + // ── Shared and group mailboxes ── + "Mail to show": "E-mail a exibir", + "My mail": "Meu e-mail", + "Shared mailbox:": "Caixa postal compartilhada:", + "Back to my mail": "Voltar ao meu e-mail", + "Send or close the message you're writing first.": "Envie ou feche primeiro a mensagem que está escrevendo.", + "You no longer have access to {name}. Back to your own mail.": "Você não tem mais acesso a {name}. De volta ao seu próprio e-mail.", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index d92fc14..5605570 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1705,6 +1705,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Мнение языковой модели", "One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр", + // ── Shared and group mailboxes ── + "Mail to show": "Какую почту показать", + "My mail": "Моя почта", + "Shared mailbox:": "Общий почтовый ящик:", + "Back to my mail": "Вернуться к моей почте", + "Send or close the message you're writing first.": "Сначала отправьте или закройте письмо, которое пишете.", + "You no longer have access to {name}. Back to your own mail.": "У вас больше нет доступа к {name}. Возвращаемся к вашей почте.", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index a2259a7..e12e784 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1602,6 +1602,13 @@ export const catalog: Catalog = { "“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.": "“{name}” devre dışı bırakılacak (silinmeyecek) ve yeni bir “ihasmail” betiği görevi devralacaktır.", "•••": "•••", "+ New folder…": "+ Yeni klasör…", + // ── Shared and group mailboxes ── + "Mail to show": "Gösterilecek posta", + "My mail": "Postam", + "Shared mailbox:": "Paylaşılan posta kutusu:", + "Back to my mail": "Postama dön", + "Send or close the message you're writing first.": "Önce yazdığınız iletiyi gönderin veya kapatın.", + "You no longer have access to {name}. Back to your own mail.": "{name} hesabına artık erişiminiz yok. Kendi postanıza dönün.", }, plurals: { "({n} deleted first)": { diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index eb217b1..bd87886 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1699,6 +1699,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "Думка мовної моделі", "One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр", + // ── Shared and group mailboxes ── + "Mail to show": "Яку пошту показати", + "My mail": "Моя пошта", + "Shared mailbox:": "Спільна поштова скринька:", + "Back to my mail": "Повернутися до моєї пошти", + "Send or close the message you're writing first.": "Спочатку надішліть або закрийте лист, який пишете.", + "You no longer have access to {name}. Back to your own mail.": "У вас більше немає доступу до {name}. Повертаємося до вашої пошти.", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 9d8b4de..5a7316b 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1710,6 +1710,13 @@ export const catalog: Catalog = { // ── Spam filter: the language model's opinion ────────────────── "Language model's opinion": "语言模型的判断", "One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一", + // ── Shared and group mailboxes ── + "Mail to show": "要显示的邮件", + "My mail": "我的邮件", + "Shared mailbox:": "共享邮箱:", + "Back to my mail": "返回我的邮件", + "Send or close the message you're writing first.": "请先发送或关闭您正在撰写的邮件。", + "You no longer have access to {name}. Back to your own mail.": "您已无法访问 {name}。已返回您自己的邮件。", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/store/__tests__/shared-mail.test.ts b/web/src/store/__tests__/shared-mail.test.ts new file mode 100644 index 0000000..510220e --- /dev/null +++ b/web/src/store/__tests__/shared-mail.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; +import { useSession } from "@/store/session"; +import { useMail } from "@/store/mail"; +import { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail"; + +/** + * A group's mailbox, or folders someone shared, can be opened in place of the + * reader's own mail. Only accounts that answer with a mailbox are offered, + * only mail follows the switch, and losing the account takes the reader back. + */ + +const sessionWith = (extra: Record = {}) => + ({ + capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} }, + accounts: { + own: { + name: "me@example.com", + isPersonal: true, + accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} }, + }, + // A group: every capability, and mailboxes + group: { + name: "support@example.com", + isPersonal: false, + accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} }, + }, + // Someone who shared one calendar: mail is advertised, but no mailbox answers + calendarOnly: { name: "colleague@example.com", isPersonal: false, accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {} } }, + // No mail at all + filesOnly: { name: "files@example.com", isPersonal: false, accountCapabilities: { [CAP.filenode]: {} } }, + ...extra, + }, + primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" }, + state: "s", + }) as unknown as JmapSession; + +/** Accounts whose Mailbox/get answers with a mailbox. */ +let withMailboxes = new Set(["group"]); +let nextSession: JmapSession; + +beforeEach(() => { + withMailboxes = new Set(["group"]); + nextSession = sessionWith(); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).includes("/api/auth/session")) { + return { ok: true, status: 200, json: async () => nextSession } as Response; + } + const { methodCalls } = JSON.parse((init?.body as string) ?? "{}") as { methodCalls: [string, Record, string][] }; + const methodResponses = methodCalls.map(([name, args, id]) => [ + name, + { accountId: args.accountId, state: "1", list: withMailboxes.has(String(args.accountId)) ? [{ id: "a" }] : [], notFound: [] }, + id, + ]); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response; + }), + ); + const session = sessionWith(); + client.session = session; + useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, sharedMail: [], viewEnded: null }); +}); + +afterEach(() => { + useSession.setState({ viewing: null, sharedMail: [] }); + vi.unstubAllGlobals(); +}); + +describe("shared mail", () => { + it("considers only other people's accounts that advertise mail", () => { + expect(sharedMailCandidates(sessionWith()).map((a) => a.id)).toEqual(["calendarOnly", "group"]); + }); + + it("offers only accounts that answer with a mailbox", async () => { + expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["support@example.com"]); + }); + + it("can't be opened until it is found, and then shows mail only", async () => { + useSession.getState().view("group"); + expect(useSession.getState().viewing).toBeNull(); + + await useSession.getState().loadSharedMail(); + useSession.getState().view("group"); + expect(useSession.getState().viewing).toBe("group"); + expect(useMail.getState().accountId).toBe("group"); + // Calendars, contacts and files stay the reader's own + expect(useSession.getState().accountFor(CAP.calendars)).toBe("own"); + expect(useSession.getState().accountFor(CAP.contacts)).toBe("own"); + expect(useSession.getState().accountFor(CAP.filenode)).toBe("own"); + + // An account with no mailboxes is never offered + useSession.getState().view("calendarOnly"); + expect(useSession.getState().viewing).toBe("group"); + + // And back + useSession.getState().view(null); + expect(useMail.getState().accountId).toBe("own"); + }); + + it("takes the reader back to their own mail when the account goes away", async () => { + await useSession.getState().loadSharedMail(); + useSession.getState().view("group"); + const session = sessionWith(); + delete (session.accounts as Record).group; + nextSession = session; + withMailboxes = new Set(); + await useSession.getState().refresh(); + expect(useSession.getState().viewing).toBeNull(); + expect(useSession.getState().viewEnded).toBe("support@example.com"); + expect(useMail.getState().accountId).toBe("own"); + }); +}); diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts index 59ed65c..575680f 100644 --- a/web/src/store/mail/index.ts +++ b/web/src/store/mail/index.ts @@ -105,6 +105,15 @@ export const useMail = create((set, get) => ({ setAccount(accountId) { if (accountId === get().accountId) return; + // Leaving the reader's own mail for a shared mailbox, remember where theirs + // was, so new mail there is still announced + const own = useSession.getState().accountId; + const leaving = get().accountId; + if (leaving && leaving === own && accountId && accountId !== own) { + ownWhileAway = { accountId: own, inbox: get().roleId("inbox"), state: get().emailState }; + } else if (accountId === own) { + ownWhileAway = null; + } resetBodyOrder(); snapshots.clear(); set({ @@ -1490,10 +1499,56 @@ function removeFromList(ids: Id[], set: (fn: (s: MailState) => Partial MailState) { - const s = settings(); const inbox = get().roleId("inbox"); if (!inbox) return; const emails = await get().getEmails(created); + announceNewMail(emails, inbox); +} + +/** + * The reader's own account while a shared mailbox is in view: its inbox and + * how far its mail was seen, so what arrives there meanwhile is still + * announced. + */ +let ownWhileAway: { accountId: Id; inbox: Id | null; state: string | null } | null = null; + +/** New mail in the reader's own account, while a shared mailbox is in view. */ +export async function notifyOwnWhileAway(): Promise { + const away = ownWhileAway; + if (!away?.inbox || !away.state) return; + try { + const changes = await client.call("Email/changes", { + accountId: away.accountId, + sinceState: away.state, + maxChanges: 50, + }); + if (ownWhileAway !== away) return; + away.state = changes.newState; + if (!changes.created.length) return; + const got = await client.call>("Email/get", { + accountId: away.accountId, + ids: changes.created, + properties: LIST_PROPS, + }); + announceNewMail(got.list, away.inbox); + } catch { + /* the next change tries again */ + } +} + +export function ownAccountAway(): Id | null { + return ownWhileAway?.accountId ?? null; +} + +/** The reader's own inbox, whatever account is in view. */ +export function ownInboxId(): Id | null { + const mail = useMail.getState(); + if (mail.accountId === useSession.getState().accountId) return mail.roleId("inbox"); + return ownWhileAway?.inbox ?? null; +} + +function announceNewMail(emails: Email[], inbox: Id) { + const s = settings(); const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft); if (!fresh.length) return; if (s.notificationSound) playNewMailSound(); @@ -1519,9 +1574,9 @@ async function notifyNewMail(created: Id[], get: () => MailState) { } } -/** Keep the store bound to the selected account. */ +/** Keep the store bound to the account in view: a shared mailbox while one is open, the reader's own otherwise. */ useSession.subscribe((s) => { - useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null); + useMail.getState().setAccount(s.status === "authenticated" ? (s.viewing ?? s.accountId) : null); }); diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 1132f25..6a7d592 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -9,6 +9,7 @@ import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild"; import { unsubscribeThisDevice } from "@/lib/notify/webpush"; import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage"; import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout"; +import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -17,6 +18,16 @@ interface SessionState { session: JmapSession | null; /** Selected mail account (defaults to primary). */ accountId: Id | null; + /** + * A group's mailbox or someone's shared folders, shown in Mail instead of + * the reader's own. Mail only: calendars, contacts, files, settings, filters + * and push stay the reader's own. + */ + viewing: Id | null; + /** Shared and group mailboxes the reader can open; see lib/sharedMail. */ + sharedMail: SharedMailAccount[]; + /** The name of a shared mailbox the reader lost while it was in view. */ + viewEnded: string | null; error: string | null; pushConnected: boolean; /** Finer than pushConnected: tells "reconnecting" from "not connected". */ @@ -26,6 +37,11 @@ interface SessionState { logout(): Promise; refresh(): Promise; setAccount(id: Id): void; + /** Show a shared mailbox's mail, or the reader's own with null. */ + view(id: Id | null): void; + /** Finds the shared and group mailboxes the reader can open. */ + loadSharedMail(): Promise; + clearViewEnded(): void; /** The account to read and write for a capability, honoring the account switcher. */ accountFor(cap: string): Id | null; /** The user's own account for a capability, whatever they are looking at. */ @@ -38,6 +54,9 @@ export const useSession = create((set, get) => ({ status: "loading", session: null, accountId: null, + viewing: null, + sharedMail: [], + viewEnded: null, error: null, pushConnected: false, pushState: "disconnected", @@ -47,7 +66,7 @@ export const useSession = create((set, get) => ({ const s = await apiFetch("/api/auth/session"); applySession(s, set); } catch (err) { - if (err instanceof ApiError && err.status === 401) set({ status: "anonymous", session: null, accountId: null }); + if (err instanceof ApiError && err.status === 401) set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); else set({ status: "anonymous", error: (err as Error).message }); } }, @@ -99,7 +118,7 @@ export const useSession = create((set, get) => ({ // problem next -- and the address book cached here is the same argument. clearSignedInData(); client.session = null; - set({ status: "anonymous", session: null, accountId: null }); + set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); }, refresh() { @@ -109,7 +128,15 @@ export const useSession = create((set, get) => ({ const s = await apiFetch("/api/auth/session?refresh=1"); client.session = s; setServerLocale(s.ihasmail?.userLocale); - set({ session: s }); + // A shared mailbox taken away, or a group left, takes the reader back to their own mail + const viewing = get().viewing; + if (viewing && !sharedMailCandidates(s).some((a) => a.id === viewing)) { + const name = get().session?.accounts[viewing]?.name ?? null; + set({ session: s, viewing: null, viewEnded: name }); + } else { + set({ session: s }); + } + void get().loadSharedMail(); } catch { /* ignore */ } finally { @@ -123,6 +150,24 @@ export const useSession = create((set, get) => ({ set({ accountId: id }); }, + view(id) { + if (id === get().viewing) return; + if (id && !get().sharedMail.some((a) => a.id === id)) return; + set({ viewing: id }); + }, + + async loadSharedMail() { + const session = get().session; + const found = await findSharedMail(session); + // A sign-out or another session while it was asking + if (get().session !== session) return; + set({ sharedMail: found }); + }, + + clearViewEnded() { + set({ viewEnded: null }); + }, + accountFor(cap) { return accountForCapability(get().session, get().accountId, cap); }, @@ -149,7 +194,8 @@ function applySession(s: JmapSession, set: (p: Partial) => void) { startIdleLogout(() => void useSession.getState().logout()); } const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null; - set({ status: "authenticated", session: s, accountId, error: null }); + set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null }); + void useSession.getState().loadSharedMail(); } client.onUnauthenticated(() => { @@ -162,7 +208,7 @@ client.onUnauthenticated(() => { // usual reason to be signed out here, and reloading a form someone has // already started typing into would throw the password away. void reloadIfServerRebuilt().then((reloading) => { - if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null }); + if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); }); }); @@ -171,3 +217,10 @@ push.onConnection((state) => useSession.setState({ pushConnected: state === "con export function hasCap(cap: string): boolean { return client.hasCapability(cap); } + +/** The shared or group mailbox in view, if one is. */ +export function useViewingShared(): SharedMailAccount | null { + const viewing = useSession((s) => s.viewing); + const sharedMail = useSession((s) => s.sharedMail); + return viewing ? (sharedMail.find((a) => a.id === viewing) ?? null) : null; +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 4f1deac..ba3bd90 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1243,6 +1243,12 @@ a.menu-item:hover { color: var(--fg); } * bar shrink instead of setting the width for everything below it. */ .app { height: 100%; display: grid; grid-template-rows: var(--topbar-h) 1fr; grid-template-columns: minmax(0, 1fr); overflow: hidden; } +/* A shared or group mailbox in view: its bar is a grid row of its own above the top bar, in the palette's accent. */ +.app.viewing-shared { grid-template-rows: auto var(--topbar-h) 1fr; } +.shared-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--accent); color: var(--accent-fg); font-size: .9em; min-width: 0; } +.shared-bar strong { font-weight: 700; } +.shared-bar button { flex: none; border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; } +.shared-bar button:hover, .shared-bar button:focus-visible { background: color-mix(in srgb, currentColor 15%, transparent); } .topbar { display: flex; align-items: center; gap: 8px; padding: 0 12px; background: var(--bg); position: relative; z-index: 50; } .topbar .brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 1.2em; letter-spacing: -.01em; color: var(--fg); text-decoration: none; padding-right: 8px; min-width: 0; } .topbar .brand img { width: 34px; height: 34px; object-fit: contain; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index aafb62c..c2c20bb 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -1,7 +1,9 @@ import { lazy, Suspense, useEffect, useRef, useState, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; -import { BookOpen, Calendar, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; +import { BookOpen, Calendar, Check, ChevronsUpDown, FolderOpen, Globe, HelpCircle, LogOut, Mail, Menu as MenuIcon, Moon, PanelLeftClose, PanelLeftOpen, PenSquare, Plus, RefreshCw, Settings, ShieldCheck, Sun, Upload, Users, X } from "lucide-react"; import { useSession } from "@/store/session"; +import { toast } from "@/ui/toast"; +import { SharedBar } from "./SharedBar"; import { withBase } from "@/lib/basePath"; import { DEFAULT_APP_NAME } from "@/lib/brand"; import { useEffectiveTheme, useSettings } from "@/store/settings"; @@ -131,18 +133,34 @@ export function AppShell({ children }: { children: ReactNode }) { }, [openShare, navigate]); /* - * There is no account switcher any more. + * Mail other people let the reader into: a group's mailbox, or folders + * someone shared. * - * It existed to reach what other people shared, and was the wrong door: it - * moved the whole app to somebody else's account, and Stalwart advertises - * every capability on a shared account, so mail, calendar and contacts went - * with it and were refused. Shares are listed where they belong now -- in - * Files and in Contacts, beside the reader's own -- and found without anyone - * having to know an account switch was involved. + * There used to be an account switcher, and it was the wrong door: it moved + * the whole app to somebody else's account, and Stalwart advertises every + * capability on a shared account, so mail, calendar and contacts went with + * it and were refused. Calendars, contacts and files shares are listed where + * they belong, beside the reader's own. Mail is the one thing shown in place + * of the reader's, so this switches Mail alone, and offers only accounts + * that answered with mailboxes (lib/sharedMail). */ + const viewing = useSession((s) => s.viewing); + const sharedMail = useSession((s) => s.sharedMail); + const switchTo = (id: string | null) => { + acctMenu.close(); + if (id === viewing) return; + // A message being written belongs to the account it was started in + if (useCompose.getState().drafts.length) { + toast.show(t("Send or close the message you're writing first.")); + return; + } + useSession.getState().view(id); + navigate("/mail"); + }; return ( -
+
+
{/* On a wide screen the folder list is toggled from the bottom of the rail; phones keep the menu button for the drawer. */} {isMobile && ( @@ -189,6 +207,22 @@ export function AppShell({ children }: { children: ReactNode }) {
+ {sharedMail.length > 0 && ( + <> +
{t("Mail to show")}
+ : } label={t("My mail")} active={!viewing} onClick={() => switchTo(null)} /> + {sharedMail.map((account) => ( + : } + label={{account.name}} + active={viewing === account.id} + onClick={() => switchTo(account.id)} + /> + ))} + + + )} } label={t("Documentation")} href="https://docs.ihasmail.org" external /> {/* The project site. It is linked from the login screen footer, which is a page a signed-in user never sees again -- so from inside the diff --git a/web/src/views/SharedBar.tsx b/web/src/views/SharedBar.tsx new file mode 100644 index 0000000..cb4855f --- /dev/null +++ b/web/src/views/SharedBar.tsx @@ -0,0 +1,30 @@ +import { Users } from "lucide-react"; +import { useLocation } from "wouter"; +import { useSession, useViewingShared } from "@/store/session"; +import { t } from "@/lib/i18n"; + +/** + * While a shared or group mailbox is in view, a bar across the whole app says + * whose mail this is, with the way back. In the palette's accent, so it reads + * as part of the app and not as a warning. + */ +export function SharedBar() { + const shared = useViewingShared(); + const [, navigate] = useLocation(); + if (!shared) return null; + const back = () => { + useSession.getState().view(null); + navigate("/mail"); + }; + return ( +
+ + + {t("Shared mailbox:")} {shared.name} + + +
+ ); +} diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 2bd752a..adf5056 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type Reac import { Link, useLocation } from "wouter"; import { AlertOctagon, Archive, ArrowDown, ArrowUp, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react"; import { useMail } from "@/store/mail"; +import { useSession } from "@/store/session"; import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder"; import { labelTree, visibleLabels } from "@/lib/mailbox/labelTree"; import { isScheduledMailbox } from "@/store/scheduled"; @@ -99,7 +100,9 @@ export function MailboxTree() { toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(mailboxes[id]!), reason: (err as Error).message })); } }; - const shown = (m: Mailbox) => showHidden || m.isSubscribed || m.role === "inbox"; + // Subscriptions are the reader's own, and they have none in a shared mailbox, so every folder there shows. + const viewingOther = useSession((s) => s.viewing !== null); + const shown = (m: Mailbox) => showHidden || viewingOther || m.isSubscribed || m.role === "inbox"; // Tree: in `compareFolders` order at every level (Inbox, then any order the // user has dragged into place, then the special folders, then A–Z), @@ -516,6 +519,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onSt }; const empty = () => confirmAndEmpty({ id: m.id, name: mailboxDisplayName(m), role: m.role, totalEmails: m.totalEmails }); const isSpecial = Boolean(m.role) && m.role !== "subscribed"; + const viewingOther = useSession((s) => s.viewing !== null); const color = folderColor(colors, m.id); const setColor = (c: string | null) => { onClose(); @@ -542,7 +546,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onSt {/* The way to reorder without a drag: from the keyboard, and on touch. */} } label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} /> } label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} /> - : } label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} /> + {!viewingOther && : } label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />} {/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and stores the share, and it never reaches the other account -- its own docs list calendars, address books and files as shareable and not mail -- 2.54.0 From 60fd2be5fbdef5ebf5be7477ec4de00d482c4225 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 18:44:45 -0700 Subject: [PATCH 2/5] Account switcher: more than one account signed in at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone who looks after several mailboxes of their own can now keep them all signed in in one browser and move between them from the account menu, without signing out. The session cookie is unchanged: it is the account in front, and every request is answered with it. The others ride in a second cookie, _more, as a list of their own session cookies. Each session stays its own -- sealed credential, expiry and "this is my device" -- and nothing about one is read through another. At most 5 in all, all on the same Stalwart server. - Add account (account menu) asks for the address and password in a small dialog. Refused, and the front stays, when the account is on another server or 5 are open. The same account again just comes to the front. - Switching (POST /api/auth/accounts//front) swaps it into front; the web app clears what it cached for the previous account and reloads. A message being written blocks the switch. - Sign out ends only the account in front, and the next one comes forward; Sign out of all accounts ends every one. - GET /api/auth/accounts lists them, the front first, and says whether one more may be added. The mock can sign in a second user (MOCK_SECOND_USER/PASS) for the server tests; the client tests cover listing, switching and both sign-outs. New strings (8, translated in all ten catalogs): "Add account", "Add an account", "Sign out of all accounts", "Both accounts stay signed in here; switch between them from this menu.", "Working…", "That account couldn't be added.", "You can't add more accounts here.", and the other-server refusal. --- server/src/accounts.test.ts | 123 +++++++++++++++++ server/src/accounts.ts | 43 ++++++ server/src/app.ts | 129 +++++++++++++++++- server/src/mock/index.ts | 20 ++- web/src/locales/de.ts | 9 ++ web/src/locales/es.ts | 9 ++ web/src/locales/fr.ts | 9 ++ web/src/locales/ja.ts | 9 ++ web/src/locales/nl.ts | 9 ++ web/src/locales/pt-BR.ts | 9 ++ web/src/locales/ru.ts | 9 ++ web/src/locales/tr.ts | 9 ++ web/src/locales/uk.ts | 9 ++ web/src/locales/zh-Hans.ts | 9 ++ .../store/__tests__/account-switcher.test.ts | 77 +++++++++++ web/src/store/session.ts | 58 +++++++- web/src/views/AddAccountDialog.tsx | 80 +++++++++++ web/src/views/AppShell.tsx | 45 ++++++ 18 files changed, 661 insertions(+), 4 deletions(-) create mode 100644 server/src/accounts.test.ts create mode 100644 server/src/accounts.ts create mode 100644 web/src/store/__tests__/account-switcher.test.ts create mode 100644 web/src/views/AddAccountDialog.tsx diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts new file mode 100644 index 0000000..97485b8 --- /dev/null +++ b/server/src/accounts.test.ts @@ -0,0 +1,123 @@ +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +/** + * More than one account signed in in one browser. The session cookie is the + * account in front; the others ride in `_more`. Adding signs a second + * account in beside the first, switching swaps them, and signing out ends + * only the one in front. + */ + +const PORT = 18801; +process.env.MOCK_PORT = String(PORT); +process.env.MOCK_USER = "first@example.com"; +process.env.MOCK_PASS = "first-password"; +process.env.MOCK_SECOND_USER = "second@example.com"; +process.env.MOCK_SECOND_PASS = "second-password"; +process.env.STALWART_URL = `http://127.0.0.1:${PORT}`; +process.env.APP_SECRET = "test-secret-for-accounts"; + +const mock = await import("./mock/index.js"); +const { createApp } = await import("./app.js"); +const { config } = await import("./config.js"); +const { MAX_ACCOUNTS, parseOthers, serializeOthers } = await import("./accounts.js"); + +const app = createApp(); +const FRONT = config.cookieName; +const MORE = `${config.cookieName}_more`; + +after(() => { + (mock as { server?: { close(): void } }).server?.close(); +}); + +/** A browser's cookie jar, as far as these two cookies go. */ +class Browser { + jar = new Map(); + + private take(res: Response) { + for (const line of res.headers.getSetCookie()) { + const [pair, ...attrs] = line.split(";"); + const at = pair!.indexOf("="); + const name = pair!.slice(0, at).trim(); + const value = pair!.slice(at + 1).trim(); + const expired = attrs.some((a) => /max-age=0/i.test(a) || /expires=thu, 01 jan 1970/i.test(a)); + if (expired || !value) this.jar.delete(name); + else this.jar.set(name, value); + } + } + + async call(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: any }> { + const cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; "); + const res = await app.request(path, { + method: init.method ?? "GET", + headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}) }, + ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}), + }); + this.take(res); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : null }; + } + + signIn(username: string, password: string, add = false) { + return this.call("/api/auth/login", { method: "POST", body: { username, password, ...(add ? { add: true } : {}) } }); + } + + async accounts(): Promise<{ username: string; front: boolean; id: string }[]> { + const res = await this.call("/api/auth/accounts"); + assert.equal(res.status, 200, JSON.stringify(res.body)); + return res.body.accounts; + } +} + +test("the cookie list keeps only well-formed session cookies, at most one fewer than the cap", () => { + const good = "abcdefghij.ABCDEFGHIJKLMN"; + assert.deepEqual(parseOthers(`${good}~not a cookie~${good}`), [good]); + const many = Array.from({ length: 9 }, (_, i) => `abcdefgh${i}x.ABCDEFGHIJKLMN`); + assert.equal(parseOthers(many.join("~")).length, MAX_ACCOUNTS - 1); + assert.equal(serializeOthers(["bad", good]), good); +}); + +test("a second account joins the first, and switching swaps them", async () => { + const b = new Browser(); + assert.equal((await b.signIn("first@example.com", "first-password")).status, 200); + assert.deepEqual((await b.accounts()).map((a) => a.username), ["first@example.com"]); + const first = b.jar.get(FRONT); + + const added = await b.signIn("second@example.com", "second-password", true); + assert.equal(added.status, 200, JSON.stringify(added.body)); + assert.equal(added.body.added, true); + assert.equal(b.jar.get(MORE), first, "the first account moved beside the new one"); + let accounts = await b.accounts(); + assert.deepEqual(accounts.map((a) => [a.username, a.front]), [["second@example.com", true], ["first@example.com", false]]); + + // The same account again is not a second copy + await b.signIn("first@example.com", "first-password", true); + accounts = await b.accounts(); + assert.equal(accounts.length, 2); + assert.equal(accounts[0]!.username, "first@example.com", "it came to the front instead"); + + // Switch back + const second = accounts.find((a) => !a.front)!; + assert.equal((await b.call(`/api/auth/accounts/${second.id}/front`, { method: "POST" })).status, 200); + assert.equal((await b.accounts())[0]!.username, "second@example.com"); + + // Signing out ends only the one in front; the other comes forward + const out = await b.call("/api/auth/logout", { method: "POST" }); + assert.equal(out.body.next, true); + assert.deepEqual((await b.accounts()).map((a) => a.username), ["first@example.com"]); + + // Sign out of all + await b.signIn("second@example.com", "second-password", true); + assert.equal((await b.accounts()).length, 2); + await b.call("/api/auth/logout-all", { method: "POST" }); + assert.equal(b.jar.has(FRONT), false); + assert.equal(b.jar.has(MORE), false); + assert.equal((await b.call("/api/auth/accounts")).status, 401); +}); + +test("an account in front can't switch to one it doesn't hold", async () => { + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404); +}); + diff --git a/server/src/accounts.ts b/server/src/accounts.ts new file mode 100644 index 0000000..1541e49 --- /dev/null +++ b/server/src/accounts.ts @@ -0,0 +1,43 @@ +/** + * More than one signed-in account in a browser. + * + * The session cookie is unchanged: it is the account in front, and every + * request is answered with it, so nothing else in the server has to know + * there may be others. The others ride in a second cookie, `_more`: a + * list of their own session cookies, each `id.secret` exactly as the front one + * is. Switching swaps one of them into front; adding moves the front one into + * the list. Each session stays its own -- its own sealed credential, its own + * expiry, its own "this is my device" -- and nothing about one can be read + * through another. + * + * At most `MAX_ACCOUNTS` in all, all on the same mail server. + */ + +export const MAX_ACCOUNTS = 5; + +/** A session cookie's shape: `id.secret`, both base64url. Anything else is dropped. */ +const COOKIE_SHAPE = /^[A-Za-z0-9_-]{8,128}\.[A-Za-z0-9_-]{8,256}$/; +const SEP = "~"; + +export function parseOthers(value: string | undefined): string[] { + if (!value) return []; + const seen = new Set(); + const out: string[] = []; + for (const part of value.split(SEP)) { + if (!COOKIE_SHAPE.test(part) || seen.has(part)) continue; + seen.add(part); + out.push(part); + if (out.length >= MAX_ACCOUNTS - 1) break; + } + return out; +} + +export function serializeOthers(cookies: string[]): string { + return cookies.filter((c) => COOKIE_SHAPE.test(c)).slice(0, MAX_ACCOUNTS - 1).join(SEP); +} + +/** Why an account can't be added, in words for the person. */ +export const ADD_REFUSED: Record = { + add_full: `You can have at most ${MAX_ACCOUNTS} accounts open here.`, + add_other_server: "That account is on another mail server. Only accounts on this server can be added.", +}; diff --git a/server/src/app.ts b/server/src/app.ts index 7e7e73e..2dab674 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -43,6 +43,7 @@ import { import { imageProxyHandler } from "./imageproxy.js"; import { icsProxyHandler } from "./icsproxy.js"; import { staticHandler } from "./static.js"; +import { ADD_REFUSED, MAX_ACCOUNTS, parseOthers, serializeOthers } from "./accounts.js"; type Env = { Variables: { session: LiveSession } }; @@ -263,6 +264,79 @@ function setSessionCookie(c: Context, value: string, remember: boolean) { }); } +/* + * The other signed-in accounts, beside the one in front. See + * accounts.ts. Kept across a browser restart only when every account in it + * would be. + */ +const OTHERS_COOKIE = `${config.cookieName}_more`; + +function setOthersCookie(c: Context, cookies: string[]) { + if (!cookies.length) { + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return; + } + const remember = cookies.every((cookie) => sessions.resolve(cookie)?.remember === true); + setCookie(c, OTHERS_COOKIE, serializeOthers(cookies), { + httpOnly: true, + sameSite: "Lax", + secure: isSecureRequest(c), + path: cookiePath, + ...(remember ? { maxAge: config.sessionRememberTtl } : {}), + }); +} + +/** The other accounts still signed in, in their order; ended ones are left out. */ +function liveOthers(c: Context): { cookie: string; session: LiveSession }[] { + const out: { cookie: string; session: LiveSession }[] = []; + for (const cookie of parseOthers(getCookie(c, OTHERS_COOKIE))) { + const session = sessions.resolve(cookie); + if (session) out.push({ cookie, session }); + } + return out; +} + +/** Whether the account in front may have more beside it, or why not. */ +function addRefusal(c: Context): string | null { + if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full"; + return null; +} + +/** + * A session just signed in to be added beside the one in front. It + * comes to the front and the old front joins the others; or, refused, it is + * ended and the front stays. Returns the refusal, or null. + */ +function joinAccount(c: Context, created: { cookie: string; session: LiveSession }): string | null { + const frontCookie = getCookie(c, config.cookieName); + const front = sessions.resolve(frontCookie); + if (!front || !frontCookie) { + // Nobody in front any more: an ordinary sign-in + setSessionCookie(c, created.cookie, created.session.remember); + return null; + } + const others = liveOthers(c); + const end = (code: string | null) => { + sessions.destroy(created.session.id); + forgetUpstreamSession(created.session.id); + return code; + }; + if (upstreamFor(created.session.username) !== upstreamFor(front.username)) return end("add_other_server"); + // Already open: that one comes to the front instead of a second copy + if (created.session.account === front.account) return end(null); + const existing = others.find((o) => o.session.account === created.session.account); + if (existing) { + end(null); + setSessionCookie(c, existing.cookie, existing.session.remember); + setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== existing).map((o) => o.cookie)]); + return null; + } + if (1 + others.length >= MAX_ACCOUNTS) return end("add_full"); + setSessionCookie(c, created.cookie, created.session.remember); + setOthersCookie(c, [frontCookie, ...others.map((o) => o.cookie)]); + return null; +} + function upstreamFailure(c: Context, err: unknown) { if (err instanceof UpstreamError) { return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502); @@ -329,7 +403,7 @@ export function createApp(basePath = config.basePath): Hono { c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp))); return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429); } - let body: { username?: string; password?: string; totp?: string; remember?: boolean }; + let body: { username?: string; password?: string; totp?: string; remember?: boolean; add?: boolean }; try { body = await c.req.json(); } catch { @@ -392,6 +466,12 @@ export function createApp(basePath = config.basePath): Hono { userAgent: c.req.header("user-agent") ?? "", ip, }); + // Beside the account in front, when that's what was asked + if (body.add && sessions.resolve(getCookie(c, config.cookieName))) { + const refused = joinAccount(c, { cookie, session }); + if (refused) return c.json({ error: refused, message: ADD_REFUSED[refused] }, 403); + return c.json({ ok: true, added: true }); + } setSessionCookie(c, cookie, session.remember); // Start the account's push subscription now, so it is usually verified // by the time the browser opens its stream. See push.ts. @@ -462,7 +542,54 @@ export function createApp(basePath = config.basePath): Hono { sessions.destroy(session.id); forgetUpstreamSession(session.id); } + // Only this account ends; the next one comes to the front + const [next, ...rest] = liveOthers(c); + if (next) { + setSessionCookie(c, next.cookie, next.session.remember); + setOthersCookie(c, rest.map((o) => o.cookie)); + return c.json({ ok: true, next: true }); + } deleteCookie(c, config.cookieName, { path: cookiePath }); + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return c.json({ ok: true }); + }); + + /* Every account signed in here ends. */ + api.post("/auth/logout-all", async (c) => { + const front = sessions.resolve(getCookie(c, config.cookieName)); + for (const session of [front, ...liveOthers(c).map((o) => o.session)]) { + if (!session) continue; + sessions.destroy(session.id); + forgetUpstreamSession(session.id); + } + deleteCookie(c, config.cookieName, { path: cookiePath }); + deleteCookie(c, OTHERS_COOKIE, { path: cookiePath }); + return c.json({ ok: true }); + }); + + /* The accounts signed in here, the one in front first, and whether one more may be added. */ + api.get("/auth/accounts", requireSession, async (c) => { + const front = c.get("session"); + const others = liveOthers(c); + if (others.length !== parseOthers(getCookie(c, OTHERS_COOKIE)).length) { + setOthersCookie(c, others.map((o) => o.cookie)); + } + const canAdd = addRefusal(c) === 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, + }); + }); + + /* Bring another signed-in account to the front. */ + api.post("/auth/accounts/:id/front", requireSession, async (c) => { + const frontCookie = getCookie(c, config.cookieName)!; + const others = liveOthers(c); + const chosen = others.find((o) => o.session.id === c.req.param("id")); + if (!chosen) return c.json({ error: "not_found" }, 404); + setSessionCookie(c, chosen.cookie, chosen.session.remember); + setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== chosen).map((o) => o.cookie)]); return c.json({ ok: true }); }); diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 5099fec..d885a22 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -22,8 +22,24 @@ function unauthorized(res: ServerResponse) { res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" })); } +/* + * An optional second user, so the account switcher has two real + * accounts to move between. It signs in with a password only, and reads the + * same mailbox: what the tests look at is who the session says it is. + */ +const SECOND_USER = process.env.MOCK_SECOND_USER; +const SECOND_PASS = process.env.MOCK_SECOND_PASS; + +/** Who a Basic header signs in as, when it is the second user. */ +function secondUser(req: IncomingMessage): boolean { + const h = req.headers.authorization ?? ""; + if (!SECOND_USER || !h.startsWith("Basic ")) return false; + return Buffer.from(h.slice(6), "base64").toString() === `${SECOND_USER}:${SECOND_PASS}`; +} + function checkAuth(req: IncomingMessage): boolean { const h = req.headers.authorization ?? ""; + if (secondUser(req)) return true; if (!h.startsWith("Basic ")) return false; const raw = Buffer.from(h.slice(6), "base64").toString(); const sep = raw.indexOf(":"); @@ -80,7 +96,9 @@ export const server = createServer(async (req, res) => { if (!checkAuth(req)) return unauthorized(res); if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") { res.writeHead(200, { "content-type": "application/json" }); - return res.end(JSON.stringify(session())); + const answer = session() as ReturnType & { username: string }; + if (secondUser(req)) answer.username = SECOND_USER!; + return res.end(JSON.stringify(answer)); } // The account info endpoint; the only place a server reports its edition. if (url.pathname === "/api/account" && req.method === "GET") { diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 16288e5..c0cc059 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1737,6 +1737,15 @@ export const catalog: Catalog = { "Back to my mail": "Zurück zu meinen E-Mails", "Send or close the message you're writing first.": "Senden oder schließen Sie zuerst die Nachricht, die Sie gerade schreiben.", "You no longer have access to {name}. Back to your own mail.": "Sie haben keinen Zugriff mehr auf {name}. Zurück zu Ihren eigenen E-Mails.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Hier können keine weiteren Konten hinzugefügt werden.", + "That account is on another mail server. Only accounts on this server can be added.": "Dieses Konto liegt auf einem anderen Mailserver. Nur Konten auf diesem Server können hinzugefügt werden.", + "That account couldn't be added.": "Dieses Konto konnte nicht hinzugefügt werden.", + "Add an account": "Konto hinzufügen", + "Working…": "Bitte warten…", + "Add account": "Konto hinzufügen", + "Both accounts stay signed in here; switch between them from this menu.": "Beide Konten bleiben hier angemeldet; wechseln Sie über dieses Menü zwischen ihnen.", + "Sign out of all accounts": "Von allen Konten abmelden", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index bc8e94e..9e312c9 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1710,6 +1710,15 @@ export const catalog: Catalog = { "Back to my mail": "Volver a mi correo", "Send or close the message you're writing first.": "Envíe o cierre primero el mensaje que está escribiendo.", "You no longer have access to {name}. Back to your own mail.": "Ya no tiene acceso a {name}. Vuelve a su propio correo.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "No puede añadir más cuentas aquí.", + "That account is on another mail server. Only accounts on this server can be added.": "Esa cuenta está en otro servidor de correo. Solo se pueden añadir cuentas de este servidor.", + "That account couldn't be added.": "No se pudo añadir esa cuenta.", + "Add an account": "Añadir una cuenta", + "Working…": "Procesando…", + "Add account": "Añadir cuenta", + "Both accounts stay signed in here; switch between them from this menu.": "Ambas cuentas siguen con la sesión iniciada aquí; cambie entre ellas desde este menú.", + "Sign out of all accounts": "Cerrar sesión en todas las cuentas", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 4d85520..70d3896 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1715,6 +1715,15 @@ export const catalog: Catalog = { "Back to my mail": "Retour à mon courrier", "Send or close the message you're writing first.": "Envoyez ou fermez d’abord le message que vous rédigez.", "You no longer have access to {name}. Back to your own mail.": "Vous n’avez plus accès à {name}. Retour à votre propre courrier.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Vous ne pouvez pas ajouter d'autres comptes ici.", + "That account is on another mail server. Only accounts on this server can be added.": "Ce compte se trouve sur un autre serveur de messagerie. Seuls les comptes de ce serveur peuvent être ajoutés.", + "That account couldn't be added.": "Ce compte n'a pas pu être ajouté.", + "Add an account": "Ajouter un compte", + "Working…": "Traitement…", + "Add account": "Ajouter le compte", + "Both accounts stay signed in here; switch between them from this menu.": "Les deux comptes restent connectés ici ; passez de l'un à l'autre depuis ce menu.", + "Sign out of all accounts": "Se déconnecter de tous les comptes", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 2de51ca..cb14cce 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1718,6 +1718,15 @@ export const catalog: Catalog = { "Back to my mail": "自分のメールに戻る", "Send or close the message you're writing first.": "作成中のメッセージを先に送信するか閉じてください。", "You no longer have access to {name}. Back to your own mail.": "{name} にアクセスできなくなりました。自分のメールに戻ります。", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "これ以上アカウントを追加できません。", + "That account is on another mail server. Only accounts on this server can be added.": "そのアカウントは別のメールサーバーにあります。追加できるのはこのサーバーのアカウントだけです。", + "That account couldn't be added.": "そのアカウントを追加できませんでした。", + "Add an account": "アカウントを追加", + "Working…": "処理中…", + "Add account": "アカウントを追加", + "Both accounts stay signed in here; switch between them from this menu.": "どちらのアカウントもここでサインインしたままになります。このメニューから切り替えられます。", + "Sign out of all accounts": "すべてのアカウントからサインアウト", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 7fae0e1..1336aa7 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1710,6 +1710,15 @@ export const catalog: Catalog = { "Back to my mail": "Terug naar mijn mail", "Send or close the message you're writing first.": "Verzend of sluit eerst het bericht dat u schrijft.", "You no longer have access to {name}. Back to your own mail.": "U hebt geen toegang meer tot {name}. Terug naar uw eigen mail.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "U kunt hier geen accounts meer toevoegen.", + "That account is on another mail server. Only accounts on this server can be added.": "Dat account staat op een andere mailserver. Alleen accounts op deze server kunnen worden toegevoegd.", + "That account couldn't be added.": "Dat account kon niet worden toegevoegd.", + "Add an account": "Een account toevoegen", + "Working…": "Bezig…", + "Add account": "Account toevoegen", + "Both accounts stay signed in here; switch between them from this menu.": "Beide accounts blijven hier ingelogd; wissel ertussen via dit menu.", + "Sign out of all accounts": "Uitloggen bij alle accounts", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 9bdef19..00c6604 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1713,6 +1713,15 @@ export const catalog: Catalog = { "Back to my mail": "Voltar ao meu e-mail", "Send or close the message you're writing first.": "Envie ou feche primeiro a mensagem que está escrevendo.", "You no longer have access to {name}. Back to your own mail.": "Você não tem mais acesso a {name}. De volta ao seu próprio e-mail.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Não é possível adicionar mais contas aqui.", + "That account is on another mail server. Only accounts on this server can be added.": "Essa conta está em outro servidor de e-mail. Só é possível adicionar contas deste servidor.", + "That account couldn't be added.": "Não foi possível adicionar essa conta.", + "Add an account": "Adicionar uma conta", + "Working…": "Processando…", + "Add account": "Adicionar conta", + "Both accounts stay signed in here; switch between them from this menu.": "As duas contas continuam conectadas aqui; alterne entre elas por este menu.", + "Sign out of all accounts": "Sair de todas as contas", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 5605570..0ac1845 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1712,6 +1712,15 @@ export const catalog: Catalog = { "Back to my mail": "Вернуться к моей почте", "Send or close the message you're writing first.": "Сначала отправьте или закройте письмо, которое пишете.", "You no longer have access to {name}. Back to your own mail.": "У вас больше нет доступа к {name}. Возвращаемся к вашей почте.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Добавить здесь ещё учётные записи нельзя.", + "That account is on another mail server. Only accounts on this server can be added.": "Эта учётная запись находится на другом почтовом сервере. Добавить можно только учётные записи этого сервера.", + "That account couldn't be added.": "Не удалось добавить эту учётную запись.", + "Add an account": "Добавить учётную запись", + "Working…": "Выполняется…", + "Add account": "Добавить учётную запись", + "Both accounts stay signed in here; switch between them from this menu.": "Обе учётные записи остаются здесь в системе; переключайтесь между ними в этом меню.", + "Sign out of all accounts": "Выйти из всех учётных записей", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index e12e784..dbcc533 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1609,6 +1609,15 @@ export const catalog: Catalog = { "Back to my mail": "Postama dön", "Send or close the message you're writing first.": "Önce yazdığınız iletiyi gönderin veya kapatın.", "You no longer have access to {name}. Back to your own mail.": "{name} hesabına artık erişiminiz yok. Kendi postanıza dönün.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Buraya daha fazla hesap ekleyemezsiniz.", + "That account is on another mail server. Only accounts on this server can be added.": "Bu hesap başka bir posta sunucusunda. Yalnızca bu sunucudaki hesaplar eklenebilir.", + "That account couldn't be added.": "Bu hesap eklenemedi.", + "Add an account": "Hesap ekle", + "Working…": "İşleniyor…", + "Add account": "Hesap ekle", + "Both accounts stay signed in here; switch between them from this menu.": "Her iki hesap da burada oturum açık kalır; bu menüden aralarında geçiş yapın.", + "Sign out of all accounts": "Tüm hesaplardan çıkış yap", }, plurals: { "({n} deleted first)": { diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index bd87886..1a1341d 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1706,6 +1706,15 @@ export const catalog: Catalog = { "Back to my mail": "Повернутися до моєї пошти", "Send or close the message you're writing first.": "Спочатку надішліть або закрийте лист, який пишете.", "You no longer have access to {name}. Back to your own mail.": "У вас більше немає доступу до {name}. Повертаємося до вашої пошти.", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "Додати тут ще облікові записи не можна.", + "That account is on another mail server. Only accounts on this server can be added.": "Цей обліковий запис розміщено на іншому поштовому сервері. Додати можна лише облікові записи цього сервера.", + "That account couldn't be added.": "Не вдалося додати цей обліковий запис.", + "Add an account": "Додати обліковий запис", + "Working…": "Виконується…", + "Add account": "Додати обліковий запис", + "Both accounts stay signed in here; switch between them from this menu.": "Обидва облікові записи залишаються тут у системі; перемикайтеся між ними в цьому меню.", + "Sign out of all accounts": "Вийти з усіх облікових записів", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 5a7316b..cc2def7 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1717,6 +1717,15 @@ export const catalog: Catalog = { "Back to my mail": "返回我的邮件", "Send or close the message you're writing first.": "请先发送或关闭您正在撰写的邮件。", "You no longer have access to {name}. Back to your own mail.": "您已无法访问 {name}。已返回您自己的邮件。", + // ── Signed in to more than one account ── + "You can't add more accounts here.": "无法在此添加更多账户。", + "That account is on another mail server. Only accounts on this server can be added.": "该账户位于其他邮件服务器上。只能添加此服务器上的账户。", + "That account couldn't be added.": "无法添加该账户。", + "Add an account": "添加账户", + "Working…": "正在处理…", + "Add account": "添加账户", + "Both accounts stay signed in here; switch between them from this menu.": "两个账户都会在这里保持登录;可从此菜单在它们之间切换。", + "Sign out of all accounts": "退出所有账户", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/store/__tests__/account-switcher.test.ts b/web/src/store/__tests__/account-switcher.test.ts new file mode 100644 index 0000000..62964f7 --- /dev/null +++ b/web/src/store/__tests__/account-switcher.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSession } from "@/store/session"; + +/** + * The accounts signed in in this browser. The server lists them + * and swaps the one in front; the web app clears what it cached for the + * previous account and reloads, and signing out of one reloads into the next. + */ + +let reload: ReturnType; +let calls: { url: string; method: string }[]; +let answers: Record; + +beforeEach(() => { + reload = vi.fn(); + Object.defineProperty(window, "location", { configurable: true, value: { ...window.location, reload } }); + calls = []; + answers = {}; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + const path = String(url); + calls.push({ url: path, method: init?.method ?? "GET" }); + const key = Object.keys(answers).find((k) => path.endsWith(k)); + const body = key ? answers[key] : { ok: true }; + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response; + }), + ); + localStorage.setItem("ihasmail:cached-thing", "from the account in front"); + useSession.setState({ status: "authenticated", signedIn: [], canAddAccount: false }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); +}); + +describe("account switcher", () => { + it("lists the accounts and whether one more may be added", async () => { + answers["/api/auth/accounts"] = { + accounts: [ + { id: "a", username: "first@example.com", front: true }, + { id: "b", username: "second@example.com", front: false }, + ], + canAdd: true, + }; + await useSession.getState().loadSignedIn(); + expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["first@example.com", "second@example.com"]); + expect(useSession.getState().canAddAccount).toBe(true); + }); + + it("switching asks the server, forgets the cache and reloads", async () => { + await useSession.getState().switchTo("b"); + expect(calls.some((c) => c.url.endsWith("/api/auth/accounts/b/front") && c.method === "POST")).toBe(true); + expect(localStorage.getItem("ihasmail:cached-thing")).toBeNull(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("signing out of one reloads into the next when there is one", async () => { + answers["/api/auth/logout"] = { ok: true, next: true }; + await useSession.getState().logout(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it("signing out of the last one, or of all, ends at the sign-in page", async () => { + answers["/api/auth/logout-all"] = { ok: true }; + await useSession.getState().logoutAll(); + expect(calls.some((c) => c.url.endsWith("/api/auth/logout-all"))).toBe(true); + expect(reload).not.toHaveBeenCalled(); + expect(useSession.getState().status).toBe("anonymous"); + // The next ordinary sign-out goes back to signing out of one + useSession.setState({ status: "authenticated" }); + answers["/api/auth/logout"] = { ok: true }; + await useSession.getState().logout(); + expect(calls.filter((c) => c.url.endsWith("/api/auth/logout")).length).toBe(1); + }); +}); diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 6a7d592..b54429c 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -28,13 +28,24 @@ interface SessionState { sharedMail: SharedMailAccount[]; /** The name of a shared mailbox the reader lost while it was in view. */ viewEnded: string | null; + /** The accounts signed in in this browser, the one in front first. */ + signedIn: SignedInAccount[]; + /** Whether one more may be added (at most five, all on the same server). */ + canAddAccount: boolean; error: string | null; pushConnected: boolean; /** Finer than pushConnected: tells "reconnecting" from "not connected". */ pushState: PushState; bootstrap(): Promise; login(username: string, password: string, totp: string, remember: boolean): Promise; + /** Signs out of the account in front; another signed-in one comes forward. */ logout(): Promise; + /** Ends every account signed in in this browser. */ + logoutAll(): Promise; + /** Finds the other accounts signed in here. */ + loadSignedIn(): Promise; + /** Brings another signed-in account to the front, and reloads. */ + switchTo(sessionId: string): Promise; refresh(): Promise; setAccount(id: Id): void; /** Show a shared mailbox's mail, or the reader's own with null. */ @@ -50,6 +61,16 @@ interface SessionState { let refreshing: Promise | null = null; +/** What a signed-in account looks like in the switcher. */ +export interface SignedInAccount { + id: string; + username: string; + front: boolean; +} + +/** Which sign-out: the account in front, or every one. */ +let signOutPath = "/api/auth/logout"; + export const useSession = create((set, get) => ({ status: "loading", session: null, @@ -57,6 +78,8 @@ export const useSession = create((set, get) => ({ viewing: null, sharedMail: [], viewEnded: null, + signedIn: [], + canAddAccount: false, error: null, pushConnected: false, pushState: "disconnected", @@ -107,8 +130,11 @@ export const useSession = create((set, get) => ({ } catch { /* never block signing out over this */ } + const path = signOutPath; + signOutPath = "/api/auth/logout"; + let next = false; try { - await apiFetch("/api/auth/logout", { method: "POST" }); + next = Boolean((await apiFetch<{ next?: boolean }>(path, { method: "POST" }))?.next); } catch { /* ignore */ } @@ -118,7 +144,34 @@ export const useSession = create((set, get) => ({ // problem next -- and the address book cached here is the same argument. clearSignedInData(); client.session = null; - set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); + // Another signed-in account is in front now + if (next) { + window.location.reload(); + return; + } + set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [], signedIn: [], canAddAccount: false }); + }, + + async logoutAll() { + // The same care as signing out of one, then every account ends + signOutPath = "/api/auth/logout-all"; + await get().logout(); + }, + + async loadSignedIn() { + try { + const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts"); + set({ signedIn: answer.accounts, canAddAccount: answer.canAdd }); + } catch { + set({ signedIn: [], canAddAccount: false }); + } + }, + + async switchTo(sessionId) { + await apiFetch(`/api/auth/accounts/${encodeURIComponent(sessionId)}/front`, { method: "POST" }); + // What was cached belongs to the account that was in front + clearSignedInData(); + window.location.reload(); }, refresh() { @@ -196,6 +249,7 @@ function applySession(s: JmapSession, set: (p: Partial) => void) { const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null; set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null }); void useSession.getState().loadSharedMail(); + void useSession.getState().loadSignedIn(); } client.onUnauthenticated(() => { diff --git a/web/src/views/AddAccountDialog.tsx b/web/src/views/AddAccountDialog.tsx new file mode 100644 index 0000000..d2739b9 --- /dev/null +++ b/web/src/views/AddAccountDialog.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { Dialog } from "@/ui/dialog"; +import { ApiError, apiFetch } from "@/jmap/client"; +import { clearSignedInData, isDeviceTrusted } from "@/lib/storage"; +import { t } from "@/lib/i18n"; + +/** + * Sign a second account in beside the one in front. It joins the others and + * comes to the front, and the app reloads into it. + * + * It is remembered on this device exactly as the first one was. + */ +export function AddAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const remember = isDeviceTrusted(); + + useEffect(() => { + if (open) setError(null); + }, [open]); + + const submit = async (e: FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password) return; + setBusy(true); + setError(null); + try { + await apiFetch("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: username.trim(), password, remember, add: true }), + }); + clearSignedInData(); + window.location.reload(); + } catch (err) { + const code = err instanceof ApiError ? err.code : ""; + setError( + code === "add_full" + ? t("You can't add more accounts here.") + : code === "add_other_server" + ? t("That account is on another mail server. Only accounts on this server can be added.") + : (err as Error).message || t("That account couldn't be added."), + ); + setBusy(false); + } + }; + + return ( + + + + + } + > +
void submit(e)}> +

{t("Both accounts stay signed in here; switch between them from this menu.")}

+
+ + setUsername(e.target.value)} autoFocus /> +
+
+ + setPassword(e.target.value)} /> +
+ {error &&

{error}

} +
+
+ ); +} diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index c2c20bb..a3465f6 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -10,6 +10,7 @@ import { useEffectiveTheme, useSettings } from "@/store/settings"; import { toggleTarget } from "@/lib/palette"; import { useMail } from "@/store/mail"; import { draftFromMailto, useCompose } from "@/store/compose"; +import { AddAccountDialog } from "./AddAccountDialog"; import { Avatar, useIsMobile } from "@/ui/misc"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { Splitter } from "@/ui/Splitter"; @@ -72,6 +73,19 @@ export function AppShell({ children }: { children: ReactNode }) { const pushState = useSession((s) => s.pushState); const session = useSession((s) => s.session); const logout = useSession((s) => s.logout); + // The other accounts signed in here, and adding one + const signedIn = useSession((s) => s.signedIn); + const canAddAccount = useSession((s) => s.canAddAccount); + const [addingAccount, setAddingAccount] = useState(false); + const bringForward = (sessionId: string) => { + acctMenu.close(); + // A message being written belongs to the account it was started in + if (useCompose.getState().drafts.length) { + toast.show(t("Send or close the message you're writing first.")); + return; + } + void useSession.getState().switchTo(sessionId); + }; const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; const acctMenu = useMenu(); const administers = hasAdministration(usePermissions()); @@ -207,6 +221,33 @@ export function AppShell({ children }: { children: ReactNode }) { + {/* Each account signed in here, the one in front ticked */} + {(signedIn.length > 1 || canAddAccount) && ( + <> + {signedIn.length > 1 &&
{t("Accounts")}
} + {signedIn.length > 1 && + signedIn.map((account) => ( + : } + label={{account.username}} + active={account.front} + onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))} + /> + ))} + {canAddAccount && ( + } + label={t("Add account")} + onClick={() => { + acctMenu.close(); + setAddingAccount(true); + }} + /> + )} + + + )} {sharedMail.length > 0 && ( <>
{t("Mail to show")}
@@ -249,7 +290,11 @@ export function AppShell({ children }: { children: ReactNode }) { )} } label={t("Refresh")} onClick={() => window.location.reload()} /> } label={t("Sign out")} onClick={() => void logout()} /> + {signedIn.length > 1 && ( + } label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} /> + )} + setAddingAccount(false)} /> -- 2.54.0 From 58c933da10dc68ec4cfd78855f2e718b4fa8a1cd Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 20 Oct 2026 13:46:25 -0700 Subject: [PATCH 3/5] New mail in the other signed-in accounts: counts, a dot, and a notification With more than one account signed in, mail arriving in one that isn't in front went unseen until someone switched to it. - GET /api/auth/accounts/unread answers the Inbox unread count of each account not in front, asked through that account's own session, 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. New strings (3, translated in all ten catalogs): "New mail for {name}", "Unread in the Inbox: {count}", "Account: new mail in another account". --- server/src/accounts.test.ts | 14 ++++ server/src/app.ts | 47 ++++++++++++ web/src/lib/__tests__/other-accounts.test.ts | 68 ++++++++++++++++++ web/src/lib/otherAccounts.ts | 76 ++++++++++++++++++++ web/src/locales/de.ts | 4 ++ web/src/locales/es.ts | 4 ++ web/src/locales/fr.ts | 4 ++ web/src/locales/ja.ts | 4 ++ web/src/locales/nl.ts | 4 ++ web/src/locales/pt-BR.ts | 4 ++ web/src/locales/ru.ts | 4 ++ web/src/locales/tr.ts | 4 ++ web/src/locales/uk.ts | 4 ++ web/src/locales/zh-Hans.ts | 4 ++ web/src/styles/app.css | 2 + web/src/views/AppShell.tsx | 25 ++++++- 16 files changed, 270 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 97485b8..b2c377a 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -121,3 +121,17 @@ test("an account in front can't switch to one it doesn't hold", async () => { assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404); }); +test("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 2dab674..cca750e 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -296,6 +296,30 @@ function liveOthers(c: Context): { cookie: string; session: LiveSession }[] { return out; } +/** 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. */ function addRefusal(c: Context): string | null { if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full"; @@ -582,6 +606,29 @@ export function createApp(basePath = config.basePath): Hono { }); }); + /* + * 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 ({ session }) => { + const cached = unreadCache.get(session.id); + if (cached && Date.now() - cached.at < UNREAD_CACHE_MS) return { id: session.id, unread: cached.unread }; + try { + const unread = await inboxUnread(session); + 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 }); + }); + /* 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..f59191e --- /dev/null +++ b/web/src/lib/__tests__/other-accounts.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** 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..bdaaaa3 --- /dev/null +++ b/web/src/lib/otherAccounts.ts @@ -0,0 +1,76 @@ +/** + * 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/locales/de.ts b/web/src/locales/de.ts index c0cc059..d6e8068 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1746,6 +1746,10 @@ export const catalog: Catalog = { "Add account": "Konto hinzufügen", "Both accounts stay signed in here; switch between them from this menu.": "Beide Konten bleiben hier angemeldet; wechseln Sie über dieses Menü zwischen ihnen.", "Sign out of all accounts": "Von allen Konten abmelden", + // ── New mail in other accounts ── + "New mail for {name}": "Neue E-Mail für {name}", + "Unread in the Inbox: {count}": "Ungelesen im Posteingang: {count}", + "Account: new mail in another account": "Konto: neue E-Mails in einem anderen Konto", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index 9e312c9..d69438b 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1719,6 +1719,10 @@ export const catalog: Catalog = { "Add account": "Añadir cuenta", "Both accounts stay signed in here; switch between them from this menu.": "Ambas cuentas siguen con la sesión iniciada aquí; cambie entre ellas desde este menú.", "Sign out of all accounts": "Cerrar sesión en todas las cuentas", + // ── New mail in other accounts ── + "New mail for {name}": "Correo nuevo para {name}", + "Unread in the Inbox: {count}": "No leídos en la bandeja de entrada: {count}", + "Account: new mail in another account": "Cuenta: correo nuevo en otra cuenta", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 70d3896..b5cf8c7 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1724,6 +1724,10 @@ export const catalog: Catalog = { "Add account": "Ajouter le compte", "Both accounts stay signed in here; switch between them from this menu.": "Les deux comptes restent connectés ici ; passez de l'un à l'autre depuis ce menu.", "Sign out of all accounts": "Se déconnecter de tous les comptes", + // ── New mail in other accounts ── + "New mail for {name}": "Nouveau courrier pour {name}", + "Unread in the Inbox: {count}": "Non lus dans la boîte de réception : {count}", + "Account: new mail in another account": "Compte : nouveau courrier dans un autre compte", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index cb14cce..1711792 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1727,6 +1727,10 @@ export const catalog: Catalog = { "Add account": "アカウントを追加", "Both accounts stay signed in here; switch between them from this menu.": "どちらのアカウントもここでサインインしたままになります。このメニューから切り替えられます。", "Sign out of all accounts": "すべてのアカウントからサインアウト", + // ── New mail in other accounts ── + "New mail for {name}": "{name} に新着メール", + "Unread in the Inbox: {count}": "受信トレイの未読: {count}", + "Account: new mail in another account": "アカウント: 別のアカウントに新着メールがあります", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 1336aa7..09176fe 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1719,6 +1719,10 @@ export const catalog: Catalog = { "Add account": "Account toevoegen", "Both accounts stay signed in here; switch between them from this menu.": "Beide accounts blijven hier ingelogd; wissel ertussen via dit menu.", "Sign out of all accounts": "Uitloggen bij alle accounts", + // ── New mail in other accounts ── + "New mail for {name}": "Nieuwe mail voor {name}", + "Unread in the Inbox: {count}": "Ongelezen in Postvak IN: {count}", + "Account: new mail in another account": "Account: nieuwe mail in een ander account", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 00c6604..17e5595 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1722,6 +1722,10 @@ export const catalog: Catalog = { "Add account": "Adicionar conta", "Both accounts stay signed in here; switch between them from this menu.": "As duas contas continuam conectadas aqui; alterne entre elas por este menu.", "Sign out of all accounts": "Sair de todas as contas", + // ── New mail in other accounts ── + "New mail for {name}": "E-mail novo para {name}", + "Unread in the Inbox: {count}": "Não lidas na Caixa de entrada: {count}", + "Account: new mail in another account": "Conta: e-mail novo em outra conta", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 0ac1845..53d137b 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1721,6 +1721,10 @@ export const catalog: Catalog = { "Add account": "Добавить учётную запись", "Both accounts stay signed in here; switch between them from this menu.": "Обе учётные записи остаются здесь в системе; переключайтесь между ними в этом меню.", "Sign out of all accounts": "Выйти из всех учётных записей", + // ── New mail in other accounts ── + "New mail for {name}": "Новое письмо для {name}", + "Unread in the Inbox: {count}": "Непрочитанных во «Входящих»: {count}", + "Account: new mail in another account": "Учётная запись: новые письма в другой учётной записи", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/tr.ts b/web/src/locales/tr.ts index dbcc533..74a1ec8 100644 --- a/web/src/locales/tr.ts +++ b/web/src/locales/tr.ts @@ -1618,6 +1618,10 @@ export const catalog: Catalog = { "Add account": "Hesap ekle", "Both accounts stay signed in here; switch between them from this menu.": "Her iki hesap da burada oturum açık kalır; bu menüden aralarında geçiş yapın.", "Sign out of all accounts": "Tüm hesaplardan çıkış yap", + // ── New mail in other accounts ── + "New mail for {name}": "{name} için yeni posta", + "Unread in the Inbox: {count}": "Gelen Kutusu'nda okunmamış: {count}", + "Account: new mail in another account": "Hesap: başka bir hesapta yeni posta", }, plurals: { "({n} deleted first)": { diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 1a1341d..e5c240a 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1715,6 +1715,10 @@ export const catalog: Catalog = { "Add account": "Додати обліковий запис", "Both accounts stay signed in here; switch between them from this menu.": "Обидва облікові записи залишаються тут у системі; перемикайтеся між ними в цьому меню.", "Sign out of all accounts": "Вийти з усіх облікових записів", + // ── New mail in other accounts ── + "New mail for {name}": "Новий лист для {name}", + "Unread in the Inbox: {count}": "Непрочитаних у «Вхідних»: {count}", + "Account: new mail in another account": "Обліковий запис: нові листи в іншому обліковому записі", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index cc2def7..b1a7409 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1726,6 +1726,10 @@ export const catalog: Catalog = { "Add account": "添加账户", "Both accounts stay signed in here; switch between them from this menu.": "两个账户都会在这里保持登录;可从此菜单在它们之间切换。", "Sign out of all accounts": "退出所有账户", + // ── New mail in other accounts ── + "New mail for {name}": "{name} 有新邮件", + "Unread in the Inbox: {count}": "收件箱中的未读邮件:{count}", + "Account: new mail in another account": "账户:其他账户有新邮件", }, plurals: { // ── Administration: domains ──────────────────────────────────── diff --git a/web/src/styles/app.css b/web/src/styles/app.css index ba3bd90..f5d858b 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1254,6 +1254,8 @@ a.menu-item:hover { color: var(--fg); } .topbar .brand img { width: 34px; height: 34px; object-fit: contain; } .topbar .brand .brand-name { color: #14b8a6; } .topbar .brand .brand-name span { color: var(--fg-muted); font-weight: 500; } +/* 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); } /* `min-width: 0`, or the flex default of `auto` holds the search field at its own min-content and the account buttons beside it are pushed off a phone. */ .searchbar { flex: 1 1 auto; min-width: 0; max-width: 720px; margin: 0 auto; position: relative; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index a3465f6..42fbf50 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -11,6 +11,7 @@ import { toggleTarget } from "@/lib/palette"; import { useMail } from "@/store/mail"; import { draftFromMailto, useCompose } from "@/store/compose"; 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"; @@ -77,6 +78,10 @@ export function AppShell({ children }: { children: ReactNode }) { const signedIn = useSession((s) => s.signedIn); const canAddAccount = useSession((s) => s.canAddAccount); const [addingAccount, setAddingAccount] = useState(false); + // 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 @@ -207,8 +212,15 @@ export function AppShell({ children }: { children: ReactNode }) { )} -
@@ -230,7 +242,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))} /> -- 2.54.0 From b4167b2947cd70bad0902e3a8158258d2020f49d Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 20 Oct 2026 13:47:16 -0700 Subject: [PATCH 4/5] A narrow route to another signed-in account, for its push subscription First of two for notifications with the app closed, for every signed-in account. No behavior changes yet. A JMAP push subscription belongs to whoever signs the request, so an account that isn't in front can only have one registered, verified and renewed through its own session. POST /api/auth/accounts//jmap forwards to Stalwart as that session, when it is one of this browser's other accounts, and only for PushSubscription/get and /set, Mailbox/get, and Email/set limited to keywords and mailboxIds updates (what a notification's Archive and Mark read need). Anything else is 403; the account in front, or a session this browser doesn't hold, is 404. The browser already holds the session, so nothing new becomes reachable. On the web app side the push helpers (list, create, extend, destroy, verify) take a JMAP caller, defaulting to the account in front exactly as before, and lib/notify/otherAccount gives the caller for another account through the route. Tests: the allowlist, the route end to end with two accounts, and the caller. No new strings. --- server/src/accounts.test.ts | 36 +++++++++++ server/src/app.ts | 60 +++++++++++++++++++ .../__tests__/other-account-call.test.ts | 35 +++++++++++ web/src/lib/notify/otherAccount.ts | 22 +++++++ web/src/lib/notify/webpush.ts | 29 +++++---- 5 files changed, 172 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/notify/__tests__/other-account-call.test.ts create mode 100644 web/src/lib/notify/otherAccount.ts diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index b2c377a..b551bd2 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -16,6 +16,8 @@ process.env.MOCK_SECOND_USER = "second@example.com"; process.env.MOCK_SECOND_PASS = "second-password"; process.env.STALWART_URL = `http://127.0.0.1:${PORT}`; process.env.APP_SECRET = "test-secret-for-accounts"; +// Every test here signs in several times from one address +process.env.LOGIN_RATE_LIMIT = "100"; const mock = await import("./mock/index.js"); const { createApp } = await import("./app.js"); @@ -135,3 +137,37 @@ test("the accounts not in front report their Inbox unread count", async () => { assert.equal(other.id, listed.id); assert.equal(typeof other.unread, "number", JSON.stringify(res.body)); }); + +test("only push, mailboxes and marking mail reach an account not in front", async () => { + const { otherAccountCallAllowed } = await import("./app.js"); + assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true); + // Anything else is refused + assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false); + assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false); + + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + const added = await b.signIn("second@example.com", "second-password", true); + assert.equal(added.status, 200, JSON.stringify(added.body)); + const other = (await b.accounts()).find((a) => !a.front)!; + const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, { + method: "POST", + body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] }, + }); + assert.equal(ok.status, 200, JSON.stringify(ok.body)); + assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get"); + const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, { + method: "POST", + body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] }, + }); + assert.equal(refused.status, 403); + // The account in front isn't reached this way, nor a session not held here + 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); +}); diff --git a/server/src/app.ts b/server/src/app.ts index cca750e..a42caf8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -296,6 +296,35 @@ function liveOthers(c: Context): { cookie: string; session: LiveSession }[] { return out; } +/* + * What may be asked of a signed-in account that isn't in front. + * + * Its push subscription has to be registered, verified and renewed through + * its own session -- a JMAP push subscription belongs to whoever signs the + * request -- and a notification's Archive and Mark read act on its mail. Those + * four methods, and for Email/set only changes to keywords and mailboxes: the + * browser already holds the session, so this reaches nothing new, but it is + * kept to what the notifications need. + */ +const OTHER_ACCOUNT_METHODS = new Set(["PushSubscription/get", "PushSubscription/set", "Mailbox/get", "Email/set"]); + +export function otherAccountCallAllowed(call: unknown): boolean { + if (!Array.isArray(call) || call.length !== 3) return false; + const [method, args] = call as [unknown, unknown, unknown]; + if (typeof method !== "string" || !OTHER_ACCOUNT_METHODS.has(method)) return false; + if (typeof args !== "object" || args === null) return false; + if (method !== "Email/set") return true; + const set = args as { create?: unknown; destroy?: unknown; update?: unknown }; + if (set.create !== undefined || set.destroy !== undefined) return false; + if (typeof set.update !== "object" || set.update === null) return false; + return Object.values(set.update as Record).every( + (patch) => + typeof patch === "object" && + patch !== null && + Object.keys(patch).every((k) => k === "keywords" || k === "mailboxIds" || k.startsWith("keywords/") || k.startsWith("mailboxIds/")), + ); +} + /** Inbox unread counts of accounts not in front, briefly kept. */ const UNREAD_CACHE_MS = 60_000; const unreadCache = new Map(); @@ -629,6 +658,37 @@ export function createApp(basePath = config.basePath): Hono { return c.json({ accounts: answers }); }); + /* A narrow JMAP route to a signed-in account not in front; see OTHER_ACCOUNT_METHODS. */ + api.post("/auth/accounts/:id/jmap", requireSession, async (c) => { + const other = liveOthers(c).find((o) => o.session.id === c.req.param("id")); + if (!other) return c.json({ error: "not_found" }, 404); + let body: { using?: unknown; methodCalls?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "bad_request" }, 400); + } + const calls = body.methodCalls; + if (!Array.isArray(calls) || calls.length === 0 || calls.length > 16 || !calls.every(otherAccountCallAllowed)) { + return c.json({ error: "forbidden", message: "Only push subscriptions, mailboxes and marking mail can be reached in another account." }, 403); + } + const using = Array.isArray(body.using) ? body.using.filter((u): u is string => typeof u === "string") : []; + const session = other.session; + try { + const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); + 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, methodCalls: calls }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401 || res.status === 403) return c.json({ error: "unauthenticated" }, 401); + return c.json(await res.json(), res.ok ? 200 : 502); + } catch (err) { + return upstreamFailure(c, err); + } + }); + /* 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/notify/__tests__/other-account-call.test.ts b/web/src/lib/notify/__tests__/other-account-call.test.ts new file mode 100644 index 0000000..9264941 --- /dev/null +++ b/web/src/lib/notify/__tests__/other-account-call.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { otherAccountCall } from "@/lib/notify/otherAccount"; +import { listSubscriptions } from "@/lib/notify/webpush"; + +/** Push calls made as an account that isn't in front. */ + +afterEach(() => vi.unstubAllGlobals()); + +function stub(response: unknown) { + const seen: { url: string; body: any }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); + return { ok: true, status: 200, json: async () => response, text: async () => JSON.stringify(response) } as Response; + }), + ); + return seen; +} + +describe("otherAccountCall", () => { + it("goes through that account's route and answers the method's result", async () => { + const seen = stub({ methodResponses: [["PushSubscription/get", { list: [{ id: "p1", deviceClientId: "d" }] }, "0"]] }); + const subs = await listSubscriptions(otherAccountCall("sess-2")); + expect(subs.map((s) => s.id)).toEqual(["p1"]); + expect(seen[0]!.url).toContain("/api/auth/accounts/sess-2/jmap"); + expect(seen[0]!.body.methodCalls[0][0]).toBe("PushSubscription/get"); + expect(seen[0]!.body.using).toContain("urn:ietf:params:jmap:core"); + }); + + it("turns a JMAP error into a thrown one", async () => { + stub({ methodResponses: [["error", { type: "forbidden" }, "0"]] }); + await expect(listSubscriptions(otherAccountCall("sess-2"))).rejects.toThrow("forbidden"); + }); +}); diff --git a/web/src/lib/notify/otherAccount.ts b/web/src/lib/notify/otherAccount.ts new file mode 100644 index 0000000..4bfd5e4 --- /dev/null +++ b/web/src/lib/notify/otherAccount.ts @@ -0,0 +1,22 @@ +/** + * JMAP calls made as a signed-in account that isn't in front, + * through the webmail server's narrow route for it + * (POST /api/auth/accounts//jmap). The server allows only what + * notifications need: push subscriptions, mailboxes, and marking or filing + * mail. + */ +import { apiFetch, CAP } from "@/jmap/client"; +import type { JmapCall } from "@/lib/notify/webpush"; + +export function otherAccountCall(sessionId: string): JmapCall { + return async (method: string, args: Record, using: string[]): Promise => { + const res = await apiFetch<{ methodResponses?: [string, unknown, string][] }>( + `/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap`, + { method: "POST", body: JSON.stringify({ using: [...new Set([CAP.core, ...using])], methodCalls: [[method, args, "0"]] }) }, + ); + const [name, out] = res.methodResponses?.[0] ?? []; + if (!name) throw new Error("The mail server sent no response."); + if (name === "error") throw new Error(String((out as { type?: string } | undefined)?.type ?? "error")); + return out as T; + }; +} diff --git a/web/src/lib/notify/webpush.ts b/web/src/lib/notify/webpush.ts index 5c6970a..6599275 100644 --- a/web/src/lib/notify/webpush.ts +++ b/web/src/lib/notify/webpush.ts @@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types"; import { isDeviceTrusted } from "@/lib/storage"; export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid"; + +/** + * Who a push call is made as. A JMAP push subscription belongs + * to whoever signs the request, so registering one for an account that isn't + * in front goes through that account's own session (lib/notify/otherAccount). + * Everything here defaults to the account in front. + */ +export type JmapCall = (method: string, args: Record, using: string[]) => Promise; +export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using); export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush"; /** @@ -285,13 +294,13 @@ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now return at - now <= RENEW_WITHIN_MS; } -export async function listSubscriptions(): Promise { - const res = await client.call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); +export async function listSubscriptions(call: JmapCall = frontCall): Promise { + const res = await call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); return res.list; } -export async function createSubscription(body: Record): Promise { - const res = await client.call>( +export async function createSubscription(body: Record, call: JmapCall = frontCall): Promise { + const res = await call>( "PushSubscription/set", { create: { s: body } }, [CAP.core, VAPID_CAP, EMAILPUSH_CAP], @@ -307,16 +316,16 @@ export async function createSubscription(body: Record): Promise * Seven days is JMAP's ceiling and what Stalwart grants a new one; the server * may shorten what is asked for, and whatever it keeps is what counts. */ -export async function extendSubscription(id: Id, now: number = Date.now()): Promise { +export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise { const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z"); - const res = await client.call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); + const res = await call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); const err = res.notUpdated?.[id]; if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type)); } -export async function destroySubscriptions(ids: Id[]): Promise { +export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise { if (!ids.length) return; - await client.call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); + await call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); } /** @@ -353,8 +362,8 @@ export function rememberEndpoint(endpoint: string | null): void { * the client echoes it. A subscription left unverified looks registered and is * silent, which is the confusing failure worth being explicit about. */ -export async function verifySubscription(id: Id, verificationCode: string): Promise { - const res = await client.call>( +export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise { + const res = await call>( "PushSubscription/set", { update: { [id]: { verificationCode } } }, [CAP.core, VAPID_CAP], -- 2.54.0 From aedcf74d3f61fd9e7821263de8e2dd8e9e6ae2c1 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 20 Oct 2026 13:48:28 -0700 Subject: [PATCH 5/5] Notifications with the app closed, for every signed-in account Turning on "Notify me even when ihasmail 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/, 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. No new strings. --- server/src/accounts.test.ts | 8 ++ server/src/app.ts | 20 ++- web/public/sw.js | 86 +++++++++++- web/src/App.tsx | 5 +- web/src/lib/__tests__/other-accounts.test.ts | 17 +++ .../__tests__/push-every-account.test.ts | 45 +++++++ web/src/lib/notify/webpush.ts | 35 ++++- web/src/lib/notify/webpushEnable.ts | 125 +++++++++++++++--- web/src/lib/otherAccounts.ts | 9 +- web/src/lib/storage.ts | 3 + web/src/lib/sw/__tests__/swFacts.test.ts | 8 ++ web/src/lib/sw/swFacts.ts | 29 +++- web/src/store/session.ts | 43 +++++- 13 files changed, 394 insertions(+), 39 deletions(-) create mode 100644 web/src/lib/notify/__tests__/push-every-account.test.ts diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index b551bd2..feb9814 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -171,3 +171,11 @@ test("only push, mailboxes and marking mail reach an account not in front", asyn 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("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 a42caf8..04760ed 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -628,11 +628,21 @@ export function createApp(basePath = config.basePath): Hono { setOthersCookie(c, others.map((o) => o.cookie)); } const canAdd = addRefusal(c) === 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, - }); + // 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 843c97a..1322a03 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -384,8 +384,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) { + // 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" }, @@ -454,6 +457,15 @@ self.addEventListener("push", (event) => { const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : []; event.waitUntil((async () => { + const facts = await readFacts(); + /* + * 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 @@ -462,7 +474,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. @@ -500,8 +511,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, @@ -513,6 +523,70 @@ self.addEventListener("push", (event) => { })()); }); +/* + * 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. * @@ -533,7 +607,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 d21435b..17a2a9a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -324,13 +324,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(() => { // The worker acts on the reader's own mail; while a shared mailbox 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 f59191e..69b0ea3 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..e4f0d97 --- /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"; + +/** 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 6599275..39f094d 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 { +/* + * 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]); } +/** + * 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 b0af4f3..4bd70f4 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 */ } } +/** + * 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(); + // 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. */ + endpointOf: Id | null; +} + +function frontTarget(): PushTarget { + // The reader's own inbox, never a shared mailbox'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)); - // The reader's own inbox, never a shared mailbox'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); +} + +/** + * 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(); } +/** 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 bdaaaa3..43277ba 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: 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..209ed37 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; + // 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..c735e68 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; + /** + * 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[] = []; + +/** 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 b54429c..97ec5ea 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -6,9 +6,10 @@ 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 { withBase } from "@/lib/basePath"; import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -66,6 +67,31 @@ export interface SignedInAccount { id: string; username: string; front: boolean; + /** Its mail account, for its push subscription; null when not known yet. */ + mailAccountId?: string | null; +} + +/** + * 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. */ @@ -117,7 +143,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) { + // Every account's subscription goes, the others' first + const { unsubscribeOtherAccounts } = await import("@/lib/notify/webpushEnable"); + await unsubscribeOtherAccounts(); + } + if (othersRemain) { + // 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 */ } @@ -162,6 +200,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 }); } -- 2.54.0