Open a group's mailbox, or folders someone shared, from the account menu
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 41s
github/ci (branch) GitHub Actions

A group's members, and anyone a folder was shared with, could reach that
mail over IMAP but not here: Mail read only the reader's own account.
Calendar, Contacts and Files already list other people's shares; Mail
now does too (multi-account spec, MA-A).

Such an account is listed under "Mail to show" in the account menu,
after any locked account handed to the reader, and opens in place of
the reader's own mail through the same switch AL-7 uses. Only accounts
whose Mailbox/get answers with a mailbox are offered: the server
advertises every capability on any shared account, so a colleague who
shared one calendar would otherwise appear with mail.

While one is in view:

- a bar in the palette's accent (not the locked account's red) names
  it, with "Back to my mail"; the tab title names it without a padlock;
- calendars, contacts and files stay the reader's own (viewAccountFor
  follows only a delegation now);
- writing a message uses the viewed account's identities, so a reply in
  support@ goes out as support@ and is saved in its Drafts and Sent.

Losing the account (removed from the group, share withdrawn) takes the
reader back to their own mail with the existing "You no longer have
access" notice.

New string: "Shared mailbox:" (1, English only in the other ten
catalogs). Checked in Chrome against a scratch server with a support@
group and two members. typecheck and vitest (174 files, 1533 tests)
pass.
This commit is contained in:
jcoffey-dev committed 2026-10-05 14:49:28 -07:00
1 parent e94508ea89
commit 722a68432c
7 files changed
+244 -28

No files matched your search

+6 -4
View File
@@ -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.
+48
View File
@@ -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<JmapSession, "accounts">;
/** 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<SharedMailAccount[]> {
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(
(res) => (res.list.length > 0 ? account : null),
() => null,
),
),
);
return answers.filter((a): a is SharedMailAccount => a !== null);
}
+114
View File
@@ -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<string, unknown> = {}) =>
({
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: {
own: {
name: "[email protected]",
isPersonal: true,
accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {}, [CAP.contacts]: {}, [CAP.filenode]: {} },
},
// A group: every capability, and mailboxes
group: {
name: "[email protected]",
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: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.mail]: {}, [CAP.calendars]: {} } },
// No mail at all
filesOnly: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.filenode]: {} } },
// A locked account handed over: listed by delegation.ts, never here
locked: {
name: "[email protected]",
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, unknown>, 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(["[email protected]"]);
});
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<string, unknown>).group;
nextSession = session;
withMailboxes = new Set();
await useSession.getState().refresh();
expect(useSession.getState().viewing).toBeNull();
expect(useSession.getState().delegationEnded).toBe("[email protected]");
});
});
+37 -12
View File
@@ -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<void>;
refresh(): Promise<void>;
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<void>;
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<SessionState>((set, get) => ({
session: null,
accountId: null,
viewing: null,
sharedMail: [],
delegationEnded: null,
error: null,
pushConnected: false,
@@ -120,7 +127,7 @@ export const useSession = create<SessionState>((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<SessionState>((set, get) => ({
const s = await apiFetch<JmapSession>("/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<SessionState>((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<SessionState>((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<SessionState>) => 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);
+2
View File
@@ -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; }
+15 -3
View File
@@ -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 }) {
<MenuIcon size={22} />
</button>
)}
<Link href="/mail" className={`brand ${viewing ? "locked" : ""}`}>
<Link href="/mail" className={`brand ${lockedInView ? "locked" : ""}`}>
<img src={brandImage(appName === DEFAULT_APP_NAME ? "/img/inbuxa-mark.png" : "/img/logo.png")} alt="" />
{viewing && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
{lockedInView && <Lock size={18} className="brand-lock" aria-label={t("Locked account")} />}
{/* 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 }) {
</div>
</div>
<MenuSep />
{delegated.length > 0 && (
{delegated.length + sharedMail.length > 0 && (
<>
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
<MenuItem
@@ -235,6 +238,15 @@ export function AppShell({ children }: { children: ReactNode }) {
onClick={() => switchTo(account.id)}
/>
))}
{sharedMail.map((account) => (
<MenuItem
key={account.id}
icon={viewing === account.id ? <Check size={16} /> : <Users size={16} />}
label={<span className="notranslate" translate="no">{account.name}</span>}
active={viewing === account.id}
onClick={() => switchTo(account.id)}
/>
))}
<MenuSep />
</>
)}
+22 -9
View File
@@ -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 (
<div className="delegated-bar shared" role="status">
<Users size={15} aria-hidden />
<span className="grow truncate">
{t("Shared mailbox:")} <strong className="notranslate" translate="no">{shared.name}</strong>
</span>
<button type="button" onClick={back}>
{t("Back to my mail")}
</button>
</div>
);
}
if (!viewing || !delegation) return null;
return (
<div className="delegated-bar" role="status">
@@ -36,13 +55,7 @@ export function DelegatedBar() {
{accessText(delegation.access)}
{delegation.sendAs ? ` · ${t("You can send as this account")}` : ""}
</span>
<button
type="button"
onClick={() => {
useSession.getState().view(null);
navigate("/mail");
}}
>
<button type="button" onClick={back}>
{t("Back to my mail")}
</button>
</div>