Merge pull request 'Open a group's mailbox, or folders someone shared, from the account menu' (#47) from feat/shared-mail into main
ci / version (push) Skipped
ci / github (push) Skipped
github/ci (branch) GitHub Actions
ci / node (push) Successful in 3m1s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 16s

This commit was merged in pull request #47.
This commit is contained in:
jcoffey-dev committed 2026-10-05 21:52:10 +00:00
commit 7f12a7d8ee
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>