Switching to an account somebody shared pointed the whole app at it. The
rule was "use the selected account if it can do this", and a shared file
account can, by definition, do files.
ihasmail keeps its settings in the account's Files -- that is what makes
them follow you between devices -- so changing any setting while looking
at somebody's shared folder wrote `settings.json` into *their* storage,
creating the `ihasmail` folder there to do it. Signature images went the
same way, and push registration would have gone to whichever account was
on screen. Reading someone else's data by mistake is bad; writing yours
into theirs is worse, and one line was doing both.
There are two questions, and they had one answer:
- what am I looking at -- follows the switcher, because switching to a
shared account is how you read what was shared
- what is mine -- never does
So `accountFor` keeps the first meaning and `ownAccountFor` is the
second, used by settings sync, signature images and push. A `??
accountId` fallback in `loadStoredSignature` went with it: the reader's
own signature, reached through whoever happened to be selected.
A third rule was hiding in the first. A capability the selected account
does not advertise fell back to the selected account anyway, so a session
naming no primary for something aimed it at whoever was selected --
somebody else. It now answers with nothing, which is honest: the feature
is unavailable, rather than pointed at a stranger.
What this does not settle is whether the mail, calendar and contacts the
switcher appeared to offer were ever really reachable, or only asked for
and refused. That depends on what Stalwart advertises on a shared
account, which needs a look at a sharee's session; if it advertises
capabilities nobody shared, more is needed here than routing.
79 lines
3.6 KiB
TypeScript
79 lines
3.6 KiB
TypeScript
/**
|
|
* Which account a request goes to.
|
|
*
|
|
* A JMAP session lists more than one account whenever anything is shared with
|
|
* you: the sharer's account appears alongside your own, carrying whichever
|
|
* capabilities they shared. Switching to one is how you read their files, so
|
|
* some requests have to follow that selection.
|
|
*
|
|
* Others must never follow it, and telling the two apart is the whole point of
|
|
* this file. ihasmail keeps its own settings in the account's Files — that is
|
|
* what makes them travel between devices — and a shared file account advertises
|
|
* the file capability by definition. So the obvious rule, "use whichever
|
|
* account is selected if it can do this", writes your settings into the other
|
|
* person's storage the moment you change one while looking at their folder. It
|
|
* would create the `ihasmail` folder there to do it.
|
|
*
|
|
* Two questions, then, and they have different answers:
|
|
*
|
|
* - what am I *looking at* -> `accountForCapability`, follows the selection
|
|
* - what is *mine* -> `ownAccountForCapability`, never does
|
|
*
|
|
* There is a third rule hiding in the first. A capability the selected account
|
|
* does not advertise used to fall back to that account anyway, so a session
|
|
* with no primary account for something would aim it at whoever was selected —
|
|
* someone else. Falling back to nothing is the honest answer: the feature is
|
|
* unavailable, which is true, rather than pointed at a stranger's data.
|
|
*/
|
|
import type { Id } from "@/jmap/types";
|
|
|
|
export interface AccountLike {
|
|
/** JMAP: true when the account belongs to the authenticated user. */
|
|
isPersonal: boolean;
|
|
accountCapabilities?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface SessionLike {
|
|
accounts: Record<Id, AccountLike>;
|
|
primaryAccounts: Record<string, Id>;
|
|
}
|
|
|
|
const advertises = (account: AccountLike | undefined, cap: string): boolean =>
|
|
Boolean(account && cap in (account.accountCapabilities ?? {}));
|
|
|
|
/**
|
|
* The account to read and write for this capability, honouring the switcher.
|
|
*
|
|
* Use for anything the reader is looking at: their mail, a shared calendar,
|
|
* somebody's files. Not for anything of the reader's own — see below.
|
|
*/
|
|
export function accountForCapability(session: SessionLike | null, selectedId: Id | null, cap: string): Id | null {
|
|
if (!session) return null;
|
|
const selected = selectedId ? session.accounts[selectedId] : undefined;
|
|
if (selected && advertises(selected, cap)) return selectedId;
|
|
const primary = session.primaryAccounts[cap];
|
|
if (primary) return primary;
|
|
// No primary, and the selection cannot serve this. Falling back to the
|
|
// selection would aim the request at a shared account for something nobody
|
|
// shared; only one of the reader's own accounts may stand in.
|
|
if (selected && selected.isPersonal) return selectedId;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The reader's own account for this capability, whatever they are looking at.
|
|
*
|
|
* Use for the reader's own state -- synced settings, signature images, push
|
|
* registration. These belong to them and follow them, and must not land in an
|
|
* account somebody shared just because it happens to be on screen.
|
|
*/
|
|
export function ownAccountForCapability(session: SessionLike | null, cap: string): Id | null {
|
|
if (!session) return null;
|
|
const primary = session.primaryAccounts[cap];
|
|
// A primary account is the reader's own by definition, but check rather than
|
|
// assume: a server that named a shared one here would otherwise be trusted.
|
|
if (primary && session.accounts[primary]?.isPersonal !== false) return primary;
|
|
const own = Object.entries(session.accounts).find(([, a]) => a.isPersonal && advertises(a, cap));
|
|
return own?.[0] ?? null;
|
|
}
|