mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 08:07:23 +00:00
396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { createPersistStorage } from './persist-storage';
|
|
import { useAccountStore, type AccountEntry } from './account-store';
|
|
import { useAuthStore } from './auth-store';
|
|
import {
|
|
SETTINGS_KEY_MAP,
|
|
isPolicySettingKey,
|
|
setPolicyLockedWriteListener,
|
|
useSettingsStore,
|
|
} from './settings-store';
|
|
import { useToastStore } from './toast-store';
|
|
import { useLocaleStore } from './locale-store';
|
|
import { jmapClient } from '../api/jmap-client';
|
|
import { fetchWithoutForeignRedirect } from '../lib/client-cert';
|
|
import { isLoopbackHttp } from '../lib/oauth';
|
|
import {
|
|
EMPTY_SETTINGS_RULES,
|
|
isFeatureEnabled,
|
|
isLockedSetting,
|
|
resolvePolicy,
|
|
sanitizePolicy,
|
|
settingsRulesFromPolicy,
|
|
webmailLinksFromConfig,
|
|
type FeatureGate,
|
|
type OrgPolicy,
|
|
type PolicySubject,
|
|
type SettingsRules,
|
|
type WebmailLinks,
|
|
} from '../lib/org-policy';
|
|
import { BUILTIN_THEMES } from '../theme/builtin-themes';
|
|
|
|
// The organisation policy of the webmail each account signed in through
|
|
// (webmail stores/policy-store.ts, read with the account's access token:
|
|
// bulwarkmail.org/docs/development/mobile-handoff). The last answer is kept
|
|
// per account, so a start without network still applies it. Accounts
|
|
// signed in to a mail server directly have no webmail and no policy.
|
|
|
|
export const POLICY_FETCH_TIMEOUT_MS = 10_000;
|
|
/** Header the webmail sets on the public subset (lib/admin/types.ts). */
|
|
const POLICY_SCOPE_HEADER = 'x-bulwark-policy-scope';
|
|
|
|
export interface CachedPolicy {
|
|
/** The policy as fetched, scopes included. */
|
|
policy: OrgPolicy;
|
|
/** The webmail answered with the public subset (no token, or a refused one). */
|
|
partial: boolean;
|
|
fetchedAt: number;
|
|
/** The webmail it came from; an account that moved to another drops it. */
|
|
webmailUrl: string;
|
|
/** Only in policies cached before configByAccount; the config now has its own entry. */
|
|
links?: WebmailLinks;
|
|
}
|
|
|
|
/** The last answer of the webmail's /api/config, kept apart from the policy (an older webmail has no policy route). */
|
|
export interface CachedConfig {
|
|
webmailUrl: string;
|
|
links: WebmailLinks;
|
|
}
|
|
|
|
interface PolicyState {
|
|
byAccount: Record<string, CachedPolicy>;
|
|
configByAccount: Record<string, CachedConfig>;
|
|
/** The account `effective` was resolved for. */
|
|
accountId: string | null;
|
|
/** The active account's policy with its scopes applied; null without a webmail. */
|
|
effective: OrgPolicy | null;
|
|
/** What `effective` does to the settings. */
|
|
rules: SettingsRules;
|
|
links: WebmailLinks;
|
|
/**
|
|
* The active account has a webmail whose /api/config hasn't been read
|
|
* yet (nor cached). Its "no third-party traffic" mode counts as on
|
|
* meanwhile, as in the webmail (hooks/use-third-party-mode.ts).
|
|
*/
|
|
configPending: boolean;
|
|
|
|
/** Resolve and apply the cached policy of this account (the active one). */
|
|
activate: (accountId: string | null) => void;
|
|
/** Fetch the account's policy from its webmail, cache it, and apply it when it is the active one. */
|
|
refresh: (accountId: string) => Promise<void>;
|
|
/** Drop the cached policies of accounts no longer in the registry. */
|
|
prune: (accountIds: readonly string[]) => void;
|
|
}
|
|
|
|
const reverseKeyMap = new Map<string, string>(
|
|
Object.entries(SETTINGS_KEY_MAP).map(([local, web]) => [web as string, local]),
|
|
);
|
|
|
|
/** The app's setting key for a webmail setting key, or null when the app doesn't have it. */
|
|
export function settingKeyFor(webmailKey: string): string | null {
|
|
const mapped = reverseKeyMap.get(webmailKey);
|
|
if (mapped) return mapped;
|
|
// An app key the webmail names differently isn't the webmail's key.
|
|
if (Object.prototype.hasOwnProperty.call(SETTINGS_KEY_MAP, webmailKey)) return null;
|
|
return isPolicySettingKey(webmailKey) ? webmailKey : null;
|
|
}
|
|
|
|
const THEME_IDS = BUILTIN_THEMES.map((theme) => theme.id);
|
|
|
|
/** The settings rules of an effective policy, by the app's keys. */
|
|
export function rulesFor(policy: OrgPolicy | null): SettingsRules {
|
|
return policy ? settingsRulesFromPolicy(policy, { keyFor: settingKeyFor, themeIds: THEME_IDS }) : EMPTY_SETTINGS_RULES;
|
|
}
|
|
|
|
function sameClientAccount(account: AccountEntry): boolean {
|
|
return jmapClient.serverUrl === account.serverUrl && jmapClient.username === account.username;
|
|
}
|
|
|
|
/**
|
|
* Who the policy is for (webmail lib/admin/policy-subject.ts): the
|
|
* account's address, and the group accounts its session shows (shared,
|
|
* not personal, named by an address).
|
|
*/
|
|
export function policySubjectFor(account: AccountEntry): PolicySubject {
|
|
const email = (account.email || account.username).trim().toLowerCase();
|
|
const session = sameClientAccount(account) ? jmapClient.currentSession : null;
|
|
const groups = session
|
|
? Object.values(session.accounts)
|
|
.filter((info) => !info.isPersonal && info.name.includes('@'))
|
|
.map((info) => info.name.trim().toLowerCase())
|
|
.sort()
|
|
: [];
|
|
return { email, groups };
|
|
}
|
|
|
|
function isSecureUrl(url: string): boolean {
|
|
return /^https:\/\//i.test(url) || isLoopbackHttp(url);
|
|
}
|
|
|
|
async function readJson(response: Response): Promise<unknown> {
|
|
try {
|
|
return await response.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET <webmail>/api/admin/policy. With an access token for the account,
|
|
* the full policy for its domains; the token goes only over TLS, and names
|
|
* the account's server so the webmail checks it there and nowhere else.
|
|
* Without one (password accounts), the public part. Null when unreachable.
|
|
*/
|
|
export async function fetchOrgPolicy(
|
|
webmailUrl: string,
|
|
auth: { authorization: string; serverUrl: string } | null,
|
|
): Promise<{ policy: OrgPolicy; partial: boolean } | null> {
|
|
const base = webmailUrl.replace(/\/+$/, '');
|
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
if (auth && /^Bearer\s/i.test(auth.authorization) && isSecureUrl(base)) {
|
|
headers.Authorization = auth.authorization;
|
|
headers['X-JMAP-Server-Url'] = auth.serverUrl;
|
|
}
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), POLICY_FETCH_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetchWithoutForeignRedirect(`${base}/api/admin/policy`, {
|
|
headers,
|
|
signal: controller.signal,
|
|
timeoutMs: POLICY_FETCH_TIMEOUT_MS,
|
|
});
|
|
if (!response.ok) return null;
|
|
const policy = sanitizePolicy(await readJson(response));
|
|
if (!policy) return null;
|
|
const partial = response.headers?.get?.(POLICY_SCOPE_HEADER) === 'public';
|
|
return { policy, partial };
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/** The operator's links from <webmail>/api/config; null when unreachable. */
|
|
export async function fetchWebmailLinks(webmailUrl: string): Promise<WebmailLinks | null> {
|
|
const base = webmailUrl.replace(/\/+$/, '');
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), POLICY_FETCH_TIMEOUT_MS);
|
|
try {
|
|
const response = await fetchWithoutForeignRedirect(`${base}/api/config`, {
|
|
headers: { Accept: 'application/json' },
|
|
signal: controller.signal,
|
|
timeoutMs: POLICY_FETCH_TIMEOUT_MS,
|
|
});
|
|
if (!response.ok) return null;
|
|
return webmailLinksFromConfig(await readJson(response));
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
/** The account's Authorization header when the live client serves it with a token. */
|
|
async function bearerFor(account: AccountEntry): Promise<{ authorization: string; serverUrl: string } | null> {
|
|
if (!sameClientAccount(account) || !jmapClient.usesBearerAuth) return null;
|
|
try {
|
|
await jmapClient.ensureFreshToken();
|
|
return { authorization: jmapClient.authHeader, serverUrl: account.serverUrl };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const refreshes = new Map<string, Promise<void>>();
|
|
|
|
export const usePolicyStore = create<PolicyState>()(
|
|
persist(
|
|
(set, get) => ({
|
|
byAccount: {},
|
|
configByAccount: {},
|
|
accountId: null,
|
|
effective: null,
|
|
rules: EMPTY_SETTINGS_RULES,
|
|
links: {},
|
|
// Until the cache is read and the active account known.
|
|
configPending: true,
|
|
|
|
activate: (accountId) => {
|
|
const account = accountId ? useAccountStore.getState().getAccountById(accountId) : undefined;
|
|
const cached = accountId ? get().byAccount[accountId] : undefined;
|
|
// Only a policy from the webmail the account signed in through.
|
|
const usable = account?.webmailUrl && cached && cached.webmailUrl === account.webmailUrl ? cached : null;
|
|
const effective = usable && account ? resolvePolicy(usable.policy, policySubjectFor(account)) : null;
|
|
const rules = rulesFor(effective);
|
|
const config = accountId && account?.webmailUrl ? get().configByAccount[accountId] : undefined;
|
|
// A policy cached before the config had its own entry came with the config's links.
|
|
const links = config?.webmailUrl === account?.webmailUrl && config ? config.links : usable?.links;
|
|
set({
|
|
accountId: accountId ?? null,
|
|
effective,
|
|
rules,
|
|
links: links ?? {},
|
|
configPending: !!account?.webmailUrl && !links,
|
|
});
|
|
useSettingsStore.getState().applyPolicyRules(rules);
|
|
},
|
|
|
|
refresh: (accountId) => {
|
|
const running = refreshes.get(accountId);
|
|
if (running) return running;
|
|
const run = (async () => {
|
|
const account = useAccountStore.getState().getAccountById(accountId);
|
|
const webmailUrl = account?.webmailUrl;
|
|
if (!account || !webmailUrl) return;
|
|
const auth = await bearerFor(account);
|
|
const [fetched, links] = await Promise.all([fetchOrgPolicy(webmailUrl, auth), fetchWebmailLinks(webmailUrl)]);
|
|
// Offline: keep what is cached. A webmail without the policy route
|
|
// still has its config.
|
|
if (!fetched && !links) return;
|
|
// Signed out of the account meanwhile.
|
|
if (!useAccountStore.getState().getAccountById(accountId)) return;
|
|
const previous = get().byAccount[accountId];
|
|
const legacyLinks = previous?.webmailUrl === webmailUrl ? previous.links : undefined;
|
|
set({
|
|
...(fetched ? {
|
|
byAccount: {
|
|
...get().byAccount,
|
|
[accountId]: { policy: fetched.policy, partial: fetched.partial, fetchedAt: Date.now(), webmailUrl },
|
|
},
|
|
} : {}),
|
|
// Config links cached inside an older policy entry move to their own.
|
|
...(links || (fetched && legacyLinks) ? {
|
|
configByAccount: {
|
|
...get().configByAccount,
|
|
[accountId]: { webmailUrl, links: (links ?? legacyLinks)! },
|
|
},
|
|
} : {}),
|
|
});
|
|
if (useAuthStore.getState().activeAccountId === accountId) get().activate(accountId);
|
|
})().finally(() => refreshes.delete(accountId));
|
|
refreshes.set(accountId, run);
|
|
return run;
|
|
},
|
|
|
|
prune: (accountIds) => {
|
|
const keep = new Set(accountIds);
|
|
const { byAccount, configByAccount } = get();
|
|
if ([...Object.keys(byAccount), ...Object.keys(configByAccount)].every((id) => keep.has(id))) return;
|
|
set({
|
|
byAccount: Object.fromEntries(Object.entries(byAccount).filter(([id]) => keep.has(id))),
|
|
configByAccount: Object.fromEntries(Object.entries(configByAccount).filter(([id]) => keep.has(id))),
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
name: 'org-policy-cache',
|
|
storage: createPersistStorage(),
|
|
partialize: (state) => ({ byAccount: state.byAccount, configByAccount: state.configByAccount }) as unknown as PolicyState,
|
|
},
|
|
),
|
|
);
|
|
|
|
/** Whether a feature gate of the active account's policy is on (everything is without a webmail). */
|
|
export function usePolicyFeature(feature: FeatureGate): boolean {
|
|
return usePolicyStore((s) => isFeatureEnabled(s.effective, feature));
|
|
}
|
|
|
|
/** Whether the active account's policy pins a setting (by the app's key). */
|
|
export function useSettingLocked(key: string): boolean {
|
|
return usePolicyStore((s) => isLockedSetting(s.rules, key));
|
|
}
|
|
|
|
/**
|
|
* Whether the active account's webmail runs without third-party traffic
|
|
* (webmail hooks/use-third-party-mode.ts): remote content in mail never
|
|
* loads, whatever the setting or a trusted sender says. On while the
|
|
* config is still unknown, so nothing reaches out before it says it may.
|
|
*/
|
|
export function noThirdPartyTraffic(state: Pick<PolicyState, 'links' | 'configPending'>): boolean {
|
|
return state.configPending || state.links.noThirdPartyTraffic === true;
|
|
}
|
|
|
|
export function useNoThirdPartyTraffic(): boolean {
|
|
return usePolicyStore(noThirdPartyTraffic);
|
|
}
|
|
|
|
/** Whether the active account's policy hides a setting (by the app's key). */
|
|
export function useSettingHidden(key: string): boolean {
|
|
return usePolicyStore((s) => s.rules.hidden.includes(key));
|
|
}
|
|
|
|
let syncStarted = false;
|
|
|
|
/**
|
|
* Keep the policy following the active account: the cached one applies at
|
|
* once (also offline), and it is fetched again on start and on every
|
|
* account switch, once the account's session is up (its token is needed).
|
|
* Returns the unsubscribe.
|
|
*/
|
|
export function startPolicySync(): () => void {
|
|
if (syncStarted) return () => undefined;
|
|
syncStarted = true;
|
|
|
|
const t = () => useLocaleStore.getState().t;
|
|
setPolicyLockedWriteListener(() => {
|
|
useToastStore.getState().addToast({
|
|
type: 'info',
|
|
title: t()('settings.org_locked', 'Set by your organisation'),
|
|
});
|
|
});
|
|
|
|
// Undefined until the first activate, which also settles configPending without an account.
|
|
let activeId: string | null | undefined;
|
|
let fetchedFor: string | null = null;
|
|
const follow = () => {
|
|
const auth = useAuthStore.getState();
|
|
const id = auth.activeAccountId ?? useAccountStore.getState().activeAccountId;
|
|
if (id !== activeId) {
|
|
activeId = id;
|
|
fetchedFor = null;
|
|
usePolicyStore.getState().activate(id);
|
|
}
|
|
if (id && auth.session && fetchedFor !== id) {
|
|
fetchedFor = id;
|
|
void usePolicyStore.getState().refresh(id);
|
|
}
|
|
};
|
|
|
|
const whenHydrated = (fn: () => void) => {
|
|
if (usePolicyStore.persist.hasHydrated()) fn();
|
|
else usePolicyStore.persist.onFinishHydration(fn);
|
|
};
|
|
whenHydrated(follow);
|
|
|
|
const offAuth = useAuthStore.subscribe((state, prev) => {
|
|
if (state.activeAccountId === prev.activeAccountId && state.session === prev.session) return;
|
|
whenHydrated(follow);
|
|
});
|
|
const offAccounts = useAccountStore.subscribe((state, prev) => {
|
|
if (state.accounts === prev.accounts) return;
|
|
usePolicyStore.getState().prune(state.accounts.map((a) => a.id));
|
|
// A sign-in through a webmail stores its address after the account
|
|
// became active: fetch now that it is known.
|
|
const id = activeId;
|
|
const now = id ? state.accounts.find((a) => a.id === id) : undefined;
|
|
const before = id ? prev.accounts.find((a) => a.id === id) : undefined;
|
|
if (id && now?.webmailUrl && now.webmailUrl !== before?.webmailUrl) {
|
|
fetchedFor = null;
|
|
// Its config is unknown until the fetch answers.
|
|
whenHydrated(() => {
|
|
usePolicyStore.getState().activate(id);
|
|
follow();
|
|
});
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
offAuth();
|
|
offAccounts();
|
|
setPolicyLockedWriteListener(null);
|
|
syncStarted = false;
|
|
};
|
|
}
|