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 // 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 // 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 viewingName = useSession((s) => (s.viewing ? s.session?.accounts[s.viewing]?.name : undefined));
const lockedInView = useViewingDelegation() !== null; const lockedInView = useViewingDelegation()?.kind === "lock";
useEffect(() => { useEffect(() => {
setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName); setBaseTitle(viewingName ? `${lockedInView ? "🔒 " : ""}${viewingName} · ${appName}` : appName);
setUnreadBadge(inboxUnread); setUnreadBadge(inboxUnread);
+9 -2
View File
@@ -13,6 +13,12 @@ export type DelegationAccess = "read" | "organize" | "full";
export interface Delegation { export interface Delegation {
locked: boolean; 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; access: DelegationAccess;
sendAs: boolean; sendAs: boolean;
/** UTC date the delegation ends, if it does. */ /** 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"; const access: DelegationAccess = raw.access === "organize" || raw.access === "full" ? raw.access : "read";
return { return {
locked: true, locked: true,
kind: raw.kind === "sharedMailbox" ? "sharedMailbox" : "lock",
access, access,
sendAs: raw.sendAs === true && access !== "read", sendAs: raw.sendAs === true && access !== "read",
until: typeof raw.until === "string" ? raw.until : null, 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[] { export function delegatedAccounts(session: SessionLike | null): DelegatedAccount[] {
if (!session) return []; if (!session) return [];
return Object.entries(session.accounts) return Object.entries(session.accounts)
.map(([id, account]) => { .map(([id, account]) => {
const delegation = delegationOf(session, id); 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) .filter((a): a is DelegatedAccount => a !== null)
.sort((a, b) => a.name.localeCompare(b.name)); .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 * 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`. * Either one arrives as another account in the session, `isPersonal: false`.
* That alone proves nothing about mail -- the server advertises every * 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 * `Mailbox/get` answers with at least one mailbox has mail the reader can
* open, and only those are offered. * open, and only those are offered.
* *
* A locked account handed to the reader (AL-7) is listed by `delegation.ts` * A shared mailbox needs no asking: the server marks it, as a delegation of
* instead, and left out here so it is never offered twice. * 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"; import { CAP, client } from "@/jmap/client";
@@ -28,7 +31,7 @@ type SessionLike = Pick<JmapSession, "accounts">;
export function sharedMailCandidates(session: SessionLike | null): SharedMailAccount[] { export function sharedMailCandidates(session: SessionLike | null): SharedMailAccount[] {
if (!session) return []; if (!session) return [];
return Object.entries(session.accounts) 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 })) .map(([id, account]) => ({ id, name: account.name }))
.sort((a, b) => a.name.localeCompare(b.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 candidates = sharedMailCandidates(session);
const answers = await Promise.all( const answers = await Promise.all(
candidates.map((account) => 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), (res) => (res.list.length > 0 ? account : null),
() => null, () => null,
), ),
@@ -70,7 +70,7 @@ afterEach(() => {
describe("delegation", () => { describe("delegation", () => {
it("is read only from the session's mark, never from a shared account's capabilities", () => { it("is read only from the session's mark, never from a shared account's capabilities", () => {
const session = sessionWith(delegated("organize", true)); 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, "shared")).toBeNull();
expect(delegationOf(session, "own")).toBeNull(); expect(delegationOf(session, "own")).toBeNull();
expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]); 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client"; import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types"; import type { JmapSession } from "@/jmap/types";
import { useSession } from "@/store/session"; import { useSession, viewingDelegation } from "@/store/session";
import { findSharedMail, sharedMailCandidates } from "@/lib/sharedMail"; 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 * 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, isPersonal: false,
accountCapabilities: { [CAP.mail]: {}, "urn:inbuxa:jmap": { delegation: { locked: true, access: "read", sendAs: false, until: null } } }, 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, ...extra,
}, },
primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" }, primaryAccounts: { [CAP.mail]: "own", [CAP.calendars]: "own", [CAP.contacts]: "own", [CAP.filenode]: "own" },
@@ -76,11 +87,12 @@ afterEach(() => {
describe("shared mail", () => { describe("shared mail", () => {
it("considers only other people's accounts that advertise mail, leaving locked accounts to delegation", () => { 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 () => { 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 () => { 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().viewing).toBeNull();
expect(useSession.getState().delegationEnded).toBe("[email protected]"); 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) { viewAccountFor(cap) {
const { session, viewing } = get(); const { session, viewing } = get();
const viewed = viewing ? session?.accounts[viewing] : undefined; const viewed = viewing ? session?.accounts[viewing] : undefined;
// A shared or group mailbox in view is mail only (MA-A) // A shared or group mailbox in view is mail only (MA-A, MA-S)
if (viewing && viewed && delegationOf(session, viewing) && cap in (viewed.accountCapabilities ?? {})) return viewing; if (viewing && viewed && delegationOf(session, viewing)?.kind === "lock" && cap in (viewed.accountCapabilities ?? {})) return viewing;
return ownAccountForCapability(session, cap); return ownAccountForCapability(session, cap);
}, },
})); }));
+3
View File
@@ -38,6 +38,9 @@ export function DelegatedBar() {
<Users size={15} aria-hidden /> <Users size={15} aria-hidden />
<span className="grow truncate"> <span className="grow truncate">
{t("Shared mailbox:")} <strong className="notranslate" translate="no">{shared.name}</strong> {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> </span>
<button type="button" onClick={back}> <button type="button" onClick={back}>
{t("Back to my mail")} {t("Back to my mail")}