From 722a68432cd9a3982af0282f63d38e2ad4ae0500 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 14:49:28 -0700 Subject: [PATCH] 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. Calendar, Contacts and Files already list other people's shares; Mail now does too (multi-account spec, MA-A). Such an account is listed under "Mail to show" in the account menu, after any locked account handed to the reader, and opens in place of the reader's own mail through the same switch AL-7 uses. Only accounts whose Mailbox/get answers with a mailbox are offered: the server advertises every capability on any shared account, so a colleague who shared one calendar would otherwise appear with mail. While one is in view: - a bar in the palette's accent (not the locked account's red) names it, with "Back to my mail"; the tab title names it without a padlock; - calendars, contacts and files stay the reader's own (viewAccountFor follows only a delegation now); - writing a message uses the viewed account's identities, so a reply in support@ goes out as support@ and is saved in its Drafts and Sent. Losing the account (removed from the group, share withdrawn) takes the reader back to their own mail with the existing "You no longer have access" notice. New string: "Shared mailbox:" (1, English only in the other ten catalogs). Checked in Chrome against a scratch server with a support@ group and two members. typecheck and vitest (174 files, 1533 tests) pass. --- web/src/App.tsx | 10 +- web/src/lib/sharedMail.ts | 48 +++++++++ web/src/store/__tests__/shared-mail.test.ts | 114 ++++++++++++++++++++ web/src/store/session.ts | 49 ++++++--- web/src/styles/app.css | 2 + web/src/views/AppShell.tsx | 18 +++- web/src/views/DelegatedBar.tsx | 31 ++++-- 7 files changed, 244 insertions(+), 28 deletions(-) create mode 100644 web/src/lib/sharedMail.ts create mode 100644 web/src/store/__tests__/shared-mail.test.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 1270162..0d7c52e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ import { Fragment, lazy, Suspense, useEffect, useRef, useState } from "react"; import { Route, Switch, Redirect, useLocation, Router } from "wouter"; -import { useSession } from "@/store/session"; +import { useSession, useViewingDelegation } from "@/store/session"; import { notifyOwnWhileAway, ownAccountAway, useMail } from "@/store/mail"; import { scheduleSupported, useScheduled } from "@/store/scheduled"; import { useContacts } from "@/store/contacts"; @@ -311,12 +311,14 @@ function AuthedApp() { return id ? (s.mailboxes[id]?.unreadEmails ?? 0) : 0; }); const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME; - // inbuxa AL-7: a locked account in view is named, with a padlock, in the tab + // inbuxa AL-7: a locked account in view is named, with a padlock, in the + // tab; a shared or group mailbox (MA-A) is named without one const viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined)); + const lockedInView = useViewingDelegation() !== null; useEffect(() => { - setBaseTitle(viewingName ? `🔒 ${viewingName} · ${appName}` : appName); + setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName); setUnreadBadge(inboxUnread); - }, [inboxUnread, appName, viewingName]); + }, [inboxUnread, appName, viewingName, lockedInView]); /* * Leave the service worker its briefing. diff --git a/web/src/lib/sharedMail.ts b/web/src/lib/sharedMail.ts new file mode 100644 index 0000000..68568d4 --- /dev/null +++ b/web/src/lib/sharedMail.ts @@ -0,0 +1,48 @@ +/** + * Mail other people let the reader into (multi-account spec, MA-A): 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 -- the server 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. + * + * A locked account handed to the reader (AL-7) is listed by `delegation.ts` + * instead, and left out here so it is never offered twice. + */ + +import { CAP, client } from "@/jmap/client"; +import type { GetResponse, Id, JmapSession, Mailbox } from "@/jmap/types"; +import { delegationOf } from "@/lib/delegation"; + +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(([id, account]) => account.isPersonal === false && CAP.mail in (account.accountCapabilities ?? {}) && !delegationOf(session, id)) + .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/store/__tests__/shared-mail.test.ts b/web/src/store/__tests__/shared-mail.test.ts new file mode 100644 index 0000000..3d29f65 --- /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 { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail"; + +/** + * MA-A: 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]: {} } }, + // A locked account handed over: listed by delegation.ts, never here + locked: { + name: "gone@example.com", + isPersonal: false, + accountCapabilities: { [CAP.mail]: {}, "urn:inbuxa:jmap": { delegation: { locked: true, access: "read", sendAs: false, until: null } } }, + }, + ...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: [], delegationEnded: null }); +}); + +afterEach(() => { + useSession.setState({ viewing: null, sharedMail: [] }); + vi.unstubAllGlobals(); +}); + +describe("shared mail", () => { + it("considers only other people's accounts that advertise mail, leaving locked accounts to delegation", () => { + 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"); + // Calendars, contacts and files stay the reader's own + expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own"); + expect(useSession.getState().viewAccountFor(CAP.contacts)).toBe("own"); + expect(useSession.getState().viewAccountFor(CAP.filenode)).toBe("own"); + + // An account with no mailboxes is never offered + useSession.getState().view("calendarOnly"); + expect(useSession.getState().viewing).toBe("group"); + }); + + 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().delegationEnded).toBe("support@example.com"); + }); +}); diff --git a/web/src/store/session.ts b/web/src/store/session.ts index a5a5a21..5be4219 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -10,6 +10,7 @@ import { unsubscribeThisDevice } from "@/lib/notify/webpush"; import { clearAllData, clearSignedInData, setDeviceTrusted } from "@/lib/storage"; import { startIdleLogout, stopIdleLogout } from "@/lib/idleLogout"; import { delegationOf, type Delegation } from "@/lib/delegation"; +import { findSharedMail, sharedMailCandidates, type SharedMailAccount } from "@/lib/sharedMail"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -19,12 +20,15 @@ interface SessionState { /** Selected mail account (defaults to primary). */ accountId: Id | null; /** - * A locked account handed to the reader that the app shows instead of - * their own (inbuxa AL-7): its mail, calendar, contacts and files. The - * reader's settings, filters, signatures and push stay their own. + * Another account whose mail the app shows instead of the reader's own: + * a locked account handed to them (inbuxa AL-7), whose calendar, contacts + * and files follow, or a shared or group mailbox (MA-A), which is mail + * only. The reader's settings, filters, signatures and push stay their own. */ viewing: Id | null; - /** The name of an account whose delegation ended while it was in view. */ + /** Shared and group mailboxes the reader can open (MA-A); see lib/sharedMail. */ + sharedMail: SharedMailAccount[]; + /** The name of an account the reader lost while it was in view. */ delegationEnded: string | null; error: string | null; pushConnected: boolean; @@ -35,8 +39,10 @@ interface SessionState { logout(): Promise; refresh(): Promise; setAccount(id: Id): void; - /** Show a delegated account's mail, or the reader's own with null. */ + /** Show a delegated account's or 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; clearDelegationEnded(): void; /** The account to read and write for a capability, honoring the account switcher. */ accountFor(cap: string): Id | null; @@ -58,6 +64,7 @@ export const useSession = create((set, get) => ({ session: null, accountId: null, viewing: null, + sharedMail: [], delegationEnded: null, error: null, pushConnected: false, @@ -120,7 +127,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, viewing: null }); + set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); }, refresh() { @@ -130,14 +137,16 @@ export const useSession = create((set, get) => ({ const s = await apiFetch("/api/auth/session?refresh=1"); client.session = s; setServerLocale(s.ihasmail?.userLocale); - // A delegation that ended takes the reader back to their own mail + // A delegation that ended, or a shared mailbox taken away, takes the + // reader back to their own mail const viewing = get().viewing; - if (viewing && !delegationOf(s, viewing)) { + if (viewing && !delegationOf(s, viewing) && !sharedMailCandidates(s).some((a) => a.id === viewing)) { const name = get().session?.accounts[viewing]?.name ?? null; set({ session: s, viewing: null, delegationEnded: name }); } else { set({ session: s }); } + void get().loadSharedMail(); } catch { /* ignore */ } finally { @@ -152,11 +161,18 @@ export const useSession = create((set, get) => ({ }, view(id) { - if (id && !delegationOf(get().session, id)) return; + if (id && !delegationOf(get().session, id) && !get().sharedMail.some((a) => a.id === id)) return; if (id === get().viewing) return; set({ viewing: id }); }, + async loadSharedMail() { + const session = get().session; + const found = await findSharedMail(session); + // A sign-out or another account's session arrived while it was asking + if (get().session === session) set({ sharedMail: found }); + }, + clearDelegationEnded() { set({ delegationEnded: null }); }, @@ -172,7 +188,8 @@ export const useSession = create((set, get) => ({ viewAccountFor(cap) { const { session, viewing } = get(); const viewed = viewing ? session?.accounts[viewing] : undefined; - if (viewing && viewed && cap in (viewed.accountCapabilities ?? {})) return viewing; + // A shared or group mailbox in view is mail only (MA-A) + if (viewing && viewed && delegationOf(session, viewing) && cap in (viewed.accountCapabilities ?? {})) return viewing; return ownAccountForCapability(session, cap); }, })); @@ -194,7 +211,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, viewing: null, error: null }); + set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null }); + void useSession.getState().loadSharedMail(); } client.onUnauthenticated(() => { @@ -207,7 +225,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, viewing: null }); + if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] }); }); }); @@ -220,6 +238,13 @@ export function useViewingDelegation(): Delegation | null { return delegationOf(session, viewing); } +/** The shared or group mailbox in view, if one is (MA-A). */ +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; +} + export function viewingDelegation(): Delegation | null { const s = useSession.getState(); return delegationOf(s.session, s.viewing); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 609de75..1c7452c 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1257,6 +1257,8 @@ a.menu-item:hover { color: var(--fg); } .app.delegated { grid-template-rows: auto var(--topbar-h) 1fr; } .delegated-bar { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: #b91c1c; color: #fff; font-size: .9em; min-width: 0; } .delegated-bar strong { font-weight: 700; } +/* MA-A: a shared or group mailbox is the reader's to be in, so the palette's own color, not the locked account's red. */ +.delegated-bar.shared { background: var(--accent); color: var(--accent-fg); } .delegated-bar button { flex: none; border: 1px solid rgba(255, 255, 255, .7); background: transparent; color: #fff; border-radius: 999px; padding: 3px 12px; font: inherit; font-weight: 600; cursor: pointer; } .delegated-bar button:hover, .delegated-bar button:focus-visible { background: rgba(255, 255, 255, .15); } .topbar .brand.locked .brand-name, .topbar .brand.locked .brand-lock { color: #dc2626; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index f96d98b..a0a28a5 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -150,6 +150,9 @@ export function AppShell({ children }: { children: ReactNode }) { */ const viewing = useSession((s) => s.viewing); const delegated = delegatedAccounts(session); + // MA-A: shared and group mailboxes are offered in the same list, after locked accounts + const sharedMail = useSession((s) => s.sharedMail); + const lockedInView = viewing !== null && delegated.some((a) => a.id === viewing); const switchTo = (id: string | null) => { acctMenu.close(); if (id === viewing) return; @@ -173,9 +176,9 @@ export function AppShell({ children }: { children: ReactNode }) { )} - + - {viewing && } + {lockedInView && } {/* A product name, not a word: translated it is a different product. Read from the session rather than written here, so a deployment that set APP_NAME is called what it calls itself -- the document @@ -217,7 +220,7 @@ export function AppShell({ children }: { children: ReactNode }) { - {delegated.length > 0 && ( + {delegated.length + sharedMail.length > 0 && ( <>
{t("Mail to show")}
switchTo(account.id)} /> ))} + {sharedMail.map((account) => ( + : } + label={{account.name}} + active={viewing === account.id} + onClick={() => switchTo(account.id)} + /> + ))} )} diff --git a/web/src/views/DelegatedBar.tsx b/web/src/views/DelegatedBar.tsx index 13479ac..2083926 100644 --- a/web/src/views/DelegatedBar.tsx +++ b/web/src/views/DelegatedBar.tsx @@ -1,6 +1,6 @@ -import { Lock } from "lucide-react"; +import { Lock, Users } from "lucide-react"; import { useLocation } from "wouter"; -import { useSession, useViewingDelegation } from "@/store/session"; +import { useSession, useViewingDelegation, useViewingShared } from "@/store/session"; import { t } from "@/lib/i18n"; import type { DelegationAccess } from "@/lib/delegation"; @@ -25,7 +25,26 @@ export function DelegatedBar() { const viewing = useSession((s) => s.viewing); const name = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined)); const delegation = useViewingDelegation(); + const shared = useViewingShared(); const [, navigate] = useLocation(); + const back = () => { + useSession.getState().view(null); + navigate("/mail"); + }; + // MA-A: a shared or group mailbox in view says whose it is, with the same way back + if (viewing && shared) { + return ( +
+ + + {t("Shared mailbox:")} {shared.name} + + +
+ ); + } if (!viewing || !delegation) return null; return (
@@ -36,13 +55,7 @@ export function DelegatedBar() { {accessText(delegation.access)} {delegation.sendAs ? ` · ${t("You can send as this account")}` : ""} -
-- 2.54.0