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")}` : ""} -