Files
webmail/lib/admin/server-device-policy.ts
T
Linus Rath adbec718ef feat: let admins keep sender and subject out of push notifications, enforced by the server
The push preview route now reads the device policy of the login it
serves (its scopes worked out from the login's own JMAP session, every
non-personal account counted as a group) and under
`pushWithoutContent` asks the mail server for the message's ids only,
so sender, subject and text never reach the notification. The service
worker shows "New mail" for such an answer. A policy that can't be read
counts as on.

The three existing push preview route tests now mock the config manager
with the default policy: without it the route can't read a policy in the
test environment and holds the text back, which those tests didn't mean
to cover. No assertion changed.
2026-11-01 08:40:02 +01:00

101 lines
4.1 KiB
TypeScript

/**
* The device policies (policy.json `organisation.devices`) as the server
* applies them itself, where it can: the push notification text
* (app/api/push/preview) and the office editor (app/api/wopi). The browser
* talks to the mail server directly for everything else, so those gates
* are browser-only (lib/device-policy.ts).
*
* Who the caller is comes from their own JMAP session, never from the
* browser. For these gates every non-personal account in the session
* counts as a group (a mailbox another person shared counts too): more
* scopes matched means more held back, the safe direction. A session that
* can't be read counts the login address alone.
*/
import { createHash } from 'node:crypto';
import { configManager } from './config-manager';
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from './organisation-policy';
import { resolvePolicy, type PolicySubject } from './policy-scopes';
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
export type ServerDevicePolicy = OrganisationPolicy['devices'];
export interface DeviceCreds {
serverUrl: string;
authHeader: string;
username: string;
accountName?: string;
trusted: boolean;
}
const TTL_MS = 5 * 60 * 1000;
const CACHE_KEY = Symbol.for('bulwark.serverDeviceSubjects');
// One cache per process, not per bundle copy.
function cache(): Map<string, { at: number; subject: Promise<PolicySubject> }> {
const g = globalThis as typeof globalThis & { [CACHE_KEY]?: Map<string, { at: number; subject: Promise<PolicySubject> }> };
g[CACHE_KEY] ??= new Map();
return g[CACHE_KEY];
}
async function readSubject(creds: DeviceCreds): Promise<PolicySubject> {
const email = (creds.accountName || creds.username || '').trim().toLowerCase();
const session = await fetchJmapSession(creds.serverUrl, creds.authHeader, { trusted: creds.trusted }).catch(() => null);
const accounts = (session?.accounts ?? {}) as Record<string, { name?: string; isPersonal?: boolean }>;
const groups = Object.values(accounts)
.filter((account) => account?.isPersonal === false && typeof account.name === 'string' && account.name.includes('@'))
.map((account) => account.name!.trim().toLowerCase())
.filter((name) => name !== email)
.sort();
return { email, groups };
}
/** The caller as the device gates see them, kept for a few minutes per credential. Never rejects. */
export function strictDeviceSubject(creds: DeviceCreds): Promise<PolicySubject> {
const key = createHash('sha256').update(`${creds.serverUrl}\n${creds.authHeader}`).digest('hex');
const entries = cache();
const now = Date.now();
const hit = entries.get(key);
if (hit && now - hit.at < TTL_MS) return hit.subject;
const subject = readSubject(creds);
entries.set(key, { at: now, subject });
if (entries.size > 1000) {
for (const [k, v] of entries) if (now - v.at >= TTL_MS) entries.delete(k);
}
return subject;
}
/** The device policy that applies to this login. */
export async function serverDevicePolicy(creds: DeviceCreds): Promise<ServerDevicePolicy> {
await configManager.ensureLoaded();
const subject = await strictDeviceSubject(creds);
const devices = resolvePolicy(configManager.getPolicy(), subject).policy.organisation?.devices;
return { ...DEFAULT_ORGANISATION_POLICY.devices, ...(devices ?? {}) };
}
/**
* The WOPI CheckFileInfo switches for the office editor (Collabora and
* ONLYOFFICE read these): no printing, no export or "save a copy" when
* downloads or export are off, and the watermark.
*/
export function wopiDeviceInfo(devices: ServerDevicePolicy, username: string, now: Date = new Date()): Record<string, unknown> {
const out: Record<string, unknown> = {};
if (devices.noPrint) {
out.DisablePrint = true;
out.HidePrintOption = true;
}
if (devices.noDownloads || devices.noExport) {
out.DisableExport = true;
out.HideExportOption = true;
out.HideSaveOption = true;
out.DisableCopy = true;
}
if (devices.watermark) out.WatermarkText = `${username} · ${now.toISOString().slice(0, 10)}`;
return out;
}
/** For tests. */
export function clearServerDeviceSubjects(): void {
cache().clear();
}