Show an assigned shared mailbox as shared, not as a locked account
ci / version (pull_request) Skipped
ci / github (pull_request) Skipped
ci / node (pull_request) Successful in 1m29s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 36s
github/ci (branch) GitHub Actions

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.
This commit is contained in:
jcoffey-dev committed 2026-10-05 15:15:57 -07:00
1 parent 7f12a7d8ee
commit c66a8aadd7
7 files changed
+51 -14

No files matched your search

+1 -1
View File
@@ -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);
+9 -2
View File
@@ -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));
+10 -5
View File
@@ -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<JmapSession, "accounts">;
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<Share
const candidates = sharedMailCandidates(session);
const answers = await Promise.all(
candidates.map((account) =>
client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then(
delegationOf(session, account.id)?.kind === "sharedMailbox"
? Promise.resolve(account)
: client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId: account.id, ids: null, properties: ["id"] }).then(
(res) => (res.list.length > 0 ? account : null),
() => null,
),
@@ -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"]);
+25 -3
View File
@@ -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<string, unknown> = {}) =>
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: "[email protected]",
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(["[email protected]"]);
// The shared mailbox answers no Mailbox/get here, and is offered anyway
expect((await findSharedMail(sessionWith())).map((a) => a.name)).toEqual(["[email protected]", "[email protected]"]);
});
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("[email protected]");
});
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");
});
});
+2 -2
View File
@@ -188,8 +188,8 @@ export const useSession = create<SessionState>((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);
},
}));
+3
View File
@@ -38,6 +38,9 @@ export function DelegatedBar() {
<Users size={15} aria-hidden />
<span className="grow truncate">
{t("Shared mailbox:")} <strong className="notranslate" translate="no">{shared.name}</strong>
{/* MA-S: one an administrator assigned says at what level */}
{delegation ? ` · ${accessText(delegation.access)}` : ""}
{delegation?.sendAs ? ` · ${t("You can send as this account")}` : ""}
</span>
<button type="button" onClick={back}>
{t("Back to my mail")}