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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user