From c66a8aadd79a05d3a7cca1b98442a80b2445ef5a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 15:15:57 -0700 Subject: [PATCH] Show an assigned shared mailbox as shared, not as a locked account The server now marks a shared mailbox (support@, legal@) as a delegation of kind "sharedMailbox" (multi-account spec, MA-S). Without this, the webmail would show one exactly as it shows a locked account: a red bar, a padlock in the tab and on the brand, and its calendars and files in place of the reader's own. Now such a mailbox is listed with the other shared mailboxes under "Mail to show", opens with the shared bar, which also gives the person's access level and whether they can send as it, and changes mail only. Its access level still applies exactly as a lock's does: read changes nothing, organize never deletes, no sending without send-as. Found without asking Mailbox/get, since the server's mark says it is mail. A server that sends no kind is treated as before: every delegation is a lock. No new strings. typecheck and vitest (174 files, 1534 tests) pass. --- web/src/App.tsx | 2 +- web/src/lib/delegation.ts | 11 ++++++-- web/src/lib/sharedMail.ts | 15 ++++++---- .../__tests__/delegated-accounts.test.ts | 2 +- web/src/store/__tests__/shared-mail.test.ts | 28 +++++++++++++++++-- web/src/store/session.ts | 4 +-- web/src/views/DelegatedBar.tsx | 3 ++ 7 files changed, 51 insertions(+), 14 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 0d7c52e..fd8aab1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -314,7 +314,7 @@ function AuthedApp() { // 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; + const lockedInView = useViewingDelegation()?.kind === "lock"; useEffect(() => { setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName); setUnreadBadge(inboxUnread); diff --git a/web/src/lib/delegation.ts b/web/src/lib/delegation.ts index a7ca3f4..65d46c7 100644 --- a/web/src/lib/delegation.ts +++ b/web/src/lib/delegation.ts @@ -13,6 +13,12 @@ export type DelegationAccess = "read" | "organize" | "full"; export interface Delegation { locked: boolean; + /** + * A locked account, or a shared mailbox such as support@ (MA-S). Both are + * reached as a delegate at an access level; only how they are shown + * differs. Absent from older servers, where it is always a lock. + */ + kind: "lock" | "sharedMailbox"; access: DelegationAccess; sendAs: boolean; /** UTC date the delegation ends, if it does. */ @@ -38,19 +44,20 @@ export function delegationOf(session: SessionLike | null, accountId: Id | null): const access: DelegationAccess = raw.access === "organize" || raw.access === "full" ? raw.access : "read"; return { locked: true, + kind: raw.kind === "sharedMailbox" ? "sharedMailbox" : "lock", access, sendAs: raw.sendAs === true && access !== "read", until: typeof raw.until === "string" ? raw.until : null, }; } -/** Every locked account handed to the reader, by name. */ +/** Every locked account handed to the reader, by name. Shared mailboxes are listed by lib/sharedMail. */ export function delegatedAccounts(session: SessionLike | null): DelegatedAccount[] { if (!session) return []; return Object.entries(session.accounts) .map(([id, account]) => { const delegation = delegationOf(session, id); - return delegation ? { id, name: account.name, delegation } : null; + return delegation?.kind === "lock" ? { id, name: account.name, delegation } : null; }) .filter((a): a is DelegatedAccount => a !== null) .sort((a, b) => a.name.localeCompare(b.name)); diff --git a/web/src/lib/sharedMail.ts b/web/src/lib/sharedMail.ts index 68568d4..22433af 100644 --- a/web/src/lib/sharedMail.ts +++ b/web/src/lib/sharedMail.ts @@ -1,6 +1,7 @@ /** * Mail other people let the reader into (multi-account spec, MA-A): a group's - * mailbox, or folders someone shared. + * mailbox, folders someone shared, or a shared mailbox an administrator + * assigned them to (MA-S). * * Either one arrives as another account in the session, `isPersonal: false`. * That alone proves nothing about mail -- the server advertises every @@ -9,8 +10,10 @@ * `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. + * A shared mailbox needs no asking: the server marks it, as a delegation of + * kind `sharedMailbox`, and it is mail by definition. 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"; @@ -28,7 +31,7 @@ type SessionLike = Pick; 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)) + .filter(([id, account]) => account.isPersonal === false && CAP.mail in (account.accountCapabilities ?? {}) && delegationOf(session, id)?.kind !== "lock") .map(([id, account]) => ({ id, name: account.name })) .sort((a, b) => a.name.localeCompare(b.name)); } @@ -38,7 +41,9 @@ export async function findSharedMail(session: SessionLike | null): Promise - client.call>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then( + delegationOf(session, account.id)?.kind === "sharedMailbox" + ? Promise.resolve(account) + : client.call>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then( (res) => (res.list.length > 0 ? account : null), () => null, ), diff --git a/web/src/store/__tests__/delegated-accounts.test.ts b/web/src/store/__tests__/delegated-accounts.test.ts index cb2d97e..5bcb8d7 100644 --- a/web/src/store/__tests__/delegated-accounts.test.ts +++ b/web/src/store/__tests__/delegated-accounts.test.ts @@ -70,7 +70,7 @@ afterEach(() => { describe("delegation", () => { it("is read only from the session's mark, never from a shared account's capabilities", () => { const session = sessionWith(delegated("organize", true)); - expect(delegationOf(session, "locked")).toEqual({ locked: true, access: "organize", sendAs: true, until: null }); + expect(delegationOf(session, "locked")).toEqual({ locked: true, kind: "lock", access: "organize", sendAs: true, until: null }); expect(delegationOf(session, "shared")).toBeNull(); expect(delegationOf(session, "own")).toBeNull(); expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]); diff --git a/web/src/store/__tests__/shared-mail.test.ts b/web/src/store/__tests__/shared-mail.test.ts index 3d29f65..6e52298 100644 --- a/web/src/store/__tests__/shared-mail.test.ts +++ b/web/src/store/__tests__/shared-mail.test.ts @@ -1,8 +1,9 @@ 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 { useSession, viewingDelegation } from "@/store/session"; import { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail"; +import { delegatedAccounts } from "@/lib/delegation"; /** * MA-A: a group's mailbox, or folders someone shared, can be opened in place @@ -36,6 +37,16 @@ const sessionWith = (extra: Record = {}) => isPersonal: false, accountCapabilities: { [CAP.mail]: {}, "urn:inbuxa:jmap": { delegation: { locked: true, access: "read", sendAs: false, until: null } } }, }, + // A shared mailbox an administrator assigned (MA-S): marked, so never asked about + desk: { + name: "desk@example.com", + isPersonal: false, + accountCapabilities: { + [CAP.mail]: {}, + [CAP.calendars]: {}, + "urn:inbuxa:jmap": { delegation: { locked: true, kind: "sharedMailbox", access: "organize", sendAs: true, until: null } }, + }, + }, ...extra, }, primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" }, @@ -76,11 +87,12 @@ afterEach(() => { 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"]); + expect(sharedMailCandidates(sessionWith()).map((a) => a.id)).toEqual(["calendarOnly", "desk", "group"]); }); it("offers only accounts that answer with a mailbox", async () => { - expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["support@example.com"]); + // The shared mailbox answers no Mailbox/get here, and is offered anyway + expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["desk@example.com", "support@example.com"]); }); it("can't be opened until it is found, and then shows mail only", async () => { @@ -111,4 +123,14 @@ describe("shared mail", () => { expect(useSession.getState().viewing).toBeNull(); expect(useSession.getState().delegationEnded).toBe("support@example.com"); }); + + it("shows an assigned shared mailbox as shared, not locked, keeping its access level", async () => { + await useSession.getState().loadSharedMail(); + expect(delegatedAccounts(useSession.getState().session).map((a) => a.id)).toEqual(["locked"]); + useSession.getState().view("desk"); + expect(useSession.getState().viewing).toBe("desk"); + expect(viewingDelegation()?.access).toBe("organize"); + // Mail only, like any shared mailbox + expect(useSession.getState().viewAccountFor(CAP.calendars)).toBe("own"); + }); }); diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 5be4219..1a9582f 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -188,8 +188,8 @@ export const useSession = create((set, get) => ({ viewAccountFor(cap) { const { session, viewing } = get(); const viewed = viewing ? session?.accounts[viewing] : undefined; - // A shared or group mailbox in view is mail only (MA-A) - if (viewing && viewed && delegationOf(session, viewing) && cap in (viewed.accountCapabilities ?? {})) return viewing; + // A shared or group mailbox in view is mail only (MA-A, MA-S) + if (viewing && viewed && delegationOf(session, viewing)?.kind === "lock" && cap in (viewed.accountCapabilities ?? {})) return viewing; return ownAccountForCapability(session, cap); }, })); diff --git a/web/src/views/DelegatedBar.tsx b/web/src/views/DelegatedBar.tsx index 2083926..1e685fd 100644 --- a/web/src/views/DelegatedBar.tsx +++ b/web/src/views/DelegatedBar.tsx @@ -38,6 +38,9 @@ export function DelegatedBar() { {t("Shared mailbox:")} {shared.name} + {/* MA-S: one an administrator assigned says at what level */} + {delegation ? ` · ${accessText(delegation.access)}` : ""} + {delegation?.sendAs ? ` · ${t("You can send as this account")}` : ""}