Files
webmail/lib/device-policy.ts
T
Linus Rath e64c6c7bc3 fix: keep data on a mistyped offline policy and use the shortest lock time for an unreadable one
Forced OfflineEnabled values that make no sense read as forced off,
which wipes kept mail. They now stop new mail being kept and delete
nothing. An unreadable AppLockTimeoutMinutes now means the shortest
time, so the existing out-of-range test changes from no value to 1:
no value let the user's own choice win.

- recommended AutoUpdate, AppLockRequired, TelemetryAllowed and
  CrashReportsAllowed are gone: nothing read them, or one acted as forced
- auth lists and certificate rules can't name a whole top-level domain
- ALL_WIRED is gone, as every key has a consumer
2026-11-04 03:08:30 +01:00

186 lines
7.9 KiB
TypeScript

/**
* Device policies (policy.json `organisation.devices`, enterprise pack item
* 13): admin switches that make copying mail out of Bulwark harder: no
* attachment downloads or opening in a new tab, no mail or folder export, no
* printing, no install prompt, push without sender and subject, and a
* watermark.
*
* Which account's policy applies (several accounts can be signed in, with
* different domain and group scopes):
* - for a message, attachment or file: the account it belongs to
* (devicesForAccount, with the email's `sourceClientAccountId` in the
* unified views);
* - where there is no such thing (the install prompt, Ctrl+P, a page that
* mixes accounts): the strictest of every account signed in here
* (getStrictestDevices).
*
* Everything here except the push text and the office editor (both checked
* on the server, app/api/push/preview and app/api/wopi) runs in the
* browser: someone who knows the browser's tools can get around it, and
* other mail apps or a screenshot always can. The admin page and the docs
* say so.
*
* Every user-facing path asks canDownload/canExport/canPrint to hide its
* button, and the anchor and new-tab helpers (lib/download-guard.ts) refuse
* as a backstop. Never fetchBlob or fetchBlobArrayBuffer: phishing reports,
* the export engine and previews read blobs too.
*/
import { DEFAULT_ORGANISATION_POLICY, strictestDevices, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { setDownloadGuard } from '@/lib/download-guard';
import { desktopOfflinePolicy } from '@/lib/desktop/policy';
import { subjectForAccount } from '@/lib/org/account-subjects';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
export type DevicePolicy = OrganisationPolicy['devices'];
export const NO_DEVICE_LIMITS: DevicePolicy = DEFAULT_ORGANISATION_POLICY.devices;
function devicesOf(policy: { organisation?: OrganisationPolicy } | null | undefined): DevicePolicy {
return { ...NO_DEVICE_LIMITS, ...(policy?.organisation?.devices ?? {}) };
}
/**
* A local account id, something that knows which account it came from (an
* email in a unified view, `sourceClientAccountId`), or the JMAP client of
* an account.
*/
export type DeviceSubject = string | null | undefined | { sourceClientAccountId?: string | null } | { client: object };
/**
* The signed-in account a message belongs to, for every device decision:
* its stamp in the unified views and Pro tabs, else the account whose
* folders the sidebar shows (another account's Inbox opened from the
* multi-account sidebar), else the active account (null). The same rule as
* resolveEmailBlobContext uses to fetch the message.
*/
export function deviceSubjectOf(email: { sourceClientAccountId?: string | null } | null | undefined): string | null {
return email?.sourceClientAccountId ?? useEmailStore.getState().viewingAccountId ?? null;
}
function accountIdOf(subject: DeviceSubject): string | null {
if (!subject) return null;
if (typeof subject === 'string') return subject;
if ('client' in subject) {
const auth = useAuthStore.getState();
if (subject.client === auth.client) return auth.activeAccountId ?? null;
try {
for (const [id, client] of auth.getAllConnectedClients?.() ?? []) if (client === subject.client) return id;
} catch {
// A stand-in auth store in tests.
}
return null;
}
return deviceSubjectOf(subject);
}
/** The device policy of one signed-in account (the active one for null). */
export function devicesForAccount(subject: DeviceSubject): DevicePolicy {
const accountId = accountIdOf(subject);
const store = usePolicyStore.getState();
const accountSubject = subjectForAccount(accountId);
const policy = accountSubject === store.subject ? store.policy : store.resolveFor(accountSubject);
const devices = devicesOf(policy);
// The desktop app's OS policy says the same thing as the organisation's switch. Not a
// value that makes no sense: noOffline wipes, and offlineAllowed asks about that one itself.
const offline = desktopOfflinePolicy();
return offline.forced === false && !offline.invalid ? { ...devices, noOffline: true } : devices;
}
/** What applies to anyone, with no account's groups: for data of an account nobody here is signed in to. */
export function devicesForNoOne(): DevicePolicy {
return devicesOf(usePolicyStore.getState().resolveFor(null));
}
/** Local ids of every account signed in here, the active one included. */
export function signedInAccountIds(): string[] {
const auth = useAuthStore.getState();
const ids = new Set<string>();
try {
for (const id of auth.getAllConnectedClients?.().keys() ?? []) ids.add(id);
} catch {
// A stand-in auth store in tests.
}
if (auth.activeAccountId) ids.add(auth.activeAccountId);
return [...ids];
}
/** Every switch that any signed-in account has on, and the lowest of each cap: for pages that aren't about one account. */
export function getStrictestDevices(): DevicePolicy {
return signedInAccountIds().reduce((out, id) => strictestDevices(out, devicesForAccount(id)), devicesForAccount(null));
}
/** The switches that keep mail in: a copy may only go where they are at least as strict. */
const KEEP_IN: (keyof DevicePolicy)[] = ['noDownloads', 'noExport', 'noPrint', 'watermark'];
/**
* Whether mail of account `from` may be copied into account `to` ("Copy to"
* another signed-in account): not into an account where downloads, export
* or printing are allowed or the watermark is off while `from` has them.
*/
export function canCopyBetween(from: DeviceSubject, to: DeviceSubject): boolean {
const source = devicesForAccount(from);
const target = devicesForAccount(to);
return KEEP_IN.every((key) => !source[key] || target[key]);
}
/** Attachments and files may be downloaded or opened in a new tab. */
export function canDownload(subject?: DeviceSubject): boolean {
return !devicesForAccount(subject).noDownloads;
}
/** Messages and folders may be exported (EML, mbox, ZIP, View source, drag-out). */
export function canExport(subject?: DeviceSubject): boolean {
return !devicesForAccount(subject).noExport;
}
/** Messages and pages may be printed. */
export function canPrint(subject?: DeviceSubject): boolean {
return !devicesForAccount(subject).noPrint;
}
/** For pages without one account: nothing may be downloaded if any signed-in account forbids it. */
export function canDownloadAnywhere(): boolean {
return !getStrictestDevices().noDownloads;
}
export function canPrintAnywhere(): boolean {
return !getStrictestDevices().noPrint;
}
export function canExportAnywhere(): boolean {
return !getStrictestDevices().noExport;
}
// The backstop in the anchor and new-tab helpers asks this. Without an
// account (undefined) it takes the strictest of the signed-in accounts; null
// is the active account.
setDownloadGuard((kind, subject) => {
const devices = subject === undefined ? getStrictestDevices() : devicesForAccount(subject);
return kind === 'export' ? !devices.noExport : !devices.noDownloads;
});
/** The signed-in address of the account a message belongs to, for the watermark. */
export function watermarkAddress(subject: DeviceSubject): string {
const accountId = accountIdOf(subject);
const auth = useAuthStore.getState();
if (accountId && accountId !== auth.activeAccountId) {
const email = subjectForAccount(accountId)?.email;
if (email) return email;
}
return auth.username ?? usePolicyStore.getState().subject?.email ?? '';
}
/** The text of the watermark: the user's address and today's date. */
export function watermarkText(address: string, now: Date = new Date(), locale?: string): string {
let date: string;
try {
date = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(now);
} catch {
date = now.toISOString().slice(0, 10);
}
return [address.trim(), date].filter(Boolean).join(' · ');
}