Merge pull request 'Open a group's mailbox, or folders someone shared, from the account menu' (#47) from feat/shared-mail into main
This commit was merged in pull request #47.
This commit is contained in:
commit
7f12a7d8ee
7 files changed
+244
-28
No files matched your search
+6
-4
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user