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