Open locked accounts handed to you, beside your own mail

When the server hands a locked account to the reader (urn:inbuxa:jmap
delegation), the account popover offers it. Only mail follows the switch;
the reader's own settings, push and notifications stay theirs. A red bar,
a red wordmark with a padlock and the tab title say which account is in
view. Read delegates can't change anything, organize delegates can't
delete, and writing needs send-as. A delegation taken away drops back to
the reader's own mail.

14 new strings in all nine catalogs, unreviewed (inbuxa AL-7, AL-8).
This commit is contained in:
jcoffey-dev committed 2026-09-27 14:32:10 -07:00
1 parent ffe1a898f5
commit 8674b70f62
22 files changed
+679 -25

No files matched your search

@@ -0,0 +1,127 @@
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 { useMail } from "@/store/mail";
import { composeBlocked, useCompose } from "@/store/compose";
import { delegatedAccounts, delegationOf, mayDestroy, mayWrite } from "@/lib/delegation";
/**
* inbuxa AL-7: a locked account handed to the reader shows in place of their
* own mail, and only mail follows it; the reader never loses their own
* account, and a delegation taken away takes them back.
*/
const delegated = (access: string, sendAs = false) => ({
name: "[email protected]",
isPersonal: false,
isReadOnly: access === "read",
accountCapabilities: {
[CAP.mail]: {},
"urn:inbuxa:jmap": { delegation: { locked: true, access, sendAs, until: null } },
},
});
const sessionWith = (locked: Record<string, unknown> | null) =>
({
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.mail]: {} },
accounts: {
own: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.mail]: {} } },
shared: { name: "[email protected]", isPersonal: false, accountCapabilities: { [CAP.mail]: {} } },
...(locked ? { locked } : {}),
},
primaryAccounts: { [CAP.mail]: "own" },
state: "s",
}) as unknown as JmapSession;
let nextSession: JmapSession;
beforeEach(() => {
nextSession = sessionWith(delegated("read"));
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: [], notFound: [] }, id]);
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}),
);
const session = sessionWith(delegated("read"));
client.session = session;
useSession.setState({ status: "authenticated", session, accountId: "own", viewing: null, delegationEnded: null });
useCompose.setState({ drafts: [] });
});
afterEach(() => {
useSession.setState({ viewing: null });
vi.unstubAllGlobals();
});
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, "shared")).toBeNull();
expect(delegationOf(session, "own")).toBeNull();
expect(delegatedAccounts(session).map((a) => a.id)).toEqual(["locked"]);
});
it("never lets a read delegate send, whatever the server says", () => {
const session = sessionWith(delegated("read", true));
expect(delegationOf(session, "locked")?.sendAs).toBe(false);
});
it("says what each level may do", () => {
const read = delegationOf(sessionWith(delegated("read")), "locked");
const organize = delegationOf(sessionWith(delegated("organize")), "locked");
expect(mayWrite(read)).toBe(false);
expect(mayWrite(organize)).toBe(true);
expect(mayDestroy(organize)).toBe(false);
expect(mayWrite(null) && mayDestroy(null)).toBe(true);
});
});
describe("viewing a locked account", () => {
it("moves only the mail store; the session's own account stays", () => {
useSession.getState().view("locked");
expect(useMail.getState().accountId).toBe("locked");
expect(useSession.getState().accountId).toBe("own");
expect(useSession.getState().ownAccountFor(CAP.mail)).toBe("own");
useSession.getState().view(null);
expect(useMail.getState().accountId).toBe("own");
});
it("refuses an account that isn't delegated", () => {
useSession.getState().view("shared");
expect(useSession.getState().viewing).toBeNull();
expect(useMail.getState().accountId).toBe("own");
});
it("goes back to the reader's own mail when the delegation ends", async () => {
useSession.getState().view("locked");
nextSession = sessionWith(null);
await useSession.getState().refresh();
expect(useSession.getState().viewing).toBeNull();
expect(useSession.getState().delegationEnded).toBe("[email protected]");
expect(useMail.getState().accountId).toBe("own");
});
it("blocks writing mail where the delegate can't send", () => {
expect(composeBlocked()).toBeNull();
useSession.getState().view("locked");
expect(composeBlocked()).toMatch(/[email protected]/);
expect(useCompose.getState().open()).toBe("");
expect(useCompose.getState().drafts).toHaveLength(0);
});
it("lets a send-as delegate write", () => {
const session = sessionWith(delegated("full", true));
client.session = session;
useSession.setState({ session });
useSession.getState().view("locked");
expect(composeBlocked()).toBeNull();
});
});
+27
View File
@@ -16,6 +16,7 @@ import { settings } from "./settings";
import { emlFilename } from "@/lib/text/emlName";
import { fillPlaceholders, type PlaceholderContext } from "@/lib/templatePlaceholders";
import { shareBody, type SharedContent } from "@/lib/shareTarget";
import { delegationOf } from "@/lib/delegation";
export interface ComposeAttachment {
id: string;
@@ -170,12 +171,33 @@ function defaultIdentity(identities: Identity[], email?: Email | null): Identity
return useMail.getState().defaultIdentity() ?? identities[0];
}
/**
* Why nothing can be written from the account in view, if it can't: a locked
* account handed to the reader without the right to send as it (inbuxa
* AL-8). Nothing is then saved to its Drafts either.
*/
export function composeBlocked(): string | null {
const s = useSession.getState();
if (!s.viewing) return null;
const delegation = delegationOf(s.session, s.viewing);
if (!delegation || delegation.sendAs) return null;
const name = s.session?.accounts[s.viewing]?.name ?? "";
return translate("You can't send from {name}. It was handed to you to read, not to send as.", { name });
}
function refuseCompose(): boolean {
const why = composeBlocked();
if (why) toast.show(why);
return why !== null;
}
export const useCompose = create<ComposeState>((set, get) => ({
drafts: [],
activeKey: null,
pendingSends: {},
open(init = {}) {
if (refuseCompose()) return "";
const identities = useMail.getState().identities;
const ident = init.identityId ? identities.find((i) => i.id === init.identityId) : useMail.getState().defaultIdentity();
const d = blankDraft({ identityId: ident?.id ?? null, replyTo: ident?.replyTo ?? [], showReplyTo: Boolean(ident?.replyTo?.length), ...init });
@@ -201,6 +223,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
* signature from every message that started as a share.
*/
openFromShare(share) {
if (refuseCompose()) return "";
const body = shareBody(share);
const key = get().open({ subject: share.title.trim() });
if (body) {
@@ -212,6 +235,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
},
async openDraftEmail(email) {
if (refuseCompose()) return "";
const existing = get().drafts.find((d) => d.draftId === email.id);
if (existing) {
get().focus(existing.key);
@@ -273,6 +297,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
* both are new without anything here asking for it.
*/
async composeAsNew(email) {
if (refuseCompose()) return "";
const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
@@ -324,6 +349,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
},
async reply(email, mode) {
if (refuseCompose()) return "";
const mail = useMail.getState();
const full = (await mail.getEmails([email.id], true))[0] ?? email;
const identities = mail.identities.length ? mail.identities : await mail.loadIdentities();
@@ -440,6 +466,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
},
forwardAsAttachment(email) {
if (refuseCompose()) return "";
const accountId = useMail.getState().accountId;
const key = get().open({
subject: replySubject(email.subject, "Fwd"),
+61 -3
View File
@@ -104,6 +104,15 @@ export const useMail = create<MailState>((set, get) => ({
setAccount(accountId) {
if (accountId === get().accountId) return;
// inbuxa AL-7: leaving the reader's own mail for a delegated account,
// remember where theirs was, so new mail there is still announced
const own = useSession.getState().accountId;
const leaving = get().accountId;
if (leaving && leaving === own && accountId && accountId !== own) {
ownWhileAway = { accountId: own, inbox: get().roleId("inbox"), state: get().emailState };
} else if (accountId === own) {
ownWhileAway = null;
}
resetBodyOrder();
snapshots.clear();
set({
@@ -1485,10 +1494,56 @@ function removeFromList(ids: Id[], set: (fn: (s: MailState) => Partial<MailState
}
async function notifyNewMail(created: Id[], get: () => MailState) {
const s = settings();
const inbox = get().roleId("inbox");
if (!inbox) return;
const emails = await get().getEmails(created);
announceNewMail(emails, inbox);
}
/**
* The reader's own account while a delegated one is in view (inbuxa AL-7):
* its inbox and how far its mail was seen, so what arrives there meanwhile
* is still announced.
*/
let ownWhileAway: { accountId: Id; inbox: Id | null; state: string | null } | null = null;
/** New mail in the reader's own account, while a delegated one is in view. */
export async function notifyOwnWhileAway(): Promise<void> {
const away = ownWhileAway;
if (!away?.inbox || !away.state) return;
try {
const changes = await client.call<ChangesResponse>("Email/changes", {
accountId: away.accountId,
sinceState: away.state,
maxChanges: 50,
});
if (ownWhileAway !== away) return;
away.state = changes.newState;
if (!changes.created.length) return;
const got = await client.call<GetResponse<Email>>("Email/get", {
accountId: away.accountId,
ids: changes.created,
properties: LIST_PROPS,
});
announceNewMail(got.list, away.inbox);
} catch {
/* the next change tries again */
}
}
export function ownAccountAway(): Id | null {
return ownWhileAway?.accountId ?? null;
}
/** The reader's own inbox, whatever account is in view (inbuxa AL-7). */
export function ownInboxId(): Id | null {
const mail = useMail.getState();
if (mail.accountId === useSession.getState().accountId) return mail.roleId("inbox");
return ownWhileAway?.inbox ?? null;
}
function announceNewMail(emails: Email[], inbox: Id) {
const s = settings();
const fresh = emails.filter((e) => e.mailboxIds[inbox] && !e.keywords.$seen && !e.keywords.$draft);
if (!fresh.length) return;
if (s.notificationSound) playNewMailSound();
@@ -1514,9 +1569,12 @@ async function notifyNewMail(created: Id[], get: () => MailState) {
}
}
/** Keep the store bound to the selected account. */
/**
* Keep the store bound to the account in view: a delegated account while one
* is open (inbuxa AL-7), the reader's own otherwise.
*/
useSession.subscribe((s) => {
useMail.getState().setAccount(s.status === "authenticated" ? s.accountId : null);
useMail.getState().setAccount(s.status === "authenticated" ? (s.viewing ?? s.accountId) : null);
});
+47 -4
View File
@@ -9,6 +9,7 @@ import { reloadIfServerRebuilt } from "@/lib/sw/staleBuild";
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";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -17,6 +18,14 @@ interface SessionState {
session: JmapSession | null;
/** Selected mail account (defaults to primary). */
accountId: Id | null;
/**
* A locked account handed to the reader that the mail view shows instead
* of their own (inbuxa AL-7). Only mail follows it: settings, filters,
* push and everything else stay the reader's own.
*/
viewing: Id | null;
/** The name of an account whose delegation ended while it was in view. */
delegationEnded: string | null;
error: string | null;
pushConnected: boolean;
/** Finer than pushConnected: tells "reconnecting" from "not connected". */
@@ -26,6 +35,9 @@ 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. */
view(id: Id | null): void;
clearDelegationEnded(): void;
/** The account to read and write for a capability, honoring the account switcher. */
accountFor(cap: string): Id | null;
/** The user's own account for a capability, whatever they are looking at. */
@@ -38,6 +50,8 @@ export const useSession = create<SessionState>((set, get) => ({
status: "loading",
session: null,
accountId: null,
viewing: null,
delegationEnded: null,
error: null,
pushConnected: false,
pushState: "disconnected",
@@ -99,7 +113,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 });
set({ status: "anonymous", session: null, accountId: null, viewing: null });
},
refresh() {
@@ -109,7 +123,14 @@ export const useSession = create<SessionState>((set, get) => ({
const s = await apiFetch<JmapSession>("/api/auth/session?refresh=1");
client.session = s;
setServerLocale(s.ihasmail?.userLocale);
set({ session: s });
// A delegation that ended takes the reader back to their own mail
const viewing = get().viewing;
if (viewing && !delegationOf(s, viewing)) {
const name = get().session?.accounts[viewing]?.name ?? null;
set({ session: s, viewing: null, delegationEnded: name });
} else {
set({ session: s });
}
} catch {
/* ignore */
} finally {
@@ -123,6 +144,16 @@ export const useSession = create<SessionState>((set, get) => ({
set({ accountId: id });
},
view(id) {
if (id && !delegationOf(get().session, id)) return;
if (id === get().viewing) return;
set({ viewing: id });
},
clearDelegationEnded() {
set({ delegationEnded: null });
},
accountFor(cap) {
return accountForCapability(get().session, get().accountId, cap);
},
@@ -149,7 +180,7 @@ 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, error: null });
set({ status: "authenticated", session: s, accountId, viewing: null, error: null });
}
client.onUnauthenticated(() => {
@@ -162,12 +193,24 @@ 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 });
if (!reloading) useSession.setState({ status: "anonymous", session: null, accountId: null, viewing: null });
});
});
push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state }));
/** The delegation of the locked account in view, if one is (inbuxa AL-6). */
export function useViewingDelegation(): Delegation | null {
const session = useSession((s) => s.session);
const viewing = useSession((s) => s.viewing);
return delegationOf(session, viewing);
}
export function viewingDelegation(): Delegation | null {
const s = useSession.getState();
return delegationOf(s.session, s.viewing);
}
export function hasCap(cap: string): boolean {
return client.hasCapability(cap);
}